JavaScript 56.7%
TypeScript 42.6%
1"use client";23import { useState, useEffect } from "react";4import { motion } from "framer-motion";5import { Plus, Trash2, Edit2, Users, UserPlus, UserMinus } from "lucide-react";6import Button from "../ui/Button";7import Modal from "../ui/Modal";8import UserAvatar from "../ui/UserAvatar";9import { EmptyState } from "../ui/PageHeader";10import toast from "react-hot-toast";11import { formatFileSize } from "@/lib/utils";1213interface MemberData {14 id: string;15 user: { id: string; name: string | null; username: string | null };16}1718interface SpaceData {19 id: string;20 name: string;21 quotaBytes: number;22 usedBytes: number;23 createdAt: string;24 _count: { members: number; folders: number };25 members: MemberData[];26}2728interface UserOption {29 id: string;30 name: string | null;31 username: string | null;32}3334const inputClasses = "field h-11 mb-4";3536export default function SharedSpaceManagement() {37 const [spaces, setSpaces] = useState<SpaceData[]>([]);38 const [users, setUsers] = useState<UserOption[]>([]);39 const [loading, setLoading] = useState(true);40 const [showCreate, setShowCreate] = useState(false);41 const [editSpace, setEditSpace] = useState<SpaceData | null>(null);42 const [form, setForm] = useState({ name: "", memberIds: [] as string[] });43 const [toDelete, setToDelete] = useState<SpaceData | null>(null);4445 const fetchData = async () => {46 const [spacesRes, usersRes] = await Promise.all([47 fetch("/api/shared-spaces"),48 fetch("/api/users"),49 ]);50 if (spacesRes.ok) setSpaces(await spacesRes.json());51 if (usersRes.ok) setUsers(await usersRes.json());52 setLoading(false);53 };5455 useEffect(() => { fetchData(); }, []);5657 const handleCreate = async () => {58 const res = await fetch("/api/shared-spaces", {59 method: "POST",60 headers: { "Content-Type": "application/json" },61 body: JSON.stringify(form),62 });63 if (res.ok) {64 toast.success("Espace créé");65 setShowCreate(false);66 setForm({ name: "", memberIds: [] });67 fetchData();68 } else {69 toast.error("Erreur lors de la création");70 }71 };7273 const handleDelete = async () => {74 if (!toDelete) return;75 const res = await fetch(`/api/shared-spaces/${toDelete.id}`, { method: "DELETE" });76 setToDelete(null);77 if (res.ok) { toast.success("Espace supprimé"); fetchData(); }78 else toast.error("Erreur");79 };8081 const handleRename = async () => {82 if (!editSpace) return;83 const res = await fetch(`/api/shared-spaces/${editSpace.id}`, {84 method: "PATCH",85 headers: { "Content-Type": "application/json" },86 body: JSON.stringify({ name: editSpace.name }),87 });88 if (res.ok) { toast.success("Renommé"); setEditSpace(null); fetchData(); }89 else toast.error("Erreur");90 };9192 const handleAddMember = async (spaceId: string, userId: string) => {93 const res = await fetch(`/api/shared-spaces/${spaceId}/members`, {94 method: "POST",95 headers: { "Content-Type": "application/json" },96 body: JSON.stringify({ userId }),97 });98 if (res.ok) { toast.success("Membre ajouté"); fetchData(); }99 else toast.error("Déjà membre ou erreur");100 };101102 const handleRemoveMember = async (spaceId: string, userId: string) => {103 const res = await fetch(`/api/shared-spaces/${spaceId}/members`, {104 method: "DELETE",105 headers: { "Content-Type": "application/json" },106 body: JSON.stringify({ userId }),107 });108 if (res.ok) { toast.success("Membre retiré"); fetchData(); }109 else toast.error("Erreur");110 };111112 const toggleMember = (userId: string) => {113 setForm((f) => ({114 ...f,115 memberIds: f.memberIds.includes(userId)116 ? f.memberIds.filter((id) => id !== userId)117 : [...f.memberIds, userId],118 }));119 };120121 if (loading) {122 return (123 <div className="grid gap-4 lg:grid-cols-2">124 {[0, 1].map((i) => (125 <div key={i} className="card p-5 animate-pulse space-y-4">126 <div className="flex items-center gap-3"><div className="w-10 h-10 rounded-2xl bg-gray-200 dark:bg-white/10" /><div className="flex-1 space-y-2"><div className="h-3 w-1/2 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-1/3 rounded bg-gray-200 dark:bg-white/10" /></div></div>127 <div className="h-1.5 rounded-full bg-gray-200 dark:bg-white/10" />128 <div className="h-9 rounded-xl bg-gray-100 dark:bg-white/[0.06]" />129 </div>130 ))}131 </div>132 );133 }134135 return (136 <div className="space-y-4">137 <div className="flex items-center justify-between gap-3">138 <p className="text-[13px] text-gray-500 dark:text-gray-400">{spaces.length} espace{spaces.length !== 1 ? "s" : ""}</p>139 <Button variant="accent" size="sm" onClick={() => setShowCreate(true)}>140 <Plus className="w-4 h-4" /> Nouvel espace141 </Button>142 </div>143144 {spaces.length === 0 && (145 <EmptyState icon={Users} title="Aucun espace partagé" description="Un espace partagé est un cloud commun à plusieurs utilisateurs, avec son propre quota." action={<Button variant="accent" onClick={() => setShowCreate(true)}><Plus className="w-4 h-4" /> Créer un espace</Button>} />146 )}147148 <div className="grid gap-4 lg:grid-cols-2">149 {spaces.map((space) => {150 const percent = space.quotaBytes > 0 ? Math.min(Math.round((space.usedBytes / space.quotaBytes) * 100), 100) : 0;151 const memberUserIds = space.members.map((m) => m.user.id);152 const nonMembers = users.filter((u) => !memberUserIds.includes(u.id));153154 return (155 <motion.div156 key={space.id}157 initial={{ opacity: 0, y: 10 }}158 animate={{ opacity: 1, y: 0 }}159 className="card p-5"160 >161 {/* Header */}162 <div className="flex items-start justify-between gap-3 mb-4">163 <div className="flex items-center gap-2 min-w-0">164 <div className="w-10 h-10 rounded-2xl bg-violet-500/10 flex items-center justify-center flex-shrink-0">165 <Users className="w-5 h-5 text-violet-600 dark:text-violet-300" />166 </div>167 <div className="min-w-0">168 <p className="text-[15px] font-semibold text-gray-900 dark:text-white truncate">{space.name}</p>169 <p className="text-[12.5px] text-gray-500 dark:text-gray-400">{space._count.members} membre{space._count.members !== 1 ? "s" : ""} · {space._count.folders} dossier{space._count.folders !== 1 ? "s" : ""}</p>170 </div>171 </div>172 <div className="flex items-center gap-1 flex-shrink-0">173 <button onClick={() => setEditSpace(space)} className="w-9 h-9 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/[0.08] transition-colors" title="Renommer">174 <Edit2 className="w-4 h-4" />175 </button>176 <button onClick={() => setToDelete(space)} className="w-9 h-9 flex items-center justify-center rounded-lg text-gray-400 hover:text-rose-500 hover:bg-rose-500/10 transition-colors" title="Supprimer">177 <Trash2 className="w-4 h-4" />178 </button>179 </div>180 </div>181182 {/* Storage quota */}183 <div className="mb-4">184 <div className="flex justify-between text-xs text-gray-500 mb-1">185 <span>Stockage utilisé</span>186 <span>{formatFileSize(space.usedBytes)} / {formatFileSize(space.quotaBytes)}</span>187 </div>188 <div className="w-full bg-gray-200 dark:bg-white/10 rounded-full h-1.5 overflow-hidden">189 <div190 className={`h-1.5 rounded-full transition-all ${percent > 90 ? "bg-rose-500" : percent > 70 ? "bg-amber-500" : "bg-accent-gradient"}`}191 style={{ width: `${Math.max(1, percent)}%` }}192 />193 </div>194 <p className="text-xs text-gray-400 mt-1">{percent}% utilisé</p>195 </div>196197 {/* Members */}198 <div>199 <p className="text-[11px] font-semibold text-gray-400 uppercase tracking-[0.12em] mb-2">Membres</p>200 <div className="space-y-0.5 mb-3">201 {space.members.map((m) => (202 <div key={m.id} className="flex items-center justify-between py-1 pl-2 pr-1 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5">203 <div className="flex items-center gap-2.5 min-w-0">204 <UserAvatar userId={m.user.id} name={m.user.name || m.user.username} size="sm" />205 <span className="text-[13.5px] text-gray-700 dark:text-gray-200 truncate">{m.user.name || m.user.username}</span>206 </div>207 <button onClick={() => handleRemoveMember(space.id, m.user.id)} className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-rose-500 hover:bg-rose-500/10 transition-colors" title="Retirer">208 <UserMinus className="w-3.5 h-3.5" />209 </button>210 </div>211 ))}212 {space.members.length === 0 && (213 <p className="text-xs text-gray-400 px-2">Aucun membre pour l'instant</p>214 )}215 </div>216217 {/* Add member */}218 {nonMembers.length > 0 && (219 <div className="flex gap-2">220 <select221 className="field h-10 flex-1"222 defaultValue=""223 onChange={(e) => {224 if (e.target.value) { handleAddMember(space.id, e.target.value); e.target.value = ""; }225 }}226 >227 <option value="" disabled>Ajouter un membre...</option>228 {nonMembers.map((u) => (229 <option key={u.id} value={u.id}>{u.name || u.username}</option>230 ))}231 </select>232 <span className="w-10 h-10 flex items-center justify-center rounded-xl bg-blue-500/10 text-blue-600 dark:text-blue-300 shrink-0" aria-hidden>233 <UserPlus className="w-5 h-5" />234 </span>235 </div>236 )}237 </div>238 </motion.div>239 );240 })}241 </div>242243 {/* Create modal */}244 <Modal isOpen={showCreate} onClose={() => { setShowCreate(false); setForm({ name: "", memberIds: [] }); }} title="Nouvel espace partagé">245 <input246 type="text"247 placeholder="Nom de l'espace"248 value={form.name}249 onChange={(e) => setForm({ ...form, name: e.target.value })}250 className={inputClasses}251 autoFocus252 />253 {users.length > 0 && (254 <div className="mb-4">255 <p className="text-[11px] font-semibold text-gray-400 uppercase tracking-[0.12em] mb-2">Membres</p>256 <div className="space-y-0.5 max-h-48 overflow-auto scrollbar-thin">257 {users.map((u) => (258 <label key={u.id} className="flex items-center gap-3 px-2 h-10 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5 cursor-pointer">259 <input260 type="checkbox"261 checked={form.memberIds.includes(u.id)}262 onChange={() => toggleMember(u.id)}263 className="w-4 h-4 rounded accent-[rgb(var(--accent-500))]"264 />265 <span className="text-[13.5px] text-gray-700 dark:text-gray-200">{u.name || u.username}</span>266 </label>267 ))}268 </div>269 </div>270 )}271 <p className="text-xs text-gray-400 mb-4">Quota : 500 Go par défaut</p>272 <div className="flex gap-2 justify-end">273 <Button variant="ghost" onClick={() => { setShowCreate(false); setForm({ name: "", memberIds: [] }); }}>Annuler</Button>274 <Button variant="accent" onClick={handleCreate} disabled={!form.name.trim()}>Créer</Button>275 </div>276 </Modal>277278 <Modal279 isOpen={!!toDelete}280 onClose={() => setToDelete(null)}281 title="Supprimer cet espace partagé ?"282 description={toDelete?.name}283 footer={<><Button variant="ghost" onClick={() => setToDelete(null)}>Annuler</Button><Button variant="danger" onClick={handleDelete}><Trash2 className="w-4 h-4" /> Supprimer</Button></>}284 >285 <p className="text-sm text-gray-600 dark:text-gray-300">Les dossiers seront conservés mais retirés de l'espace ; les membres perdront l'accès partagé.</p>286 </Modal>287288 {/* Edit modal */}289 <Modal isOpen={!!editSpace} onClose={() => setEditSpace(null)} title="Modifier l'espace">290 {editSpace && (291 <>292 <input293 type="text"294 value={editSpace.name}295 onChange={(e) => setEditSpace({ ...editSpace, name: e.target.value })}296 className={inputClasses}297 autoFocus298 />299 <div className="flex gap-2 justify-end">300 <Button variant="ghost" onClick={() => setEditSpace(null)}>Annuler</Button>301 <Button onClick={handleRename}>Enregistrer</Button>302 </div>303 </>304 )}305 </Modal>306 </div>307 );308}309