feat: favicon multi-variant — 1 upload generates 6 sizes + PWA manifest
Deploy to VPS / deploy (push) Has been cancelled

The single-favicon upload was leaving most platforms with the wrong icon:
Android Chrome looks for 192×192, iOS for 180×180, Windows for 48×48,
HiDPI tabs for 32×32, legacy tabs for 16×16, PWA splash for 512×512 —
six different files, all from the same source image.

ONE EDITOR ACTION → SIX FILES
- POST /api/branding/favicon takes a single PNG/JPG/WebP (≥ 192×192,
  ideally 512×512+) and runs it through sharp to produce:
    /branding/favicon-16.png    /branding/favicon-32.png
    /branding/favicon-48.png    /branding/favicon-180.png
    /branding/favicon-192.png   /branding/favicon-512.png
    /branding/favicon-master.png  (kept for re-generation)
- Square-ish source images get center-cropped automatically.
- Returns the variant list + soft warnings ("not square — center-cropped",
  "upload at least 512×512 for retina") so the editor sees what happened.
- Validates dimensions and file type, caps at 20MB.

ROOT LAYOUT (src/app/[locale]/layout.tsx)
- Detects whether the multi-variant set exists on disk. If yes, emits
  <link rel="icon" sizes="16x16 / 32x32 / 192x192">, an explicit
  Apple touch link, and a Safari pinned-tab mask-icon. If no, falls
  back to the legacy single faviconUrl from SiteSetting.
- Adds <link rel="manifest" href="/manifest.webmanifest"> so Android
  Chrome surfaces "Add to Home Screen" properly.

PWA MANIFEST (src/app/manifest.ts)
- Auto-served at /manifest.webmanifest via Next.js's MetadataRoute.
- Pulls themeColor from SiteSetting.branding so editor changes there
  cascade to the standalone-app theme.
- Lists 192 + 512 icons with both 'any' and 'maskable' purposes for
  Android adaptive icons.

HQ SETTINGS UX (src/app/hq-command/dashboard/settings/page.tsx)
- New <FaviconMasterField/> at the top of the Branding tab — replaces
  the old standalone Favicon ImageField. Shows a 32×32 preview, an
  upload button, the generation result with each variant's preview,
  and warnings when the source isn't ideal.
- The Apple Touch Icon ImageField below it stays as an override path
  for editors who want a different icon on iOS (rare, but supported).
- Cache buster query string on previews so re-uploads visibly refresh
  without forcing the editor to hard-reload.

