feat: site settings CMS — favicon, logo, footer, social, OG image

Adds a full settings dashboard at /hq-command/dashboard/settings so the
client can update favicon, logos, footer text, social links and OG image
without code changes — wired into the SiteSetting model from the previous
commit.

NEW
- src/lib/siteSettingsTypes.ts: pure types + defaults (client-safe import)
- src/lib/siteSettings.ts: server-only loader using the SiteSetting model
- /api/assets gains a "branding" flat scope that writes to /public/branding
- /hq-command/dashboard/settings/{actions.ts, page.tsx} with three tabs:
    1. Branding — favicon, Apple touch icon, main logo, email logo, OG
       image, theme color. Each field has helper text with recommended
       size/format and live preview.
    2. Footer — CTA banner, HQ address, legal links. Optional one-click
       AI translation to IT, VEC, ES, DE.
    3. Social — LinkedIn, Instagram, YouTube, contact email.

WIRED INTO LAYOUT
- src/app/[locale]/layout.tsx now uses generateMetadata + generateViewport
  to pull favicon, OG image and theme color dynamically. Adds Twitter
  Card metadata. Falls back to the default flux-logo when SiteSetting
  table is empty.
- src/components/layout/Footer.tsx reads CTA/HQ/legal copy from DB,
  supports per-locale overrides via translationsJson, and renders social
  icons (LinkedIn / Instagram / YouTube / Mail) only for filled fields.

