From 25c30409492231313babd21cd98b867fce5f678a Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 5 Jul 2026 23:22:44 +0200 Subject: [PATCH] Add favicon upload option in admin panel --- src/actions/save-favicon.ts | 84 ++++++++++++++++++++ src/app/admin/favicon/favicon-form.tsx | 103 +++++++++++++++++++++++++ src/app/admin/favicon/page.tsx | 34 ++++++++ src/app/admin/layout.tsx | 1 + src/app/layout.tsx | 3 + src/messages/en.json | 16 ++++ src/messages/nl.json | 16 ++++ 7 files changed, 257 insertions(+) create mode 100644 src/actions/save-favicon.ts create mode 100644 src/app/admin/favicon/favicon-form.tsx create mode 100644 src/app/admin/favicon/page.tsx diff --git a/src/actions/save-favicon.ts b/src/actions/save-favicon.ts new file mode 100644 index 00000000..76ce226c --- /dev/null +++ b/src/actions/save-favicon.ts @@ -0,0 +1,84 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { mkdir, writeFile, unlink } from "fs/promises"; +import path from "path"; +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; + +const FAVICON_DIR = "public/assets/images/media/favicon"; +const MAX_SIZE = 2 * 1024 * 1024; // 2MB +const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"]; + +export async function saveFavicon(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> { + try { + const file = formData.get("file") as File | null; + if (!file || file.size === 0) return { success: false, error: "No file provided" }; + if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" }; + if (!ALLOWED.includes(file.type)) return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" }; + + const mimeExt: Record = { + "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", + "image/webp": "webp", "image/x-icon": "ico", "image/svg+xml": "svg", + }; + const ext = mimeExt[file.type] ?? "png"; + const filename = `favicon-${Date.now()}.${ext}`; + const dir = path.join(process.cwd(), FAVICON_DIR); + const filePath = path.join(dir, filename); + + const buffer = Buffer.from(await file.arrayBuffer()); + await mkdir(dir, { recursive: true }); + await writeFile(filePath, buffer); + + const url = `/api/media/favicon/${filename}`; + + // Remove old favicon file if it exists + const oldUrl = await siteSettings.get("cms_favicon"); + if (oldUrl && oldUrl.startsWith("/api/media/favicon/")) { + const oldName = oldUrl.replace("/api/media/favicon/", ""); + if (!oldName.includes("..") && !oldName.includes("/")) { + try { + await unlink(path.join(dir, oldName)); + } catch { /* ignore if file doesn't exist */ } + } + } + + await prisma.websiteSetting.upsert({ + where: { key: "cms_favicon" }, + update: { value: url }, + create: { key: "cms_favicon", value: url, comment: "Favicon URL" }, + }); + + siteSettings.reload(); + revalidatePath("/", "layout"); + revalidatePath("/admin/favicon"); + + return { success: true, url }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + +export async function deleteFavicon(): Promise<{ success: boolean; error?: string }> { + try { + const oldUrl = await siteSettings.get("cms_favicon"); + if (oldUrl && oldUrl.startsWith("/api/media/favicon/")) { + const dir = path.join(process.cwd(), FAVICON_DIR); + const oldName = oldUrl.replace("/api/media/favicon/", ""); + if (!oldName.includes("..") && !oldName.includes("/")) { + try { + await unlink(path.join(dir, oldName)); + } catch { /* ignore */ } + } + } + + await prisma.websiteSetting.delete({ where: { key: "cms_favicon" } }).catch(() => {}); + siteSettings.reload(); + revalidatePath("/", "layout"); + revalidatePath("/admin/favicon"); + + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} diff --git a/src/app/admin/favicon/favicon-form.tsx b/src/app/admin/favicon/favicon-form.tsx new file mode 100644 index 00000000..8fc8c7e8 --- /dev/null +++ b/src/app/admin/favicon/favicon-form.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { saveFavicon, deleteFavicon } from "@/actions/save-favicon"; + +export function FaviconForm({ currentUrl }: { currentUrl: string | null }) { + const t = useTranslations("pages.admin.favicon"); + const [uploading, setUploading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [preview, setPreview] = useState(currentUrl); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const formRef = useRef(null); + + async function handleUpload(formData: FormData) { + setUploading(true); + setMessage(null); + const result = await saveFavicon(formData); + setUploading(false); + + if (result.success && result.url) { + setPreview(result.url); + setMessage({ type: "success", text: t("saved") }); + formRef.current?.reset(); + } else { + setMessage({ type: "error", text: result.error ?? t("error") }); + } + } + + async function handleDelete() { + if (!confirm(t("confirmDelete"))) return; + setDeleting(true); + setMessage(null); + const result = await deleteFavicon(); + setDeleting(false); + + if (result.success) { + setPreview(null); + setMessage({ type: "success", text: t("deleted") }); + } else { + setMessage({ type: "error", text: result.error ?? t("error") }); + } + } + + return ( +
+ {preview && ( +
+ +
+ Favicon preview +
{preview}
+
+
+ )} + +
+
+ + +

