Skip to content

Commit a15b2c6

Browse files
authored
feat: repo público — ribbon de actualización semanal, releases semver y CSP (#2)
* feat: repo público — ribbon de actualización semanal, releases semver y CSP El repo pasa a público (gh repo rename panelsolar → helios, historia saneada en commits previos). Con el repo público, el check de actualizaciones deja de ser humo (antes repo privado → 404 → siempre 'al día'). - UpdateRibbon: check semanal vía localStorage contra releases de GitHub (solo admin), enlace 'Ver novedades'. Patrón app-auto-update. - Ajustes: REPO_URL → gnacho/helios (el rewrite con filter-repo lo dejó corrupto como gdemo/panelsolar). - CSP server: connect-src https://api.github.com (Deltos ya lo tenía). - release.yml: releases estables semver (tests + build + tarballs por arch con node_modules pre-built + checksums.txt). - bump 0.5.0 → 0.6.0 (app + server + locks). * fix(ajustes): tokens para el preview de tema y la paleta de acentos (theme-audit) El CI fallaba por hex hardcodeados en el preview de tema y en ACCENTS (fallo preexistente, el CI de main ya estaba rojo). Movidos a lib/colors.ts (fichero de tokens permitido por theme-audit): THEME_BG/THEME_SURFACE/THEME_BAR y ACCENTS. --------- Co-authored-by: gnacho <hnacho@proton.me>
1 parent fdcd52e commit a15b2c6

10 files changed

Lines changed: 235 additions & 42 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: release
2+
3+
# Release estable por tag v*: empaqueta la webapp completa (frontend build +
4+
# server + node_modules de producción PRE-BUILT por arch) + checksums.txt.
5+
# El 'Comprobar actualizaciones' del front y el ribbon semanal consumen estas
6+
# releases (releases/latest, semver).
7+
8+
on:
9+
push:
10+
tags: ['v*']
11+
workflow_dispatch: # sin tag = empaqueta y sube como artifact de prueba
12+
13+
permissions:
14+
contents: write
15+
16+
jobs:
17+
build:
18+
strategy:
19+
fail-fast: false
20+
matrix:
21+
include:
22+
- arch: amd64
23+
runs-on: ubuntu-latest
24+
- arch: arm64
25+
runs-on: ubuntu-24.04-arm
26+
runs-on: ${{ matrix.runs-on }}
27+
steps:
28+
- uses: actions/checkout@v4
29+
30+
- uses: actions/setup-node@v4
31+
with:
32+
node-version: 22
33+
cache: npm
34+
cache-dependency-path: |
35+
app/package-lock.json
36+
server/package-lock.json
37+
38+
- name: Tests del server (vitest)
39+
working-directory: server
40+
run: npm ci --no-audit --no-fund && npm test
41+
42+
- name: Build frontend
43+
working-directory: app
44+
run: npm ci --no-audit --no-fund && npm run build
45+
46+
- name: node_modules de producción (sin dev, compila prebuilds del arch)
47+
working-directory: server
48+
run: rm -rf node_modules && npm ci --omit=dev --no-audit --no-fund
49+
50+
- name: Empaqueta release
51+
env:
52+
REF: ${{ github.ref_name }}
53+
run: |
54+
VERSION="${REF#v}"
55+
[ "${{ github.ref_type }}" = "tag" ] || VERSION="0.0.0-SNAPSHOT-${GITHUB_SHA::7}"
56+
mkdir -p stage/server
57+
cp -r app/dist stage/dist
58+
cp -r server/src server/package.json stage/server/
59+
cp -r shared stage/shared
60+
cp -r server/node_modules stage/server/node_modules
61+
tar -czf "helios_${VERSION}_linux_${{ matrix.arch }}.tar.gz" -C stage .
62+
sha256sum helios_*.tar.gz > checksums.txt
63+
cat checksums.txt
64+
65+
- name: Publica en la release (solo tags)
66+
if: github.ref_type == 'tag'
67+
uses: softprops/action-gh-release@v2
68+
with:
69+
files: |
70+
helios_*.tar.gz
71+
checksums.txt
72+
73+
- name: Sube artifact de prueba (solo workflow_dispatch)
74+
if: github.ref_type != 'tag'
75+
uses: actions/upload-artifact@v4
76+
with:
77+
name: helios-snapshot-linux-${{ matrix.arch }}
78+
path: |
79+
helios_*.tar.gz
80+
checksums.txt
81+
retention-days: 7

‎app/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "helios",
33
"private": true,
4-
"version": "0.5.0",
4+
"version": "0.6.0",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { useEffect, useState } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { RefreshCw } from 'lucide-react';
4+
import { apiFetch } from '@/data/api-client';
5+
import pkg from '../../package.json';
6+
7+
const CHECK_KEY = 'helios-last-update-check';
8+
const CHECK_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 1 vez por semana (regla app-auto-update)
9+
const REPO_URL = 'https://github.com/gnacho/helios';
10+
11+
/** a vs b semver ('1.10.0' > '1.9.0'); prefijos 'v' ignorados. */
12+
function compareSemver(a: string, b: string): number {
13+
const pa = a.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
14+
const pb = b.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
15+
for (let i = 0; i < 3; i++) {
16+
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
17+
}
18+
return 0;
19+
}
20+
21+
/**
22+
* Ribbon de actualización (patrón app-auto-update: check semanal + aviso si hay
23+
* versión nueva en el repo público). Solo admin. GET anónima a GitHub releases
24+
* (el repo es público), 1 vez por semana vía localStorage; aplicar = redesplegar
25+
* en el servidor, así que el ribbon enlaza a la release.
26+
*/
27+
export default function UpdateRibbon() {
28+
const { t } = useTranslation();
29+
const [state, setState] = useState<'idle' | 'checking' | 'uptodate' | 'available' | 'error'>('idle');
30+
const [latestVersion, setLatestVersion] = useState('');
31+
32+
useEffect(() => {
33+
let stale = false;
34+
const run = async () => {
35+
try {
36+
// Solo admin ve el ribbon (como el resto de la zona de actualizaciones).
37+
const me = await apiFetch<{ user?: { role?: string } }>('/api/auth/me').catch(() => null);
38+
if (me?.user?.role !== 'admin') return;
39+
40+
const last = Number(window.localStorage.getItem(CHECK_KEY) || 0);
41+
if (Date.now() - last < CHECK_INTERVAL) return; // ya se comprobó esta semana
42+
window.localStorage.setItem(CHECK_KEY, String(Date.now()));
43+
44+
const repo = REPO_URL.match(/github\.com\/([^/]+\/[^/.]+)/)?.[1];
45+
if (!repo) return;
46+
setState('checking');
47+
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
48+
headers: { Accept: 'application/vnd.github+json' },
49+
});
50+
let version = '';
51+
if (res.ok) {
52+
const data = (await res.json()) as { tag_name?: string };
53+
version = data.tag_name || '';
54+
} else if (res.status === 404) {
55+
const tagRes = await fetch(`https://api.github.com/repos/${repo}/tags?per_page=1`, {
56+
headers: { Accept: 'application/vnd.github+json' },
57+
});
58+
if (tagRes.ok) {
59+
const tags = (await tagRes.json()) as { name?: string }[];
60+
version = tags[0]?.name || '';
61+
}
62+
}
63+
if (stale) return;
64+
if (!version || compareSemver(version, pkg.version) <= 0) setState('uptodate');
65+
else {
66+
setLatestVersion(version.replace(/^v/, ''));
67+
setState('available');
68+
}
69+
} catch {
70+
if (!stale) setState('error');
71+
}
72+
};
73+
void run();
74+
return () => {
75+
stale = true;
76+
};
77+
}, []);
78+
79+
if (state !== 'available') return null;
80+
81+
return (
82+
<div
83+
role="status"
84+
className="mb-4 flex items-center gap-2.5 rounded-xl border border-amber-500/35 bg-amber-500/10 px-3.5 py-2.5 text-[13px] font-semibold text-amber-500"
85+
>
86+
<RefreshCw className="h-4 w-4 shrink-0" />
87+
<span>{t('ajustes.about.updateAvailable', { version: latestVersion })}</span>
88+
<a
89+
href={`${REPO_URL}/releases`}
90+
target="_blank"
91+
rel="noreferrer"
92+
className="ml-auto flex h-8 shrink-0 items-center rounded-lg border border-amber-500/40 px-3 text-xs font-medium text-amber-500 transition-colors hover:bg-amber-500/15"
93+
>
94+
{t('ajustes.about.viewRelease')}
95+
</a>
96+
</div>
97+
);
98+
}

