feat: favicon multi-variant — 1 upload generates 6 sizes + PWA manifest
Deploy to VPS / deploy (push) Has been cancelled
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:
@@ -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<Metadata> {
|
||||
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.",
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -170,16 +170,11 @@ function BrandingTab({
|
||||
field. Changes appear on the live site within 60 seconds, no rebuild needed.
|
||||
</Tip>
|
||||
|
||||
<ImageField
|
||||
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 })}
|
||||
/>
|
||||
<FaviconMasterField />
|
||||
|
||||
<ImageField
|
||||
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}
|
||||
onChange={(url) => onChange({ ...value, appleTouchIconUrl: url })}
|
||||
/>
|
||||
@@ -495,3 +490,133 @@ function SaveButton({
|
||||
</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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user