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:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 01:02:18 +02:00
1 parent ae2726b0ee
commit 4f442ec06d
19 files changed
+217 -77

No files matched your search

+8
View File
@@ -72,6 +72,14 @@ const nextConfig: NextConfig = {
turbopackFileSystemCacheForBuild: false, turbopackFileSystemCacheForBuild: false,
}, },
// Das Favicon ist im Admin austauschbar und wird von app/icon.tsx erzeugt;
// eine statische app/favicon.ico gibt es deshalb nicht mehr. Browser und
// Crawler, die ohne <link rel="icon"> direkt /favicon.ico abfragen,
// bekommen dasselbe Bild.
async rewrites() {
return [{ source: "/favicon.ico", destination: "/icon" }];
},
async headers() { async headers() {
// Content-Security-Policy wird bewusst NICHT hier gesetzt: sie braucht // Content-Security-Policy wird bewusst NICHT hier gesetzt: sie braucht
// eine pro Request neue Nonce (siehe proxy.ts), was ein statischer // eine pro Request neue Nonce (siehe proxy.ts), was ein statischer
+1
View File
@@ -204,6 +204,7 @@ export default async function AdminInhaltePage() {
const current = await loadCurrentValues(); const current = await loadCurrentValues();
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [ const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
{ key: "brand", heading: t.adminContentPage.groupBrand },
{ key: "company", heading: t.adminContentPage.groupCompany }, { key: "company", heading: t.adminContentPage.groupCompany },
{ key: "shipping", heading: t.adminContentPage.groupShipping }, { key: "shipping", heading: t.adminContentPage.groupShipping },
{ key: "home", heading: t.adminContentPage.groupHome }, { key: "home", heading: t.adminContentPage.groupHome },
+3
View File
@@ -7,6 +7,7 @@ import { logoutAction } from "@/app/konto/actions";
import { AdminShell } from "@/components/admin-shell"; import { AdminShell } from "@/components/admin-shell";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { getBrandAssets } from "@/lib/brand";
// Admin-Daten (Bestellungen, Bestand) muessen immer aktuell sein; zudem // Admin-Daten (Bestellungen, Bestand) muessen immer aktuell sein; zudem
// entkoppelt das den Docker-Build-Schritt von einer DB-Verbindung. // entkoppelt das den Docker-Build-Schritt von einer DB-Verbindung.
@@ -47,6 +48,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
// Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder // Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder
// abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren, // abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren,
// unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen. // unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen.
const brand = await getBrandAssets();
const [openOrders, openReturns, pendingReviews, newInquiries] = await Promise.all([ const [openOrders, openReturns, pendingReviews, newInquiries] = await Promise.all([
prisma.order.count({ prisma.order.count({
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null }, where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
@@ -59,6 +61,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
return ( return (
<AdminShell <AdminShell
adminName={`${admin.firstName} ${admin.lastName}`} adminName={`${admin.firstName} ${admin.lastName}`}
logoSrc={brand.logoInverted}
badges={{ badges={{
"/admin/bestellungen": openOrders, "/admin/bestellungen": openOrders,
"/admin/retouren": openReturns, "/admin/retouren": openReturns,
+15 -33
View File
@@ -1,41 +1,23 @@
import { ImageResponse } from "next/og"; import { getBrandAssets } from "@/lib/brand";
import { renderBrandIcon } from "@/lib/brand-icon";
export const size = { width: 180, height: 180 }; export const size = { width: 180, height: 180 };
export const contentType = "image/png"; export const contentType = "image/png";
// Wie app/icon.tsx: im Admin austauschbar, deshalb nicht zur Build-Zeit festschreiben.
export const dynamic = "force-dynamic";
/** /**
* Apple-Touch-Icon fuer "Zum Home-Bildschirm hinzufuegen" auf iOS/iPadOS — * Apple-Touch-Icon fuer "Zum Home-Bildschirm hinzufuegen" auf iOS/iPadOS —
* Apple akzeptiert dafuer ausschliesslich PNG/JPG, kein SVG (anders als * Apple akzeptiert dafuer ausschliesslich PNG/JPG, kein SVG. Ohne diese
* app/icon.svg, das moderne Chrome/Android direkt lesen, siehe * Datei nimmt iOS ersatzweise einen Screenshot der Seite als Icon. Gleiche
* app/manifest.ts). Ohne diese Datei nimmt iOS ersatzweise einen * Quelle wie das Favicon, aber auf Creme-Grund: iOS stellt transparente
* Screenshot der Seite als Icon — unbrandet und meist unbrauchbar. Gleiche * Flaechen schwarz dar.
* Grafik wie app/icon.svg, per ImageResponse als Raster gerendert.
*/ */
export default function AppleIcon() { export default async function AppleIcon(): Promise<Response> {
return new ImageResponse( const { favicon } = await getBrandAssets();
( const png = await renderBrandIcon(favicon, size.width, "#FAF8F3");
<svg width={180} height={180} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"> return new Response(new Uint8Array(png), {
<rect width={100} height={100} rx={20} fill="#24352B" /> headers: { "Content-Type": contentType, "Cache-Control": "no-cache" },
<polygon points="50,12 82,30 82,68 50,86 18,68 18,30" fill="none" stroke="#B88A44" strokeWidth={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>
),
{ ...size }
);
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-21
View File
@@ -1,21 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 844 B

+24
View File
@@ -0,0 +1,24 @@
import { getBrandAssets } from "@/lib/brand";
import { renderBrandIcon } from "@/lib/brand-icon";
export const size = { width: 512, height: 512 };
export const contentType = "image/png";
// Das Favicon ist im Admin austauschbar (Inhalte > Logo und Favicon) — nicht
// zur Build-Zeit einfrieren. "no-cache" statt langem Caching: Browser halten
// Favicons sonst tagelang fest, ein neues Bild waere nicht zu sehen.
export const dynamic = "force-dynamic";
/**
* Favicon und Manifest-Icon. 512 px, weil dieselbe Datei auch als Android-
* Startbildschirm-Icon dient (app/manifest.ts); Browser skalieren fuer den
* Tab selbst herunter. /favicon.ico wird in next.config.ts hierher
* umgeleitet.
*/
export default async function Icon(): Promise<Response> {
const { favicon } = await getBrandAssets();
const png = await renderBrandIcon(favicon, size.width);
return new Response(new Uint8Array(png), {
headers: { "Content-Type": contentType, "Cache-Control": "no-cache" },
});
}
+9 -3
View File
@@ -14,6 +14,7 @@ import { getSocialLinks } from "@/lib/social-links";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { I18nProvider } from "@/i18n/i18n-provider"; import { I18nProvider } from "@/i18n/i18n-provider";
import { getBrandAssets } from "@/lib/brand";
const cormorant = Cormorant_Garamond({ const cormorant = Cormorant_Garamond({
variable: "--font-cormorant", variable: "--font-cormorant",
@@ -47,7 +48,12 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function RootLayout({ children }: LayoutProps<"/">) { export default async function RootLayout({ children }: LayoutProps<"/">) {
const locale = await getLocale(); const locale = await getLocale();
const dict = getDictionary(locale); const dict = getDictionary(locale);
const [company, socialLinks, flash] = await Promise.all([getCompanyInfo(), getSocialLinks(), readFlash()]); const [company, socialLinks, flash, brand] = await Promise.all([
getCompanyInfo(),
getSocialLinks(),
readFlash(),
getBrandAssets(),
]);
const base = getSiteUrl(); const base = getSiteUrl();
// LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt // LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt
@@ -131,13 +137,13 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
{dict.nav.skipToContent} {dict.nav.skipToContent}
</a> </a>
<HideOnAdmin> <HideOnAdmin>
<Header companyName={company.name} /> <Header companyName={company.name} logoSrc={brand.logo} logoInvertedSrc={brand.logoInverted} />
</HideOnAdmin> </HideOnAdmin>
<main id="main-content" className="flex-1"> <main id="main-content" className="flex-1">
{children} {children}
</main> </main>
<HideOnAdmin> <HideOnAdmin>
<Footer companyInfo={company} socialLinks={socialLinks} /> <Footer companyInfo={company} socialLinks={socialLinks} logoSrc={brand.logoInverted} />
</HideOnAdmin> </HideOnAdmin>
<CartDrawer /> <CartDrawer />
<CookieConsentBanner /> <CookieConsentBanner />
+6 -7
View File
@@ -4,10 +4,9 @@ import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
/** /**
* Web-App-Manifest fuer "Zum Startbildschirm hinzufuegen" auf Mobilgeraeten — * Web-App-Manifest fuer "Zum Startbildschirm hinzufuegen" auf Mobilgeraeten.
* nutzt das bestehende icon.svg direkt statt zusaetzlich generierter * Icon ist das im Admin austauschbare Favicon (app/icon.tsx, 512 px PNG).
* Raster-Varianten (Chrome/Android unterstuetzen SVG-Manifest-Icons mit * Sprachabhaengig wie der Rest der Seite.
* sizes:"any" nativ, siehe MDN). Sprachabhaengig wie der Rest der Seite.
*/ */
export default async function manifest(): Promise<MetadataRoute.Manifest> { export default async function manifest(): Promise<MetadataRoute.Manifest> {
const locale = await getLocale(); const locale = await getLocale();
@@ -24,9 +23,9 @@ export default async function manifest(): Promise<MetadataRoute.Manifest> {
theme_color: "#24352B", theme_color: "#24352B",
icons: [ icons: [
{ {
src: "/icon.svg", src: "/icon",
sizes: "any", sizes: "512x512",
type: "image/svg+xml", type: "image/png",
purpose: "any", purpose: "any",
}, },
], ],
+8 -6
View File
@@ -147,10 +147,10 @@ function NavLinks({
); );
} }
function Wordmark() { function Wordmark({ logoSrc }: { logoSrc: string | null }) {
return ( return (
<Link href="/admin" className="flex items-center gap-2"> <Link href="/admin" className="flex items-center gap-2">
<Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" /> <Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" src={logoSrc} />
<span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span> <span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span>
</Link> </Link>
); );
@@ -175,6 +175,8 @@ function PaletteTrigger({ onClick, label, shortcut }: { onClick: () => void; lab
interface AdminShellProps { interface AdminShellProps {
adminName: string; adminName: string;
/** Logo fuer dunkle Flaechen, siehe lib/brand.ts. */
logoSrc?: string | null;
badges?: AdminBadges; badges?: AdminBadges;
logoutAction: () => void | Promise<void>; logoutAction: () => void | Promise<void>;
children: ReactNode; children: ReactNode;
@@ -188,7 +190,7 @@ interface AdminShellProps {
* Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus * Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus
* (components/hide-on-admin.tsx). * (components/hide-on-admin.tsx).
*/ */
export function AdminShell({ adminName, badges = {}, logoutAction, children }: AdminShellProps) { export function AdminShell({ adminName, logoSrc = null, badges = {}, logoutAction, children }: AdminShellProps) {
const { t } = useI18n(); const { t } = useI18n();
const groups = getAdminNavGroups(t); const groups = getAdminNavGroups(t);
const pathname = usePathname(); const pathname = usePathname();
@@ -219,7 +221,7 @@ export function AdminShell({ adminName, badges = {}, logoutAction, children }: A
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-64 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream"> <aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-64 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" /> <HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" />
<div className="flex items-center h-16 px-5 border-b border-cream/10"> <div className="flex items-center h-16 px-5 border-b border-cream/10">
<Wordmark /> <Wordmark logoSrc={logoSrc} />
</div> </div>
<div className="pt-4"> <div className="pt-4">
<PaletteTrigger <PaletteTrigger
@@ -236,7 +238,7 @@ export function AdminShell({ adminName, badges = {}, logoutAction, children }: A
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5"> <header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5">
<Wordmark /> <Wordmark logoSrc={logoSrc} />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
aria-label={t.adminShell.paletteOpen} aria-label={t.adminShell.paletteOpen}
@@ -274,7 +276,7 @@ export function AdminShell({ adminName, badges = {}, logoutAction, children }: A
className="lg:hidden fixed inset-0 z-50 bg-forest text-cream flex flex-col" className="lg:hidden fixed inset-0 z-50 bg-forest text-cream flex flex-col"
> >
<div className="flex items-center justify-between h-16 px-5 border-b border-cream/10 flex-shrink-0"> <div className="flex items-center justify-between h-16 px-5 border-b border-cream/10 flex-shrink-0">
<Wordmark /> <Wordmark logoSrc={logoSrc} />
<button <button
aria-label={t.adminShell.closeMenu} aria-label={t.adminShell.closeMenu}
className="w-10 h-10 flex items-center justify-center flex-shrink-0" className="w-10 h-10 flex items-center justify-center flex-shrink-0"
+20
View File
@@ -7,10 +7,30 @@
export function Logomark({ export function Logomark({
className = "h-8 w-8", className = "h-8 w-8",
variant = "default", variant = "default",
src,
}: { }: {
className?: string; className?: string;
variant?: "default" | "inverted"; variant?: "default" | "inverted";
/** Im Admin hochgeladenes Logo (lib/brand.ts); ersetzt das gezeichnete Zeichen. */
src?: string | null;
}) { }) {
if (src) {
// Rohes <img> statt next/image: das Seitenverhaeltnis eines hochgeladenen
// Logos ist unbekannt (quadratisch bis breite Wortmarke). Die Hoehe kommt
// aus className, die Breite folgt dem Bild — begrenzt, damit eine sehr
// breite Datei den Kopf nicht sprengt. alt="" weil der Firmenname direkt
// daneben als Text steht.
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt=""
className={`${className} ${className.includes("max-w-") ? "" : "max-w-40"} object-contain`}
style={{ width: "auto" }}
/>
);
}
const hexColor = variant === "inverted" ? "#F4EFE5" : "#B88A44"; const hexColor = variant === "inverted" ? "#F4EFE5" : "#B88A44";
const treeColor = variant === "inverted" ? "#F4EFE5" : "#24352B"; const treeColor = variant === "inverted" ? "#F4EFE5" : "#24352B";
+14 -5
View File
@@ -75,10 +75,11 @@ function useEscapeKey(active: boolean, onEscape: () => void) {
}, [active, onEscape]); }, [active, onEscape]);
} }
function Logo({ t, companyName }: { t: Dictionary; companyName: string }) { function Logo({ t, companyName, logoSrc }: { t: Dictionary; companyName: string; logoSrc: string | null }) {
return ( return (
<Link href="/" className="flex items-center gap-3 group"> <Link href="/" className="flex items-center gap-3 group">
<Logomark className="h-9 w-9 flex-shrink-0" /> {/* Kopfzeile ist bei 1280-1440 px knapp: breite Logos hier enger begrenzt. */}
<Logomark className="h-9 w-9 max-w-14 flex-shrink-0 sm:max-w-28" src={logoSrc} />
<span className="flex flex-col leading-none"> <span className="flex flex-col leading-none">
<span className="text-2xl tracking-[0.2em] text-forest font-serif"> <span className="text-2xl tracking-[0.2em] text-forest font-serif">
{companyName} {companyName}
@@ -375,7 +376,15 @@ function isActivePath(pathname: string, href: string): boolean {
return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`); return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
} }
export function Header({ companyName }: { companyName: string }) { export function Header({
companyName,
logoSrc = null,
logoInvertedSrc = null,
}: {
companyName: string;
logoSrc?: string | null;
logoInvertedSrc?: string | null;
}) {
const { t } = useI18n(); const { t } = useI18n();
const pathname = usePathname(); const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
@@ -410,7 +419,7 @@ export function Header({ companyName }: { companyName: string }) {
Navigation und Button ab 1280px Breite ohne jeden Abstand aneinander. Navigation und Button ab 1280px Breite ohne jeden Abstand aneinander.
*/} */}
<div className="mx-auto flex h-20 w-full max-w-[100rem] items-center justify-between px-6 2xl:px-10"> <div className="mx-auto flex h-20 w-full max-w-[100rem] items-center justify-between px-6 2xl:px-10">
<Logo t={t} companyName={companyName} /> <Logo t={t} companyName={companyName} logoSrc={logoSrc} />
<nav aria-label="Hauptnavigation" className="hidden xl:flex items-center gap-3 2xl:gap-6"> <nav aria-label="Hauptnavigation" className="hidden xl:flex items-center gap-3 2xl:gap-6">
{mainNav.map((item) => { {mainNav.map((item) => {
@@ -478,7 +487,7 @@ export function Header({ companyName }: { companyName: string }) {
> >
<Container className="flex items-center justify-between h-20"> <Container className="flex items-center justify-between h-20">
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
<Logomark className="h-8 w-8" variant="inverted" /> <Logomark className="h-8 w-8" variant="inverted" src={logoInvertedSrc} />
<span className="text-xl tracking-[0.2em] font-serif"> <span className="text-xl tracking-[0.2em] font-serif">
{companyName} {companyName}
</span> </span>
+4 -1
View File
@@ -89,9 +89,12 @@ function SocialLink({
export async function Footer({ export async function Footer({
companyInfo, companyInfo,
socialLinks, socialLinks,
logoSrc = null,
}: { }: {
companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } }; companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } };
socialLinks: SocialLinks; socialLinks: SocialLinks;
/** Logo fuer dunkle Flaechen, siehe lib/brand.ts. */
logoSrc?: string | null;
}) { }) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const hasAnySocialLink = socialLinks.tiktok || socialLinks.youtube || socialLinks.instagram; const hasAnySocialLink = socialLinks.tiktok || socialLinks.youtube || socialLinks.instagram;
@@ -123,7 +126,7 @@ export async function Footer({
<div className="grid grid-cols-2 md:grid-cols-5 gap-10"> <div className="grid grid-cols-2 md:grid-cols-5 gap-10">
<div className="col-span-2"> <div className="col-span-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Logomark className="h-9 w-9" variant="inverted" /> <Logomark className="h-9 w-9" variant="inverted" src={logoSrc} />
<span className="text-2xl tracking-[0.2em] font-serif"> <span className="text-2xl tracking-[0.2em] font-serif">
{companyInfo.name} {companyInfo.name}
</span> </span>
+1
View File
@@ -1281,6 +1281,7 @@ export const de: Dictionary = {
sectionImagesIntro: "Alle Bilder und das Hero-Video, die du hochladen kannst, sortiert nach Seite.", sectionImagesIntro: "Alle Bilder und das Hero-Video, die du hochladen kannst, sortiert nach Seite.",
sectionText: "Texte", sectionText: "Texte",
sectionTextIntro: "Alle Textinhalte, sortiert nach Bereich.", sectionTextIntro: "Alle Textinhalte, sortiert nach Bereich.",
groupBrand: "Logo und Favicon",
groupCompany: "Firmendaten", groupCompany: "Firmendaten",
groupShipping: "Versand", groupShipping: "Versand",
groupHome: "Homepage", groupHome: "Homepage",
+1
View File
@@ -1267,6 +1267,7 @@ export const en: Dictionary = {
sectionImagesIntro: "Every image and the hero video you can upload, grouped by page.", sectionImagesIntro: "Every image and the hero video you can upload, grouped by page.",
sectionText: "Text", sectionText: "Text",
sectionTextIntro: "Every text field, grouped by section.", sectionTextIntro: "Every text field, grouped by section.",
groupBrand: "Logo and favicon",
groupCompany: "Company details", groupCompany: "Company details",
groupShipping: "Shipping", groupShipping: "Shipping",
groupHome: "Homepage", groupHome: "Homepage",
+1
View File
@@ -940,6 +940,7 @@ export interface Dictionary {
sectionImagesIntro: string; sectionImagesIntro: string;
sectionText: string; sectionText: string;
sectionTextIntro: string; sectionTextIntro: string;
groupBrand: string;
groupCompany: string; groupCompany: string;
groupShipping: string; groupShipping: string;
groupHome: string; groupHome: string;
+58
View File
@@ -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();
}
+35
View File
@@ -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"]),
};
}
+9 -1
View File
@@ -18,7 +18,7 @@ export type ContentFieldType =
export interface ContentFieldDef { export interface ContentFieldDef {
key: string; key: string;
group: "company" | "shipping" | "home" | "pages" | "legal" | "social"; group: "brand" | "company" | "shipping" | "home" | "pages" | "legal" | "social";
labelDe: string; labelDe: string;
labelEn: string; labelEn: string;
type: ContentFieldType; type: ContentFieldType;
@@ -54,6 +54,14 @@ function joinSections(sections: LegalSectionContent[]): string {
} }
export const CONTENT_REGISTRY: ContentFieldDef[] = [ 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) --- // --- Firmendaten (nicht lokalisiert) ---
{ key: "company.name", group: "company", labelDe: "Firmenname", labelEn: "Company name", type: "text", localized: false, getDefault: () => siteConfig.name }, { 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 }, { key: "company.claim", group: "company", labelDe: "Slogan", labelEn: "Tagline", type: "text", localized: false, getDefault: () => siteConfig.claim },