Files
flux-srl/src/app/hq-command/dashboard/settings/page.tsx
T
davidherran b9a744bdbc 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.
2026-05-04 12:47:10 -05:00

482 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}