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:
1 parent
1ee8288c7e
commit
148aefc68f
19 files changed
+862
-6
No files matched your search
@@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user