chore(web): veraltete Kommentare, Theme-Skript in IIFE, CARD_W/CARD_H und reportsLabel nur aus einer Quelle
- publish.ts und editor-page.ts: Kommentare zum Aufräumen bzw. zur Adresse /gestalten?karte= stimmen wieder. - index.html: Inline-Skript für Hell/Dunkel in einer sofort aufgerufenen Funktion (keine globale Variable theme); Test führt es aus und vergleicht mit initialTheme(). - CARD_W/CARD_H nur noch aus card/geometry.ts (Startseite, Detailseite, Raster), Vorschauhöhe des Rasters daraus abgeleitet; reportsLabel nur aus pages/format.ts. Ein Test sorgt dafür, dass es dabei bleibt. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f6b9917b07
commit
a3523d3316
11 files changed
+124
-30
No files matched your search
+11
-8
@@ -11,16 +11,19 @@
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
|
||||
<script>
|
||||
// Hell/Dunkel vor dem ersten Paint setzen (kein Aufblitzen); dieselbe Regel wie initialTheme() in src/shell/theme.ts.
|
||||
try {
|
||||
var theme = null;
|
||||
// In einer sofort aufgerufenen Funktion, damit keine globale Variable `theme` entsteht.
|
||||
(function () {
|
||||
try {
|
||||
theme = localStorage.getItem('aircard.theme');
|
||||
var theme = null;
|
||||
try {
|
||||
theme = localStorage.getItem('aircard.theme');
|
||||
} catch (e) {}
|
||||
if (theme !== 'light' && theme !== 'dark') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
} catch (e) {}
|
||||
if (theme !== 'light' && theme !== 'dark') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import indexHtml from '../index.html?raw';
|
||||
import { initialTheme } from './shell/theme';
|
||||
|
||||
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../public', name));
|
||||
|
||||
@@ -47,3 +49,54 @@ describe('Icon-Dateien', () => {
|
||||
expect(pngInfo(publicFile('apple-touch-icon.png'))).toEqual({ width: 180, height: 180, colorType: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inline-Skript für Hell/Dunkel in index.html', () => {
|
||||
const script = /<script>([\s\S]*?)<\/script>/.exec(indexHtml)?.[1] ?? '';
|
||||
|
||||
/** Führt das Skript in einer leeren Umgebung aus (kein Browser) und liefert, was es gesetzt hat. */
|
||||
function run(stored: string | null, prefersDark: boolean, storageThrows = false) {
|
||||
const attributes: Record<string, string> = {};
|
||||
const sandbox: Record<string, unknown> = {
|
||||
localStorage: {
|
||||
getItem: () => {
|
||||
if (storageThrows) throw new Error('gesperrt');
|
||||
return stored;
|
||||
},
|
||||
},
|
||||
window: { matchMedia: () => ({ matches: prefersDark }) },
|
||||
document: { documentElement: { setAttribute: (name: string, value: string) => (attributes[name] = value) } },
|
||||
};
|
||||
runInNewContext(script, sandbox);
|
||||
return { attributes, sandbox };
|
||||
}
|
||||
|
||||
it('ist da und läuft ohne Browser-Umgebung durch', () => {
|
||||
expect(script).toContain('data-theme');
|
||||
expect(() => run(null, true)).not.toThrow();
|
||||
});
|
||||
|
||||
it('legt keine globale Variable an (Skript steckt in einer sofort aufgerufenen Funktion)', () => {
|
||||
const { sandbox } = run('dark', false);
|
||||
expect(Object.keys(sandbox).sort()).toEqual(['document', 'localStorage', 'window']);
|
||||
expect('theme' in sandbox).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['dark', false],
|
||||
['light', true],
|
||||
['dark', true],
|
||||
['light', false],
|
||||
['blau', true],
|
||||
['blau', false],
|
||||
['', true],
|
||||
[null, true],
|
||||
[null, false],
|
||||
] as const)('gleiche Regel wie initialTheme(): gespeichert %j, Systemvorgabe dunkel %s', (stored, prefersDark) => {
|
||||
expect(run(stored, prefersDark).attributes['data-theme']).toBe(initialTheme(stored, prefersDark));
|
||||
});
|
||||
|
||||
it('gesperrter localStorage: es gilt die Systemvorgabe', () => {
|
||||
expect(run(null, true, true).attributes['data-theme']).toBe('dark');
|
||||
expect(run(null, false, true).attributes['data-theme']).toBe('light');
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,9 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
|
||||
expect(load.mock.calls.map((c) => (c as unknown as [GalleryCard])[0].id)).toEqual(['g1', 'g2']);
|
||||
const srcs = [...host.querySelectorAll<HTMLImageElement>('.gallery-thumb img')].map((i) => i.getAttribute('src'));
|
||||
expect(srcs).toEqual(['blob:t-1', 'blob:t-2']);
|
||||
// Vorschau 480 breit, Höhe nach 1536 : 969 (wie exportCard sie rendert)
|
||||
const first = host.querySelector('.gallery-thumb img');
|
||||
expect([first?.getAttribute('width'), first?.getAttribute('height')]).toEqual(['480', '303']);
|
||||
});
|
||||
|
||||
it('gibt alle Object-URLs beim Aufräumen frei', async () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags.
|
||||
// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket, mit Pixelgrenze geprüft: loadGalleryImageChecked)
|
||||
// als fertige Object-URL; `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML.
|
||||
import { CARD_H, CARD_W, PREVIEW_WIDTH } from '../card/geometry';
|
||||
import { el } from '../editor/dom';
|
||||
import { reportsLabel } from '../pages/format';
|
||||
import type { CheckedImage, GalleryCard } from './gallery';
|
||||
|
||||
export interface GalleryGridOptions {
|
||||
@@ -22,13 +24,8 @@ export interface GalleryGrid {
|
||||
|
||||
const PARALLEL = 6;
|
||||
const MAX_TAGS = 3;
|
||||
const THUMB_W = 480;
|
||||
const THUMB_H = 303; // 480 x 1536:969
|
||||
|
||||
/** „1 Meldung“, „3 Meldungen“. */
|
||||
function reportsLabel(count: number): string {
|
||||
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
|
||||
}
|
||||
const THUMB_W = PREVIEW_WIDTH;
|
||||
const THUMB_H = Math.round((THUMB_W * CARD_H) / CARD_W); // wie exportCard: 480 x 1536:969 = 303
|
||||
|
||||
export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOptions): GalleryGrid {
|
||||
const list = el('ul', { class: 'gallery-grid' });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen.
|
||||
// Scheitert danach irgendetwas, löscht der Client genau die zwei Dateien dieses Versuchs wieder.
|
||||
// Aufgeräumt wird nur bei eindeutigem Scheitern (Upload-Fehler, eindeutige Ablehnung der API): dann löscht der Client genau
|
||||
// die zwei Dateien dieses Versuchs wieder. Bleibt der Ausgang offen, bleiben sie liegen (siehe publishSavedCard).
|
||||
import { ID, Permission, Role } from 'appwrite';
|
||||
import { IDS, storage } from '../account/appwrite';
|
||||
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
|
||||
|
||||
@@ -205,7 +205,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
onStatus: onSaveStatus,
|
||||
});
|
||||
|
||||
// Karte öffnen (/?karte=<id>): nur die zuletzt angeforderte zählt.
|
||||
// Karte öffnen (/gestalten?karte=<id>): nur die zuletzt angeforderte zählt.
|
||||
let loadToken = 0;
|
||||
function cancelCardLoad(): void {
|
||||
loadToken += 1;
|
||||
|
||||
@@ -16,10 +16,11 @@ import {
|
||||
type GalleryReport,
|
||||
} from '../community/gallery';
|
||||
import { REPORT_REASONS, reportDialog } from '../community/report-dialog';
|
||||
import { CARD_H, CARD_W } from '../card/geometry';
|
||||
import { confirmDialog, type ConfirmOptions } from '../editor/confirm-dialog';
|
||||
import { el } from '../editor/dom';
|
||||
import { downloadFileName, saveBlobAs } from '../editor/download';
|
||||
import { formatDate } from './format';
|
||||
import { formatDate, reportsLabel } from './format';
|
||||
import { navigate, titleFor } from './router';
|
||||
|
||||
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
|
||||
@@ -56,9 +57,6 @@ export const defaultDeps: CommunityDeps = {
|
||||
withLogin: (fn) => withLogin(fn),
|
||||
};
|
||||
|
||||
const CARD_W = 1536;
|
||||
const CARD_H = 969;
|
||||
|
||||
const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.';
|
||||
const LOADING = 'Karte wird geladen …';
|
||||
const LOAD_ERROR = 'Die Karte konnte gerade nicht geladen werden.';
|
||||
@@ -76,11 +74,6 @@ interface ReportsState {
|
||||
list: GalleryReport[];
|
||||
}
|
||||
|
||||
/** „1 Meldung“, „3 Meldungen“. */
|
||||
function reportsLabel(count: number): string {
|
||||
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
|
||||
}
|
||||
|
||||
/** Der Text für den Nutzer: Servertexte (deutsch) unverändert, alles andere über errorMessage. */
|
||||
function failureText(err: unknown): string {
|
||||
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatDate } from './format';
|
||||
import { formatDate, reportsLabel } from './format';
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('schreibt dd.mm.yyyy', () => {
|
||||
@@ -11,3 +11,12 @@ describe('formatDate', () => {
|
||||
expect(formatDate('kaputt')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportsLabel', () => {
|
||||
it('„1 Meldung“, sonst „n Meldungen“', () => {
|
||||
expect(reportsLabel(1)).toBe('1 Meldung');
|
||||
expect(reportsLabel(0)).toBe('0 Meldungen');
|
||||
expect(reportsLabel(2)).toBe('2 Meldungen');
|
||||
expect(reportsLabel(12)).toBe('12 Meldungen');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Gemeinsame Formatierung der Seiten (Meine Karten, Community-Detailseite).
|
||||
// Gemeinsame Formatierung der Seiten (Meine Karten, Community-Detailseite, Galerie-Raster).
|
||||
|
||||
/** `dd.mm.yyyy` in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */
|
||||
export function formatDate(iso: string): string {
|
||||
@@ -7,3 +7,8 @@ export function formatDate(iso: string): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** „1 Meldung“, „3 Meldungen“. */
|
||||
export function reportsLabel(count: number): string {
|
||||
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type GalleryFilter,
|
||||
type GalleryOptions,
|
||||
} from '../community/gallery';
|
||||
import { CARD_H, CARD_W } from '../card/geometry';
|
||||
import { el } from '../editor/dom';
|
||||
import { navigate, scrollToHash } from './router';
|
||||
|
||||
@@ -38,8 +39,6 @@ export const defaultDeps: HomeDeps = {
|
||||
|
||||
const EXAMPLE_IMAGE = '/hero-beispiel.png';
|
||||
const EXAMPLE_ALT = 'Beispielmotiv: violetter Verlauf mit Wellen und dem AirCard-IOS-Symbol';
|
||||
const CARD_W = 1536;
|
||||
const CARD_H = 969;
|
||||
|
||||
const LOADING = 'Karten werden geladen …';
|
||||
const LOAD_ERROR = 'Die Community-Karten konnten gerade nicht geladen werden.';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** Alle Quelldateien (ohne Tests) unter src/ als [Pfad relativ zu src, Text]. */
|
||||
function sources(dir = import.meta.dirname): Array<[string, string]> {
|
||||
const out: Array<[string, string]> = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) out.push(...sources(path));
|
||||
else if (/\.ts$/.test(name) && !/\.test\.ts$/.test(name)) out.push([relative(import.meta.dirname, path), readFileSync(path, 'utf8')]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const definedIn = (pattern: RegExp) =>
|
||||
sources()
|
||||
.filter(([, text]) => pattern.test(text))
|
||||
.map(([path]) => path)
|
||||
.sort();
|
||||
|
||||
describe('Einzelne Quellen', () => {
|
||||
it('CARD_W und CARD_H werden nur in card/geometry.ts definiert', () => {
|
||||
expect(definedIn(/\b(?:const|let|var)\s+CARD_W\b/)).toEqual(['card/geometry.ts']);
|
||||
expect(definedIn(/\b(?:const|let|var)\s+CARD_H\b/)).toEqual(['card/geometry.ts']);
|
||||
});
|
||||
|
||||
it('reportsLabel wird nur in pages/format.ts definiert', () => {
|
||||
expect(definedIn(/\bfunction\s+reportsLabel\b/)).toEqual(['pages/format.ts']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user