NO BACKEND/DB CHANGES OUTSIDE FILE I/O.
The SiteSetting.branding row is unchanged. The favicon variants are
plain files on disk under public/branding/, served by Nginx with the
existing 5-min cache headers.
This commit is contained in:
2026-05-05 19:07:16 -05:00
parent ea1300bfdc
commit 330fecc3cc
4 changed files with 352 additions and 12 deletions
+44 -5
View File
@@ -25,17 +25,56 @@ const inter = Inter({ subsets: ["latin"] });
// URLs to absolute ones — otherwise it warns and falls back to localhost:3000. // 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(/\/$/, ""); 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<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const branding = await getBranding(); 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 { return {
metadataBase: new URL(APP_BASE_URL), metadataBase: new URL(APP_BASE_URL),
title: "FLUX | Energy, Directed.", title: "FLUX | Energy, Directed.",
description: "Advanced Radio Frequency Solutions by Patrizio Grando.", description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
icons: { icons,
icon: branding.faviconUrl, manifest: "/manifest.webmanifest",
shortcut: branding.faviconUrl,
apple: branding.appleTouchIconUrl,
},
openGraph: { openGraph: {
title: "FLUX | Energy, Directed.", title: "FLUX | Energy, Directed.",
description: "Advanced Radio Frequency Solutions by Patrizio Grando.", description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
+148
View File
@@ -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 <link rel="icon"> 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 }
);
}
}
+132 -7
View File
@@ -170,16 +170,11 @@ function BrandingTab({
field. Changes appear on the live site within 60 seconds, no rebuild needed. field. Changes appear on the live site within 60 seconds, no rebuild needed.
</Tip> </Tip>
<ImageField <FaviconMasterField />
label="Favicon"
helper="PNG, square, transparent background. Minimum 512×512. Auto-resized for tabs and bookmarks."
value={value.faviconUrl}
onChange={(url) => onChange({ ...value, faviconUrl: url })}
/>
<ImageField <ImageField
label="Apple Touch Icon" label="Apple Touch Icon"
helper="PNG, 180×180. Shown when users add the site to their iPhone home screen." helper="Auto-generated from the master favicon above. Override here only if you want a different image for iOS home screens."
value={value.appleTouchIconUrl} value={value.appleTouchIconUrl}
onChange={(url) => onChange({ ...value, appleTouchIconUrl: url })} onChange={(url) => onChange({ ...value, appleTouchIconUrl: url })}
/> />
@@ -495,3 +490,133 @@ function SaveButton({
</div> </div>
); );
} }
// ─── 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 <link rel="icon"> tags.
function FaviconMasterField() {
const fileInputRef = useRef<HTMLInputElement>(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 (
<div className="bg-gradient-to-br from-[#00F0FF]/[0.04] to-transparent border border-[#00F0FF]/20 rounded-2xl p-5">
<div className="flex items-start gap-4 mb-4">
<div className="w-10 h-10 rounded-xl bg-[#00F0FF]/10 text-[#00F0FF] flex items-center justify-center flex-shrink-0">
<ImageIcon size={18} />
</div>
<div className="flex-1">
<div className="text-sm font-medium text-white mb-1">Brand master image all favicon sizes</div>
<div className="text-xs text-[#86868B] leading-relaxed">
Upload <strong>one square PNG</strong> at minimum 512×512 (transparent background recommended).
We&apos;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.
</div>
</div>
</div>
<div className="flex flex-col md:flex-row md:items-center gap-4">
<div className="w-32 h-32 bg-black rounded-xl overflow-hidden border border-white/10 flex items-center justify-center flex-shrink-0">
{result?.master ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={`${result.master}?v=${cacheBust}`} alt="Favicon master" className="w-full h-full object-contain" />
) : (
<ImageIcon size={32} className="text-[#86868B]/40" />
)}
</div>
<div className="flex-1 space-y-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) upload(file);
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={busy}
className="inline-flex items-center gap-2 bg-[#00F0FF] text-black hover:bg-[#00F0FF]/80 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
{busy ? "Generating variants…" : "Upload master image"}
</button>
{result?.error && (
<div className="text-rose-400 text-xs">{result.error}</div>
)}
{result?.warnings && result.warnings.length > 0 && (
<div className="text-amber-400 text-xs space-y-1">
{result.warnings.map((w, i) => (
<div key={i} className="flex items-start gap-1.5">
<Info size={11} className="mt-0.5 flex-shrink-0" /> <span>{w}</span>
</div>
))}
</div>
)}
{result?.variants && result.variants.length > 0 && (
<div className="bg-black/40 border border-white/10 rounded-lg p-3 mt-3">
<div className="text-[10px] uppercase tracking-widest text-emerald-400 font-bold mb-2 flex items-center gap-1.5">
<Check size={11} /> {result.variants.length} variants generated
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{result.variants.map((v) => (
<div key={v.url} className="bg-white/[0.02] border border-white/5 rounded p-2 flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`${v.url}?v=${cacheBust}`} alt="" className="w-8 h-8 object-contain bg-black rounded" />
<div className="min-w-0">
<div className="text-[11px] text-white font-mono">{v.size}×{v.size}</div>
<div className="text-[9px] text-[#86868B] truncate">{v.description}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
+28
View File
@@ -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<MetadataRoute.Manifest> {
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" },
],
};
}