Maskottchen als Symbol und Credits

harle.png steht jetzt gross auf der Anmeldeseite und klein in der Fusszeile,
dazu die Zeile "Created by Harlekin | CC-Head".

Aus der Vorlage entstehen drei Groessen: das volle Bild fuer die Anzeige und
ein Ausschnitt des Kopfes fuer Favicon und Startsymbol - ganzfigurig waere die
Figur bei 32 Pixeln nicht mehr zu erkennen.

Dabei fiel auf, dass die Cache-Regel zu grob war: sie gab allem ausser HTML
ein Jahr immutable, also auch Bildern. Deren Name aendert sich beim Austausch
aber nicht, ein neues Bild waere ein Jahr lang nicht angekommen. Jetzt gilt
das lange Ablaufdatum nur fuer die gebauten Assets, die einen Inhalts-Hash im
Namen tragen; alles andere haelt einen Tag und fragt dann nach.
This commit is contained in:
2026-09-18 19:44:29 +02:00
parent bebb89c10e
commit 3344592c6e
10 changed files with 112 additions and 6 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

+10 -6
View File
@@ -1,5 +1,5 @@
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join, sep } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import Fastify, { type FastifyInstance } from 'fastify'; import Fastify, { type FastifyInstance } from 'fastify';
import cookie from '@fastify/cookie'; import cookie from '@fastify/cookie';
@@ -135,13 +135,17 @@ export async function buildApp(): Promise<FastifyInstance> {
// sonst zeigt der Browser nach einem Update monatelang die alte Oberfläche. // sonst zeigt der Browser nach einem Update monatelang die alte Oberfläche.
// Deshalb hier abschalten und beides unten selbst setzen. // Deshalb hier abschalten und beides unten selbst setzen.
cacheControl: false, cacheControl: false,
setHeaders: (res, path) => { setHeaders: (res, filePath) => {
if (path.endsWith('.html')) { if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, must-revalidate'); res.setHeader('Cache-Control', 'no-cache, must-revalidate');
} else { } else if (filePath.includes(`${sep}assets${sep}`)) {
// Assets tragen einen Inhalts-Hash im Namen: ändert sich der Inhalt, // Nur die gebauten Assets tragen einen Inhalts-Hash im Namen: ändert
// ändert sich der Dateiname. Die dürfen beliebig lange liegen. // sich der Inhalt, ändert sich der Dateiname. Die dürfen liegen bleiben.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); 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');
} }
}, },
}); });
+10
View File
@@ -309,6 +309,16 @@ describe('Auslieferung der Oberfläche', () => {
assert.match(response.headers['cache-control'] as string, /no-cache/); 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 () => { it('lässt gehashte Assets lange liegen', async () => {
const page = await app.inject({ method: 'GET', url: '/' }); const page = await app.inject({ method: 'GET', url: '/' });
const asset = /\/assets\/[^"]+\.js/.exec(page.body)?.[0]; const asset = /\/assets\/[^"]+\.js/.exec(page.body)?.[0];
+3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#12141a" />
<title>CC-Manager</title> <title>CC-Manager</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+7
View File
@@ -41,6 +41,13 @@ export default function Layout({ children }: { children: ReactNode }) {
</header> </header>
<main className="content">{children}</main> <main className="content">{children}</main>
<footer className="footer">
<img src="/harle.png" alt="" className="footer-mascot" />
<span>
Created by <strong>Harlekin</strong> <span className="credits-sep">|</span> CC-Head
</span>
</footer>
</div> </div>
); );
} }
+5
View File
@@ -73,6 +73,7 @@ export default function Login() {
return ( return (
<div className="login-page"> <div className="login-page">
<div className="login-card"> <div className="login-card">
<img src="/harle.png" alt="" className="login-mascot" />
<h1>CC-Manager</h1> <h1>CC-Manager</h1>
<p className="login-sub"> <p className="login-sub">
{challenge ? 'Bestätigungscode eingeben' : 'Bitte anmelden'} {challenge ? 'Bestätigungscode eingeben' : 'Bitte anmelden'}
@@ -136,6 +137,10 @@ export default function Login() {
</form> </form>
)} )}
</div> </div>
<p className="credits">
Created by <strong>Harlekin</strong> <span className="credits-sep">|</span> CC-Head
</p>
</div> </div>
); );
} }
+77
View File
@@ -833,3 +833,80 @@ textarea {
.usage-hint { .usage-hint {
margin: -0.5rem 0 1.25rem; 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;
}
}