feat(admin): Logo und Favicon per Bild-Upload austauschbar
Neue Gruppe "Logo und Favicon" unter Admin > Inhalte mit drei Bildfeldern: Logo (Kopfzeile), optionales Logo fuer dunkle Flaechen (Mobilmenue, Fusszeile, Admin-Leiste; faellt sonst auf das normale Logo zurueck) und Favicon. Leer bleibt alles wie bisher (gezeichnetes Waben-Zeichen bzw. Standard-Icon). - lib/brand.ts liest die Pfade und prueft sie erneut gegen das Upload-Muster, bevor sie in ein src-Attribut gelangen. - Statische app/icon.svg und app/favicon.ico ersetzt durch dynamische app/icon.tsx (512 px PNG) und app/apple-icon.tsx (180 px auf Creme); lib/brand-icon.ts rendert Upload oder Standard-Icon per sharp und liest Dateien nur ueber resolveUploadPath. Cache-Control no-cache, damit ein neues Favicon ohne Tage Verzoegerung erscheint. - /favicon.ico per Rewrite auf /icon, Manifest nutzt /icon. - Hochgeladenes Logo mit fester Hoehe und begrenzter Breite (Kopfzeile mobil 56 px, ab sm 112 px), damit breite Wortmarken nichts sprengen. Uploads bleiben auf Rasterbilder beschraenkt (kein SVG, XSS-Schutz). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ae2726b0ee
commit
4f442ec06d
19 files changed
+217
-77
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import sharp from "sharp";
|
||||
import { isValidUploadPath, resolveUploadPath } from "@/lib/uploads";
|
||||
|
||||
/**
|
||||
* Standard-Icon (Waben-Rahmen mit drei Tannen auf Waldgruen) — frueher als
|
||||
* statische app/icon.svg ausgeliefert. Jetzt Quelle fuer app/icon.tsx und
|
||||
* app/apple-icon.tsx, solange im Admin kein eigenes Favicon hochgeladen ist.
|
||||
*/
|
||||
const DEFAULT_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#24352B" />
|
||||
<polygon points="50,12 82,30 82,68 50,86 18,68 18,30" fill="none" stroke="#B88A44" stroke-width="4" />
|
||||
<g fill="#F4EFE5">
|
||||
<g transform="translate(26,42) scale(0.62)">
|
||||
<polygon points="10,2 5,13 15,13" /><polygon points="10,10 2,22 18,22" /><rect x="8" y="22" width="4" height="4" />
|
||||
</g>
|
||||
<g transform="translate(40,30) scale(0.82)">
|
||||
<polygon points="10,2 5,13 15,13" /><polygon points="10,10 2,22 18,22" /><rect x="8" y="22" width="4" height="4" />
|
||||
</g>
|
||||
<g transform="translate(56,42) scale(0.62)">
|
||||
<polygon points="10,2 5,13 15,13" /><polygon points="10,10 2,22 18,22" /><rect x="8" y="22" width="4" height="4" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
async function readUploadedIcon(faviconPath: string | null): Promise<Buffer | null> {
|
||||
if (!faviconPath || !isValidUploadPath(faviconPath)) return null;
|
||||
// resolveUploadPath haelt den Zugriff innerhalb von UPLOAD_DIR.
|
||||
const file = resolveUploadPath(faviconPath.replace(/^\/uploads\//, "").split("/"));
|
||||
if (!file) return null;
|
||||
try {
|
||||
return await readFile(file);
|
||||
} catch {
|
||||
// Datei fehlt (z.B. Upload-Volume nicht persistent): bewusst kein Fehler,
|
||||
// der Browser bekaeme sonst gar kein Icon — das Standard-Icon springt ein.
|
||||
// Das Dashboard meldet fehlende Upload-Dateien ohnehin gesondert.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quadratisches PNG in `size` Pixeln. Nicht quadratische Uploads werden
|
||||
* eingepasst (nicht beschnitten), der Rand bleibt transparent — ausser
|
||||
* `background` ist gesetzt (Apple-Touch-Icon: iOS zeigt Transparenz schwarz).
|
||||
*/
|
||||
export async function renderBrandIcon(
|
||||
faviconPath: string | null,
|
||||
size: number,
|
||||
background?: string
|
||||
): Promise<Buffer> {
|
||||
const uploaded = await readUploadedIcon(faviconPath);
|
||||
const input = uploaded ?? Buffer.from(DEFAULT_ICON_SVG);
|
||||
const image = sharp(input, { density: 384 }).resize(size, size, {
|
||||
fit: "contain",
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
});
|
||||
return (background ? image.flatten({ background }) : image).png().toBuffer();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getContentValues } from "@/lib/content";
|
||||
import { isValidUploadPath } from "@/lib/uploads";
|
||||
|
||||
export interface BrandAssets {
|
||||
/** Logo fuer helle Flaechen (Kopfzeile); null = gezeichnetes Waben-Zeichen. */
|
||||
logo: string | null;
|
||||
/** Logo fuer dunkle Flaechen (Mobilmenue, Fusszeile, Admin-Leiste); faellt auf `logo` zurueck. */
|
||||
logoInverted: string | null;
|
||||
/** Quadratisches Favicon-Bild; null = Standard-Icon (siehe lib/brand-icon.ts). */
|
||||
favicon: string | null;
|
||||
}
|
||||
|
||||
export const BRAND_CONTENT_KEYS = ["brand.logo", "brand.logoInverted", "brand.favicon"] as const;
|
||||
|
||||
/**
|
||||
* Hochgeladene Marken-Bilder aus den Inhalten (Admin > Inhalte > Logo und
|
||||
* Favicon). Jeder Wert wird hier noch einmal gegen das Upload-Pfadmuster
|
||||
* geprueft, obwohl updateSiteContentAction das beim Speichern schon tut:
|
||||
* der Pfad landet ungefiltert in einem src-Attribut auf jeder Seite, ein
|
||||
* irgendwie in die Datenbank geratener fremder Wert (z.B. eine externe URL)
|
||||
* soll dort nie ankommen.
|
||||
*/
|
||||
export async function getBrandAssets(): Promise<BrandAssets> {
|
||||
// Nicht lokalisierte Felder — die Locale spielt fuer den Wert keine Rolle.
|
||||
const values = await getContentValues([...BRAND_CONTENT_KEYS], "de");
|
||||
const pick = (value: string | undefined): string | null =>
|
||||
value && isValidUploadPath(value) ? value : null;
|
||||
|
||||
const logo = pick(values["brand.logo"]);
|
||||
return {
|
||||
logo,
|
||||
logoInverted: pick(values["brand.logoInverted"]) ?? logo,
|
||||
favicon: pick(values["brand.favicon"]),
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export type ContentFieldType =
|
||||
|
||||
export interface ContentFieldDef {
|
||||
key: string;
|
||||
group: "company" | "shipping" | "home" | "pages" | "legal" | "social";
|
||||
group: "brand" | "company" | "shipping" | "home" | "pages" | "legal" | "social";
|
||||
labelDe: string;
|
||||
labelEn: string;
|
||||
type: ContentFieldType;
|
||||
@@ -54,6 +54,14 @@ function joinSections(sections: LegalSectionContent[]): string {
|
||||
}
|
||||
|
||||
export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
||||
// --- Logo und Favicon (nicht lokalisiert). Leer = das gezeichnete
|
||||
// Waben-Zeichen (components/logomark.tsx) bzw. das Standard-Icon
|
||||
// (lib/brand-icon.ts). Nur Rasterbilder: der Upload nimmt bewusst kein SVG
|
||||
// an (ein SVG kann Skripte enthalten). Gelesen ueber lib/brand.ts.
|
||||
{ key: "brand.logo", group: "brand", labelDe: "Logo (PNG mit transparentem Hintergrund, erscheint neben dem Firmennamen)", labelEn: "Logo (PNG with transparent background, shown next to the company name)", type: "image", localized: false, getDefault: () => "" },
|
||||
{ key: "brand.logoInverted", group: "brand", labelDe: "Logo für dunkle Flächen (optional: Mobilmenü, Fußzeile, Admin-Leiste; leer = normales Logo)", labelEn: "Logo for dark backgrounds (optional: mobile menu, footer, admin sidebar; empty = regular logo)", type: "image", localized: false, getDefault: () => "" },
|
||||
{ key: "brand.favicon", group: "brand", labelDe: "Favicon (quadratisch, mindestens 512 × 512 px; leer = Standard-Wabe)", labelEn: "Favicon (square, at least 512 × 512 px; empty = default honeycomb)", type: "image", localized: false, getDefault: () => "" },
|
||||
|
||||
// --- Firmendaten (nicht lokalisiert) ---
|
||||
{ key: "company.name", group: "company", labelDe: "Firmenname", labelEn: "Company name", type: "text", localized: false, getDefault: () => siteConfig.name },
|
||||
{ key: "company.claim", group: "company", labelDe: "Slogan", labelEn: "Tagline", type: "text", localized: false, getDefault: () => siteConfig.claim },
|
||||
|
||||
Reference in new issue
Block a user