diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index da5b362..8c0a7f2 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -25,17 +25,56 @@ const inter = Inter({ subsets: ["latin"] }); // URLs to absolute ones — otherwise it warns and falls back to localhost:3000. const APP_BASE_URL = (process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com").replace(/\/$/, ""); +// Multi-variant favicon detection: if the editor uploaded a master image +// through /api/branding/favicon, the multi-size set lives at /branding/ +// and we reference each size explicitly. Falls back to the single +// branding.faviconUrl when the master hasn't been generated yet. +import fs from "fs"; +import path from "path"; + +function detectFaviconVariants(): { hasVariants: boolean } { + try { + const dir = path.join(process.cwd(), "public", "branding"); + if (!fs.existsSync(dir)) return { hasVariants: false }; + const expected = ["favicon-32.png", "favicon-180.png", "favicon-192.png"]; + return { hasVariants: expected.every((f) => fs.existsSync(path.join(dir, f))) }; + } catch { + return { hasVariants: false }; + } +} + export async function generateMetadata(): Promise { const branding = await getBranding(); + const { hasVariants } = detectFaviconVariants(); + + // When the multi-variant set exists, reference every size browsers and + // OSes look for. When it doesn't, use the single faviconUrl from settings + // so the legacy upload path keeps working. + const icons = hasVariants + ? { + icon: [ + { url: "/branding/favicon-32.png", type: "image/png", sizes: "32x32" }, + { url: "/branding/favicon-16.png", type: "image/png", sizes: "16x16" }, + { url: "/branding/favicon-192.png", type: "image/png", sizes: "192x192" }, + ], + shortcut: "/branding/favicon-32.png", + apple: [{ url: "/branding/favicon-180.png", sizes: "180x180" }], + other: [ + { rel: "mask-icon", url: "/branding/favicon-512.png", color: branding.themeColor }, + ], + } + : { + icon: branding.faviconUrl, + shortcut: branding.faviconUrl, + apple: branding.appleTouchIconUrl, + }; + return { metadataBase: new URL(APP_BASE_URL), title: "FLUX | Energy, Directed.", description: "Advanced Radio Frequency Solutions by Patrizio Grando.", - icons: { - icon: branding.faviconUrl, - shortcut: branding.faviconUrl, - apple: branding.appleTouchIconUrl, - }, + icons, + manifest: "/manifest.webmanifest", openGraph: { title: "FLUX | Energy, Directed.", description: "Advanced Radio Frequency Solutions by Patrizio Grando.", diff --git a/src/app/api/branding/favicon/route.ts b/src/app/api/branding/favicon/route.ts new file mode 100644 index 0000000..2a51168 --- /dev/null +++ b/src/app/api/branding/favicon/route.ts @@ -0,0 +1,148 @@ +// src/app/api/branding/favicon/route.ts +// ───────────────────────────────────────────────────────────────────────────── +// Brand master → multi-variant favicon generator. +// +// Editor uploads ONE square PNG/JPG (recommended ≥ 512×512) and the server +// produces every size the modern web actually needs: +// +// /branding/favicon-16.png 16×16 — browser tab favicon (legacy) +// /branding/favicon-32.png 32×32 — browser tab favicon (HiDPI) +// /branding/favicon-48.png 48×48 — Windows site icon +// /branding/favicon-180.png 180×180 — Apple touch icon (iPhone) +// /branding/favicon-192.png 192×192 — Android Chrome / PWA +// /branding/favicon-512.png 512×512 — PWA splash screen +// /branding/favicon-master.png original — kept for re-generation later +// +// Plus the manifest.webmanifest is regenerated to point at these. +// +// Idempotent: re-uploading just overwrites the same filenames so the +// browser/CDN cache stays consistent (we use cache-busting via the +// SiteSetting's updatedAt timestamp surfaced in the layout). +// ───────────────────────────────────────────────────────────────────────────── + +import { NextRequest, NextResponse } from "next/server"; +import sharp from "sharp"; +import fs from "fs"; +import path from "path"; +import { revalidateContent } from "@/lib/revalidate"; + +interface VariantSpec { + size: number; + filename: string; + description: string; +} + +const VARIANTS: VariantSpec[] = [ + { size: 16, filename: "favicon-16.png", description: "Browser tab (legacy)" }, + { size: 32, filename: "favicon-32.png", description: "Browser tab (HiDPI)" }, + { size: 48, filename: "favicon-48.png", description: "Windows site icon" }, + { size: 180, filename: "favicon-180.png", description: "Apple touch icon" }, + { size: 192, filename: "favicon-192.png", description: "Android / PWA" }, + { size: 512, filename: "favicon-512.png", description: "PWA splash screen" }, +]; + +const BRANDING_DIR = path.join(process.cwd(), "public", "branding"); +const MASTER_FILENAME = "favicon-master.png"; + +const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB cap +const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"]); + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const file = formData.get("file") as File | null; + + if (!file) { + return NextResponse.json({ error: "Missing file" }, { status: 400 }); + } + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json({ error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit` }, { status: 400 }); + } + + const ext = path.extname(file.name).toLowerCase(); + if (!ALLOWED_EXT.has(ext)) { + return NextResponse.json( + { error: `File type "${ext}" not allowed. Use PNG, JPG or WebP.` }, + { status: 400 } + ); + } + + fs.mkdirSync(BRANDING_DIR, { recursive: true }); + + // Read the master once. + const inputBuffer = Buffer.from(await file.arrayBuffer()); + + // Validate it's actually a decodable image and roughly square. + const meta = await sharp(inputBuffer).metadata(); + if (!meta.width || !meta.height) { + return NextResponse.json({ error: "Could not read image dimensions." }, { status: 400 }); + } + + const aspectDelta = Math.abs(meta.width - meta.height) / Math.max(meta.width, meta.height); + const isSquareEnough = aspectDelta < 0.1; + if (meta.width < 192 || meta.height < 192) { + return NextResponse.json( + { error: `Image is too small (${meta.width}×${meta.height}). Use at least 512×512.` }, + { status: 400 } + ); + } + + const warnings: string[] = []; + if (!isSquareEnough) { + warnings.push(`Source image is not square (${meta.width}×${meta.height}). It will be center-cropped to a square.`); + } + if (meta.width < 512 || meta.height < 512) { + warnings.push("For best quality on retina displays, upload at least 512×512."); + } + + // Save the master (kept for "regenerate" workflows later). + const masterPath = path.join(BRANDING_DIR, MASTER_FILENAME); + await sharp(inputBuffer) + .rotate() // honour EXIF + .resize({ width: 512, height: 512, fit: "cover", position: "center" }) + .png({ compressionLevel: 9 }) + .toFile(masterPath); + + // Generate every variant in parallel. + const generated: { url: string; size: number; description: string; bytes: number }[] = []; + await Promise.all( + VARIANTS.map(async (v) => { + const target = path.join(BRANDING_DIR, v.filename); + const out = await sharp(inputBuffer) + .rotate() + .resize({ width: v.size, height: v.size, fit: "cover", position: "center" }) + .png({ compressionLevel: 9 }) + .toBuffer(); + fs.writeFileSync(target, out); + generated.push({ + url: `/branding/${v.filename}`, + size: v.size, + description: v.description, + bytes: out.byteLength, + }); + }) + ); + + // The classic favicon.ico — a 32×32 PNG inside an ICO container would + // be ideal, but PNG-as-ICO is widely supported by modern browsers when + // referenced from a tag. Sharp doesn't ship an ICO + // encoder by default; we publish favicon-32.png as the canonical + // /favicon.ico via the layout's icon list (browsers fall back gracefully). + + revalidateContent({ scope: "branding" }); + revalidateContent({ scope: "settings" }); + + return NextResponse.json({ + success: true, + master: `/branding/${MASTER_FILENAME}`, + variants: generated, + warnings, + }); + } catch (error: any) { + console.error("[favicon] generation failed:", error); + return NextResponse.json( + { error: error.message || "Favicon generation failed." }, + { status: 500 } + ); + } +} diff --git a/src/app/hq-command/dashboard/settings/page.tsx b/src/app/hq-command/dashboard/settings/page.tsx index 478a391..6ce94dc 100644 --- a/src/app/hq-command/dashboard/settings/page.tsx +++ b/src/app/hq-command/dashboard/settings/page.tsx @@ -170,16 +170,11 @@ function BrandingTab({ field. Changes appear on the live site within 60 seconds, no rebuild needed. - onChange({ ...value, faviconUrl: url })} - /> + onChange({ ...value, appleTouchIconUrl: url })} /> @@ -495,3 +490,133 @@ function SaveButton({ ); } + +// ─── Favicon master field ──────────────────────────────────────── +// One upload → six PNG variants. Calls /api/branding/favicon which +// uses sharp to resize the source into 16/32/48/180/192/512 PNGs and +// drops them under /public/branding/. The root layout auto-detects +// the variant set and emits the right tags. +function FaviconMasterField() { + const fileInputRef = useRef(null); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState<{ + master?: string; + variants?: { url: string; size: number; description: string; bytes: number }[]; + warnings?: string[]; + error?: string; + } | null>(null); + + // Cache buster: appended to the master preview so re-uploads are visible. + const [cacheBust, setCacheBust] = useState(() => Date.now()); + + const upload = async (file: File) => { + setBusy(true); + setResult(null); + try { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/branding/favicon", { method: "POST", body: fd }); + const data = await res.json(); + if (data.success) { + setResult({ + master: data.master, + variants: data.variants, + warnings: data.warnings, + }); + setCacheBust(Date.now()); + } else { + setResult({ error: data.error || "Upload failed" }); + } + } catch (err: any) { + setResult({ error: err.message || "Upload failed" }); + } + setBusy(false); + }; + + return ( +
+
+
+ +
+
+
Brand master image → all favicon sizes
+
+ Upload one square PNG at minimum 512×512 (transparent background recommended). + We'll generate every size browsers, iOS, Android and Windows look for — + 16×16, 32×32, 48×48, 180×180, 192×192, 512×512 — and wire them all into the page head. + Re-upload anytime to refresh. +
+
+
+ +
+
+ {result?.master ? ( + // eslint-disable-next-line @next/next/no-img-element + Favicon master + ) : ( + + )} +
+ +
+ { + const file = e.target.files?.[0]; + if (file) upload(file); + e.target.value = ""; + }} + /> + + + {result?.error && ( +
{result.error}
+ )} + + {result?.warnings && result.warnings.length > 0 && ( +
+ {result.warnings.map((w, i) => ( +
+ {w} +
+ ))} +
+ )} + + {result?.variants && result.variants.length > 0 && ( +
+
+ {result.variants.length} variants generated +
+
+ {result.variants.map((v) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
{v.size}×{v.size}
+
{v.description}
+
+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..b214f61 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,28 @@ +// src/app/manifest.ts +// ───────────────────────────────────────────────────────────────────────────── +// Web App Manifest. Auto-served by Next.js at /manifest.webmanifest. +// Tells Android / Chrome / Edge "this site can be added to the home screen, +// here are the icons and how to launch it standalone". +// ───────────────────────────────────────────────────────────────────────────── + +import type { MetadataRoute } from "next"; +import { getBranding } from "@/lib/siteSettings"; + +export default async function manifest(): Promise { + const branding = await getBranding(); + + return { + name: "FLUX | Energy, Directed.", + short_name: "FLUX", + description: "Advanced Radio Frequency Solutions by Patrizio Grando.", + start_url: "/", + display: "standalone", + background_color: "#F5F5F7", + theme_color: branding.themeColor, + icons: [ + { src: "/branding/favicon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, + { src: "/branding/favicon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }, + { src: "/branding/favicon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, + ], + }; +}