diff --git a/materials/harle.png b/materials/harle.png new file mode 100644 index 0000000..f7e17a4 Binary files /dev/null and b/materials/harle.png differ diff --git a/server/src/app.ts b/server/src/app.ts index c24c19f..00ac561 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname, join, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import Fastify, { type FastifyInstance } from 'fastify'; import cookie from '@fastify/cookie'; @@ -135,13 +135,17 @@ export async function buildApp(): Promise { // sonst zeigt der Browser nach einem Update monatelang die alte Oberfläche. // Deshalb hier abschalten und beides unten selbst setzen. cacheControl: false, - setHeaders: (res, path) => { - if (path.endsWith('.html')) { + setHeaders: (res, filePath) => { + if (filePath.endsWith('.html')) { res.setHeader('Cache-Control', 'no-cache, must-revalidate'); - } else { - // Assets tragen einen Inhalts-Hash im Namen: ändert sich der Inhalt, - // ändert sich der Dateiname. Die dürfen beliebig lange liegen. + } else if (filePath.includes(`${sep}assets${sep}`)) { + // Nur die gebauten Assets tragen einen Inhalts-Hash im Namen: ändert + // sich der Inhalt, ändert sich der Dateiname. Die dürfen liegen bleiben. res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } else { + // Alles andere - Bilder, Favicon - behält den Namen bei neuem Inhalt. + // Ein Tag mit Rückfrage, damit ein Austausch auch ankommt. + res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate'); } }, }); diff --git a/server/test/http.test.ts b/server/test/http.test.ts index 9331a0e..67236c1 100644 --- a/server/test/http.test.ts +++ b/server/test/http.test.ts @@ -309,6 +309,16 @@ describe('Auslieferung der Oberfläche', () => { assert.match(response.headers['cache-control'] as string, /no-cache/); }); + it('lässt Bilder nicht ein Jahr lang kleben', async () => { + // Sie tragen keinen Hash im Namen - ein Austausch muss ankommen. + const response = await app.inject({ method: 'GET', url: '/favicon.png' }); + + assert.equal(response.statusCode, 200); + const header = response.headers['cache-control'] as string; + assert.doesNotMatch(header, /immutable/, 'kein immutable ohne Hash im Namen'); + assert.match(header, /must-revalidate/); + }); + it('lässt gehashte Assets lange liegen', async () => { const page = await app.inject({ method: 'GET', url: '/' }); const asset = /\/assets\/[^"]+\.js/.exec(page.body)?.[0]; diff --git a/web/index.html b/web/index.html index e4e1032..89a0ddd 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,9 @@ + + + CC-Manager diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 0000000..500aa9b Binary files /dev/null and b/web/public/apple-touch-icon.png differ diff --git a/web/public/favicon.png b/web/public/favicon.png new file mode 100644 index 0000000..14a3bec Binary files /dev/null and b/web/public/favicon.png differ diff --git a/web/public/harle.png b/web/public/harle.png new file mode 100644 index 0000000..ba13ca0 Binary files /dev/null and b/web/public/harle.png differ diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 8370cdd..95df298 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -41,6 +41,13 @@ export default function Layout({ children }: { children: ReactNode }) {
{children}
+ + ); } diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index c2b8a65..0081062 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -73,6 +73,7 @@ export default function Login() { return (
+

CC-Manager

{challenge ? 'Bestätigungscode eingeben' : 'Bitte anmelden'} @@ -136,6 +137,10 @@ export default function Login() { )}

+ +

+ Created by Harlekin | CC-Head +

); } diff --git a/web/src/styles.css b/web/src/styles.css index 2ed5fea..9c45a00 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -833,3 +833,80 @@ textarea { .usage-hint { margin: -0.5rem 0 1.25rem; } + +/* --- Maskottchen und Credits --- */ + +.login-page { + flex-direction: column; + gap: 1.25rem; +} + +.login-mascot { + display: block; + width: 190px; + height: auto; + margin: -3.25rem auto 0.25rem; + /* Der Charakter steht frei, ein weicher Schein hebt ihn vom Karton ab. */ + filter: drop-shadow(0 6px 18px rgba(91, 140, 255, 0.28)); + pointer-events: none; + user-select: none; +} + +.credits { + margin: 0; + font-size: 0.85rem; + color: var(--text-muted); + text-align: center; +} + +.credits strong { + color: var(--text); + font-weight: 600; +} + +.credits-sep { + opacity: 0.4; + margin: 0 0.15rem; +} + +.shell { + display: flex; + flex-direction: column; +} + +.shell .content { + flex: 1; +} + +.footer { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 1.1rem 1.5rem 1.6rem; + border-top: 1px solid var(--border); + font-size: 0.85rem; + color: var(--text-muted); +} + +.footer strong { + color: var(--text); + font-weight: 600; +} + +.footer-mascot { + width: 30px; + height: 30px; + /* Kopfbereich ausschneiden: ganzfigurig ist die Figur hier zu klein. */ + object-fit: cover; + object-position: 50% 16%; + border-radius: 50%; + background: var(--bg-input); +} + +@media (max-width: 820px) { + .login-mascot { + width: 140px; + margin-top: -2.25rem; + } +}