feat(team): public Team page + HQ CMS panel

New "Team" section — a LinkedIn-style minimal profile page for the FLUX
team, fully editable from the HQ Command Center.

Data model:
- New TeamMember model (name, role, bio, photoUrl, optional social links:
  email/linkedin/x/website, order, isActive, translationsJson).
- Additive migration 20260602120000_add_team_member (IF NOT EXISTS guards).
- Name stays as written; role + bio are translatable via the AI engine.

HQ panel (/hq-command/dashboard/team):
- Drag-to-reorder (same HTML5 pattern as the Hero panel).
- Inline auto-save for name/role/visibility; expandable editor for photo
  upload, bio, social links, and AI auto-translate to IT/VEC/ES/DE.
- Photo upload reuses /api/assets with a new flat "team" scope -> /public/team/.
- Dashboard tile added.

Public page (/[locale]/team):
- Responsive card grid (framer-motion stagger), portrait + name + role +
  bio + social icons (only the links that exist render).
- Per-member Person JSON-LD + breadcrumb for SEO.
- Localized via getLocalizedData; new TeamPage namespace in all 5 locales.
- NavBar item "Team" inserted before "Spare Parts" (translated 5 locales).
- Added to sitemap.

Infra:
- "team" scope registered in /api/assets (SCOPE_ROOTS + FLAT_SCOPES +
  buildPublicUrl) and revalidate.ts (RevalidateScope + path).
- Nginx serves /team/ from disk; docker-compose mounts public/team in both
  app and nginx (rw + ro).

