SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
4 h agolast push
JavaScript 56.7% TypeScript 42.6%
15.4 KB · 290 lines tsx
Raw Blame History
1"use client";23import { useCallback, useEffect, useMemo, useRef, useState } from "react";4import { useRouter, usePathname } from "next/navigation";5import { motion, AnimatePresence } from "framer-motion";6import {7  Search, Home, FolderOpen, Star, Clock, Network, Share2, Lock, Trash2, Shield, Camera, LayoutDashboard,8  Upload, FolderPlus, Sun, Moon, SlidersHorizontal, Sparkles, LogOut, CornerDownLeft, ArrowUp, ArrowDown,9  Folder, FileText, Image as ImageIcon, Video, Music, FileCode, FileSpreadsheet, Archive, File, Loader2, History,10  type LucideIcon,11} from "lucide-react";12import { useShell, toggleAiPanel } from "./ShellContext";13import { useTheme } from "../theme/ThemeProvider";14import { formatFileSize, getFileIcon } from "@/lib/utils";15import type { FileData } from "@/types/files";1617type ItemKind = "nav" | "action" | "file" | "folder" | "recent";1819interface Item {20  id: string;21  kind: ItemKind;22  label: string;23  hint?: string;24  icon: LucideIcon;25  iconClass?: string;26  keywords?: string;27  shortcut?: string;28  run: () => void;29}3031interface TreeFolder { id: string; name: string; children?: TreeFolder[]; _count?: { files: number } }3233const fileIconMap: Record<string, LucideIcon> = {34  image: ImageIcon, video: Video, audio: Music, pdf: FileText, code: FileCode, spreadsheet: FileSpreadsheet,35  archive: Archive, document: FileText, file: File, presentation: FileText,36};37const fileTone: Record<string, string> = {38  image: "text-emerald-500", video: "text-purple-500", audio: "text-pink-500", pdf: "text-rose-500", code: "text-amber-500",39  spreadsheet: "text-green-600", archive: "text-orange-500", document: "text-blue-500", presentation: "text-orange-500", file: "text-gray-400",40};4142const RECENT_KEY = "spb-cmd-recent";4344/** Score fuzzy simple : sous-séquence + bonus préfixe/mot. 0 = pas de correspondance. */45function score(query: string, text: string): number {46  const q = query.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");47  const t = text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");48  if (!q) return 1;49  if (t.startsWith(q)) return 100 - t.length * 0.01;50  const idx = t.indexOf(q);51  if (idx >= 0) return 80 - idx * 0.5;52  let ti = 0, s = 0;53  for (const ch of q) {54    const f = t.indexOf(ch, ti);55    if (f < 0) return 0;56    s += f === ti ? 3 : 1;57    ti = f + 1;58  }59  return 20 + s;60}6162function flattenTree(nodes: TreeFolder[], path: string[] = []): { id: string; name: string; path: string }[] {63  const out: { id: string; name: string; path: string }[] = [];64  for (const n of nodes) {65    out.push({ id: n.id, name: n.name, path: path.join(" / ") });66    if (n.children?.length) out.push(...flattenTree(n.children, [...path, n.name]));67  }68  return out;69}7071export default function CommandPalette() {72  const router = useRouter();73  const pathname = usePathname();74  const { commandOpen, closeCommand, commandQuery, isAdmin, setPrefsOpen } = useShell();75  const { theme, toggleTheme } = useTheme();76  const [query, setQuery] = useState("");77  const [active, setActive] = useState(0);78  const [files, setFiles] = useState<FileData[]>([]);79  const [folders, setFolders] = useState<{ id: string; name: string; path: string }[]>([]);80  const [searching, setSearching] = useState(false);81  const [recentIds, setRecentIds] = useState<string[]>([]);82  const inputRef = useRef<HTMLInputElement>(null);83  const listRef = useRef<HTMLDivElement>(null);84  const abortRef = useRef<AbortController | null>(null);8586  const isFilesPage = pathname.startsWith("/dashboard/files") || pathname.startsWith("/dashboard/folder");87  const filesTarget = isFilesPage ? pathname : "/dashboard/files";8889  // Ouverture : reset + focus + chargement de l'arbre90  useEffect(() => {91    if (!commandOpen) return;92    setQuery(commandQuery);93    setActive(0);94    try { setRecentIds(JSON.parse(localStorage.getItem(RECENT_KEY) || "[]")); } catch {}95    const t = setTimeout(() => inputRef.current?.focus(), 30);96    fetch("/api/folders?tree=true").then((r) => (r.ok ? r.json() : [])).then((d) => setFolders(flattenTree(Array.isArray(d) ? d : []))).catch(() => {});97    return () => clearTimeout(t);98  }, [commandOpen, commandQuery]);99100  // Recherche de fichiers (debounce 160 ms)101  useEffect(() => {102    if (!commandOpen) return;103    const q = query.trim();104    if (q.length < 2) { setFiles([]); setSearching(false); return; }105    setSearching(true);106    const t = setTimeout(() => {107      abortRef.current?.abort();108      const ac = new AbortController();109      abortRef.current = ac;110      fetch(`/api/files?search=${encodeURIComponent(q)}&limit=8&folderId=`, { signal: ac.signal })111        .then((r) => (r.ok ? r.json() : { files: [] }))112        .then((d) => setFiles(Array.isArray(d) ? d : d.files || []))113        .catch(() => {})114        .finally(() => setSearching(false));115    }, 160);116    return () => clearTimeout(t);117  }, [query, commandOpen]);118119  const remember = useCallback((id: string) => {120    try {121      const next = [id, ...recentIds.filter((x) => x !== id)].slice(0, 6);122      localStorage.setItem(RECENT_KEY, JSON.stringify(next));123    } catch {}124  }, [recentIds]);125126  const go = useCallback((href: string) => { router.push(href); closeCommand(); }, [router, closeCommand]);127128  const staticItems = useMemo<Item[]>(() => {129    const nav: Item[] = [130      { id: "nav-home", kind: "nav", label: "Accueil", icon: Home, keywords: "dashboard tableau", run: () => go("/dashboard") },131      { id: "nav-files", kind: "nav", label: "Fichiers", icon: FolderOpen, keywords: "explorateur documents", run: () => go("/dashboard/files") },132      { id: "nav-fav", kind: "nav", label: "Favoris", icon: Star, keywords: "étoile", run: () => go("/dashboard/favorites") },133      { id: "nav-recent", kind: "nav", label: "Récents", icon: Clock, keywords: "historique", run: () => go("/dashboard/recent") },134      { id: "nav-graph", kind: "nav", label: "Visualisation", icon: Network, keywords: "graphe", run: () => go("/dashboard/graph") },135      { id: "nav-shared", kind: "nav", label: "Partagé avec moi", icon: Share2, run: () => go("/dashboard/shared-with-me") },136      { id: "nav-pw", kind: "nav", label: "Mots de passe", icon: Lock, keywords: "coffre vault", run: () => go("/dashboard/passwords") },137      { id: "nav-trash", kind: "nav", label: "Corbeille", icon: Trash2, keywords: "supprimés", run: () => go("/dashboard/trash") },138      { id: "nav-sec", kind: "nav", label: "Sécurité", icon: Shield, keywords: "appareils sessions 2fa", run: () => go("/dashboard/security") },139      { id: "nav-snap", kind: "nav", label: "Snapshots", icon: Camera, run: () => go("/admin/snapshots") },140    ];141    if (isAdmin) nav.push({ id: "nav-admin", kind: "nav", label: "Administration", icon: LayoutDashboard, keywords: "admin utilisateurs backups", run: () => go("/admin") });142143    const actions: Item[] = [144      { id: "act-upload", kind: "action", label: "Envoyer des fichiers", hint: "Ouvre la zone de dépôt", icon: Upload, shortcut: "U", keywords: "upload téléverser", run: () => go(`${filesTarget}?upload=1`) },145      { id: "act-folder", kind: "action", label: "Nouveau dossier", icon: FolderPlus, shortcut: "⇧N", keywords: "créer", run: () => go(`${filesTarget}?new=1`) },146      { id: "act-ai", kind: "action", label: "Ouvrir l'assistant IA", icon: Sparkles, keywords: "chat claude analyse", run: () => { toggleAiPanel(); closeCommand(); } },147      { id: "act-theme", kind: "action", label: theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre", icon: theme === "dark" ? Sun : Moon, keywords: "thème dark light", run: () => { toggleTheme(); closeCommand(); } },148      { id: "act-prefs", kind: "action", label: "Préférences d'apparence", hint: "Accent, densité, animations", icon: SlidersHorizontal, shortcut: "⌘,", keywords: "couleur accent réglages", run: () => { setPrefsOpen(true); closeCommand(); } },149      { id: "act-logout", kind: "action", label: "Déconnexion", icon: LogOut, keywords: "quitter", run: async () => { await fetch("/api/auth/logout", { method: "POST" }); go("/login"); } },150    ];151    return [...nav, ...actions];152  }, [isAdmin, theme, toggleTheme, go, filesTarget, closeCommand, setPrefsOpen]);153154  const folderItems = useMemo<Item[]>(() => folders.map((f) => ({155    id: `folder-${f.id}`, kind: "folder", label: f.name, hint: f.path || "Racine", icon: Folder, iconClass: "text-blue-500",156    run: () => go(`/dashboard/folder/${f.id}`),157  })), [folders, go]);158159  const fileItems = useMemo<Item[]>(() => files.map((f) => {160    const t = getFileIcon(f.mimeType);161    return {162      id: `file-${f.id}`, kind: "file", label: f.name, hint: formatFileSize(f.size), icon: fileIconMap[t] || File, iconClass: fileTone[t],163      run: () => go(f.folderId ? `/dashboard/folder/${f.folderId}?file=${f.id}` : `/dashboard/files?file=${f.id}`),164    };165  }), [files, go]);166167  // Résultats groupés168  const groups = useMemo(() => {169    const q = query.trim();170    const rank = (items: Item[], limit: number) =>171      items.map((it) => ({ it, s: Math.max(score(q, it.label), it.keywords ? score(q, it.keywords) * 0.8 : 0) }))172        .filter((x) => x.s > 0).sort((a, b) => b.s - a.s).slice(0, limit).map((x) => x.it);173174    if (!q) {175      const recent = recentIds.map((id) => staticItems.find((i) => i.id === id)).filter((x): x is Item => !!x).slice(0, 4)176        .map((i) => ({ ...i, kind: "recent" as ItemKind }));177      return [178        { title: "Récents", items: recent },179        { title: "Actions", items: staticItems.filter((i) => i.kind === "action") },180        { title: "Navigation", items: staticItems.filter((i) => i.kind === "nav") },181      ].filter((g) => g.items.length);182    }183    return [184      { title: "Fichiers", items: fileItems },185      { title: "Dossiers", items: rank(folderItems, 6) },186      { title: "Actions", items: rank(staticItems.filter((i) => i.kind === "action"), 4) },187      { title: "Navigation", items: rank(staticItems.filter((i) => i.kind === "nav"), 5) },188    ].filter((g) => g.items.length);189  }, [query, staticItems, folderItems, fileItems, recentIds]);190191  const flat = useMemo(() => groups.flatMap((g) => g.items), [groups]);192  useEffect(() => setActive(0), [query, flat.length]);193194  const select = useCallback((it: Item) => {195    if (it.kind === "nav" || it.kind === "action" || it.kind === "recent") remember(it.id);196    it.run();197  }, [remember]);198199  const onKey = (e: React.KeyboardEvent) => {200    if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(flat.length - 1, a + 1)); }201    else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); }202    else if (e.key === "Enter") { e.preventDefault(); const it = flat[active]; if (it) select(it); }203    else if (e.key === "Escape") { e.preventDefault(); closeCommand(); }204  };205206  // Garder l'élément actif visible207  useEffect(() => {208    const node = listRef.current?.querySelector<HTMLElement>(`[data-idx="${active}"]`);209    node?.scrollIntoView({ block: "nearest" });210  }, [active]);211212  return (213    <AnimatePresence>214      {commandOpen && (215        <div className="fixed inset-0 z-[80] flex items-start justify-center px-3 pt-[12vh] sm:pt-[15vh]" role="dialog" aria-modal="true" aria-label="Palette de commandes">216          <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }} className="absolute inset-0 bg-gray-950/40 dark:bg-black/60 backdrop-blur-sm" onClick={closeCommand} />217          <motion.div218            initial={{ opacity: 0, y: -12, scale: 0.98 }}219            animate={{ opacity: 1, y: 0, scale: 1 }}220            exit={{ opacity: 0, y: -8, scale: 0.98 }}221            transition={{ type: "spring", stiffness: 420, damping: 34 }}222            className="relative w-full max-w-2xl glass rounded-3xl shadow-pop overflow-hidden"223          >224            <div className="flex items-center gap-3 px-4 h-14 border-b border-line dark:border-white/10">225              {searching ? <Loader2 className="w-5 h-5 text-blue-500 animate-spin shrink-0" /> : <Search className="w-5 h-5 text-gray-400 shrink-0" />}226              <input227                ref={inputRef}228                value={query}229                onChange={(e) => setQuery(e.target.value)}230                onKeyDown={onKey}231                placeholder="Rechercher un fichier, un dossier, une action…"232                className="flex-1 bg-transparent outline-none text-[15px] text-gray-900 dark:text-white placeholder-gray-400"233                autoComplete="off"234                spellCheck={false}235              />236              <kbd className="hidden sm:inline-flex">esc</kbd>237            </div>238239            <div ref={listRef} className="max-h-[52vh] overflow-y-auto p-2 scrollbar-thin">240              {flat.length === 0 ? (241                <div className="py-12 text-center text-sm text-gray-500">242                  {searching ? "Recherche…" : <>Aucun résultat pour <span className="font-medium text-gray-700 dark:text-gray-200">« {query} »</span></>}243                </div>244              ) : (245                groups.map((g) => (246                  <div key={g.title} className="mb-1.5">247                    <div className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500 flex items-center gap-1.5">248                      {g.title === "Récents" && <History className="w-3 h-3" />}{g.title}249                    </div>250                    {g.items.map((it) => {251                      const idx = flat.indexOf(it);252                      const isActive = idx === active;253                      return (254                        <button255                          key={it.id}256                          data-idx={idx}257                          onMouseEnter={() => setActive(idx)}258                          onClick={() => select(it)}259                          className={`w-full flex items-center gap-3 px-3 h-11 rounded-xl text-left transition-colors ${isActive ? "bg-blue-500/10 dark:bg-blue-400/10" : "hover:bg-gray-100/70 dark:hover:bg-white/5"}`}260                        >261                          <span className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${isActive ? "bg-blue-500/15" : "bg-gray-100 dark:bg-white/[0.06]"}`}>262                            <it.icon className={`w-4 h-4 ${it.iconClass || (isActive ? "text-blue-600 dark:text-blue-300" : "text-gray-500 dark:text-gray-400")}`} />263                          </span>264                          <span className="flex-1 min-w-0">265                            <span className="block text-[14px] text-gray-900 dark:text-white truncate">{it.label}</span>266                            {it.hint && <span className="block text-[11.5px] text-gray-400 truncate">{it.hint}</span>}267                          </span>268                          {it.shortcut && <kbd>{it.shortcut}</kbd>}269                          {isActive && <CornerDownLeft className="w-4 h-4 text-gray-400" />}270                        </button>271                      );272                    })}273                  </div>274                ))275              )}276            </div>277278            <div className="hidden sm:flex items-center gap-4 px-4 h-10 border-t border-line dark:border-white/10 text-[11.5px] text-gray-400">279              <span className="flex items-center gap-1"><kbd><ArrowUp className="w-3 h-3" /></kbd><kbd><ArrowDown className="w-3 h-3" /></kbd> naviguer</span>280              <span className="flex items-center gap-1"><kbd>↵</kbd> ouvrir</span>281              <span className="flex items-center gap-1"><kbd>esc</kbd> fermer</span>282              <span className="ml-auto">SPB Cloud</span>283            </div>284          </motion.div>285        </div>286      )}287    </AnimatePresence>288  );289}290