diff --git a/src/app/admin/favicon/favicon-form.tsx b/src/app/admin/favicon/favicon-form.tsx
index 8fc8c7e8..09112031 100644
--- a/src/app/admin/favicon/favicon-form.tsx
+++ b/src/app/admin/favicon/favicon-form.tsx
@@ -3,6 +3,7 @@
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { saveFavicon, deleteFavicon } from "@/actions/save-favicon";
+import { FaviconGenerator } from "./favicon-generator";
export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
const t = useTranslations("pages.admin.favicon");
@@ -42,62 +43,81 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
}
}
+ async function handleGenerated(blob: Blob) {
+ setUploading(true);
+ setMessage(null);
+ const file = new File([blob], "favicon.png", { type: "image/png" });
+ const fd = new FormData();
+ fd.append("file", file);
+ const result = await saveFavicon(fd);
+ setUploading(false);
+
+ if (result.success && result.url) {
+ setPreview(result.url);
+ setMessage({ type: "success", text: t("saved") });
+ } else {
+ setMessage({ type: "error", text: result.error ?? t("error") });
+ }
+ }
+
return (
-
- {preview && (
-
-
-
-

-
{preview}
+
+
+
{t("uploadLabel")}
+ {preview && (
+
+
+
+

+
{preview}
+
-
- )}
+ )}
-
- {message && (
-
- {message.text}
-
- )}
+ {message && (
+
+ {message.text}
+
+ )}
+
+
+
);
}
diff --git a/src/app/admin/favicon/favicon-generator.tsx b/src/app/admin/favicon/favicon-generator.tsx
new file mode 100644
index 00000000..9b930633
--- /dev/null
+++ b/src/app/admin/favicon/favicon-generator.tsx
@@ -0,0 +1,170 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useTranslations } from "next-intl";
+
+const SIZE = 64;
+const HALF = SIZE / 2;
+
+interface Props {
+ onSave: (blob: Blob) => void;
+}
+
+export function FaviconGenerator({ onSave }: Props) {
+ const t = useTranslations("pages.admin.favicon.generator");
+ const [bgColor, setBgColor] = useState("#6366f1");
+ const [textColor, setTextColor] = useState("#ffffff");
+ const [text, setText] = useState("A");
+ const [rounded, setRounded] = useState(true);
+ const canvasRef = useRef
(null);
+
+ const render = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ canvas.width = SIZE;
+ canvas.height = SIZE;
+
+ // Background
+ ctx.clearRect(0, 0, SIZE, SIZE);
+ ctx.fillStyle = bgColor;
+ if (rounded) {
+ const r = 12;
+ ctx.beginPath();
+ ctx.moveTo(r, 0);
+ ctx.lineTo(SIZE - r, 0);
+ ctx.quadraticCurveTo(SIZE, 0, SIZE, r);
+ ctx.lineTo(SIZE, SIZE - r);
+ ctx.quadraticCurveTo(SIZE, SIZE, SIZE - r, SIZE);
+ ctx.lineTo(r, SIZE);
+ ctx.quadraticCurveTo(0, SIZE, 0, SIZE - r);
+ ctx.lineTo(0, r);
+ ctx.quadraticCurveTo(0, 0, r, 0);
+ ctx.closePath();
+ ctx.fill();
+ } else {
+ ctx.fillRect(0, 0, SIZE, SIZE);
+ }
+
+ // Text
+ const displayText = text.trim() || "?";
+ const fontSize = displayText.length > 2 ? 28 : displayText.length > 1 ? 32 : 40;
+ ctx.fillStyle = textColor;
+ ctx.font = `bold ${fontSize}px Arial, sans-serif`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(displayText, HALF, HALF + 1);
+ }, [bgColor, textColor, text, rounded]);
+
+ useEffect(() => {
+ render();
+ }, [render]);
+
+ function handleSave() {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ canvas.toBlob((blob) => {
+ if (blob) onSave(blob);
+ }, "image/png");
+ }
+
+ const swatches = [
+ "#6366f1", "#3b82f6", "#06b6d4", "#10b981",
+ "#f59e0b", "#ef4444", "#ec4899", "#8b5cf6",
+ "#1e1b4b", "#1f2937", "#000000", "#ffffff",
+ ];
+
+ return (
+
+
{t("title")}
+
+
+
+
+
+
+
+
+ setText(e.target.value.slice(0, 4))}
+ maxLength={4}
+ className="w-full"
+ placeholder="A"
+ />
+
+
+
+
+
+ setBgColor(e.target.value)}
+ className="w-10 h-10 rounded-lg cursor-pointer border border-[var(--border)]"
+ />
+ {bgColor}
+
+
+ {swatches.map((c) => (
+
+
+
+
+
+
+ setTextColor(e.target.value)}
+ className="w-10 h-10 rounded-lg cursor-pointer border border-[var(--border)]"
+ />
+ {textColor}
+
+
+
+
+ setRounded(e.target.checked)}
+ className="rounded"
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/src/messages/en.json b/src/messages/en.json
index d94eddbe..4c3d78a1 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -1987,7 +1987,15 @@
"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."
+ "confirmDelete": "Remove the favicon? The site will no longer show a custom favicon.",
+ "generator": {
+ "title": "Generate favicon",
+ "text": "Text",
+ "bgColor": "Background color",
+ "textColor": "Text color",
+ "rounded": "Rounded corners",
+ "save": "Save as favicon"
+ }
},
"settings": {
"title": "CMS Settings",
diff --git a/src/messages/nl.json b/src/messages/nl.json
index 28e587ed..9dadb861 100644
--- a/src/messages/nl.json
+++ b/src/messages/nl.json
@@ -1984,7 +1984,15 @@
"saved": "Favicon succesvol opgeslagen!",
"deleted": "Favicon succesvol verwijderd!",
"error": "Er is iets misgegaan",
- "confirmDelete": "Favicon verwijderen? De site zal geen aangepaste favicon meer tonen."
+ "confirmDelete": "Favicon verwijderen? De site zal geen aangepaste favicon meer tonen.",
+ "generator": {
+ "title": "Favicon genereren",
+ "text": "Tekst",
+ "bgColor": "Achtergrondkleur",
+ "textColor": "Tekstkleur",
+ "rounded": "Afgeronde hoeken",
+ "save": "Opslaan als favicon"
+ }
},
"settings": {
"title": "CMS-instellingen",