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:
davidherranandClaude Opus 4.8 committed 2026-06-02 07:17:09 -05:00
1 parent 1ee8288c7e
commit 148aefc68f
19 files changed
+862 -6

No files matched your search

+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>
);
}