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
+70
View File
@@ -0,0 +1,70 @@
// src/lib/siteSettings.ts
// ─────────────────────────────────────────────────────────────────────────────
// Server-only loader for site-wide settings (branding, footer, social).
// Reads the SiteSetting key-value table and merges with defaults.
//
// For pure types and defaults safe to import from client components,
// use src/lib/siteSettingsTypes.ts instead.
// ─────────────────────────────────────────────────────────────────────────────
import "server-only";
import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper";
import {
DEFAULT_BRANDING,
DEFAULT_FOOTER,
DEFAULT_SOCIAL,
type BrandingSettings,
type FooterSettings,
type SocialSettings,
} from "@/lib/siteSettingsTypes";
export {
DEFAULT_BRANDING,
DEFAULT_FOOTER,
DEFAULT_SOCIAL,
type BrandingSettings,
type FooterSettings,
type SocialSettings,
};
function safeParse<T>(json: string | null | undefined, fallback: T): T {
if (!json) return fallback;
try {
return JSON.parse(json) as T;
} catch {
return fallback;
}
}
async function readSetting<T>(key: string, defaults: T, locale?: string): Promise<T> {
try {
const row = await prisma.siteSetting.findUnique({ where: { key } });
if (!row) return defaults;
const localized = locale ? getLocalizedData(row, locale) : row;
const value = safeParse<Partial<T>>(localized.valueJson, {} as Partial<T>);
return { ...defaults, ...value };
} catch (error) {
console.error(`[siteSettings] Failed to read "${key}":`, error);
return defaults;
}
}
export const getBranding = (locale?: string) =>
readSetting<BrandingSettings>("branding", DEFAULT_BRANDING, locale);
export const getFooterSettings = (locale?: string) =>
readSetting<FooterSettings>("footer", DEFAULT_FOOTER, locale);
export const getSocialLinks = (locale?: string) =>
readSetting<SocialSettings>("social", DEFAULT_SOCIAL, locale);
export async function getAllSettings() {
const [branding, footer, social] = await Promise.all([
getBranding(),
getFooterSettings(),
getSocialLinks(),
]);
return { branding, footer, social };
}