‎app/src/components/shell/AppLayout.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { fmtWeekdayDate } from '@/i18n';
2525
import BrandLogo from '@/components/BrandLogo';
2626
import ConnectionStatus from '@/components/ConnectionStatus';
2727
import AlertsBell from '@/components/AlertsBell';
28+
import UpdateRibbon from '@/components/UpdateRibbon';
2829
import ThemeToggle from '@/components/ThemeToggle';
2930
import Footer from '@/components/Footer';
3031
import HeliosToaster from '@/components/HeliosToaster';
@@ -422,6 +423,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
422423
</header>
423424

424425
<main className={cn('mx-auto max-w-[1440px] px-4 pb-24 pt-4 md:ml-16 md:px-6 md:pb-6 md:pt-6', lgMargin)}>
426+
<UpdateRibbon />
425427
{isDemo && <DemoBanner onExit={exitDemo} />}
426428
{children}
427429
<div className="hidden lg:block">

‎app/src/lib/colors.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ export const SOLAR_ARC_COLORS = { nightFrom: '#101828', nightTo: '#182338', star
3535
/** Fondos base por tema (theme-color meta, swatches de preview). */
3636
export const THEME_BG = { light: '#F4F6FA', dark: '#080D1A' } as const;
3737

38+
/** Superficies del preview de tema (mini-UI pintada con los colores reales). */
39+
export const THEME_SURFACE = { light: '#FFFFFF', dark: '#0F1729' } as const;
40+
/** Barras internas del preview de tema. */
41+
export const THEME_BAR = { light: '#E2E8F0', dark: '#1E293B' } as const;
42+
43+
/** Paleta de acentos del selector de Ajustes (triplete RGB para --accent-rgb; null = naranja por defecto). */
44+
export const ACCENTS = [
45+
{ id: 'naranja', rgb: null, hex: '#F59E0B' },
46+
{ id: 'verde', rgb: '34 197 94', hex: '#22C55E' },
47+
{ id: 'azul', rgb: '59 130 246', hex: '#3B82F6' },
48+
{ id: 'violeta', rgb: '139 92 246', hex: '#8B5CF6' },
49+
] as const;
50+
3851
export function energyColor(key: EnergyColorKey, isDark: boolean): string {
3952
return isDark ? ENERGY_COLORS[key].dark : ENERGY_COLORS[key].light;
4053
}

‎app/src/pages/Ajustes.tsx‎

Lines changed: 34 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
3737
import { Switch } from '@/components/ui/switch';
3838
import { useTheme } from '@/theme/ThemeProvider';
3939
import { useEnergyData } from '@/data/EnergyDataProvider';
40+
import { THEME_BG, THEME_SURFACE, THEME_BAR, ACCENTS } from '@/lib/colors';
4041
import { useEnergySettings } from '@/hooks/useEnergySettings';
4142
import { usePush } from '@/hooks/usePush';
4243
import BrandLogo from '@/components/BrandLogo';
@@ -327,59 +328,57 @@ function HealthBadge({ ok }: { ok: boolean }) {
327328

328329
// ── §1 Tema ──────────────────────────────────────────────────────────────────
329330

330-
/** Mini-preview de tema: dibuja una mini-UI con divs usando variables CSS reales. */
331+
/** Mitad del preview 'split': un lado del tema con sus tokens reales. */
332+
function PreviewBlock({ useLight }: { useLight: boolean }) {
333+
const bgC = useLight ? THEME_BG.light : THEME_BG.dark;
334+
const surfaceC = useLight ? THEME_SURFACE.light : THEME_SURFACE.dark;
335+
const barC = useLight ? THEME_BAR.light : THEME_BAR.dark;
336+
return (
337+
<div className="flex w-1/2 flex-col p-1.5" style={{ backgroundColor: bgC }}>
338+
<div className="mb-1 h-1.5 w-full rounded" style={{ backgroundColor: barC }} />
339+
<div className="flex flex-1 gap-1">
340+
<div className="w-1/4 rounded" style={{ backgroundColor: barC }} />
341+
<div className="flex flex-1 flex-col gap-1">
342+
<div className="h-2 w-3/4 rounded bg-brand/60" />
343+
<div className="h-4 flex-1 rounded" style={{ backgroundColor: surfaceC }} />
344+
</div>
345+
</div>
346+
</div>
347+
);
348+
}
349+
350+
/** Mini-preview de tema: dibuja una mini-UI con divs usando los tokens reales. */
331351
function ThemePreview({ variant }: { variant: 'dark' | 'light' | 'split' }) {
332-
const bg = variant === 'dark' ? 'bg-[#080D1A]' : variant === 'light' ? 'bg-[#F4F6FA]' : '';
333-
const surface = variant === 'dark' ? 'bg-[#0F1729]' : variant === 'light' ? 'bg-[#FFFFFF]' : '';
352+
const { bg, surface, bar } = {
353+
dark: { bg: THEME_BG.dark, surface: THEME_SURFACE.dark, bar: THEME_BAR.dark },
354+
light: { bg: THEME_BG.light, surface: THEME_SURFACE.light, bar: THEME_BAR.light },
355+
}[variant === 'split' ? 'dark' : variant];
334356
const accent = 'bg-brand';
357+
335358
if (variant === 'split') {
336359
return (
337360
<div className="flex h-[72px] w-full overflow-hidden rounded-lg border border-app">
338-
<div className="flex w-1/2 flex-col bg-[#080D1A] p-1.5">
339-
<div className="mb-1 h-1.5 w-full rounded bg-[#1E293B]" />
340-
<div className="flex flex-1 gap-1">
341-
<div className="w-1/4 rounded bg-[#1E293B]" />
342-
<div className="flex flex-1 flex-col gap-1">
343-
<div className="h-2 w-3/4 rounded bg-brand/60" />
344-
<div className="h-4 flex-1 rounded bg-[#1E293B]" />
345-
</div>
346-
</div>
347-
</div>
348-
<div className="flex w-1/2 flex-col bg-[#F4F6FA] p-1.5">
349-
<div className="mb-1 h-1.5 w-full rounded bg-slate-200" />
350-
<div className="flex flex-1 gap-1">
351-
<div className="w-1/4 rounded bg-slate-200" />
352-
<div className="flex flex-1 flex-col gap-1">
353-
<div className="h-2 w-3/4 rounded bg-brand/60" />
354-
<div className="h-4 flex-1 rounded bg-slate-200" />
355-
</div>
356-
</div>
357-
</div>
361+
<PreviewBlock useLight={false} />
362+
<PreviewBlock useLight />
358363
</div>
359364
);
360365
}
361366

362367
return (
363-
<div className={cn('flex h-[72px] w-full flex-col rounded-lg border border-app p-1.5', bg)}>
364-
<div className={cn('mb-1 h-1.5 w-full rounded', surface === 'bg-[#FFFFFF]' ? 'bg-slate-200' : 'bg-[#1E293B]')} />
368+
<div className="flex h-[72px] w-full flex-col rounded-lg border border-app p-1.5" style={{ backgroundColor: bg }}>
369+
<div className="mb-1 h-1.5 w-full rounded" style={{ backgroundColor: bar }} />
365370
<div className="flex flex-1 gap-1">
366-
<div className={cn('w-1/4 rounded', surface === 'bg-[#FFFFFF]' ? 'bg-slate-200' : 'bg-[#1E293B]')} />
371+
<div className="w-1/4 rounded" style={{ backgroundColor: bar }} />
367372
<div className="flex flex-1 flex-col gap-1">
368373
<div className={cn('h-2 w-3/4 rounded', accent)} />
369-
<div className={cn('h-4 flex-1 rounded', surface === 'bg-[#FFFFFF]' ? 'bg-slate-200' : 'bg-[#1E293B]')} />
374+
<div className="h-4 flex-1 rounded" style={{ backgroundColor: surface }} />
370375
</div>
371376
</div>
372377
</div>
373378
);
374379
}
375380

376-
/** Acenos disponibles (triplete RGB para --accent-rgb; null = naranja por defecto). */
377-
const ACCENTS = [
378-
{ id: 'naranja', rgb: null, hex: '#F59E0B' },
379-
{ id: 'verde', rgb: '34 197 94', hex: '#22C55E' },
380-
{ id: 'azul', rgb: '59 130 246', hex: '#3B82F6' },
381-
{ id: 'violeta', rgb: '139 92 246', hex: '#8B5CF6' },
382-
] as const;
381+
/** Acenos disponibles (paleta en lib/colors.ts). */
383382

384383
function ThemeSection() {
385384
const { mode, setMode, density, setDensity, reduceMotion, setReduceMotion, accent, setAccent } = useTheme();
@@ -1642,7 +1641,7 @@ function InstallSection({ state, install }: { state: InstallState; install: () =
16421641

16431642
// ── § Acerca de ──────────────────────────────────────────────────────────────
16441643

1645-
const REPO_URL = 'https://github.com/gdemo/panelsolar';
1644+
const REPO_URL = 'https://github.com/gnacho/helios';
16461645

16471646
function compareSemver(a: string, b: string): number {
16481647
const pa = a.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);

‎server/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎server/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "helios-server",
3-
"version": "0.5.0",
3+
"version": "0.6.0",
44
"private": true,
55
"type": "module",
66
"engines": {

‎server/src/index.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ app.use('*', async (c, next) => {
123123
c.header('Referrer-Policy', 'strict-origin-when-cross-origin')
124124
c.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
125125
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
126-
c.header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
126+
c.header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.github.com")
127127
await next()
128128
})
129129
const sseClients = new Set()

0 commit comments

Comments
 (0)