JavaScript 56.7%
TypeScript 42.6%
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