SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
2 h agolast push
JavaScript 56.7% TypeScript 42.6%
13.3 KB · 309 lines tsx
Raw Blame History
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&apos;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&apos;espace ; les membres perdront l&apos;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