Verified: production build compiles, all 5 /[locale]/team routes + the HQ
panel render; TypeScript clean; 5 message files valid JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 07:17:09 -05:00
parent 1ee8288c7e
commit 148aefc68f
19 changed files with 862 additions and 6 deletions
+114
View File
@@ -0,0 +1,114 @@
"use client";
import Image from "next/image";
import { motion } from "framer-motion";
import { Linkedin, Mail, Globe, Twitter, User } from "lucide-react";
import { trackEvent } from "@/lib/analytics/gtag";
export interface TeamCard {
id: string;
name: string;
role: string;
bio: string | null;
photoUrl: string | null;
email: string | null;
linkedinUrl: string | null;
xUrl: string | null;
websiteUrl: string | null;
}
export default function TeamGrid({ members }: { members: TeamCard[] }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
{members.map((m, i) => (
<motion.article
key={m.id}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5, delay: Math.min(i * 0.06, 0.4), ease: [0.16, 1, 0.3, 1] }}
className="group relative flex flex-col rounded-3xl bg-white border border-black/[0.06] shadow-[0_2px_20px_rgba(0,0,0,0.04)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.10)] transition-all duration-500 overflow-hidden"
>
{/* Portrait */}
<div className="relative aspect-[4/5] w-full overflow-hidden bg-gradient-to-br from-[#EEF2F5] to-[#E3E9ED]">
{m.photoUrl ? (
<Image
src={m.photoUrl}
alt={m.name}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-[#B0B8BF]">
<User size={64} strokeWidth={1} />
</div>
)}
{/* Subtle gradient for text legibility if needed later */}
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{/* Body */}
<div className="flex flex-col flex-1 p-6">
<h3 className="text-lg font-semibold text-[#1D1D1F] tracking-tight">{m.name}</h3>
<p className="text-[#0066CC] text-xs font-medium uppercase tracking-[0.12em] mt-1">
{m.role}
</p>
{m.bio && (
<p className="mt-4 text-sm leading-relaxed text-[#6E6E73] line-clamp-5">{m.bio}</p>
)}
{/* Social links — only the ones that exist */}
<div className="mt-auto pt-5 flex items-center gap-2">
{m.linkedinUrl && (
<SocialLink href={m.linkedinUrl} label={`${m.name} on LinkedIn`} name={m.name} network="linkedin">
<Linkedin size={16} />
</SocialLink>
)}
{m.xUrl && (
<SocialLink href={m.xUrl} label={`${m.name} on X`} name={m.name} network="x">
<Twitter size={16} />
</SocialLink>
)}
{m.websiteUrl && (
<SocialLink href={m.websiteUrl} label={`${m.name} website`} name={m.name} network="web">
<Globe size={16} />
</SocialLink>
)}
{m.email && (
<SocialLink href={`mailto:${m.email}`} label={`Email ${m.name}`} name={m.name} network="email" external={false}>
<Mail size={16} />
</SocialLink>
)}
</div>
</div>
</motion.article>
))}
</div>
);
}
function SocialLink({
href, label, children, name, network, external = true,
}: {
href: string;
label: string;
children: React.ReactNode;
name: string;
network: string;
external?: boolean;
}) {
return (
<a
href={href}
aria-label={label}
title={label}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
onClick={() => trackEvent({ name: "contact_cta_clicked", params: { location: `team:${network}` } })}
className="inline-flex items-center justify-center w-9 h-9 rounded-full border border-black/[0.08] text-[#6E6E73] hover:text-white hover:bg-[#1D1D1F] hover:border-[#1D1D1F] transition-colors"
>
{children}
</a>
);
}
+112
View File
@@ -0,0 +1,112 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { buildPageMetadata, baseUrl } from "@/lib/seo";
import JsonLd from "@/components/seo/JsonLd";
import Breadcrumbs from "@/components/seo/Breadcrumbs";
import BreathingField from "@/components/visuals/BreathingField";
import TeamGrid, { type TeamCard } from "./TeamGrid";
// ISR: revalidate every 60s, like the other public pages.
export const revalidate = 60;
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "TeamPage" });
return buildPageMetadata({
locale,
pathWithoutLocale: "team",
title: `${t("eyebrow")} | FLUX`,
description: t("description"),
});
}
export default async function TeamPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: "TeamPage" });
let members: TeamCard[] = [];
try {
const rows = await prisma.teamMember.findMany({
where: { isActive: true },
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
});
members = rows.map((row) => {
const localized = getLocalizedData(row, locale);
return {
id: localized.id,
name: localized.name,
role: localized.role,
bio: localized.bio,
photoUrl: localized.photoUrl,
email: localized.email,
linkedinUrl: localized.linkedinUrl,
xUrl: localized.xUrl,
websiteUrl: localized.websiteUrl,
};
});
} catch (error) {
console.error("[team] DB error fetching members:", error);
}
// JSON-LD: a Person entity per member, plus a breadcrumb trail.
const orgUrl = baseUrl();
const personSchemas = members.map((m) => ({
"@context": "https://schema.org",
"@type": "Person",
name: m.name,
jobTitle: m.role,
worksFor: { "@type": "Organization", name: "FLUX Srl", url: orgUrl },
...(m.photoUrl ? { image: `${orgUrl}${m.photoUrl}` } : {}),
...(m.linkedinUrl ? { sameAs: [m.linkedinUrl] } : {}),
}));
const crumbs = [
{ name: "Home", url: `/${locale}` },
{ name: t("eyebrow"), url: `/${locale}/team` },
];
return (
<>
{personSchemas.length > 0 && <JsonLd data={personSchemas} />}
<main className="relative w-full min-h-screen bg-[#F5F5F7] overflow-hidden">
{/* Ambient visual, consistent with the News / Heritage hubs */}
<div className="absolute inset-0 opacity-60 pointer-events-none">
<BreathingField />
</div>
<div className="relative z-10 max-w-7xl mx-auto px-6 pt-28 md:pt-36 pb-24">
<Breadcrumbs items={crumbs} />
<header className="max-w-3xl mt-6 mb-16 md:mb-24">
<p className="text-[#0066CC] text-xs md:text-sm font-semibold uppercase tracking-[0.2em] mb-4">
{t("eyebrow")}
</p>
<h1 className="text-4xl md:text-6xl font-light text-[#1D1D1F] tracking-tight leading-[1.05]">
{t("title1")}{" "}
<span className="font-medium">{t("title2")}</span>
</h1>
<p className="mt-6 text-base md:text-lg text-[#6E6E73] leading-relaxed max-w-2xl">
{t("description")}
</p>
</header>
{members.length === 0 ? (
<div className="text-center py-24 text-[#86868B]">
<p>{t("empty")}</p>
</div>
) : (
<TeamGrid members={members} />
)}
</div>
</main>
</>
);
}
+4 -1
View File
@@ -43,10 +43,12 @@ const SCOPE_ROOTS: Record<string, string> = {
footage: path.join(process.cwd(), "public", "footage", "main"),
// 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image)
branding: path.join(process.cwd(), "public", "branding"),
// 🔥 NUEVO: Team member portraits (flat folder, slug ignored)
team: path.join(process.cwd(), "public", "team"),
};
// Scopes that ignore the `slug` parameter and write directly under their root.
const FLAT_SCOPES = new Set(["footage", "branding"]);
const FLAT_SCOPES = new Set(["footage", "branding", "team"]);
const MEDIA_TYPES: Record<string, string[]> = {
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
@@ -104,6 +106,7 @@ function buildSafePath(scope: string, slug: string, subPath?: string): string |
function buildPublicUrl(scope: string, slug: string, rel: string): string {
if (scope === "footage") return `/footage/main/${rel}`;
if (scope === "branding") return `/branding/${rel}`;
if (scope === "team") return `/team/${rel}`;
return `/${scope}/${slug}/${rel}`;
}
+9
View File
@@ -133,6 +133,15 @@ export default async function DashboardPage() {
bg: "bg-white/10",
border: "hover:border-white/50"
},
{
title: "The Team",
description: "Add team members with photo, bio and social links. Drag to reorder.",
icon: Users,
href: "/hq-command/dashboard/team",
color: "text-sky-400",
bg: "bg-sky-500/10",
border: "hover:border-sky-500/50"
},
{
title: "Component Matrix",
description: "Manage the spare parts catalog, pricing, and SKUs.",
@@ -0,0 +1,142 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { translateContentForCMS } from "@/lib/aiTranslator";
import { log } from "@/lib/logger";
export interface TeamMemberInput {
name: string;
role: string;
bio?: string | null;
photoUrl?: string | null;
email?: string | null;
linkedinUrl?: string | null;
xUrl?: string | null;
websiteUrl?: string | null;
autoTranslate?: boolean;
}
function revalidateTeam() {
revalidatePath("/team");
revalidatePath("/[locale]/team", "layout");
}
export async function getTeamMembers() {
try {
const members = await prisma.teamMember.findMany({
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
});
return { success: true, members };
} catch (error: unknown) {
log.error("team.list_failed", error);
return { error: error instanceof Error ? error.message : "Failed to load team" };
}
}
// Translatable fields only — name is a proper noun and never translated.
async function buildTranslations(role: string, bio: string | null | undefined, autoTranslate: boolean) {
const englishFields: Record<string, string> = { role };
if (bio) englishFields.bio = bio;
const merged: Record<string, Record<string, string>> = { en: englishFields };
if (autoTranslate) {
const aiResult = await translateContentForCMS(englishFields);
if (aiResult) {
for (const [locale, fields] of Object.entries(aiResult)) {
merged[locale] = { ...merged[locale], ...(fields as Record<string, string>) };
}
}
}
return JSON.stringify(merged);
}
export async function createTeamMember(input: TeamMemberInput) {
try {
const last = await prisma.teamMember.findFirst({
orderBy: { order: "desc" },
select: { order: true },
});
const nextOrder = last ? last.order + 1 : 0;
const translationsJson = await buildTranslations(input.role, input.bio, !!input.autoTranslate);
const member = await prisma.teamMember.create({
data: {
name: input.name,
role: input.role,
bio: input.bio || null,
photoUrl: input.photoUrl || null,
email: input.email || null,
linkedinUrl: input.linkedinUrl || null,
xUrl: input.xUrl || null,
websiteUrl: input.websiteUrl || null,
order: nextOrder,
translationsJson,
},
});
revalidateTeam();
return { success: true, member };
} catch (error: unknown) {
log.error("team.create_failed", error);
return { error: error instanceof Error ? error.message : "Failed to create member" };
}
}
export async function updateTeamMember(id: string, input: Partial<TeamMemberInput> & { isActive?: boolean }) {
try {
const data: Record<string, unknown> = {};
if (input.name !== undefined) data.name = input.name;
if (input.role !== undefined) data.role = input.role;
if (input.bio !== undefined) data.bio = input.bio || null;
if (input.photoUrl !== undefined) data.photoUrl = input.photoUrl || null;
if (input.email !== undefined) data.email = input.email || null;
if (input.linkedinUrl !== undefined) data.linkedinUrl = input.linkedinUrl || null;
if (input.xUrl !== undefined) data.xUrl = input.xUrl || null;
if (input.websiteUrl !== undefined) data.websiteUrl = input.websiteUrl || null;
if (input.isActive !== undefined) data.isActive = input.isActive;
// Rebuild translations when role or bio changed (or a translate was requested).
if (input.role !== undefined || input.bio !== undefined || input.autoTranslate) {
const existing = await prisma.teamMember.findUnique({ where: { id } });
const role = input.role ?? existing?.role ?? "";
const bio = input.bio ?? existing?.bio ?? null;
data.translationsJson = await buildTranslations(role, bio, !!input.autoTranslate);
}
const member = await prisma.teamMember.update({ where: { id }, data });
revalidateTeam();
return { success: true, member };
} catch (error: unknown) {
log.error("team.update_failed", error, { id });
return { error: error instanceof Error ? error.message : "Failed to update member" };
}
}
export async function deleteTeamMember(id: string) {
try {
await prisma.teamMember.delete({ where: { id } });
revalidateTeam();
return { success: true };
} catch (error: unknown) {
log.error("team.delete_failed", error, { id });
return { error: error instanceof Error ? error.message : "Failed to delete member" };
}
}
export async function reorderTeamMembers(orderedIds: string[]) {
try {
await prisma.$transaction(
orderedIds.map((id, idx) =>
prisma.teamMember.update({ where: { id }, data: { order: idx } }),
),
);
revalidateTeam();
return { success: true };
} catch (error: unknown) {
log.error("team.reorder_failed", error);
return { error: error instanceof Error ? error.message : "Failed to reorder" };
}
}
+365
View File
@@ -0,0 +1,365 @@
"use client";
export const dynamic = "force-dynamic";
import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link";
import {
ArrowLeft, Users, Plus, Trash2, Loader2, GripVertical, Eye, EyeOff,
Sparkles, Upload, Check, Linkedin, Mail, Globe, Twitter, ChevronDown,
} from "lucide-react";
import {
getTeamMembers, createTeamMember, updateTeamMember, deleteTeamMember,
reorderTeamMembers,
} from "./actions";
import { useHqUi } from "@/components/hq/Toast";
interface MemberRow {
id: string;
name: string;
role: string;
bio: string | null;
photoUrl: string | null;
email: string | null;
linkedinUrl: string | null;
xUrl: string | null;
websiteUrl: string | null;
isActive: boolean;
order: number;
translationsJson: string | null;
}
export default function TeamDashboard() {
const ui = useHqUi();
const [members, setMembers] = useState<MemberRow[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [savingId, setSavingId] = useState<string | null>(null);
const [savedFlash, setSavedFlash] = useState<string | null>(null);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const load = useCallback(async () => {
setLoading(true);
const res = await getTeamMembers();
if (res.success && res.members) setMembers(res.members as MemberRow[]);
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const flashSaved = (id: string) => {
setSavedFlash(id);
setTimeout(() => setSavedFlash(null), 1500);
};
const handleAdd = async () => {
setCreating(true);
const res = await createTeamMember({ name: "New member", role: "Role / Title" });
setCreating(false);
if (res.success && res.member) {
await load();
setExpandedId(res.member.id);
} else {
ui.toast(res.error || "Could not create member", "error");
}
};
// Inline patch with optimistic update + auto-save (name/role/isActive).
const patch = async (id: string, p: Partial<MemberRow>) => {
setMembers((prev) => prev.map((m) => (m.id === id ? { ...m, ...p } : m)));
setSavingId(id);
const res = await updateTeamMember(id, p as never);
setSavingId(null);
if (res.success) flashSaved(id);
else ui.toast(res.error || "Save failed", "error");
};
const handleDelete = async (id: string, name: string) => {
const ok = await ui.confirm({
title: "Remove team member",
message: `Remove ${name} from the public team page. This cannot be undone.`,
confirmLabel: "Remove",
destructive: true,
});
if (!ok) return;
await deleteTeamMember(id);
ui.toast("Member removed.", "success");
await load();
};
// Drag reorder — same pattern as the Hero panel.
const onDrop = async (targetId: string) => {
if (!draggedId || draggedId === targetId) return;
const ids = members.map((m) => m.id);
const from = ids.indexOf(draggedId);
const to = ids.indexOf(targetId);
if (from < 0 || to < 0) return;
const reordered = [...ids];
reordered.splice(from, 1);
reordered.splice(to, 0, draggedId);
setMembers((prev) => reordered.map((id, i) => ({ ...prev.find((m) => m.id === id)!, order: i })));
setDraggedId(null);
await reorderTeamMembers(reordered);
};
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="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-2 text-[#00F0FF] mb-2">
<Users size={16} />
<span className="text-[10px] uppercase tracking-widest font-bold">The Team</span>
</div>
<h1 className="text-3xl md:text-4xl font-light text-white">
Team <span className="font-medium">Members.</span>
</h1>
<p className="text-[#86868B] mt-2 text-sm">
Drag to reorder. Click a card to edit photo, bio and social links. Name &amp; role auto-save.
</p>
</div>
<button
onClick={handleAdd}
disabled={creating}
className="inline-flex items-center gap-2 bg-[#00F0FF] text-black px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-[#00F0FF]/80 transition-colors disabled:opacity-50 shrink-0"
>
{creating ? <Loader2 size={14} className="animate-spin" /> : <Plus size={16} />}
Add member
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-20 text-[#86868B]">
<Loader2 className="animate-spin mr-2" size={16} /> Loading team
</div>
) : members.length === 0 ? (
<div className="text-center py-20 text-[#86868B] bg-white/[0.02] rounded-3xl border border-white/5">
<Users size={32} className="mx-auto mb-3 opacity-40" />
<p>No team members yet.</p>
<p className="text-xs mt-1">Click Add member to build the public team page.</p>
</div>
) : (
<div className="space-y-3">
{members.map((m) => {
const isExpanded = expandedId === m.id;
const isSaving = savingId === m.id;
const justSaved = savedFlash === m.id;
return (
<div
key={m.id}
draggable
onDragStart={() => setDraggedId(m.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(m.id)}
className={`bg-[#111] border rounded-2xl overflow-hidden transition-all ${
draggedId === m.id ? "opacity-50" : ""
} ${m.isActive ? "border-white/10" : "border-white/5 opacity-60"}`}
>
<div className="flex items-center gap-3 p-3">
<button className="cursor-grab text-[#86868B] hover:text-white p-1" title="Drag to reorder">
<GripVertical size={16} />
</button>
<div className="relative w-14 h-14 rounded-full overflow-hidden bg-black/40 flex-shrink-0 border border-white/10">
{m.photoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={m.photoUrl} alt={m.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[#86868B]">
<Users size={18} />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<input
value={m.name}
onChange={(e) => setMembers((prev) => prev.map((x) => (x.id === m.id ? { ...x, name: e.target.value } : x)))}
onBlur={(e) => patch(m.id, { name: e.target.value })}
placeholder="Full name"
className="w-full bg-transparent border-0 outline-none text-white text-sm font-medium placeholder:text-[#86868B] focus:bg-white/[0.04] rounded px-2 py-1 -mx-2"
/>
<input
value={m.role}
onChange={(e) => setMembers((prev) => prev.map((x) => (x.id === m.id ? { ...x, role: e.target.value } : x)))}
onBlur={(e) => patch(m.id, { role: e.target.value })}
placeholder="Role / title"
className="w-full bg-transparent border-0 outline-none text-[#00F0FF] text-xs placeholder:text-[#86868B] focus:bg-white/[0.04] rounded px-2 py-0.5 -mx-2 mt-0.5"
/>
</div>
<div className="flex items-center gap-1 text-xs">
{isSaving && <Loader2 size={12} className="animate-spin text-[#00F0FF]" />}
{justSaved && <span className="text-emerald-400 flex items-center gap-1"><Check size={12} /> Saved</span>}
</div>
<button
onClick={() => patch(m.id, { isActive: !m.isActive })}
className={`p-2 rounded-lg transition-colors ${m.isActive ? "text-emerald-400 hover:bg-emerald-500/10" : "text-[#86868B] hover:bg-white/5"}`}
title={m.isActive ? "Hide from team page" : "Show on team page"}
>
{m.isActive ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
<button
onClick={() => setExpandedId(isExpanded ? null : m.id)}
className="p-2 rounded-lg text-[#86868B] hover:bg-white/5 hover:text-white"
title="Edit details"
>
<ChevronDown size={16} className={`transition-transform ${isExpanded ? "rotate-180 text-[#00F0FF]" : ""}`} />
</button>
<button
onClick={() => handleDelete(m.id, m.name)}
className="p-2 rounded-lg text-rose-400 hover:bg-rose-500/10"
title="Remove member"
>
<Trash2 size={16} />
</button>
</div>
{isExpanded && (
<MemberEditor
member={m}
onSaved={async () => { await load(); }}
/>
)}
</div>
);
})}
</div>
)}
</div>
);
}
// ─── Expanded editor: photo upload + bio + social links + AI translate ───────
function MemberEditor({ member, onSaved }: { member: MemberRow; onSaved: () => Promise<void> }) {
const ui = useHqUi();
const [photoUrl, setPhotoUrl] = useState(member.photoUrl || "");
const [bio, setBio] = useState(member.bio || "");
const [email, setEmail] = useState(member.email || "");
const [linkedinUrl, setLinkedinUrl] = useState(member.linkedinUrl || "");
const [xUrl, setXUrl] = useState(member.xUrl || "");
const [websiteUrl, setWebsiteUrl] = useState(member.websiteUrl || "");
const [autoTranslate, setAutoTranslate] = useState(false);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const uploadPhoto = async (file: File) => {
setUploading(true);
try {
const fd = new FormData();
fd.append("scope", "team");
fd.append("optimize", "1");
fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) setPhotoUrl(data.file.publicUrl);
else ui.toast(data.error || "Upload failed", "error");
} catch (err: unknown) {
ui.toast(err instanceof Error ? err.message : "Upload failed", "error");
}
setUploading(false);
};
const save = async () => {
setSaving(true);
const res = await updateTeamMember(member.id, {
bio, photoUrl, email, linkedinUrl, xUrl, websiteUrl, autoTranslate,
});
setSaving(false);
if (res.success) { ui.toast("Saved.", "success"); await onSaved(); }
else ui.toast(res.error || "Save failed", "error");
};
return (
<div className="border-t border-white/5 bg-white/[0.02] p-5 space-y-4">
{/* Photo */}
<div className="flex items-center gap-4">
<div className="relative w-20 h-20 rounded-full overflow-hidden bg-black/40 border border-white/10 flex-shrink-0">
{photoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={photoUrl} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[#86868B]"><Users size={20} /></div>
)}
</div>
<div>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadPhoto(f); e.target.value = ""; }}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="inline-flex items-center gap-2 bg-white/5 hover:bg-white/10 text-white text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
>
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
{photoUrl ? "Replace photo" : "Upload photo"}
</button>
<p className="text-[10px] text-[#86868B] mt-1.5">Square portrait recommended, min 400×400.</p>
</div>
</div>
{/* Bio */}
<div>
<label className="text-[10px] uppercase tracking-widest text-[#86868B] font-bold">Bio (English master)</label>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={4}
placeholder="Short biography. Markdown supported."
className="mt-1.5 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"
/>
</div>
{/* Social links */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<SocialInput icon={<Mail size={13} />} value={email} onChange={setEmail} placeholder="email@fluxsrl.com" />
<SocialInput icon={<Linkedin size={13} />} value={linkedinUrl} onChange={setLinkedinUrl} placeholder="https://linkedin.com/in/…" />
<SocialInput icon={<Twitter size={13} />} value={xUrl} onChange={setXUrl} placeholder="https://x.com/…" />
<SocialInput icon={<Globe size={13} />} value={websiteUrl} onChange={setWebsiteUrl} placeholder="https://…" />
</div>
<label className="flex items-center gap-2 text-xs text-[#86868B] cursor-pointer">
<input type="checkbox" checked={autoTranslate} onChange={(e) => setAutoTranslate(e.target.checked)} className="accent-[#00F0FF]" />
<Sparkles size={12} className="text-[#00F0FF]" /> Auto-translate role &amp; bio to IT, VEC, ES, DE with AI
</label>
<button
onClick={save}
disabled={saving}
className="px-4 py-2 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 details
</button>
</div>
);
}
function SocialInput({ icon, value, onChange, placeholder }: {
icon: React.ReactNode; value: string; onChange: (v: string) => void; placeholder: string;
}) {
return (
<div className="flex items-center gap-2 bg-black/40 border border-white/10 rounded-lg px-3 py-2 focus-within:border-[#00F0FF]/40">
<span className="text-[#86868B]">{icon}</span>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-1 bg-transparent border-0 outline-none text-white text-xs placeholder:text-[#86868B]/60"
/>
</div>
);
}
+1
View File
@@ -29,6 +29,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
{ path: "", priority: 1.0, changeFrequency: "weekly" as const },
{ path: "/news", priority: 0.7, changeFrequency: "daily" as const },
{ path: "/heritage", priority: 0.6, changeFrequency: "monthly" as const },
{ path: "/team", priority: 0.6, changeFrequency: "monthly" as const },
];
for (const locale of LOCALES) {
+1
View File
@@ -13,6 +13,7 @@ const NAV_KEYS = [
{ key: "globalMap", href: "/#global" },
{ key: "ourStory", href: "/#our-story" },
{ key: "insideFlux", href: "/news" },
{ key: "team", href: "/team" },
{ key: "parts", href: "/parts" },
];
+4
View File
@@ -21,6 +21,7 @@ export type RevalidateScope =
| "hero"
| "timeline"
| "settings"
| "team"
| "all";
export interface RevalidateOptions {
@@ -54,6 +55,9 @@ export function revalidateContent({ scope, slug }: RevalidateOptions) {
case "parts":
safeRevalidate(`/${locale}/parts`);
break;
case "team":
safeRevalidate(`/${locale}/team`);
break;
case "heritage":
safeRevalidate(`/${locale}/heritage`);
break;