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>
366 lines
15 KiB
TypeScript
366 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|