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 -6
View File
@@ -147,10 +147,10 @@ function NavLinks({
);
}
function Wordmark() {
function Wordmark({ logoSrc }: { logoSrc: string | null }) {
return (
<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>
</Link>
);
@@ -175,6 +175,8 @@ function PaletteTrigger({ onClick, label, shortcut }: { onClick: () => void; lab
interface AdminShellProps {
adminName: string;
/** Logo fuer dunkle Flaechen, siehe lib/brand.ts. */
logoSrc?: string | null;
badges?: AdminBadges;
logoutAction: () => void | Promise<void>;
children: ReactNode;
@@ -188,7 +190,7 @@ interface AdminShellProps {
* Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus
* (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 groups = getAdminNavGroups(t);
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">
<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">
<Wordmark />
<Wordmark logoSrc={logoSrc} />
</div>
<div className="pt-4">
<PaletteTrigger
@@ -236,7 +238,7 @@ export function AdminShell({ adminName, badges = {}, logoutAction, children }: A
<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">
<Wordmark />
<Wordmark logoSrc={logoSrc} />
<div className="flex items-center gap-1">
<button
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"
>
<div className="flex items-center justify-between h-16 px-5 border-b border-cream/10 flex-shrink-0">
<Wordmark />
<Wordmark logoSrc={logoSrc} />
<button
aria-label={t.adminShell.closeMenu}
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
+20
View File
@@ -7,10 +7,30 @@
export function Logomark({
className = "h-8 w-8",
variant = "default",
src,
}: {
className?: string;
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 treeColor = variant === "inverted" ? "#F4EFE5" : "#24352B";
+14 -5
View File
@@ -75,10 +75,11 @@ function useEscapeKey(active: boolean, onEscape: () => void) {
}, [active, onEscape]);
}
function Logo({ t, companyName }: { t: Dictionary; companyName: string }) {
function Logo({ t, companyName, logoSrc }: { t: Dictionary; companyName: string; logoSrc: string | null }) {
return (
<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="text-2xl tracking-[0.2em] text-forest font-serif">
{companyName}
@@ -375,7 +376,15 @@ function isActivePath(pathname: string, href: string): boolean {
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 pathname = usePathname();
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.
*/}
<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">
{mainNav.map((item) => {
@@ -478,7 +487,7 @@ export function Header({ companyName }: { companyName: string }) {
>
<Container className="flex items-center justify-between h-20">
<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">
{companyName}
</span>
+4 -1
View File
@@ -89,9 +89,12 @@ function SocialLink({
export async function Footer({
companyInfo,
socialLinks,
logoSrc = null,
}: {
companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } };
socialLinks: SocialLinks;
/** Logo fuer dunkle Flaechen, siehe lib/brand.ts. */
logoSrc?: string | null;
}) {
const t = getDictionary(await getLocale());
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="col-span-2">
<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">
{companyInfo.name}
</span>