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.
|
// 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.",
|
||||||
|
|||||||
@@ -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.
|
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'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