From 39181be3fb5bca4922192612c8c2407d44dd9e93 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 18 Jul 2026 00:46:41 +0200 Subject: [PATCH] SITE_TITLE per Docker-Variable, Favicon im Admin-Bereich austauschbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SITE_TITLE (Default "Canteen Menu") steuert Browser-Tab-Titel und H1-Fallback ohne Logo - Favicon-Upload analog zum Logo über den Design-Bereich (Storage- Bucket branding); generateMetadata setzt icons.icon jetzt immer explizit, da Next.js sonst app/favicon.ico zusätzlich einbindet – die Datei liegt daher als expliziter Fallback unter public/ --- .env.example | 3 ++ app/layout.tsx | 25 ++++++--- components/DesignEditor.tsx | 101 ++++++++++++++++++++++++++++-------- docker-compose.yml | 3 ++ {app => public}/favicon.ico | Bin 5 files changed, 105 insertions(+), 27 deletions(-) rename {app => public}/favicon.ico (100%) diff --git a/.env.example b/.env.example index 14c6e49..4a2fa4f 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,6 @@ SUPABASE_SERVICE_ROLE_KEY= # SUPABASE_ACCESS_TOKEN= node scripts/configure-passkeys.mjs # ausführen, damit Passkeys auf der neuen Domain funktionieren. APP_URL=http://localhost:3000 + +# Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist. +SITE_TITLE=Canteen Menu diff --git a/app/layout.tsx b/app/layout.tsx index cc90969..968a65d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,10 +2,10 @@ import type { Metadata } from "next"; import { Geist } from "next/font/google"; import "./globals.css"; import { createClient } from "@/lib/supabase/server"; -import { COLOR_VARS, DEFAULT_COLORS } from "@/lib/types"; +import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types"; import packageJson from "@/package.json"; -// Farben/Logo sollen Änderungen sofort widerspiegeln. +// Farben/Logo/Favicon sollen Änderungen sofort widerspiegeln. export const dynamic = "force-dynamic"; const geistSans = Geist({ @@ -13,10 +13,23 @@ const geistSans = Geist({ subsets: ["latin"], }); -export const metadata: Metadata = { - title: "Canteen Menu", - description: "Speisekarte der Betriebskantine", -}; +export async function generateMetadata(): Promise { + const supabase = await createClient(); + const { data: settings } = await supabase + .from("settings") + .select("favicon_path") + .eq("id", 1) + .maybeSingle(); + + return { + title: SITE_TITLE, + description: "Speisekarte der Betriebskantine", + // Immer explizit setzen: app/favicon.ico würde sonst zusätzlich zu + // einem eigenen Favicon eingebunden (Next.js kombiniert Datei- + // Konvention und Metadata statt sie zu ersetzen). + icons: { icon: faviconUrl(settings?.favicon_path) ?? "/favicon.ico" }, + }; +} export default async function RootLayout({ children, diff --git a/components/DesignEditor.tsx b/components/DesignEditor.tsx index b72e0f3..3afdf7b 100644 --- a/components/DesignEditor.tsx +++ b/components/DesignEditor.tsx @@ -3,13 +3,21 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { createClient } from "@/lib/supabase/client"; -import { COLOR_LABELS, DEFAULT_COLORS, logoUrl } from "@/lib/types"; +import { COLOR_LABELS, DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types"; + +type AssetKind = "logo" | "favicon"; + +const ASSET_COLUMN: Record = { + logo: "logo_path", + favicon: "favicon_path", +}; export default function DesignEditor() { const supabase = useMemo(() => createClient(), []); const router = useRouter(); const [colors, setColors] = useState>(DEFAULT_COLORS); const [logoPath, setLogoPath] = useState(null); + const [faviconPath, setFaviconPath] = useState(null); const [error, setError] = useState(null); const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); @@ -17,7 +25,7 @@ export default function DesignEditor() { const load = useCallback(async () => { const { data, error } = await supabase .from("settings") - .select("colors, logo_path") + .select("colors, logo_path, favicon_path") .eq("id", 1) .maybeSingle(); if (error) { @@ -26,6 +34,7 @@ export default function DesignEditor() { } setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) }); setLogoPath(data?.logo_path ?? null); + setFaviconPath(data?.favicon_path ?? null); }, [supabase]); useEffect(() => { @@ -52,20 +61,21 @@ export default function DesignEditor() { setColors(DEFAULT_COLORS); } - async function uploadLogo(e: React.ChangeEvent) { + async function uploadAsset(kind: AssetKind, e: React.ChangeEvent) { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; if (!/^image\//.test(file.type)) { - setError("Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP)."); + setError("Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP, ICO)."); return; } setBusy(true); setError(null); setInfo(null); + const oldPath = kind === "logo" ? logoPath : faviconPath; const ext = file.name.split(".").pop()?.toLowerCase() ?? "png"; - const path = `logo-${Date.now()}.${ext}`; + const path = `${kind}-${Date.now()}.${ext}`; const { error: uploadError } = await supabase.storage .from("branding") .upload(path, file, { contentType: file.type }); @@ -75,46 +85,53 @@ export default function DesignEditor() { return; } - const oldPath = logoPath; const { error: settingsError } = await supabase .from("settings") - .update({ logo_path: path }) + .update({ [ASSET_COLUMN[kind]]: path }) .eq("id", 1); if (settingsError) { setError(`Speichern fehlgeschlagen: ${settingsError.message}`); setBusy(false); return; } - if (oldPath) { - await supabase.storage.from("branding").remove([oldPath]); - } - setLogoPath(path); - setInfo("Logo aktualisiert."); + if (oldPath) await supabase.storage.from("branding").remove([oldPath]); + + if (kind === "logo") setLogoPath(path); + else setFaviconPath(path); + setInfo(kind === "logo" ? "Logo aktualisiert." : "Favicon aktualisiert."); router.refresh(); setBusy(false); } - async function removeLogo() { - if (!logoPath) return; - if (!window.confirm("Logo wirklich entfernen?")) return; + async function removeAsset(kind: AssetKind) { + const path = kind === "logo" ? logoPath : faviconPath; + if (!path) return; + if ( + !window.confirm( + `${kind === "logo" ? "Logo" : "Favicon"} wirklich entfernen?` + ) + ) + return; setBusy(true); setError(null); const { error } = await supabase .from("settings") - .update({ logo_path: null }) + .update({ [ASSET_COLUMN[kind]]: null }) .eq("id", 1); if (error) { setError(`Speichern fehlgeschlagen: ${error.message}`); } else { - await supabase.storage.from("branding").remove([logoPath]); - setLogoPath(null); - setInfo("Logo entfernt."); + await supabase.storage.from("branding").remove([path]); + if (kind === "logo") setLogoPath(null); + else setFaviconPath(null); + setInfo(kind === "logo" ? "Logo entfernt." : "Favicon entfernt."); router.refresh(); } setBusy(false); } const currentLogo = logoUrl(logoPath); + const currentFavicon = faviconUrl(faviconPath); return (
@@ -149,7 +166,7 @@ export default function DesignEditor() { type="file" accept="image/*" className="hidden" - onChange={uploadLogo} + onChange={(e) => uploadAsset("logo", e)} disabled={busy} /> @@ -157,7 +174,7 @@ export default function DesignEditor() {
+
+

Favicon

+

+ Wird als Icon im Browser-Tab angezeigt. Ohne eigenes Favicon gilt + das Standard-Icon der Anwendung. +

+ {currentFavicon ? ( + // eslint-disable-next-line @next/next/no-img-element + Aktuelles Favicon + ) : ( +

+ Kein eigenes Favicon hinterlegt. +

+ )} +
+ + {currentFavicon && ( + + )} +
+
+

Farbgebung

diff --git a/docker-compose.yml b/docker-compose.yml index a81f3cd..04a512e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,8 @@ services: # Wird für den CSRF-Origin-Check genutzt; dieselbe URL ist die # Passkey-Domain (siehe scripts/configure-passkeys.mjs). APP_URL: ${APP_URL:-http://localhost:3000} + # Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist. + SITE_TITLE: ${SITE_TITLE:-Canteen Menu} ports: - "0.0.0.0:3000:3000" restart: unless-stopped @@ -68,6 +70,7 @@ services: NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} APP_URL: ${APP_URL:-http://localhost:3000} + SITE_TITLE: ${SITE_TITLE:-Canteen Menu} ports: - "0.0.0.0:3000:3000" volumes: diff --git a/app/favicon.ico b/public/favicon.ico similarity index 100% rename from app/favicon.ico rename to public/favicon.ico