{t("uploadHint")}

+
+ +
+ + {preview && ( + + )} +
+
+ + {message && ( +
+ {message.text} +
+ )} +
+ ); +} diff --git a/src/app/admin/favicon/page.tsx b/src/app/admin/favicon/page.tsx new file mode 100644 index 00000000..6f50f0bf --- /dev/null +++ b/src/app/admin/favicon/page.tsx @@ -0,0 +1,34 @@ +import { getTranslations } from "next-intl/server"; +import { Image } from "lucide-react"; +import { FaviconForm } from "./favicon-form"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export default async function AdminFaviconPage() { + const t = await getTranslations("pages.admin.favicon"); + + let faviconUrl: string | null = null; + try { + const setting = await prisma.websiteSetting.findUnique({ where: { key: "cms_favicon" } }); + faviconUrl = setting?.value ?? null; + } catch { + faviconUrl = null; + } + + return ( +
+
+
+ +
+
+

{t("title")}

+

{t("subtitle")}

+
+
+ + +
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 4ddb2a32..8cd3c958 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -81,6 +81,7 @@ function getNavGroups(t: (key: string) => string) { { href: "/admin/email-templates", label: t("emailTemplates"), icon: }, { href: "/admin/theme", label: t("theme"), icon: }, { href: "/admin/media", label: t("media"), icon: }, + { href: "/admin/favicon", label: t("favicon"), icon: }, { href: "/admin/settings", label: t("settings"), icon: }, ], }, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 54fd2f53..db0ee810 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -32,11 +32,14 @@ const pixelFont = Pixelify_Sans({ export async function generateMetadata(): Promise { const appUrl = process.env.APP_URL ?? "http://localhost:3000"; const hotel = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; + const favicon = await siteSettings.get("cms_favicon"); + const icons = favicon ? [{ rel: "icon", url: favicon }, { rel: "shortcut icon", url: favicon }] : []; return { title: { default: `${hotel} - Hotel`, template: `%s · ${hotel}` }, description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`, metadataBase: new URL(appUrl), alternates: { canonical: appUrl }, + icons, openGraph: { title: `${hotel} - Hotel`, description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`, diff --git a/src/messages/en.json b/src/messages/en.json index 8f33965b..d94eddbe 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -731,6 +731,7 @@ "emailTemplates": "Email templates", "theme": "Theme", "media": "Media", + "favicon": "Favicon", "settings": "Settings", "backToSite": "Back to site", "housekeeping": "Housekeeping", @@ -1973,6 +1974,21 @@ "copyUrl": "Copy URL", "urlCopied": "URL copied to clipboard" }, + "favicon": { + "title": "Favicon", + "subtitle": "Upload and manage the site favicon", + "current": "Current favicon", + "uploadLabel": "Upload favicon", + "uploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG. Max 2MB.", + "upload": "Upload", + "uploading": "Uploading…", + "delete": "Remove", + "deleting": "Removing…", + "saved": "Favicon saved successfully!", + "deleted": "Favicon removed successfully!", + "error": "Something went wrong", + "confirmDelete": "Remove the favicon? The site will no longer show a custom favicon." + }, "settings": { "title": "CMS Settings", "subtitle": "Manage key/value CMS configuration", diff --git a/src/messages/nl.json b/src/messages/nl.json index c45f72c8..28e587ed 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -729,6 +729,7 @@ "emailTemplates": "E-mailsjablonen", "theme": "Thema", "media": "Media", + "favicon": "Favicon", "settings": "Instellingen", "backToSite": "Terug naar site", "hotelManagementPanel": "Hotelbeheerpaneel", @@ -1970,6 +1971,21 @@ "copyUrl": "URL kopiëren", "urlCopied": "URL naar klembord gekopieerd" }, + "favicon": { + "title": "Favicon", + "subtitle": "Upload en beheer de favicon van de site", + "current": "Huidige favicon", + "uploadLabel": "Favicon uploaden", + "uploadHint": "PNG, JPEG, GIF, WebP, ICO of SVG. Max 2MB.", + "upload": "Uploaden", + "uploading": "Bezig met uploaden…", + "delete": "Verwijderen", + "deleting": "Bezig met verwijderen…", + "saved": "Favicon succesvol opgeslagen!", + "deleted": "Favicon succesvol verwijderd!", + "error": "Er is iets misgegaan", + "confirmDelete": "Favicon verwijderen? De site zal geen aangepaste favicon meer tonen." + }, "settings": { "title": "CMS-instellingen", "subtitle": "Beheer sleutel/waarde CMS-configuratie",