UX FOR THE EDITOR (David's "12-year-old test")
- Drop-zone uploaders next to URL inputs — paste-or-upload either way
- Live image previews next to every branding field
- "Saved — live in 60 seconds" inline confirmation, no extra modals
- Recommended sizes spelled out next to each field (e.g. "PNG, square,
  minimum 512×512" for favicon)
- Tooltip explaining why each image is needed

NO SCHEMA CHANGES — uses the SiteSetting table created in the previous
commit. Existing rows untouched.
This commit is contained in:
2026-05-04 12:47:10 -05:00
parent b9201a437c
commit b9a744bdbc
7 changed files with 857 additions and 52 deletions
@@ -0,0 +1,481 @@
"use client";
export const dynamic = "force-dynamic";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import {
ArrowLeft, Settings as SettingsIcon, Loader2, Check, Upload,
Image as ImageIcon, Type, Share2, Sparkles, Info,
} from "lucide-react";
import {
getAllSettingsForEditor,
saveBranding,
saveFooter,
saveSocial,
} from "./actions";
import {
DEFAULT_BRANDING,
DEFAULT_FOOTER,
DEFAULT_SOCIAL,
type BrandingSettings,
type FooterSettings,
type SocialSettings,
} from "@/lib/siteSettingsTypes";
type Tab = "branding" | "footer" | "social";
export default function SiteSettingsPage() {
const [tab, setTab] = useState<Tab>("branding");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [savedFlash, setSavedFlash] = useState<string | null>(null);
const [branding, setBranding] = useState<BrandingSettings>(DEFAULT_BRANDING);
const [footer, setFooter] = useState<FooterSettings>(DEFAULT_FOOTER);
const [social, setSocial] = useState<SocialSettings>(DEFAULT_SOCIAL);
const [autoTranslateFooter, setAutoTranslateFooter] = useState(false);
const flash = (id: string) => {
setSavedFlash(id);
setTimeout(() => setSavedFlash(null), 1500);
};
const load = useCallback(async () => {
setLoading(true);
const res = await getAllSettingsForEditor();
if (res.success) {
if (res.branding) setBranding(res.branding);
if (res.footer) setFooter(res.footer);
if (res.social) setSocial(res.social);
}
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const onSaveBranding = async () => {
setSaving(true);
await saveBranding(branding);
setSaving(false);
flash("branding");
};
const onSaveFooter = async () => {
setSaving(true);
await saveFooter(footer, autoTranslateFooter);
setSaving(false);
flash("footer");
};
const onSaveSocial = async () => {
setSaving(true);
await saveSocial(social);
setSaving(false);
flash("social");
};
return (
<div className="min-h-screen p-6 md:p-12 max-w-5xl mx-auto">
<Link
href="/hq-command/dashboard"
className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-white mb-6 transition-colors"
>
<ArrowLeft size={14} /> Back to Dashboard
</Link>
<div className="mb-8">
<div className="flex items-center gap-2 text-[#00F0FF] mb-2">
<SettingsIcon size={16} />
<span className="text-[10px] uppercase tracking-widest font-bold">Site Settings</span>
</div>
<h1 className="text-3xl md:text-4xl font-light text-white">
Branding &amp; <span className="font-medium">Global Content.</span>
</h1>
<p className="text-[#86868B] mt-2 text-sm">
Favicon, logo, footer, social links applies site-wide across all locales.
</p>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-8 border-b border-white/10">
{[
{ id: "branding", label: "Branding", icon: ImageIcon },
{ id: "footer", label: "Footer", icon: Type },
{ id: "social", label: "Social", icon: Share2 },
].map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id as Tab)}
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-all ${
tab === t.id
? "border-[#00F0FF] text-[#00F0FF]"
: "border-transparent text-[#86868B] hover:text-white"
}`}
>
<t.icon size={14} /> {t.label}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-20 text-[#86868B]">
<Loader2 className="animate-spin mr-2" size={16} /> Loading settings
</div>
) : tab === "branding" ? (
<BrandingTab
value={branding}
onChange={setBranding}
onSave={onSaveBranding}
saving={saving}
justSaved={savedFlash === "branding"}
/>
) : tab === "footer" ? (
<FooterTab
value={footer}
onChange={setFooter}
autoTranslate={autoTranslateFooter}
onAutoTranslateChange={setAutoTranslateFooter}
onSave={onSaveFooter}
saving={saving}
justSaved={savedFlash === "footer"}
/>
) : (
<SocialTab
value={social}
onChange={setSocial}
onSave={onSaveSocial}
saving={saving}
justSaved={savedFlash === "social"}
/>
)}
</div>
);
}
// ─── Branding tab ────────────────────────────────────────────────
function BrandingTab({
value, onChange, onSave, saving, justSaved,
}: {
value: BrandingSettings;
onChange: (v: BrandingSettings) => void;
onSave: () => void;
saving: boolean;
justSaved: boolean;
}) {
return (
<div className="space-y-8">
<Tip>
Upload images to set the site favicon and logo. Recommended sizes are shown next to each
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 })}
/>
<ImageField
label="Apple Touch Icon"
helper="PNG, 180×180. Shown when users add the site to their iPhone home screen."
value={value.appleTouchIconUrl}
onChange={(url) => onChange({ ...value, appleTouchIconUrl: url })}
/>
<ImageField
label="Main Logo"
helper="SVG preferred (scales to any size). Or PNG at 800×200 with transparent background."
value={value.logoUrl}
onChange={(url) => onChange({ ...value, logoUrl: url })}
/>
<ImageField
label="Email Logo"
helper="PNG, 600×200. Used in transactional emails sent to clients."
value={value.logoEmailUrl}
onChange={(url) => onChange({ ...value, logoEmailUrl: url })}
/>
<ImageField
label="OpenGraph / Social Share Image"
helper="PNG or JPG, 1200×630. Shown when the site is shared on LinkedIn / WhatsApp / Twitter."
value={value.ogImageUrl}
onChange={(url) => onChange({ ...value, ogImageUrl: url })}
/>
<div>
<label className="block text-xs uppercase tracking-widest text-[#86868B] font-bold mb-2">
Theme color
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={value.themeColor}
onChange={(e) => onChange({ ...value, themeColor: e.target.value })}
className="w-14 h-10 rounded-lg cursor-pointer bg-transparent"
/>
<input
type="text"
value={value.themeColor}
onChange={(e) => onChange({ ...value, themeColor: e.target.value })}
className="bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 font-mono"
/>
<span className="text-xs text-[#86868B]">
Used for browser address bar tint on iOS / Android
</span>
</div>
</div>
<SaveButton onClick={onSave} saving={saving} justSaved={justSaved} />
</div>
);
}
// ─── Footer tab ──────────────────────────────────────────────────
function FooterTab({
value, onChange, autoTranslate, onAutoTranslateChange, onSave, saving, justSaved,
}: {
value: FooterSettings;
onChange: (v: FooterSettings) => void;
autoTranslate: boolean;
onAutoTranslateChange: (v: boolean) => void;
onSave: () => void;
saving: boolean;
justSaved: boolean;
}) {
return (
<div className="space-y-6">
<Tip>Footer text appears at the bottom of every page. Auto-translate sends the text to AI for IT, VEC, ES, DE versions.</Tip>
<FieldGroup title="Call-to-action banner">
<TextField
label="First line"
value={value.ctaTitle1}
onChange={(v) => onChange({ ...value, ctaTitle1: v })}
/>
<TextField
label="Second line (highlighted)"
value={value.ctaTitle2}
onChange={(v) => onChange({ ...value, ctaTitle2: v })}
/>
<TextField
label="Subtitle paragraph"
value={value.ctaSubtitle}
onChange={(v) => onChange({ ...value, ctaSubtitle: v })}
multiline
/>
</FieldGroup>
<FieldGroup title="Headquarters address">
<TextField label="Street" value={value.hqAddress} onChange={(v) => onChange({ ...value, hqAddress: v })} />
<TextField label="City + ZIP" value={value.hqCity} onChange={(v) => onChange({ ...value, hqCity: v })} />
<TextField label="Region" value={value.hqRegion} onChange={(v) => onChange({ ...value, hqRegion: v })} />
<TextField label="Country" value={value.hqCountry} onChange={(v) => onChange({ ...value, hqCountry: v })} />
</FieldGroup>
<FieldGroup title="Legal">
<TextField label="Copyright holder" value={value.copyrightHolder} onChange={(v) => onChange({ ...value, copyrightHolder: v })} />
<TextField label="Privacy policy URL" value={value.privacyUrl} onChange={(v) => onChange({ ...value, privacyUrl: v })} />
<TextField label="Terms of service URL" value={value.termsUrl} onChange={(v) => onChange({ ...value, termsUrl: v })} />
</FieldGroup>
<label className="flex items-center gap-2 text-xs text-[#86868B] cursor-pointer">
<input
type="checkbox"
checked={autoTranslate}
onChange={(e) => onAutoTranslateChange(e.target.checked)}
className="accent-[#00F0FF]"
/>
<Sparkles size={12} className="text-[#00F0FF]" />
Auto-translate to IT, VEC, ES, DE on save
</label>
<SaveButton onClick={onSave} saving={saving} justSaved={justSaved} />
</div>
);
}
// ─── Social tab ──────────────────────────────────────────────────
function SocialTab({
value, onChange, onSave, saving, justSaved,
}: {
value: SocialSettings;
onChange: (v: SocialSettings) => void;
onSave: () => void;
saving: boolean;
justSaved: boolean;
}) {
return (
<div className="space-y-6">
<Tip>Add the URL of each profile. Leave blank to hide. Email is shown as a mailto link.</Tip>
<TextField label="LinkedIn URL" value={value.linkedin} onChange={(v) => onChange({ ...value, linkedin: v })} placeholder="https://linkedin.com/company/flux-srl" />
<TextField label="Instagram URL" value={value.instagram} onChange={(v) => onChange({ ...value, instagram: v })} placeholder="https://instagram.com/..." />
<TextField label="YouTube URL" value={value.youtube} onChange={(v) => onChange({ ...value, youtube: v })} placeholder="https://youtube.com/@..." />
<TextField label="Contact email" value={value.email} onChange={(v) => onChange({ ...value, email: v })} placeholder="info@rf-flux.com" />
<SaveButton onClick={onSave} saving={saving} justSaved={justSaved} />
</div>
);
}
// ─── Reusable bits ──────────────────────────────────────────────
function Tip({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 bg-[#00F0FF]/5 border border-[#00F0FF]/15 rounded-2xl p-4 text-xs text-[#86868B]">
<Info size={14} className="text-[#00F0FF] mt-0.5 flex-shrink-0" />
<div className="leading-relaxed">{children}</div>
</div>
);
}
function FieldGroup({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-3">
<div className="text-[10px] uppercase tracking-widest text-[#86868B] font-bold">{title}</div>
{children}
</div>
);
}
function TextField({
label, value, onChange, placeholder, multiline,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
multiline?: boolean;
}) {
return (
<div>
<label className="block text-xs text-[#86868B] mb-1.5">{label}</label>
{multiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={3}
className="w-full bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 resize-y"
/>
) : (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40"
/>
)}
</div>
);
}
function ImageField({
label, helper, value, onChange,
}: {
label: string;
helper: string;
value: string;
onChange: (url: string) => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const upload = async (file: File) => {
setIsUploading(true);
setError(null);
try {
const fd = new FormData();
fd.append("scope", "branding");
fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) onChange(data.file.publicUrl);
else setError(data.error || "Upload failed");
} catch (err: any) {
setError(err.message || "Upload failed");
}
setIsUploading(false);
};
return (
<div className="bg-white/[0.02] border border-white/10 rounded-2xl p-5">
<div className="flex flex-col md:flex-row md:items-start gap-4">
{/* Preview */}
<div className="w-32 h-32 bg-black rounded-xl overflow-hidden flex items-center justify-center flex-shrink-0 border border-white/5">
{value ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={value} alt={label} className="max-w-full max-h-full object-contain" />
) : (
<ImageIcon size={32} className="text-[#86868B]/40" />
)}
</div>
{/* Controls */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white mb-1">{label}</div>
<div className="text-xs text-[#86868B] mb-3 leading-relaxed">{helper}</div>
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
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={isUploading}
className="inline-flex items-center gap-2 bg-[#00F0FF]/10 text-[#00F0FF] hover:bg-[#00F0FF]/20 px-3 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
>
{isUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
{isUploading ? "Uploading…" : "Upload new"}
</button>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="…or paste URL"
className="flex-1 min-w-[180px] bg-black/40 border border-white/10 text-white text-xs rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 font-mono"
/>
</div>
{error && <div className="text-rose-400 text-xs mt-2">{error}</div>}
</div>
</div>
</div>
);
}
function SaveButton({
onClick, saving, justSaved,
}: {
onClick: () => void;
saving: boolean;
justSaved: boolean;
}) {
return (
<div className="flex items-center gap-3 pt-4">
<button
onClick={onClick}
disabled={saving}
className="px-5 py-3 bg-[#00F0FF] text-black text-sm font-medium rounded-lg hover:bg-[#00F0FF]/80 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Save changes
</button>
{justSaved && (
<span className="text-emerald-400 text-sm flex items-center gap-1">
<Check size={14} /> Saved live in 60 seconds
</span>
)}
</div>
);
}