SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
1 h agolast push
JavaScript 56.7% TypeScript 42.6%

feat(v3): refonte Aurora — design system, shell repliable, palette ⌘K, DnD, accueil analytique, PWA

- tokens sémantiques + 7 accents dynamiques, Geist local, thème système, densité, mouvement réduit
- barre latérale rail, TopBar fil d Ariane, MobileTabBar, CommandPalette, PreferencesModal
- explorateur : tri par colonnes, sélection plage, menu contextuel, clavier, glisser-déposer (bureau, dossiers, arbre, miettes), deep links ?file/?upload/?new
- aperçu plein écran réel + swipe ; pages secondaires, admin, partage public, AiPanel restylés
- accueil : donut par catégorie, activité 14 j, KPI ; /api/admin/stats enrichi
- fixes : recherche 500 (mode insensitive SQLite), rate limit 600 + médias exemptés, /raw → preview, route /admin/snapshots, .gitignore snapshots ancré, SNAPSHOT_DISABLED
Simon-Pierre Boucher committed 25 days ago (Sep 7, 2026) parent 9e0fce8

107 changed files +7,797 −5,372

modified .gitignore +5 −2
@@ -29,6 +29,9 @@ Thumbs.db
29 29 # Claude
30 30 .claude/
31 31
32 −# Snapshots (26G, never commit)
33 −snapshots/
32 +# Snapshots de données (racine seulement — app/api/snapshots doit rester versionné)
33 +/snapshots/
34 34 *.tsbuildinfo
35 +
36 +# Builds de sauvegarde
37 +.next.bak-*/
modified README.md +33 −1
@@ -1,5 +1,5 @@
1 1 <p align="center">
2 − <img src="https://img.shields.io/badge/SPB_Cloud-v2.0-blue?style=for-the-badge&logo=cloud&logoColor=white" alt="Version" />
2 + <img src="https://img.shields.io/badge/SPB_Cloud-v3.0-6366f1?style=for-the-badge&logo=cloud&logoColor=white" alt="Version" />
3 3 </p>
4 4
5 5 <h1 align="center">SPB Private Cloud</h1>
@@ -29,6 +29,38 @@
29 29
30 30 ---
31 31
32 +## Nouveautés v3 « Aurora » (2026-09-07)
33 +
34 +Refonte complète du front-end, déployée sur `cloud.spboucher.ai` (M2U64, PM2 `cloud`).
35 +
36 +**Design system**
37 +- Tokens sémantiques (`canvas`, `surface`, `elevated`, `line`), thème clair / sombre / système, **7 couleurs d'accent** au choix (Aurore, Océan, Glacier, Forêt, Corail, Ambre, Améthyste) appliquées à toute l'interface via `--accent-*`.
38 +- Police **Geist** locale (plus d'appel à Google Fonts), verre dépoli, ombres douces, animations réductibles (préférence « Réduire les animations »).
39 +- Densité des listes (confortable / compacte). Préférences dans `⌘,` — persistées en `localStorage` et appliquées avant le premier rendu (script inline dans `app/layout.tsx`, clés dans `lib/prefs.ts`).
40 +
41 +**Coquille**
42 +- Barre latérale repliable en rail (`⌘B`), sections Dossiers / Espaces / Outils / Administration / Tags, jauge de stockage, menu utilisateur.
43 +- Barre supérieure avec fil d'Ariane (cible de dépôt), bouton Envoyer, assistant IA, notifications, thème.
44 +- **Palette de commandes `⌘K`** (ou `/`) : recherche de fichiers et dossiers en direct, navigation, actions (envoyer, nouveau dossier, thème, préférences, déconnexion), historique des commandes.
45 +- Mobile : barre d'onglets iOS-like, tiroir glissable, PWA installable (`manifest.webmanifest`, icônes, `viewport-fit=cover`).
46 +
47 +**Explorateur**
48 +- Liste avec en-tête collant et colonnes triables, grille avec vignettes et actions rapides, sélection par plage (⇧-clic), clic droit → menu contextuel, navigation clavier complète.
49 +- **Glisser-déposer** : fichiers du bureau (overlay plein écran), déplacement interne vers dossiers, arbre latéral (auto-expansion) et fil d'Ariane ; déplacement multiple.
50 +- Liens profonds `?file=`, `?upload=1`, `?new=1` (utilisés par la palette, la barre supérieure et l'accueil).
51 +- Aperçu : plein écran réel, balayage tactile, panneau d'informations et versions refaits.
52 +
53 +**Accueil et administration**
54 +- Bandeau vivant, anneau de stockage par catégorie, activité sur 14 jours, KPI, « Reprendre », favoris, fichiers volumineux.
55 +- `GET /api/admin/stats` enrichi (rétro-compatible) : `byCategory`, `activityByDay`, `favoriteCount`, `trashCount`, `versionCount`, `largestFiles`.
56 +
57 +**Correctifs**
58 +- Recherche `/api/files?search=` : `mode: "insensitive"` (non supporté par Prisma/SQLite) renvoyait 500.
59 +- Limiteur de débit : 600 req/min/IP et exemption des aperçus/flux (une grille de 50 vignettes dépassait l'ancien plafond de 100).
60 +- Vignettes de l'accueil pointaient vers une route `/raw` inexistante.
61 +- Route `/admin/snapshots` manquante (404) ; `app/api/snapshots` n'était pas versionné (`.gitignore` trop large).
62 +- `SNAPSHOT_DISABLED=1` désactive le snapshot quotidien (utile pour un serveur de staging).
63 +
32 64 ## Qu'est-ce que SPB Cloud ?
33 65
34 66 SPB Private Cloud est un cloud personnel auto-h&eacute;berg&eacute; con&ccedil;u pour offrir un contr&ocirc;le total sur vos donn&eacute;es. Aucune d&eacute;pendance &agrave; Google, Dropbox ou iCloud. Vos fichiers restent chez vous, chiffr&eacute;s, accessibles depuis n'importe quel appareil.
modified app/admin/activity/page.tsx +36 −38
@@ -4,8 +4,11 @@ import { useEffect, useState } from "react";
4 4 import AppShell from "@/components/layout/AppShell";
5 5 import ActivityLog from "@/components/admin/ActivityLog";
6 6 import Button from "@/components/ui/Button";
7 +import PageHeader, { EmptyState } from "@/components/ui/PageHeader";
8 +import PageTransition from "@/components/ui/PageTransition";
9 +import { Activity, ChevronLeft, ChevronRight } from "lucide-react";
7 10
8 −interface Activity {
11 +interface ActivityItem {
9 12 id: string;
10 13 action: string;
11 14 details: string | null;
@@ -14,7 +17,7 @@ interface Activity {
14 17 }
15 18
16 19 export default function ActivityPage() {
17 − const [activities, setActivities] = useState<Activity[]>([]);
20 + const [activities, setActivities] = useState<ActivityItem[]>([]);
18 21 const [loading, setLoading] = useState(true);
19 22 const [total, setTotal] = useState(0);
20 23 const [offset, setOffset] = useState(0);
@@ -23,9 +26,7 @@ export default function ActivityPage() {
23 26 const fetchActivity = async (newOffset = 0) => {
24 27 setLoading(true);
25 28 try {
26 − const res = await fetch(
27 − `/api/admin/activity?limit=${limit}&offset=${newOffset}`
28 − );
29 + const res = await fetch(`/api/admin/activity?limit=${limit}&offset=${newOffset}`);
29 30 if (res.ok) {
30 31 const data = await res.json();
31 32 setActivities(data.activities);
@@ -37,56 +38,53 @@ export default function ActivityPage() {
37 38 }
38 39 };
39 40
40 − useEffect(() => {
41 − fetchActivity();
42 − }, []);
41 + useEffect(() => { fetchActivity(); }, []);
42 +
43 + const page = Math.floor(offset / limit) + 1;
44 + const pages = Math.max(1, Math.ceil(total / limit));
43 45
44 46 return (
45 47 <AppShell>
46 − <div className="p-4 lg:p-6 space-y-6">
47 − <div>
48 − <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Activité</h1>
49 − <p className="text-sm text-gray-500 mt-1">
50 − Historique complet ({total} entrées)
51 − </p>
52 − </div>
48 + <PageTransition className="px-4 lg:px-6 pb-8 max-w-5xl">
49 + <PageHeader title="Activité" subtitle={`Journal complet · ${total.toLocaleString("fr-CA")} entrée${total !== 1 ? "s" : ""}`} icon={Activity} tone="text-emerald-600 bg-emerald-500/10 dark:text-emerald-400" />
53 50
54 51 {loading ? (
55 − <div className="flex items-center justify-center h-32">
56 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
52 + <div className="card p-0 overflow-hidden divide-y divide-line dark:divide-white/[0.06]">
53 + {Array.from({ length: 10 }).map((_, i) => (
54 + <div key={i} className="flex items-center gap-3 px-4 py-3 animate-pulse">
55 + <div className="w-2 h-2 rounded-full bg-gray-200 dark:bg-white/10" />
56 + <div className="flex-1 space-y-1.5"><div className="h-3 w-2/3 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-1/4 rounded bg-gray-200 dark:bg-white/10" /></div>
57 + <div className="h-3 w-16 rounded bg-gray-200 dark:bg-white/10" />
58 + </div>
59 + ))}
57 60 </div>
61 + ) : activities.length === 0 ? (
62 + <EmptyState icon={Activity} title="Aucune activité" description="Les actions sur vos fichiers apparaîtront ici." />
58 63 ) : (
59 64 <>
60 − <div className="bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl overflow-hidden shadow-sm dark:shadow-none transition-colors">
65 + <div className="card p-0 overflow-hidden">
61 66 <ActivityLog activities={activities} />
62 67 </div>
63 68
64 69 {total > limit && (
65 − <div className="flex items-center justify-center gap-4">
66 − <Button
67 − variant="ghost"
68 − size="sm"
69 − disabled={offset === 0}
70 − onClick={() => fetchActivity(Math.max(0, offset - limit))}
71 − >
72 − Précédent
73 − </Button>
74 − <span className="text-sm text-gray-500">
75 − {offset + 1}-{Math.min(offset + limit, total)} sur {total}
70 + <div className="mt-4 flex items-center justify-between gap-3">
71 + <span className="text-[13px] text-gray-500 tabular-nums">
72 + {offset + 1}–{Math.min(offset + limit, total)} sur {total.toLocaleString("fr-CA")}
76 73 </span>
77 − <Button
78 − variant="ghost"
79 − size="sm"
80 − disabled={offset + limit >= total}
81 − onClick={() => fetchActivity(offset + limit)}
82 − >
83 − Suivant
84 − </Button>
74 + <div className="flex items-center gap-1">
75 + <Button variant="secondary" size="sm" disabled={offset === 0} onClick={() => fetchActivity(Math.max(0, offset - limit))}>
76 + <ChevronLeft className="w-4 h-4" /> <span className="hidden sm:inline">Précédent</span>
77 + </Button>
78 + <span className="px-2 text-[13px] text-gray-500 tabular-nums">{page} / {pages}</span>
79 + <Button variant="secondary" size="sm" disabled={offset + limit >= total} onClick={() => fetchActivity(offset + limit)}>
80 + <span className="hidden sm:inline">Suivant</span> <ChevronRight className="w-4 h-4" />
81 + </Button>
82 + </div>
85 83 </div>
86 84 )}
87 85 </>
88 86 )}
89 − </div>
87 + </PageTransition>
90 88 </AppShell>
91 89 );
92 90 }
modified app/admin/api-keys/page.tsx +6 −6
@@ -2,17 +2,17 @@
2 2
3 3 import AppShell from "@/components/layout/AppShell";
4 4 import ApiKeyManager from "@/components/admin/ApiKeyManager";
5 +import PageHeader from "@/components/ui/PageHeader";
6 +import PageTransition from "@/components/ui/PageTransition";
7 +import { Key } from "lucide-react";
5 8
6 9 export default function ApiKeysPage() {
7 10 return (
8 11 <AppShell>
9 − <div className="p-4 lg:p-6 space-y-6">
10 − <div>
11 − <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Clés API</h1>
12 − <p className="text-sm text-gray-500 mt-1">Gérez l&apos;accès à l&apos;API REST publique</p>
13 − </div>
12 + <PageTransition className="px-4 lg:px-6 pb-8 max-w-5xl">
13 + <PageHeader title="Clés API" subtitle="Accès programmatique à l'API REST publique (/api/v1)" icon={Key} tone="text-amber-600 bg-amber-500/10 dark:text-amber-400" />
14 14 <ApiKeyManager />
15 − </div>
15 + </PageTransition>
16 16 </AppShell>
17 17 );
18 18 }
modified app/admin/backup/page.tsx +7 −15
@@ -2,27 +2,19 @@
2 2
3 3 import AppShell from "@/components/layout/AppShell";
4 4 import BackupPanel from "@/components/admin/BackupPanel";
5 −import SnapshotPanel from "@/components/admin/SnapshotPanel";
5 +import PageHeader from "@/components/ui/PageHeader";
6 +import PageTransition from "@/components/ui/PageTransition";
7 +import { HardDrive } from "lucide-react";
6 8
7 9 export default function BackupPage() {
8 10 return (
9 11 <AppShell>
10 − <div className="p-4 lg:p-6 space-y-8">
11 − <div>
12 − <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Backups & Snapshots</h1>
13 − <p className="text-sm text-gray-500 mt-1">Sauvegardez et restaurez vos données</p>
14 − </div>
15 −
16 − {/* Daily snapshots — rolling 7-day window */}
17 − <div className="bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-4 lg:p-6">
18 − <SnapshotPanel />
19 − </div>
20 −
21 − {/* Manual full/incremental backups */}
22 − <div className="bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-4 lg:p-6">
12 + <PageTransition className="px-4 lg:px-6 pb-8 max-w-5xl">
13 + <PageHeader title="Backups" subtitle="Sauvegardes complètes ou incrémentales de vos données" icon={HardDrive} tone="text-cyan-600 bg-cyan-500/10 dark:text-cyan-300" />
14 + <div className="card p-4 sm:p-6">
23 15 <BackupPanel />
24 16 </div>
25 − </div>
17 + </PageTransition>
26 18 </AppShell>
27 19 );
28 20 }
modified app/admin/page.tsx +89 −44
@@ -1,10 +1,18 @@
1 1 "use client";
2 2
3 3 import { useEffect, useState } from "react";
4 +import Link from "next/link";
5 +import { motion } from "framer-motion";
6 +import { LayoutDashboard, HardDrive, Clock, ArrowRight, Users, Share2, Camera, Key, Settings, Activity, Database } from "lucide-react";
4 7 import AppShell from "@/components/layout/AppShell";
5 8 import StatsCards from "@/components/admin/StatsCards";
6 9 import ActivityLog from "@/components/admin/ActivityLog";
7 10 import StorageChart from "@/components/admin/StorageChart";
11 +import ActivityBars, { type DayActivity } from "@/components/dashboard/ActivityBars";
12 +import type { CategoryStat } from "@/components/dashboard/categories";
13 +import PageHeader from "@/components/ui/PageHeader";
14 +import Card, { CardTitle } from "@/components/ui/Card";
15 +import Skeleton, { SkeletonStat, SkeletonActivity } from "@/components/ui/Skeleton";
8 16
9 17 interface Stats {
10 18 fileCount: number;
@@ -12,20 +20,26 @@ interface Stats {
12 20 shareCount: number;
13 21 storageUsed: number;
14 22 diskFileCount: number;
15 − recentActivity: Array<{
16 − id: string;
17 − action: string;
18 − details: string | null;
19 − createdAt: string;
20 − file: { name: string } | null;
21 − }>;
22 − typeBreakdown: Array<{
23 − type: string;
24 − count: number;
25 − size: number;
26 − }>;
23 + recentActivity: Array<{ id: string; action: string; details: string | null; createdAt: string; file: { name: string } | null }>;
24 + typeBreakdown: Array<{ type: string; count: number; size: number }>;
25 + byCategory?: CategoryStat[];
26 + activityByDay?: DayActivity[];
27 + favoriteCount?: number;
28 + trashCount?: number;
29 + versionCount?: number;
27 30 }
28 31
32 +const links = [
33 + { href: "/admin/users", label: "Utilisateurs", icon: Users },
34 + { href: "/admin/shared-spaces", label: "Espaces partagés", icon: Users },
35 + { href: "/admin/shares", label: "Liens partagés", icon: Share2 },
36 + { href: "/admin/activity", label: "Journal complet", icon: Activity },
37 + { href: "/admin/snapshots", label: "Snapshots", icon: Camera },
38 + { href: "/admin/backup", label: "Backups", icon: Database },
39 + { href: "/admin/api-keys", label: "Clés API", icon: Key },
40 + { href: "/admin/settings", label: "Paramètres", icon: Settings },
41 +];
42 +
29 43 export default function AdminPage() {
30 44 const [stats, setStats] = useState<Stats | null>(null);
31 45 const [loading, setLoading] = useState(true);
@@ -33,54 +47,85 @@ export default function AdminPage() {
33 47 useEffect(() => {
34 48 fetch("/api/admin/stats")
35 49 .then((res) => res.json())
36 − .then((data) => {
37 − setStats(data);
38 − setLoading(false);
39 − })
50 + .then((data) => { setStats(data); setLoading(false); })
40 51 .catch(() => setLoading(false));
41 52 }, []);
42 53
43 54 return (
44 55 <AppShell>
45 − <div className="p-3 sm:p-4 lg:p-6 space-y-4 sm:space-y-6">
46 − <div>
47 − <h1 className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-white">Dashboard</h1>
48 − <p className="text-sm text-gray-500 mt-1">Vue d&apos;ensemble de votre cloud</p>
49 − </div>
56 + <div className="p-3 sm:p-5 lg:p-6 space-y-4 sm:space-y-5 max-w-[1400px] mx-auto">
57 + <PageHeader
58 + title="Administration"
59 + subtitle="Vue d'ensemble de votre cloud : stockage, activité et outils."
60 + icon={LayoutDashboard}
61 + compact
62 + />
50 63
51 64 {loading ? (
52 − <div className="flex items-center justify-center h-32">
53 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
65 + <div className="space-y-4 sm:space-y-5" aria-busy="true">
66 + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2 sm:gap-3">{[...Array(6)].map((_, i) => <SkeletonStat key={i} />)}</div>
67 + <div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-5">
68 + <div className="card p-5 lg:col-span-5"><Skeleton variant="text" className="h-3 w-24 mb-4" /><Skeleton variant="circular" className="w-[184px] h-[184px] mx-auto" /></div>
69 + <div className="card p-5 lg:col-span-7"><Skeleton variant="text" className="h-3 w-24 mb-4" /><Skeleton className="h-[150px]" /></div>
70 + </div>
71 + <div className="card p-5">{[...Array(6)].map((_, i) => <SkeletonActivity key={i} />)}</div>
54 72 </div>
55 73 ) : stats ? (
56 74 <>
57 75 <StatsCards stats={stats} />
58 76
59 − <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
60 − {/* Storage Chart */}
61 − <div className="bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl p-4 sm:p-6 shadow-sm dark:shadow-none transition-colors">
62 − <h2 className="text-base sm:text-lg font-medium text-gray-900 dark:text-white mb-4">
63 − Répartition du stockage
64 − </h2>
65 − <StorageChart
66 − data={stats.typeBreakdown}
67 − totalSize={stats.storageUsed}
68 − />
69 − </div>
77 + <div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-5">
78 + <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="lg:col-span-5">
79 + <Card className="h-full">
80 + <CardTitle><HardDrive className="w-3.5 h-3.5" /> Répartition du stockage</CardTitle>
81 + <StorageChart data={stats.typeBreakdown} totalSize={stats.storageUsed} byCategory={stats.byCategory} />
82 + <p className="mt-4 pt-3 border-t border-line dark:border-white/[0.07] text-[11.5px] text-gray-400">
83 + {(stats.diskFileCount ?? 0).toLocaleString("fr-CA")} objets sur disque · {(stats.typeBreakdown ?? []).length} types MIME
84 + </p>
85 + </Card>
86 + </motion.div>
87 + <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="lg:col-span-7">
88 + <Card className="h-full">
89 + <CardTitle><Clock className="w-3.5 h-3.5" /> Activité · 14 jours</CardTitle>
90 + {stats.activityByDay && stats.activityByDay.length > 0 ? (
91 + <ActivityBars data={stats.activityByDay} height={150} />
92 + ) : (
93 + <p className="text-sm text-gray-400 py-8 text-center">Aucune activité récente</p>
94 + )}
95 + </Card>
96 + </motion.div>
97 + </div>
70 98
71 − {/* Recent Activity */}
72 − <div className="bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl p-4 sm:p-6 shadow-sm dark:shadow-none transition-colors">
73 − <h2 className="text-base sm:text-lg font-medium text-gray-900 dark:text-white mb-4">
74 − Activité récente
75 − </h2>
76 − <div className="max-h-80 overflow-y-auto">
77 − <ActivityLog activities={stats.recentActivity} />
78 − </div>
79 − </div>
99 + <div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-5">
100 + <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="lg:col-span-7">
101 + <Card>
102 + <CardTitle action={<Link href="/admin/activity" className="text-[12px] text-blue-600 dark:text-blue-300 hover:underline flex items-center gap-1">Tout le journal <ArrowRight className="w-3 h-3" /></Link>}>
103 + <Clock className="w-3.5 h-3.5" /> Activité récente
104 + </CardTitle>
105 + <div className="max-h-[520px] overflow-y-auto -mx-1 px-1 scrollbar-thin">
106 + <ActivityLog activities={stats.recentActivity} />
107 + </div>
108 + </Card>
109 + </motion.div>
110 + <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.25 }} className="lg:col-span-5">
111 + <Card>
112 + <CardTitle><Settings className="w-3.5 h-3.5" /> Outils d&apos;administration</CardTitle>
113 + <ul className="grid grid-cols-2 gap-2">
114 + {links.map((l) => (
115 + <li key={l.href}>
116 + <Link href={l.href} className="group flex items-center gap-2.5 p-3 rounded-xl border border-line dark:border-white/[0.08] hover:border-blue-500/40 hover:bg-blue-500/5 transition-colors min-h-[52px]">
117 + <span className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-white/[0.06] group-hover:bg-blue-500/10 flex items-center justify-center shrink-0 text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-300 transition-colors"><l.icon className="w-4 h-4" /></span>
118 + <span className="text-[13px] font-medium text-gray-800 dark:text-gray-100 truncate">{l.label}</span>
119 + </Link>
120 + </li>
121 + ))}
122 + </ul>
123 + </Card>
124 + </motion.div>
80 125 </div>
81 126 </>
82 127 ) : (
83 − <p className="text-gray-500">Impossible de charger les stats</p>
128 + <Card><p className="text-gray-500 text-sm text-center py-8">Impossible de charger les statistiques.</p></Card>
84 129 )}
85 130 </div>
86 131 </AppShell>
modified app/admin/settings/page.tsx +45 −8
@@ -3,6 +3,12 @@
3 3 import { useEffect, useState } from "react";
4 4 import AppShell from "@/components/layout/AppShell";
5 5 import TwoFactorSetup from "@/components/admin/TwoFactorSetup";
6 +import PageHeader from "@/components/ui/PageHeader";
7 +import PageTransition from "@/components/ui/PageTransition";
8 +import Button from "@/components/ui/Button";
9 +import { Settings, SlidersHorizontal, Shield, Camera, HardDrive, Key, ArrowRight } from "lucide-react";
10 +import Link from "next/link";
11 +import { openPreferences } from "@/components/layout/ShellContext";
6 12
7 13 export default function SettingsPage() {
8 14 const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
@@ -12,23 +18,54 @@ export default function SettingsPage() {
12 18 const res = await fetch("/api/auth/session");
13 19 if (res.ok) {
14 20 const data = await res.json();
15 − setTwoFactorEnabled(data.twoFactorEnabled || false);
21 + setTwoFactorEnabled(!!data.twoFactorEnabled);
16 22 }
17 23 } catch {}
18 24 };
19 25
20 26 useEffect(() => { fetchStatus(); }, []);
21 27
28 + const links = [
29 + { href: "/dashboard/security", label: "Sécurité & appareils", hint: "Sessions, analyse IA des connexions", icon: Shield },
30 + { href: "/admin/snapshots", label: "Snapshots", hint: "Instantanés quotidiens", icon: Camera },
31 + { href: "/admin/backup", label: "Backups", hint: "Sauvegardes manuelles", icon: HardDrive },
32 + { href: "/admin/api-keys", label: "Clés API", hint: "Accès REST", icon: Key },
33 + ];
34 +
22 35 return (
23 36 <AppShell>
24 − <div className="p-4 lg:p-6 space-y-6">
25 − <div>
26 − <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Paramètres</h1>
27 − <p className="text-sm text-gray-500 mt-1">Sécurité et préférences</p>
28 − </div>
37 + <PageTransition className="px-4 lg:px-6 pb-8 max-w-4xl">
38 + <PageHeader title="Paramètres" subtitle="Sécurité du compte et préférences" icon={Settings} />
39 + <div className="space-y-6">
40 + <TwoFactorSetup enabled={twoFactorEnabled} onStatusChange={fetchStatus} />
29 41
30 − <TwoFactorSetup enabled={twoFactorEnabled} onStatusChange={fetchStatus} />
31 − </div>
42 + <section className="card p-4 sm:p-5 flex items-center gap-3">
43 + <div className="w-10 h-10 rounded-2xl bg-blue-500/10 text-blue-600 dark:text-blue-300 flex items-center justify-center shrink-0">
44 + <SlidersHorizontal className="w-5 h-5" />
45 + </div>
46 + <div className="flex-1 min-w-0">
47 + <h3 className="text-[15px] font-semibold text-gray-900 dark:text-white">Apparence</h3>
48 + <p className="text-[13px] text-gray-500 dark:text-gray-400">Thème clair/sombre, couleur d&apos;accent, densité, animations.</p>
49 + </div>
50 + <Button variant="secondary" size="sm" onClick={openPreferences}>Ouvrir <span className="hidden sm:inline"><kbd>⌘,</kbd></span></Button>
51 + </section>
52 +
53 + <section className="grid sm:grid-cols-2 gap-3">
54 + {links.map((l) => (
55 + <Link key={l.href} href={l.href} className="card card-hover p-4 flex items-center gap-3 group">
56 + <div className="w-10 h-10 rounded-2xl bg-gray-100 dark:bg-white/[0.06] text-gray-500 dark:text-gray-300 flex items-center justify-center shrink-0 group-hover:bg-blue-500/10 group-hover:text-blue-600 dark:group-hover:text-blue-300 transition-colors">
57 + <l.icon className="w-5 h-5" />
58 + </div>
59 + <div className="flex-1 min-w-0">
60 + <p className="text-[14px] font-medium text-gray-900 dark:text-white">{l.label}</p>
61 + <p className="text-[12.5px] text-gray-500 dark:text-gray-400 truncate">{l.hint}</p>
62 + </div>
63 + <ArrowRight className="w-4 h-4 text-gray-300 group-hover:text-gray-500 group-hover:translate-x-0.5 transition-all" />
64 + </Link>
65 + ))}
66 + </section>
67 + </div>
68 + </PageTransition>
32 69 </AppShell>
33 70 );
34 71 }
modified app/admin/shared-spaces/page.tsx +6 −6
@@ -2,17 +2,17 @@
2 2
3 3 import AppShell from "@/components/layout/AppShell";
4 4 import SharedSpaceManagement from "@/components/admin/SharedSpaceManagement";
5 +import PageHeader from "@/components/ui/PageHeader";
6 +import PageTransition from "@/components/ui/PageTransition";
7 +import { Users } from "lucide-react";
5 8
6 9 export default function SharedSpacesPage() {
7 10 return (
8 11 <AppShell>
9 − <div className="p-4 lg:p-6 space-y-6">
10 − <div>
11 − <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Espaces partagés</h1>
12 − <p className="text-sm text-gray-500 mt-1">Créez des clouds partagés entre utilisateurs · 500 Go par espace</p>
13 − </div>
12 + <PageTransition className="px-4 lg:px-6 pb-8 max-w-6xl">
13 + <PageHeader title="Espaces partagés" subtitle="Clouds communs entre utilisateurs · 500 Go par espace" icon={Users} tone="text-violet-600 bg-violet-500/10 dark:text-violet-300" />
14 14 <SharedSpaceManagement />
15 − </div>
15 + </PageTransition>
16 16 </AppShell>
17 17 );
18 18 }
modified app/admin/shares/page.tsx +90 −100
@@ -3,9 +3,12 @@
3 3 import { useEffect, useState } from "react";
4 4 import AppShell from "@/components/layout/AppShell";
5 5 import Button from "@/components/ui/Button";
6 −import { formatDate } from "@/lib/utils";
6 +import Modal from "@/components/ui/Modal";
7 +import PageHeader, { EmptyState } from "@/components/ui/PageHeader";
8 +import PageTransition from "@/components/ui/PageTransition";
9 +import { formatDate, formatFileSize } from "@/lib/utils";
7 10 import { motion } from "framer-motion";
8 −import { Link2, Link2Off, Trash2, Copy, ExternalLink } from "lucide-react";
11 +import { Link2, Link2Off, Trash2, Copy, ExternalLink, Lock, Clock, Download, Eye, Share2 } from "lucide-react";
9 12 import toast from "react-hot-toast";
10 13
11 14 interface SharedLink {
@@ -22,6 +25,8 @@ interface SharedLink {
22 25 export default function SharesPage() {
23 26 const [shares, setShares] = useState<SharedLink[]>([]);
24 27 const [loading, setLoading] = useState(true);
28 + const [filter, setFilter] = useState<"all" | "active" | "inactive">("all");
29 + const [toDelete, setToDelete] = useState<SharedLink | null>(null);
25 30
26 31 const fetchShares = async () => {
27 32 setLoading(true);
@@ -33,127 +38,112 @@ export default function SharesPage() {
33 38 }
34 39 };
35 40
36 − useEffect(() => {
37 − fetchShares();
38 − }, []);
41 + useEffect(() => { fetchShares(); }, []);
39 42
40 43 const toggleActive = async (id: string, active: boolean) => {
41 − await fetch(`/api/shares/${id}`, {
42 − method: "PATCH",
43 − headers: { "Content-Type": "application/json" },
44 − body: JSON.stringify({ active: !active }),
45 − });
44 + await fetch(`/api/shares/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ active: !active }) });
46 45 fetchShares();
47 46 toast.success(active ? "Lien désactivé" : "Lien activé");
48 47 };
49 48
50 − const deleteShare = async (id: string) => {
51 − await fetch(`/api/shares/${id}`, { method: "DELETE" });
49 + const deleteShare = async () => {
50 + if (!toDelete) return;
51 + await fetch(`/api/shares/${toDelete.id}`, { method: "DELETE" });
52 + setToDelete(null);
52 53 fetchShares();
53 54 toast.success("Lien supprimé");
54 55 };
55 56
56 57 const copyLink = (token: string) => {
57 − navigator.clipboard.writeText(`${window.location.origin}/shared/${token}`);
58 − toast.success("Lien copié");
58 + navigator.clipboard.writeText(`${window.location.origin}/shared/${token}`).then(() => toast.success("Lien copié")).catch(() => toast.error("Copie impossible"));
59 59 };
60 60
61 + const isExpired = (s: SharedLink) => !!s.expiresAt && new Date(s.expiresAt).getTime() < Date.now();
62 + const visible = shares.filter((s) => filter === "all" ? true : filter === "active" ? s.active && !isExpired(s) : !s.active || isExpired(s));
63 + const activeCount = shares.filter((s) => s.active && !isExpired(s)).length;
64 +
61 65 return (
62 66 <AppShell>
63 − <div className="p-3 sm:p-4 lg:p-6 space-y-4 sm:space-y-6">
64 − <div>
65 − <h1 className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-white">Liens partagés</h1>
66 − <p className="text-sm text-gray-500 mt-1">
67 − Gérez vos liens de partage
68 − </p>
69 − </div>
67 + <PageTransition className="px-4 lg:px-6 pb-8 max-w-5xl">
68 + <PageHeader
69 + title="Liens partagés"
70 + subtitle={loading ? "Chargement…" : `${activeCount} actif${activeCount !== 1 ? "s" : ""} sur ${shares.length}`}
71 + icon={Share2}
72 + tone="text-violet-600 bg-violet-500/10 dark:text-violet-300"
73 + actions={
74 + <div className="flex items-center gap-0.5 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06]">
75 + {([["all", "Tous"], ["active", "Actifs"], ["inactive", "Inactifs"]] as const).map(([k, l]) => (
76 + <button key={k} onClick={() => setFilter(k)} className={`h-8 px-3 rounded-lg text-[12.5px] font-medium transition-colors ${filter === k ? "bg-surface dark:bg-white/10 text-gray-900 dark:text-white shadow-card dark:shadow-none" : "text-gray-500 hover:text-gray-800 dark:hover:text-gray-200"}`} aria-pressed={filter === k}>
77 + {l}
78 + </button>
79 + ))}
80 + </div>
81 + }
82 + />
70 83
71 84 {loading ? (
72 − <div className="flex items-center justify-center h-32">
73 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
74 − </div>
75 − ) : shares.length === 0 ? (
76 − <div className="text-center text-gray-500 py-12">
77 − <Link2 className="w-12 h-12 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
78 − <p>Aucun lien de partage</p>
85 + <div className="space-y-2">
86 + {Array.from({ length: 4 }).map((_, i) => (
87 + <div key={i} className="card p-4 flex items-center gap-3 animate-pulse">
88 + <div className="w-10 h-10 rounded-xl bg-gray-200 dark:bg-white/10" />
89 + <div className="flex-1 space-y-2"><div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-1/2 rounded bg-gray-200 dark:bg-white/10" /></div>
90 + </div>
91 + ))}
79 92 </div>
93 + ) : visible.length === 0 ? (
94 + <EmptyState icon={Link2} title={shares.length === 0 ? "Aucun lien de partage" : "Aucun lien dans ce filtre"} description="Créez un lien depuis le menu d'un fichier : expiration, mot de passe, aperçu seul ou téléchargement." />
80 95 ) : (
81 − <div className="space-y-3">
82 − {shares.map((share, i) => (
83 − <motion.div
84 − key={share.id}
85 − initial={{ opacity: 0, y: 10 }}
86 − animate={{ opacity: 1, y: 0 }}
87 − transition={{ delay: i * 0.05 }}
88 − className={`bg-white dark:bg-white/5 border rounded-xl p-3.5 sm:p-4 shadow-sm dark:shadow-none transition-colors ${
89 − share.active ? "border-gray-200 dark:border-white/10" : "border-gray-100 dark:border-white/5 opacity-60"
90 − }`}
91 − >
92 − <div className="flex flex-col gap-3">
93 − <div className="flex-1 min-w-0">
94 − <p className="text-sm font-medium text-gray-900 dark:text-white truncate">
95 − {share.file?.name || "Fichier supprimé"}
96 − </p>
97 − <div className="flex flex-wrap gap-x-2 gap-y-1 mt-1">
98 − <span className="text-xs text-gray-500">
99 − {share.mode === "DOWNLOAD" ? "Téléchargement" : "Aperçu"}
100 − </span>
101 − {share.passwordHash && (
102 − <span className="text-xs text-yellow-500">Protégé</span>
103 − )}
104 − {share.expiresAt && (
105 − <span className="text-xs text-gray-500 break-words">
106 − Expire: {formatDate(share.expiresAt)}
107 − </span>
108 − )}
109 − <span className="text-xs text-gray-400 dark:text-gray-600 break-words">
110 − Créé: {formatDate(share.createdAt)}
111 − </span>
96 + <div className="space-y-2">
97 + {visible.map((share, i) => {
98 + const expired = isExpired(share);
99 + const on = share.active && !expired;
100 + return (
101 + <motion.div
102 + key={share.id}
103 + initial={{ opacity: 0, y: 8 }}
104 + animate={{ opacity: 1, y: 0 }}
105 + transition={{ delay: Math.min(i, 10) * 0.03 }}
106 + className={`card p-3.5 sm:p-4 ${on ? "" : "opacity-60"}`}
107 + >
108 + <div className="flex flex-col sm:flex-row sm:items-center gap-3">
109 + <div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${on ? "bg-violet-500/10 text-violet-600 dark:text-violet-300" : "bg-gray-100 dark:bg-white/[0.06] text-gray-400"}`}>
110 + {on ? <Link2 className="w-5 h-5" /> : <Link2Off className="w-5 h-5" />}
111 + </div>
112 + <div className="flex-1 min-w-0">
113 + <p className="text-[14px] font-medium text-gray-900 dark:text-white truncate">{share.file?.name || "Fichier supprimé"}</p>
114 + <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1 mt-1 text-[12px] text-gray-500 dark:text-gray-400">
115 + <span className="inline-flex items-center gap-1">{share.mode === "DOWNLOAD" ? <Download className="w-3 h-3" /> : <Eye className="w-3 h-3" />}{share.mode === "DOWNLOAD" ? "Téléchargement" : "Aperçu seul"}</span>
116 + {share.file && <span>{formatFileSize(share.file.size)}</span>}
117 + {share.passwordHash && <span className="inline-flex items-center gap-1 text-amber-600 dark:text-amber-400"><Lock className="w-3 h-3" /> Protégé</span>}
118 + {share.expiresAt && <span className={`inline-flex items-center gap-1 ${expired ? "text-rose-500" : ""}`}><Clock className="w-3 h-3" />{expired ? "Expiré le" : "Expire le"} {formatDate(share.expiresAt)}</span>}
119 + <span className="text-gray-400">Créé le {formatDate(share.createdAt)}</span>
120 + </div>
121 + </div>
122 + <div className="flex items-center gap-1 shrink-0 -ml-1 sm:ml-0">
123 + <Button variant="ghost" size="sm" onClick={() => copyLink(share.token)} title="Copier le lien"><Copy className="w-4 h-4" /><span className="sm:hidden">Copier</span></Button>
124 + <Button variant="ghost" size="sm" onClick={() => window.open(`/shared/${share.token}`, "_blank")} title="Ouvrir"><ExternalLink className="w-4 h-4" /></Button>
125 + <Button variant="ghost" size="sm" onClick={() => toggleActive(share.id, share.active)} title={share.active ? "Désactiver" : "Activer"}>
126 + {share.active ? <Link2Off className="w-4 h-4" /> : <Link2 className="w-4 h-4" />}
127 + </Button>
128 + <Button variant="ghost" size="sm" onClick={() => setToDelete(share)} title="Supprimer" className="hover:!text-rose-500 hover:!bg-rose-500/10"><Trash2 className="w-4 h-4" /></Button>
112 129 </div>
113 130 </div>
114 − <div className="flex items-center gap-1.5 sm:gap-2 flex-wrap">
115 − <Button
116 − variant="ghost"
117 − size="sm"
118 − onClick={() => copyLink(share.token)}
119 − >
120 − <Copy className="w-3.5 h-3.5" />
121 − <span className="text-xs sm:hidden">Copier</span>
122 − </Button>
123 − <Button
124 − variant="ghost"
125 − size="sm"
126 − onClick={() =>
127 − window.open(`/shared/${share.token}`, "_blank")
128 − }
129 − >
130 − <ExternalLink className="w-3.5 h-3.5" />
131 − </Button>
132 − <Button
133 − variant="ghost"
134 − size="sm"
135 − onClick={() => toggleActive(share.id, share.active)}
136 − >
137 − {share.active ? (
138 − <Link2Off className="w-3.5 h-3.5" />
139 − ) : (
140 − <Link2 className="w-3.5 h-3.5" />
141 − )}
142 − </Button>
143 − <Button
144 − variant="ghost"
145 − size="sm"
146 − onClick={() => deleteShare(share.id)}
147 − >
148 − <Trash2 className="w-3.5 h-3.5 text-red-400" />
149 − </Button>
150 − </div>
151 − </div>
152 − </motion.div>
153 − ))}
131 + </motion.div>
132 + );
133 + })}
154 134 </div>
155 135 )}
156 − </div>
136 + </PageTransition>
137 +
138 + <Modal
139 + isOpen={!!toDelete}
140 + onClose={() => setToDelete(null)}
141 + title="Supprimer ce lien ?"
142 + description={toDelete?.file?.name}
143 + footer={<><Button variant="ghost" onClick={() => setToDelete(null)}>Annuler</Button><Button variant="danger" onClick={deleteShare}><Trash2 className="w-4 h-4" /> Supprimer</Button></>}
144 + >
145 + <p className="text-sm text-gray-600 dark:text-gray-300">Le lien cessera immédiatement de fonctionner pour toute personne qui le possède.</p>
146 + </Modal>
157 147 </AppShell>
158 148 );
159 149 }
added app/admin/snapshots/page.tsx +20 −0
@@ -0,0 +1,20 @@
1 +"use client";
2 +
3 +import AppShell from "@/components/layout/AppShell";
4 +import SnapshotPanel from "@/components/admin/SnapshotPanel";
5 +import PageHeader from "@/components/ui/PageHeader";
6 +import PageTransition from "@/components/ui/PageTransition";
7 +import { Camera } from "lucide-react";
8 +
9 +export default function SnapshotsPage() {
10 + return (
11 + <AppShell>
12 + <PageTransition className="px-4 lg:px-6 pb-8 max-w-5xl">
13 + <PageHeader title="Snapshots" subtitle="Instantanés quotidiens · 7 jours conservés · restauration en un clic" icon={Camera} />
14 + <div className="card p-4 sm:p-6">
15 + <SnapshotPanel />
16 + </div>
17 + </PageTransition>
18 + </AppShell>
19 + );
20 +}
modified app/admin/users/page.tsx +6 −6
@@ -2,17 +2,17 @@
2 2
3 3 import AppShell from "@/components/layout/AppShell";
4 4 import UserManagement from "@/components/admin/UserManagement";
5 +import PageHeader from "@/components/ui/PageHeader";
6 +import PageTransition from "@/components/ui/PageTransition";
7 +import { Users } from "lucide-react";
5 8
6 9 export default function UsersPage() {
7 10 return (
8 11 <AppShell>
9 − <div className="p-4 lg:p-6 space-y-6">
10 − <div>
11 − <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Utilisateurs</h1>
12 − <p className="text-sm text-gray-500 mt-1">Gérez les utilisateurs de votre cloud</p>
13 − </div>
12 + <PageTransition className="px-4 lg:px-6 pb-8 max-w-6xl">
13 + <PageHeader title="Utilisateurs" subtitle="Comptes, rôles et double authentification" icon={Users} />
14 14 <UserManagement />
15 − </div>
15 + </PageTransition>
16 16 </AppShell>
17 17 );
18 18 }
modified app/api/admin/stats/route.ts +93 −19
@@ -1,34 +1,101 @@
1 1 import { NextResponse } from "next/server";
2 2 import { prisma } from "@/lib/prisma";
3 3 import { getStorageUsage } from "@/lib/storage";
4 +import { aggregateByCategory } from "@/components/dashboard/categories";
5 +
6 +export const dynamic = "force-dynamic";
7 +
8 +const UPLOAD_ACTIONS = new Set(["UPLOAD", "upload", "file_uploaded"]);
9 +
10 +function dayKey(d: Date): string {
11 + const y = d.getFullYear();
12 + const m = String(d.getMonth() + 1).padStart(2, "0");
13 + const day = String(d.getDate()).padStart(2, "0");
14 + return `${y}-${m}-${day}`;
15 +}
4 16
5 17 export async function GET() {
6 − const [fileCount, folderCount, shareCount, recentActivity, storageUsage] =
7 − await Promise.all([
8 − prisma.file.count(),
9 − prisma.folder.count(),
10 − prisma.sharedLink.count({ where: { active: true } }),
11 − prisma.activityLog.findMany({
12 − take: 20,
13 − orderBy: { createdAt: "desc" },
14 − include: { file: { select: { name: true } } },
15 − }),
16 − getStorageUsage(),
17 − ]);
18 −
19 − // File type breakdown
20 − const filesByType = await prisma.file.groupBy({
21 − by: ["mimeType"],
22 − _count: true,
23 − _sum: { size: true },
24 − });
18 + const since = new Date();
19 + since.setHours(0, 0, 0, 0);
20 + since.setDate(since.getDate() - 13); // 14 jours incluant aujourd'hui
21 +
22 + const [
23 + fileCount,
24 + folderCount,
25 + shareCount,
26 + recentActivity,
27 + storageUsage,
28 + filesByType,
29 + favoriteCount,
30 + trashCount,
31 + versionCount,
32 + largestRaw,
33 + recentLogs,
34 + ] = await Promise.all([
35 + prisma.file.count(),
36 + prisma.folder.count(),
37 + prisma.sharedLink.count({ where: { active: true } }),
38 + prisma.activityLog.findMany({
39 + take: 20,
40 + orderBy: { createdAt: "desc" },
41 + include: { file: { select: { name: true } } },
42 + }),
43 + getStorageUsage(),
44 + prisma.file.groupBy({
45 + by: ["mimeType"],
46 + where: { deletedAt: null },
47 + _count: true,
48 + _sum: { size: true },
49 + }),
50 + prisma.fileMetadata.count({ where: { isFavorite: true, file: { deletedAt: null } } }),
51 + prisma.file.count({ where: { deletedAt: { not: null } } }),
52 + prisma.fileVersion.count(),
53 + prisma.file.findMany({
54 + where: { deletedAt: null },
55 + orderBy: { size: "desc" },
56 + take: 5,
57 + select: { id: true, name: true, size: true, mimeType: true, folderId: true },
58 + }),
59 + prisma.activityLog.findMany({
60 + where: { createdAt: { gte: since } },
61 + select: { createdAt: true, action: true },
62 + }),
63 + ]);
25 64
65 + // File type breakdown (rétro-compatible)
26 66 const typeBreakdown = filesByType.map((t) => ({
27 67 type: t.mimeType,
28 68 count: t._count,
29 69 size: Number(t._sum.size || 0),
30 70 }));
31 71
72 + const byCategory = aggregateByCategory(typeBreakdown);
73 +
74 + // Activité par jour (14 jours, jours vides inclus)
75 + const buckets = new Map<string, { date: string; count: number; uploads: number }>();
76 + for (let i = 0; i < 14; i++) {
77 + const d = new Date(since);
78 + d.setDate(since.getDate() + i);
79 + const k = dayKey(d);
80 + buckets.set(k, { date: k, count: 0, uploads: 0 });
81 + }
82 + for (const log of recentLogs) {
83 + const k = dayKey(new Date(log.createdAt));
84 + const b = buckets.get(k);
85 + if (!b) continue;
86 + b.count += 1;
87 + if (UPLOAD_ACTIONS.has(log.action)) b.uploads += 1;
88 + }
89 + const activityByDay = Array.from(buckets.values());
90 +
91 + const largestFiles = largestRaw.map((f) => ({
92 + id: f.id,
93 + name: f.name,
94 + size: Number(f.size),
95 + mimeType: f.mimeType,
96 + folderId: f.folderId,
97 + }));
98 +
32 99 return NextResponse.json({
33 100 fileCount,
34 101 folderCount,
@@ -37,5 +104,12 @@ export async function GET() {
37 104 diskFileCount: storageUsage.fileCount,
38 105 recentActivity,
39 106 typeBreakdown,
107 + // v3
108 + byCategory,
109 + activityByDay,
110 + favoriteCount,
111 + trashCount,
112 + versionCount,
113 + largestFiles,
40 114 });
41 115 }
modified app/api/files/route.ts +1 −1
@@ -36,7 +36,7 @@ export async function GET(request: NextRequest) {
36 36 }
37 37
38 38 if (search) {
39 − where.name = { contains: search, mode: "insensitive" };
39 + where.name = { contains: search };
40 40 }
41 41
42 42 if (type) {
added app/api/snapshots/[id]/restore/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { restoreSnapshot } from "@/lib/snapshot";
5 +
6 +export async function POST(
7 + request: NextRequest,
8 + { params }: { params: { id: string } }
9 +) {
10 + const response = NextResponse.next();
11 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
12 + if (!session.userId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
13 +
14 + try {
15 + await restoreSnapshot(params.id);
16 + return NextResponse.json({ success: true });
17 + } catch (error) {
18 + return NextResponse.json({ error: (error as Error).message }, { status: 500 });
19 + }
20 +}
added app/api/snapshots/[id]/route.ts +16 −0
@@ -0,0 +1,16 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { deleteSnapshot } from "@/lib/snapshot";
5 +
6 +export async function DELETE(
7 + request: NextRequest,
8 + { params }: { params: { id: string } }
9 +) {
10 + const response = NextResponse.next();
11 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
12 + if (!session.userId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
13 +
14 + const ok = await deleteSnapshot(params.id);
15 + return NextResponse.json({ success: ok });
16 +}
added app/api/snapshots/route.ts +26 −0
@@ -0,0 +1,26 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { createSnapshot, listSnapshots } from "@/lib/snapshot";
5 +
6 +export async function GET(request: NextRequest) {
7 + const response = NextResponse.next();
8 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
9 + if (!session.userId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
10 +
11 + const snapshots = await listSnapshots();
12 + return NextResponse.json(snapshots);
13 +}
14 +
15 +export async function POST(request: NextRequest) {
16 + const response = NextResponse.next();
17 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
18 + if (!session.userId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
19 +
20 + try {
21 + const result = await createSnapshot();
22 + return NextResponse.json(result, { status: 201 });
23 + } catch (error) {
24 + return NextResponse.json({ error: (error as Error).message }, { status: 500 });
25 + }
26 +}
modified app/dashboard/favorites/page.tsx +59 −36
@@ -1,21 +1,40 @@
1 1 "use client";
2 2
3 3 import { useState, useEffect, useCallback } from "react";
4 −import { Star } from "lucide-react";
4 +import { Star, LayoutGrid, List } from "lucide-react";
5 5 import AppShell from "@/components/layout/AppShell";
6 6 import FileCard from "@/components/files/FileCard";
7 7 import PreviewEngine from "@/components/preview/PreviewEngine";
8 +import PageHeader, { EmptyState } from "@/components/ui/PageHeader";
9 +import PageTransition from "@/components/ui/PageTransition";
8 10 import type { FileData } from "@/types/files";
9 11
12 +function GridSkeleton() {
13 + return (
14 + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
15 + {Array.from({ length: 10 }).map((_, i) => (
16 + <div key={i} className="card p-0 overflow-hidden animate-pulse">
17 + <div className="aspect-square bg-gray-100 dark:bg-white/[0.06]" />
18 + <div className="p-2.5 space-y-1.5">
19 + <div className="h-3 w-3/4 rounded bg-gray-200 dark:bg-white/10" />
20 + <div className="h-2 w-1/2 rounded bg-gray-200 dark:bg-white/10" />
21 + </div>
22 + </div>
23 + ))}
24 + </div>
25 + );
26 +}
27 +
10 28 export default function FavoritesPage() {
11 29 const [files, setFiles] = useState<FileData[]>([]);
12 30 const [previewFile, setPreviewFile] = useState<FileData | null>(null);
13 31 const [loading, setLoading] = useState(true);
32 + const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
14 33
15 34 const fetchFavorites = useCallback(async () => {
16 35 setLoading(true);
17 36 try {
18 − const res = await fetch("/api/files?favorite=true");
37 + const res = await fetch("/api/files?favorite=true&limit=200");
19 38 if (res.ok) {
20 39 const data = await res.json();
21 40 setFiles(Array.isArray(data) ? data : data.files || []);
@@ -24,57 +43,61 @@ export default function FavoritesPage() {
24 43 setLoading(false);
25 44 }, []);
26 45
27 − useEffect(() => {
28 − fetchFavorites();
29 − }, [fetchFavorites]);
46 + useEffect(() => { fetchFavorites(); }, [fetchFavorites]);
30 47
31 48 return (
32 49 <AppShell>
33 50 <div className="flex flex-col h-full lg:flex-row">
34 − <div className="flex-1 flex flex-col min-w-0">
35 − <div className="px-3 sm:px-4 lg:px-6 py-3 sm:py-4 border-b border-gray-200 dark:border-white/10">
36 − <h1 className="text-lg font-medium text-gray-900 dark:text-white flex items-center gap-2">
37 − <Star className="w-5 h-5 text-yellow-400" />
38 − Favoris
39 − </h1>
40 − <p className="text-xs text-gray-500 mt-1">{files.length} fichier{files.length !== 1 ? "s" : ""}</p>
51 + <PageTransition className="flex-1 flex flex-col min-w-0">
52 + <div className="px-4 lg:px-6">
53 + <PageHeader
54 + title="Favoris"
55 + subtitle={loading ? "Chargement…" : `${files.length} fichier${files.length !== 1 ? "s" : ""} marqué${files.length !== 1 ? "s" : ""} d'une étoile`}
56 + icon={Star}
57 + tone="text-amber-500 bg-amber-500/10"
58 + compact
59 + actions={
60 + <div className="flex items-center gap-0.5 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06]">
61 + {([["grid", LayoutGrid], ["list", List]] as const).map(([m, Icon]) => (
62 + <button
63 + key={m}
64 + onClick={() => setViewMode(m)}
65 + className={`w-9 h-8 flex items-center justify-center rounded-lg transition-colors ${viewMode === m ? "bg-surface dark:bg-white/10 text-gray-900 dark:text-white shadow-card dark:shadow-none" : "text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"}`}
66 + aria-label={m === "grid" ? "Vue grille" : "Vue liste"}
67 + aria-pressed={viewMode === m}
68 + >
69 + <Icon className="w-4 h-4" />
70 + </button>
71 + ))}
72 + </div>
73 + }
74 + />
41 75 </div>
42 76
43 − <div className="flex-1 overflow-auto px-3 sm:px-4 lg:px-6 py-3 sm:py-4">
77 + <div className="flex-1 overflow-auto px-4 lg:px-6 pb-6">
44 78 {loading ? (
45 − <div className="flex items-center justify-center h-32">
46 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
47 − </div>
79 + <GridSkeleton />
48 80 ) : files.length === 0 ? (
49 − <div className="flex flex-col items-center justify-center h-64 text-gray-500">
50 − <Star className="w-12 h-12 mb-4 text-gray-300 dark:text-gray-600" />
51 − <p className="text-lg">Aucun favori</p>
52 − <p className="text-sm mt-1 text-center px-4">Ajoutez des fichiers aux favoris pour les retrouver ici</p>
81 + <EmptyState icon={Star} title="Aucun favori" description="Marquez des fichiers d'une étoile pour les retrouver ici en un clin d'œil." />
82 + ) : viewMode === "grid" ? (
83 + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
84 + {files.map((file) => (
85 + <FileCard key={file.id} file={file} onPreview={setPreviewFile} onRefresh={fetchFavorites} viewMode="grid" />
86 + ))}
53 87 </div>
54 88 ) : (
55 − <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 sm:gap-3">
89 + <div className="card p-0 overflow-hidden divide-y divide-line dark:divide-white/[0.06]">
56 90 {files.map((file) => (
57 − <FileCard
58 − key={file.id}
59 − file={file}
60 − onPreview={setPreviewFile}
61 − onRefresh={fetchFavorites}
62 − viewMode="grid"
63 − />
91 + <FileCard key={file.id} file={file} onPreview={setPreviewFile} onRefresh={fetchFavorites} viewMode="list" />
64 92 ))}
65 93 </div>
66 94 )}
67 95 </div>
68 − </div>
96 + </PageTransition>
69 97
70 98 {previewFile && (
71 − <div className="fixed inset-0 z-50 lg:relative lg:inset-auto lg:w-[400px] lg:border-l lg:border-gray-200 dark:lg:border-white/10">
72 − <PreviewEngine
73 − file={previewFile}
74 − files={files}
75 − onClose={() => setPreviewFile(null)}
76 − onNavigate={setPreviewFile}
77 − />
99 + <div className="fixed inset-0 z-50 lg:relative lg:inset-auto lg:w-[420px] lg:border-l lg:border-line dark:lg:border-white/[0.07]">
100 + <PreviewEngine file={previewFile} files={files} onClose={() => setPreviewFile(null)} onNavigate={setPreviewFile} />
78 101 </div>
79 102 )}
80 103 </div>
modified app/dashboard/files/page.tsx +10 −5
@@ -1,16 +1,21 @@
1 1 "use client";
2 2
3 +import { Suspense } from "react";
3 4 import AppShell from "@/components/layout/AppShell";
4 5 import FileExplorer from "@/components/files/FileExplorer";
5 −import Breadcrumb from "@/components/layout/Breadcrumb";
6 +import { useShellHeader } from "@/components/layout/ShellContext";
7 +
8 +function FilesContent() {
9 + useShellHeader({ crumbs: [] });
10 + return <FileExplorer folderName="Fichiers" />;
11 +}
6 12
7 13 export default function FilesPage() {
8 14 return (
9 15 <AppShell>
10 − <div className="px-4 lg:px-6 pt-4">
11 − <Breadcrumb items={[]} />
12 − </div>
13 − <FileExplorer />
16 + <Suspense fallback={<div className="p-6 text-sm text-gray-400">Chargement…</div>}>
17 + <FilesContent />
18 + </Suspense>
14 19 </AppShell>
15 20 );
16 21 }
modified app/dashboard/folder/[id]/page.tsx +47 −25
@@ -1,49 +1,71 @@
1 1 "use client";
2 2
3 −import { useEffect, useState } from "react";
4 −import { useParams } from "next/navigation";
3 +import { Suspense, useEffect, useState } from "react";
4 +import { useParams, useRouter } from "next/navigation";
5 +import toast from "react-hot-toast";
5 6 import AppShell from "@/components/layout/AppShell";
6 7 import FileExplorer from "@/components/files/FileExplorer";
7 −import Breadcrumb from "@/components/layout/Breadcrumb";
8 +import { useShellHeader, type Crumb } from "@/components/layout/ShellContext";
9 +import { FOLDERS_CHANGED_EVENT } from "@/components/files/dnd";
8 10
9 −interface BreadcrumbItem {
10 − id: string;
11 − name: string;
12 −}
13 −
14 −export default function FolderPage() {
15 − const params = useParams();
16 − const folderId = params.id as string;
17 − const [breadcrumbs, setBreadcrumbs] = useState<BreadcrumbItem[]>([]);
11 +function FolderContent({ folderId }: { folderId: string }) {
12 + const router = useRouter();
13 + const [crumbs, setCrumbs] = useState<Crumb[]>([]);
18 14
19 15 useEffect(() => {
16 + let cancelled = false;
20 17 async function fetchBreadcrumbs() {
21 − const res = await fetch(`/api/folders/${folderId}`);
22 − if (res.ok) {
18 + try {
19 + const res = await fetch(`/api/folders/${folderId}`);
20 + if (res.status === 404) {
21 + toast.error("Dossier introuvable");
22 + router.replace("/dashboard/files");
23 + return;
24 + }
25 + if (!res.ok) return;
23 26 const data = await res.json();
24 − const crumbs: BreadcrumbItem[] = [];
25 −
26 − // Build breadcrumb path
27 + const list: Crumb[] = [];
27 28 let current = data;
28 29 const visited = new Set<string>();
29 30 while (current) {
30 31 if (visited.has(current.id)) break;
31 32 visited.add(current.id);
32 − crumbs.unshift({ id: current.id, name: current.name });
33 + list.unshift({ id: current.id, name: current.name });
33 34 current = current.parent;
34 35 }
35 − setBreadcrumbs(crumbs);
36 − }
36 + // La route ne renvoie que le parent direct : remonter le reste de la chaîne
37 + let parentId: string | null | undefined = data.parent?.parentId;
38 + while (parentId && !visited.has(parentId)) {
39 + const r = await fetch(`/api/folders/${parentId}`);
40 + if (!r.ok) break;
41 + const p = await r.json();
42 + visited.add(p.id);
43 + list.unshift({ id: p.id, name: p.name });
44 + parentId = p.parentId;
45 + }
46 + if (!cancelled) setCrumbs(list);
47 + } catch {}
37 48 }
38 49 fetchBreadcrumbs();
39 − }, [folderId]);
50 + const onChanged = () => fetchBreadcrumbs();
51 + window.addEventListener(FOLDERS_CHANGED_EVENT, onChanged);
52 + return () => { cancelled = true; window.removeEventListener(FOLDERS_CHANGED_EVENT, onChanged); };
53 + }, [folderId, router]);
54 +
55 + useShellHeader({ crumbs });
56 +
57 + const name = crumbs[crumbs.length - 1]?.name;
58 + return <FileExplorer folderId={folderId} folderName={name} />;
59 +}
40 60
61 +export default function FolderPage() {
62 + const params = useParams();
63 + const folderId = params.id as string;
41 64 return (
42 65 <AppShell>
43 − <div className="px-4 lg:px-6 pt-4">
44 − <Breadcrumb items={breadcrumbs} />
45 − </div>
46 − <FileExplorer folderId={folderId} />
66 + <Suspense fallback={<div className="p-6 text-sm text-gray-400">Chargement…</div>}>
67 + <FolderContent key={folderId} folderId={folderId} />
68 + </Suspense>
47 69 </AppShell>
48 70 );
49 71 }
modified app/dashboard/graph/page.tsx +29 −20
@@ -49,6 +49,14 @@ interface SimNode extends GraphNode {
49 49
50 50 // ---------- Constants ----------
51 51
52 +function accentHex(): string {
53 + if (typeof window === "undefined") return "#6366f1";
54 + const raw = getComputedStyle(document.documentElement).getPropertyValue("--accent-500").trim();
55 + const parts = raw.split(/\s+/).map((n) => parseInt(n, 10));
56 + if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) return `#${parts.map((n) => n.toString(16).padStart(2, "0")).join("")}`;
57 + return "#6366f1";
58 +}
59 +
52 60 const NODE_COLORS: Record<string, string> = {
53 61 root: "#a855f7",
54 62 folder: "#f59e0b",
@@ -101,6 +109,7 @@ function getMimeCategory(mime?: string): string {
101 109 function getNodeColor(node: GraphNode): string {
102 110 if (node.type === "file") return MIME_COLORS[getMimeCategory(node.mimeType)] || MIME_COLORS.default;
103 111 if (node.type === "tag" && node.color) return node.color;
112 + if (node.type === "root" || node.type === "shared-space") return accentHex();
104 113 return NODE_COLORS[node.type] || "#6b7280";
105 114 }
106 115
@@ -462,10 +471,10 @@ export default function GraphPage() {
462 471 ctx.textAlign = "center";
463 472 ctx.textBaseline = "middle";
464 473 if (n.type === "root") {
465 − ctx.font = `bold ${n.radius * 0.55}px Inter, system-ui, sans-serif`;
474 + ctx.font = `bold ${n.radius * 0.55}px var(--font-geist), Inter, system-ui, sans-serif`;
466 475 ctx.fillText("SPB", n.x, n.y);
467 476 } else if (n.type === "user") {
468 − ctx.font = `bold ${n.radius * 0.7}px Inter, system-ui, sans-serif`;
477 + ctx.font = `bold ${n.radius * 0.7}px var(--font-geist), Inter, system-ui, sans-serif`;
469 478 const initials = n.label.split(" ").map((w) => w[0]).join("").toUpperCase().slice(0, 2);
470 479 ctx.fillText(initials, n.x, n.y);
471 480 }
@@ -475,7 +484,7 @@ export default function GraphPage() {
475 484 const maxLen = n.type === "file" ? 14 : 20;
476 485 const label = n.label.length > maxLen ? n.label.slice(0, maxLen - 1) + "\u2026" : n.label;
477 486 const fontSize = Math.max(8, Math.min(11, n.radius * 0.75));
478 − ctx.font = `${fontSize}px Inter, system-ui, sans-serif`;
487 + ctx.font = `${fontSize}px var(--font-geist), Inter, system-ui, sans-serif`;
479 488 ctx.fillStyle = dimmed ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.7)";
480 489 ctx.fillText(label, n.x, n.y + n.radius + 10);
481 490 }
@@ -492,7 +501,7 @@ export default function GraphPage() {
492 501 const sy = (hn.y + cam.y) * cam.zoom + ch / 2;
493 502
494 503 ctx.save();
495 − ctx.font = "12px Inter, system-ui, sans-serif";
504 + ctx.font = "12px var(--font-geist), Inter, system-ui, sans-serif";
496 505 const text = hn.label;
497 506 const tw = ctx.measureText(text).width + 16;
498 507 const tx = Math.min(sx + 14, cw - tw - 8);
@@ -650,18 +659,18 @@ export default function GraphPage() {
650 659
651 660 return (
652 661 <AppShell>
653 − <div className="relative h-full w-full overflow-hidden bg-[#0a0a0a]" ref={containerRef}>
662 + <div className="relative h-full w-full overflow-hidden bg-gray-950" ref={containerRef}>
654 663 {/* Loading */}
655 664 <AnimatePresence>
656 665 {loading && (
657 666 <motion.div
658 667 initial={{ opacity: 1 }}
659 668 exit={{ opacity: 0 }}
660 − className="absolute inset-0 z-50 flex items-center justify-center bg-[#0a0a0a]"
669 + className="absolute inset-0 z-50 flex items-center justify-center bg-gray-950"
661 670 >
662 671 <div className="flex flex-col items-center gap-3">
663 − <div className="w-10 h-10 border-2 border-blue-700/30 border-t-purple-500 rounded-full animate-spin" />
664 − <span className="text-sm text-gray-400">Chargement du graphe...</span>
672 + <div className="w-10 h-10 border-2 border-blue-500/25 border-t-blue-400 rounded-full animate-spin" />
673 + <span className="text-sm text-gray-400">Chargement du graphe…</span>
665 674 </div>
666 675 </motion.div>
667 676 )}
@@ -674,7 +683,7 @@ export default function GraphPage() {
674 683 {!loading && (
675 684 <div className="absolute top-3 left-3 right-3 z-10 flex flex-wrap items-start gap-2">
676 685 {/* Stats */}
677 − <div className="flex items-center gap-2 px-3 py-2 bg-black/60 backdrop-blur-xl border border-white/10 rounded-xl text-xs text-gray-400 shrink-0">
686 + <div className="flex items-center gap-2 px-3 py-2 bg-gray-900/70 backdrop-blur-xl border border-white/10 rounded-xl shadow-pop text-xs text-gray-400 shrink-0">
678 687 <File className="w-3.5 h-3.5" />
679 688 <span>{stats.files} fichiers</span>
680 689 <span className="text-white/20">|</span>
@@ -692,8 +701,8 @@ export default function GraphPage() {
692 701 type="text"
693 702 value={searchQuery}
694 703 onChange={(e) => setSearchQuery(e.target.value)}
695 − placeholder="Rechercher..."
696 − className="w-full pl-8 pr-3 py-2 bg-black/60 backdrop-blur-xl border border-white/10 rounded-xl text-xs text-white placeholder-gray-500 outline-none focus:border-blue-700/50 transition-colors"
704 + placeholder="Rechercher…"
705 + className="w-full pl-8 pr-3 py-2 bg-gray-900/70 backdrop-blur-xl border border-white/10 rounded-xl shadow-pop text-xs text-white placeholder-gray-500 outline-none focus:border-blue-400/60 focus:ring-4 focus:ring-blue-500/15 transition-all"
697 706 />
698 707 </div>
699 708
@@ -711,8 +720,8 @@ export default function GraphPage() {
711 720 onClick={() => toggleFilter(key)}
712 721 className={`flex items-center gap-1.5 px-2.5 py-2 rounded-xl text-xs border transition-colors ${
713 722 filters[key]
714 − ? "bg-white/10 border-white/20 text-white"
715 − : "bg-black/40 border-white/5 text-gray-600"
723 + ? "bg-blue-500/20 border-blue-400/40 text-white"
724 + : "bg-gray-900/60 border-white/5 text-gray-500 hover:text-gray-300"
716 725 }`}
717 726 >
718 727 <Icon className="w-3 h-3" />
@@ -734,14 +743,14 @@ export default function GraphPage() {
734 743 <button
735 744 key={i}
736 745 onClick={action}
737 − className="w-9 h-9 flex items-center justify-center bg-black/60 backdrop-blur-xl border border-white/10 rounded-xl text-gray-400 hover:text-white hover:bg-white/10 transition-colors"
746 + className="w-9 h-9 flex items-center justify-center bg-gray-900/70 backdrop-blur-xl border border-white/10 rounded-xl shadow-pop text-gray-400 hover:text-white hover:bg-white/10 transition-colors"
738 747 >
739 748 <Icon className="w-4 h-4" />
740 749 </button>
741 750 ))}
742 751 <button
743 752 onClick={() => setShowLabels(!showLabels)}
744 − className="w-9 h-9 flex items-center justify-center bg-black/60 backdrop-blur-xl border border-white/10 rounded-xl text-gray-400 hover:text-white hover:bg-white/10 transition-colors"
753 + className="w-9 h-9 flex items-center justify-center bg-gray-900/70 backdrop-blur-xl border border-white/10 rounded-xl shadow-pop text-gray-400 hover:text-white hover:bg-white/10 transition-colors"
745 754 >
746 755 {showLabels ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
747 756 </button>
@@ -758,7 +767,7 @@ export default function GraphPage() {
758 767 animate={{ y: 0 }}
759 768 exit={{ y: "100%" }}
760 769 transition={{ type: "spring", damping: 25, stiffness: 300 }}
761 − className="lg:hidden absolute bottom-0 left-0 right-0 z-20 bg-[#111]/95 backdrop-blur-xl border-t border-white/10 rounded-t-2xl p-4 pb-safe max-h-[50vh] overflow-auto"
770 + className="lg:hidden absolute bottom-0 left-0 right-0 z-20 bg-gray-900/95 backdrop-blur-xl border-t border-white/10 rounded-t-3xl p-4 pb-safe max-h-[50vh] overflow-auto"
762 771 >
763 772 <InfoPanelContent node={selectedNode} onClose={() => setSelectedNode(null)} onNavigate={navigateToNode} />
764 773 </motion.div>
@@ -768,7 +777,7 @@ export default function GraphPage() {
768 777 animate={{ x: 0, opacity: 1 }}
769 778 exit={{ x: "100%", opacity: 0 }}
770 779 transition={{ type: "spring", damping: 25, stiffness: 300 }}
771 − className="hidden lg:block absolute top-3 right-3 bottom-3 w-72 z-20 bg-[#111]/90 backdrop-blur-xl border border-white/10 rounded-2xl p-4 overflow-auto"
780 + className="hidden lg:block absolute top-3 right-3 bottom-3 w-72 z-20 bg-gray-900/85 backdrop-blur-xl border border-white/10 rounded-3xl shadow-pop p-4 overflow-auto"
772 781 >
773 782 <InfoPanelContent node={selectedNode} onClose={() => setSelectedNode(null)} onNavigate={navigateToNode} />
774 783 </motion.div>
@@ -778,7 +787,7 @@ export default function GraphPage() {
778 787
779 788 {/* Legend */}
780 789 {!loading && (
781 − <div className="absolute bottom-4 right-4 z-10 hidden sm:flex flex-col gap-1 px-3 py-2.5 bg-black/60 backdrop-blur-xl border border-white/10 rounded-xl">
790 + <div className="absolute bottom-4 right-24 z-10 hidden sm:flex flex-col gap-1 px-3 py-2.5 bg-gray-900/70 backdrop-blur-xl border border-white/10 rounded-xl shadow-pop">
782 791 {[
783 792 ["Dossiers", NODE_COLORS.folder],
784 793 ["Fichiers", MIME_COLORS.default],
@@ -819,7 +828,7 @@ function InfoPanelContent({
819 828 };
820 829
821 830 const typeIcons: Record<string, React.ReactNode> = {
822 − root: <div className="w-8 h-8 rounded-full bg-blue-800 flex items-center justify-center text-white text-xs font-bold">SPB</div>,
831 + root: <div className="w-8 h-8 rounded-full bg-accent-gradient flex items-center justify-center text-white text-[10px] font-bold">SPB</div>,
823 832 folder: <FolderOpen className="w-5 h-5 text-amber-400" />,
824 833 file: <File className="w-5 h-5 text-gray-400" />,
825 834 "shared-space": <Users className="w-5 h-5 text-blue-400" />,
@@ -866,7 +875,7 @@ function InfoPanelContent({
866 875 {node.type !== "root" && node.type !== "user" && (
867 876 <button
868 877 onClick={() => onNavigate(node)}
869 − className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-blue-700/20 text-blue-300 hover:bg-blue-700/30 rounded-xl text-sm font-medium transition-colors"
878 + className="w-full flex items-center justify-center gap-2 h-11 bg-accent-gradient text-white rounded-xl text-sm font-medium hover:brightness-110 transition-all"
870 879 >
871 880 <ExternalLink className="w-3.5 h-3.5" />
872 881 Ouvrir
modified app/dashboard/passwords/page.tsx +205 −170
@@ -6,11 +6,13 @@ import { motion, AnimatePresence } from "framer-motion";
6 6 import {
7 7 Lock, Plus, Eye, EyeOff, Copy, Trash2, Edit2, Search,
8 8 RefreshCw, Shield, ShieldAlert, ShieldCheck, ShieldX,
9 − Zap, Key, Globe, User, FileText, Check, X, ChevronDown,
9 + Zap, Key, Globe, User, FileText, Check, X, ExternalLink, AlertTriangle,
10 10 } from "lucide-react";
11 11 import toast from "react-hot-toast";
12 12 import Modal from "@/components/ui/Modal";
13 13 import Button from "@/components/ui/Button";
14 +import PageHeader, { EmptyState } from "@/components/ui/PageHeader";
15 +import PageTransition from "@/components/ui/PageTransition";
14 16
15 17 // ─── Types ────────────────────────────────────────────────────────────────────
16 18 interface PasswordEntry {
@@ -39,8 +41,8 @@ function analyzePassword(pwd: string) {
39 41 const score = checks.filter((c) => c.ok).length;
40 42
41 43 const labels = ["", "Très faible", "Faible", "Moyen", "Bon", "Fort", "Excellent"];
42 − const colors = ["", "text-red-500", "text-orange-500", "text-yellow-500", "text-blue-500", "text-green-500", "text-emerald-500"];
43 − const bars = ["", "bg-red-500", "bg-orange-500", "bg-yellow-500", "bg-blue-500", "bg-green-500", "bg-emerald-500"];
44 + const colors = ["", "text-rose-500", "text-orange-500", "text-amber-500", "text-blue-500", "text-emerald-500", "text-emerald-500"];
45 + const bars = ["", "bg-rose-500", "bg-orange-500", "bg-amber-500", "bg-blue-500", "bg-emerald-500", "bg-emerald-500"];
44 46
45 47 return { score, label: labels[score] || "", color: colors[score] || "", barColor: bars[score] || "", checks };
46 48 }
@@ -69,20 +71,20 @@ function SiteIcon({ name, url }: { name: string; url?: string | null }) {
69 71 <img
70 72 src={`https://www.google.com/s2/favicons?domain=${domain}&sz=32`}
71 73 alt={name}
72 − className="w-8 h-8 rounded-lg"
74 + className="w-10 h-10 rounded-xl bg-white p-1.5 shadow-card"
73 75 onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
74 76 />
75 77 );
76 78 } catch {}
77 79 }
78 80 return (
79 − <div className={`w-8 h-8 rounded-lg ${color} flex items-center justify-center text-white font-bold text-sm flex-shrink-0`}>
81 + <div className={`w-10 h-10 rounded-xl ${color} flex items-center justify-center text-white font-bold text-sm flex-shrink-0 shadow-inner`}>
80 82 {name[0]?.toUpperCase()}
81 83 </div>
82 84 );
83 85 }
84 86
85 −const inputCls = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-gray-400 dark:focus:border-white/20";
87 +const inputCls = "field h-11";
86 88
87 89 // ─── Main Page ────────────────────────────────────────────────────────────────
88 90 export default function PasswordsPage() {
@@ -110,6 +112,8 @@ export default function PasswordsPage() {
110 112 // Checker state
111 113 const [checkerPwd, setCheckerPwd] = useState("");
112 114 const [checkerShow, setCheckerShow] = useState(false);
115 + const [deleteTarget, setDeleteTarget] = useState<PasswordEntry | null>(null);
116 + const [deleting, setDeleting] = useState(false);
113 117
114 118 // ── Fetch ──
115 119 const fetchEntries = useCallback(async () => {
@@ -149,7 +153,7 @@ export default function PasswordsPage() {
149 153
150 154 // ── Copy ──
151 155 const copyText = (text: string, label = "Copié !") => {
152 − navigator.clipboard.writeText(text).then(() => toast.success(label));
156 + navigator.clipboard.writeText(text).then(() => toast.success(label)).catch(() => toast.error("Copie impossible"));
153 157 };
154 158
155 159 // ── Save (add or edit) ──
@@ -198,10 +202,14 @@ export default function PasswordsPage() {
198 202 setFormShowPw(false);
199 203 };
200 204
201 − const handleDelete = async (id: string) => {
202 − if (!confirm("Supprimer cet identifiant ?")) return;
203 − const res = await fetch(`/api/passwords/${id}`, { method: "DELETE" });
204 − if (res.ok) { toast.success("Supprimé"); fetchEntries(); }
205 + const handleDelete = async () => {
206 + if (!deleteTarget) return;
207 + setDeleting(true);
208 + const res = await fetch(`/api/passwords/${deleteTarget.id}`, { method: "DELETE" });
209 + setDeleting(false);
210 + setDeleteTarget(null);
211 + if (res.ok) { toast.success("Identifiant supprimé"); fetchEntries(); }
212 + else toast.error("Suppression impossible");
205 213 };
206 214
207 215 // ── Generator ──
@@ -223,141 +231,159 @@ export default function PasswordsPage() {
223 231 // ── Render ──
224 232 return (
225 233 <AppShell>
226 − <div className="flex flex-col h-full overflow-hidden">
234 + <PageTransition className="flex flex-col h-full overflow-hidden">
227 235 {/* Header */}
228 − <div className="px-4 lg:px-6 py-4 border-b border-gray-200 dark:border-white/10">
229 − <div className="flex items-center gap-3 mb-3">
230 − <div className="w-9 h-9 rounded-xl bg-gray-100 dark:bg-white/10 flex items-center justify-center">
231 − <Lock className="w-5 h-5 text-gray-700 dark:text-white" />
232 − </div>
233 − <div>
234 − <h1 className="text-lg font-semibold text-gray-900 dark:text-white">Gestionnaire de mots de passe</h1>
235 − <p className="text-xs text-gray-500">Chiffrement AES-256-GCM · Stockage local sécurisé</p>
236 − </div>
237 − </div>
238 −
239 − {/* Tabs */}
240 − <div className="flex gap-1 bg-gray-100 dark:bg-white/5 rounded-xl p-1">
241 − {([
242 − { id: "vault", label: "Coffre-fort", icon: Key },
243 − { id: "generator", label: "Générateur", icon: Zap },
244 − { id: "checker", label: "Validateur", icon: Shield },
245 − ] as const).map(({ id, label, icon: Icon }) => (
246 − <button
247 − key={id}
248 − onClick={() => setTab(id)}
249 − className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium transition-colors ${
250 − tab === id
251 − ? "bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm"
252 − : "text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
253 − }`}
254 − >
255 − <Icon className="w-3.5 h-3.5" />
256 − <span className="hidden sm:inline">{label}</span>
257 − </button>
258 − ))}
259 − </div>
236 + <div className="px-4 lg:px-6">
237 + <PageHeader
238 + title="Mots de passe"
239 + subtitle="Coffre chiffré AES-256-GCM · stockage local"
240 + icon={Lock}
241 + tone="text-amber-600 bg-amber-500/10 dark:text-amber-400"
242 + compact
243 + actions={
244 + <div className="flex items-center gap-0.5 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06]">
245 + {([
246 + { id: "vault", label: "Coffre", icon: Key },
247 + { id: "generator", label: "Générateur", icon: Zap },
248 + { id: "checker", label: "Analyse", icon: Shield },
249 + ] as const).map(({ id, label, icon: Icon }) => (
250 + <button
251 + key={id}
252 + onClick={() => setTab(id)}
253 + className={`flex items-center justify-center gap-1.5 h-8 px-2.5 sm:px-3 rounded-lg text-[12.5px] font-medium transition-colors ${
254 + tab === id
255 + ? "bg-surface dark:bg-white/10 text-gray-900 dark:text-white shadow-card dark:shadow-none"
256 + : "text-gray-500 hover:text-gray-800 dark:hover:text-gray-200"
257 + }`}
258 + aria-pressed={tab === id}
259 + >
260 + <Icon className="w-3.5 h-3.5" />
261 + <span className="hidden sm:inline">{label}</span>
262 + </button>
263 + ))}
264 + </div>
265 + }
266 + />
260 267 </div>
261 268
262 269 {/* ── VAULT TAB ── */}
263 270 {tab === "vault" && (
264 271 <div className="flex flex-col flex-1 min-h-0">
265 − {/* Toolbar */}
266 − <div className="px-4 lg:px-6 py-3 flex items-center gap-2 border-b border-gray-200 dark:border-white/10">
272 + <div className="px-4 lg:px-6 pb-3 flex items-center gap-2">
267 273 <div className="relative flex-1">
268 − <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
274 + <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
269 275 <input
270 − type="text"
276 + type="search"
271 277 value={search}
272 278 onChange={(e) => setSearch(e.target.value)}
273 − placeholder={`Rechercher parmi ${entries.length} identifiant(s)...`}
274 − className="w-full pl-9 pr-4 py-2.5 bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none"
279 + placeholder={`Rechercher parmi ${entries.length} identifiant${entries.length !== 1 ? "s" : ""}…`}
280 + className="field pl-10 h-10 [&::-webkit-search-cancel-button]:hidden"
275 281 />
276 282 </div>
277 − <Button size="sm" onClick={() => { setEditEntry(null); setForm({ siteName: "", siteUrl: "", username: "", password: "", notes: "" }); setShowAdd(true); }}>
283 + <Button variant="accent" size="sm" className="h-10" onClick={() => { setEditEntry(null); setForm({ siteName: "", siteUrl: "", username: "", password: "", notes: "" }); setShowAdd(true); }}>
278 284 <Plus className="w-4 h-4" />
279 285 <span className="hidden sm:inline">Ajouter</span>
280 286 </Button>
281 287 </div>
282 288
283 − {/* List */}
284 − <div className="flex-1 overflow-auto px-4 lg:px-6 py-4 space-y-2">
289 + <div className="flex-1 overflow-auto px-4 lg:px-6 pb-6">
285 290 {loading && (
286 − <div className="flex justify-center py-12">
287 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-700 dark:border-t-white rounded-full animate-spin" />
291 + <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
292 + {Array.from({ length: 6 }).map((_, i) => (
293 + <div key={i} className="card p-4 flex items-center gap-3 animate-pulse">
294 + <div className="w-10 h-10 rounded-xl bg-gray-200 dark:bg-white/10" />
295 + <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-2/3 rounded bg-gray-200 dark:bg-white/10" /></div>
296 + </div>
297 + ))}
288 298 </div>
289 299 )}
290 300
291 301 {!loading && filtered.length === 0 && (
292 − <div className="flex flex-col items-center justify-center py-16 text-center">
293 − <Lock className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
294 − <p className="text-gray-500 dark:text-gray-400">
295 − {search ? "Aucun résultat" : "Aucun identifiant. Commencez par en ajouter un."}
296 − </p>
297 − </div>
302 + <EmptyState
303 + icon={Lock}
304 + title={search ? "Aucun résultat" : "Votre coffre est vide"}
305 + description={search ? `Aucun identifiant ne correspond à « ${search} ».` : "Ajoutez vos identifiants : ils sont chiffrés avant d'être enregistrés."}
306 + action={!search ? <Button variant="accent" onClick={() => setShowAdd(true)}><Plus className="w-4 h-4" /> Ajouter un identifiant</Button> : undefined}
307 + />
298 308 )}
299 309
300 − {!loading && filtered.map((entry) => {
301 − const isRevealed = !!revealed[entry.id];
302 − const displayPw = isRevealed ? revealed[entry.id] : "••••••••••••";
303 − return (
304 − <motion.div
305 − key={entry.id}
306 − initial={{ opacity: 0, y: 8 }}
307 − animate={{ opacity: 1, y: 0 }}
308 − className="flex items-center gap-3 p-3.5 bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl hover:border-gray-300 dark:hover:border-white/20 transition-colors group"
309 − >
310 − <SiteIcon name={entry.siteName} url={entry.siteUrl} />
311 −
312 − <div className="flex-1 min-w-0">
313 − <div className="flex items-center gap-2">
314 − <p className="text-sm font-medium text-gray-900 dark:text-white truncate">{entry.siteName}</p>
315 − {entry.siteUrl && (
316 − <a href={entry.siteUrl.startsWith("http") ? entry.siteUrl : `https://${entry.siteUrl}`} target="_blank" rel="noopener noreferrer" className="flex-shrink-0">
317 − <Globe className="w-3 h-3 text-gray-400 hover:text-blue-500 transition-colors" />
318 − </a>
319 − )}
320 − </div>
321 − {entry.username && (
322 − <button onClick={() => copyText(entry.username!, "Identifiant copié !")} className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors mt-0.5">
323 − <User className="w-3 h-3" />
324 − {entry.username}
325 − </button>
326 − )}
327 − <div className="flex items-center gap-1.5 mt-0.5">
328 − <span className="text-xs font-mono text-gray-400 dark:text-gray-500 tracking-wider">
329 − {displayPw}
330 − </span>
331 − </div>
332 − </div>
333 −
334 − {/* Actions */}
335 − <div className="flex items-center gap-1 flex-shrink-0">
336 − <button
337 − onClick={() => revealPassword(entry.id)}
338 − disabled={revealing === entry.id}
339 − className="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-white transition-colors"
340 − title={isRevealed ? "Masquer" : "Révéler"}
310 + {!loading && filtered.length > 0 && (
311 + <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
312 + {filtered.map((entry, i) => {
313 + const isRevealed = !!revealed[entry.id];
314 + const href = entry.siteUrl ? (entry.siteUrl.startsWith("http") ? entry.siteUrl : `https://${entry.siteUrl}`) : null;
315 + return (
316 + <motion.div
317 + key={entry.id}
318 + initial={{ opacity: 0, y: 8 }}
319 + animate={{ opacity: 1, y: 0 }}
320 + transition={{ delay: Math.min(i, 12) * 0.03 }}
321 + className="card card-hover p-3.5 group"
341 322 >
342 − {revealing === entry.id
343 − ? <div className="w-4 h-4 border border-gray-300 border-t-gray-600 rounded-full animate-spin" />
344 − : isRevealed ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
345 − </button>
346 − {isRevealed && (
347 − <button onClick={() => copyText(revealed[entry.id], "Mot de passe copié !")} className="p-2 text-gray-400 hover:text-green-500 transition-colors" title="Copier le mot de passe">
348 − <Copy className="w-4 h-4" />
349 − </button>
350 − )}
351 − <button onClick={() => openEdit(entry)} className="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-white transition-colors sm:opacity-0 sm:group-hover:opacity-100">
352 − <Edit2 className="w-4 h-4" />
353 − </button>
354 − <button onClick={() => handleDelete(entry.id)} className="p-2 text-gray-400 hover:text-red-500 transition-colors sm:opacity-0 sm:group-hover:opacity-100">
355 − <Trash2 className="w-4 h-4" />
356 − </button>
357 − </div>
358 − </motion.div>
359 − );
360 − })}
323 + <div className="flex items-start gap-3">
324 + <SiteIcon name={entry.siteName} url={entry.siteUrl} />
325 + <div className="flex-1 min-w-0">
326 + <div className="flex items-center gap-1.5">
327 + <p className="text-[14px] font-semibold text-gray-900 dark:text-white truncate">{entry.siteName}</p>
328 + {href && (
329 + <a href={href} target="_blank" rel="noopener noreferrer" className="shrink-0 w-6 h-6 flex items-center justify-center rounded-md text-gray-400 hover:text-blue-600 hover:bg-blue-500/10 transition-colors" title="Ouvrir le site">
330 + <ExternalLink className="w-3 h-3" />
331 + </a>
332 + )}
333 + </div>
334 + {entry.username ? (
335 + <button onClick={() => copyText(entry.username!, "Identifiant copié")} className="mt-0.5 flex items-center gap-1 max-w-full text-[12.5px] text-gray-500 hover:text-gray-900 dark:hover:text-gray-100 transition-colors" title="Copier l'identifiant">
336 + <User className="w-3 h-3 shrink-0" />
337 + <span className="truncate">{entry.username}</span>
338 + <Copy className="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-60" />
339 + </button>
340 + ) : (
341 + <p className="mt-0.5 text-[12.5px] text-gray-400 italic">Aucun identifiant</p>
342 + )}
343 + </div>
344 + <div className="flex items-center -mr-1 -mt-1">
345 + <button onClick={() => openEdit(entry)} 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="Modifier">
346 + <Edit2 className="w-4 h-4" />
347 + </button>
348 + <button onClick={() => setDeleteTarget(entry)} 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">
349 + <Trash2 className="w-4 h-4" />
350 + </button>
351 + </div>
352 + </div>
353 +
354 + <div className="mt-3 flex items-center gap-1.5 rounded-xl bg-gray-50 dark:bg-white/[0.04] border border-line dark:border-white/[0.06] pl-3 pr-1 h-10">
355 + <span className={`flex-1 truncate text-[13px] ${isRevealed ? "font-mono text-gray-900 dark:text-white" : "tracking-[0.2em] text-gray-400"}`}>
356 + {isRevealed ? revealed[entry.id] : "••••••••••••"}
357 + </span>
358 + <button
359 + onClick={() => revealPassword(entry.id)}
360 + disabled={revealing === entry.id}
361 + className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200/60 dark:hover:bg-white/10 transition-colors"
362 + title={isRevealed ? "Masquer" : "Révéler"}
363 + >
364 + {revealing === entry.id ? <div className="w-4 h-4 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" /> : isRevealed ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
365 + </button>
366 + <button
367 + onClick={async () => {
368 + let pw = revealed[entry.id];
369 + if (!pw) {
370 + const res = await fetch(`/api/passwords/${entry.id}`);
371 + if (res.ok) pw = (await res.json()).password;
372 + }
373 + if (pw) copyText(pw, "Mot de passe copié");
374 + }}
375 + className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
376 + title="Copier le mot de passe"
377 + >
378 + <Copy className="w-4 h-4" />
379 + </button>
380 + </div>
381 + {entry.notes && <p className="mt-2 text-[12px] text-gray-500 dark:text-gray-400 line-clamp-2">{entry.notes}</p>}
382 + </motion.div>
383 + );
384 + })}
385 + </div>
386 + )}
361 387 </div>
362 388 </div>
363 389 )}
@@ -367,8 +393,8 @@ export default function PasswordsPage() {
367 393 <div className="flex-1 overflow-auto px-4 lg:px-6 py-6">
368 394 <div className="max-w-lg mx-auto space-y-6">
369 395 {/* Generated password display */}
370 − <div className="bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-5">
371 − <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Mot de passe généré</p>
396 + <div className="card p-5">
397 + <p className="text-[11px] font-semibold text-gray-400 uppercase tracking-[0.12em] mb-3">Mot de passe généré</p>
372 398 <div className="flex items-center gap-3">
373 399 <p className="flex-1 text-lg font-mono font-semibold text-gray-900 dark:text-white break-all leading-tight">
374 400 {genResult || <span className="text-gray-400 text-sm">Sélectionnez des options</span>}
@@ -387,19 +413,12 @@ export default function PasswordsPage() {
387 413 </div>
388 414 )}
389 415 <div className="flex gap-2 mt-4">
390 − <button
391 − onClick={regenerate}
392 − className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-white/10 hover:bg-gray-300 dark:hover:bg-white/20 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 transition-colors"
393 − >
416 + <Button variant="secondary" onClick={regenerate}>
394 417 <RefreshCw className="w-4 h-4" /> Régénérer
395 − </button>
396 − <button
397 − onClick={copyGen}
398 − disabled={!genResult}
399 − className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors flex-1 justify-center ${genCopied ? "bg-green-600" : "bg-gray-900 dark:bg-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100"}`}
400 − >
418 + </Button>
419 + <Button variant={genCopied ? "primary" : "accent"} onClick={copyGen} disabled={!genResult} className={`flex-1 ${genCopied ? "!bg-emerald-600 !text-white" : ""}`}>
401 420 {genCopied ? <><Check className="w-4 h-4" /> Copié !</> : <><Copy className="w-4 h-4" /> Copier</>}
402 − </button>
421 + </Button>
403 422 </div>
404 423 </div>
405 424
@@ -417,7 +436,7 @@ export default function PasswordsPage() {
417 436 max={64}
418 437 value={genOpts.length}
419 438 onChange={(e) => setGenOpts({ ...genOpts, length: Number(e.target.value) })}
420 − className="w-full accent-gray-900 dark:accent-white"
439 + className="w-full accent-[rgb(var(--accent-500))]"
421 440 />
422 441 <div className="flex justify-between text-xs text-gray-400 mt-1">
423 442 <span>6</span><span>64</span>
@@ -433,19 +452,20 @@ export default function PasswordsPage() {
433 452 { key: "numbers", label: "Chiffres", example: "0-9" },
434 453 { key: "symbols", label: "Symboles", example: "!@#$" },
435 454 ] as const).map(({ key, label, example }) => (
436 − <label key={key} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-white/5 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-white/10 transition-colors">
455 + <label key={key} className="flex items-center justify-between p-3 card cursor-pointer hover:bg-gray-50 dark:hover:bg-white/[0.04] transition-colors">
437 456 <div>
438 457 <span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>
439 458 <span className="text-xs text-gray-400 ml-2">{example}</span>
440 459 </div>
441 − <div
460 + <button
461 + type="button"
462 + role="switch"
463 + aria-checked={genOpts[key]}
442 464 onClick={() => setGenOpts({ ...genOpts, [key]: !genOpts[key] })}
443 − className={`w-10 h-6 rounded-full transition-colors cursor-pointer ${genOpts[key] ? "bg-gray-900 dark:bg-white" : "bg-gray-300 dark:bg-white/20"}`}
465 + className={`relative w-11 h-6 rounded-full transition-colors ${genOpts[key] ? "bg-blue-500" : "bg-gray-300 dark:bg-white/15"}`}
444 466 >
445 − <div className={`w-5 h-5 bg-white dark:bg-gray-900 rounded-full shadow mt-0.5 transition-transform ${genOpts[key] ? "translate-x-4.5 ml-4.5" : "translate-x-0.5 ml-0.5"}`}
446 − style={{ transform: genOpts[key] ? "translateX(18px)" : "translateX(2px)" }}
447 − />
448 − </div>
467 + <span className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${genOpts[key] ? "translate-x-[22px]" : "translate-x-0.5"}`} />
468 + </button>
449 469 </label>
450 470 ))}
451 471 </div>
@@ -459,7 +479,7 @@ export default function PasswordsPage() {
459 479 setFormShowPw(true);
460 480 }}
461 481 disabled={!genResult}
462 − className="w-full flex items-center justify-center gap-2 py-2.5 bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-400 dark:hover:border-white/30 transition-colors"
482 + className="w-full flex items-center justify-center gap-2 h-11 rounded-xl border border-dashed border-line dark:border-white/15 text-sm text-gray-600 dark:text-gray-300 hover:text-blue-700 dark:hover:text-blue-300 hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors disabled:opacity-50"
463 483 >
464 484 <Key className="w-4 h-4" /> Utiliser ce mot de passe dans le coffre
465 485 </button>
@@ -484,9 +504,9 @@ export default function PasswordsPage() {
484 504 value={checkerPwd}
485 505 onChange={(e) => setCheckerPwd(e.target.value)}
486 506 placeholder="Votre mot de passe..."
487 − className={`${inputCls} pl-10 pr-10`}
507 + className={`${inputCls} pl-10 pr-11`}
488 508 />
489 − <button onClick={() => setCheckerShow(!checkerShow)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
509 + <button onClick={() => setCheckerShow(!checkerShow)} className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200/60 dark:hover:bg-white/10 transition-colors" aria-label="Afficher">
490 510 {checkerShow ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
491 511 </button>
492 512 </div>
@@ -496,7 +516,7 @@ export default function PasswordsPage() {
496 516 <AnimatePresence>
497 517 <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-5">
498 518 {/* Score bar */}
499 − <div className="bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-5">
519 + <div className="card p-5">
500 520 <div className="flex items-center justify-between mb-3">
501 521 <p className="text-sm font-medium text-gray-700 dark:text-gray-300">Niveau de sécurité</p>
502 522 <span className={`text-lg font-bold ${analysis.color}`}>{analysis.label}</span>
@@ -517,12 +537,12 @@ export default function PasswordsPage() {
517 537 </div>
518 538
519 539 {/* Checks */}
520 − <div className="bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-5">
540 + <div className="card p-5">
521 541 <p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Critères de sécurité</p>
522 542 <div className="space-y-2">
523 543 {analysis.checks.map((c) => (
524 544 <div key={c.label} className="flex items-center gap-2.5">
525 − <div className={`w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 ${c.ok ? "bg-green-100 dark:bg-green-500/20 text-green-600 dark:text-green-400" : "bg-red-100 dark:bg-red-500/20 text-red-500"}`}>
545 + <div className={`w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 ${c.ok ? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400" : "bg-rose-500/15 text-rose-500"}`}>
526 546 {c.ok ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
527 547 </div>
528 548 <span className={`text-sm ${c.ok ? "text-gray-700 dark:text-gray-300" : "text-gray-400 dark:text-gray-500"}`}>{c.label}</span>
@@ -533,9 +553,9 @@ export default function PasswordsPage() {
533 553
534 554 {/* Recommendation */}
535 555 <div className={`flex items-start gap-3 p-4 rounded-xl border ${
536 − analysis.score >= 5 ? "bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/30 text-green-800 dark:text-green-300"
537 − : analysis.score >= 3 ? "bg-yellow-50 dark:bg-yellow-500/10 border-yellow-200 dark:border-yellow-500/30 text-yellow-800 dark:text-yellow-300"
538 − : "bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/30 text-red-800 dark:text-red-300"
556 + analysis.score >= 5 ? "bg-emerald-500/[0.08] border-emerald-500/20 text-emerald-800 dark:text-emerald-300"
557 + : analysis.score >= 3 ? "bg-amber-500/[0.08] border-amber-500/20 text-amber-800 dark:text-amber-300"
558 + : "bg-rose-500/[0.08] border-rose-500/20 text-rose-800 dark:text-rose-300"
539 559 }`}>
540 560 {analysis.score >= 5 ? <ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5" />
541 561 : analysis.score >= 3 ? <Shield className="w-5 h-5 flex-shrink-0 mt-0.5" />
@@ -554,33 +574,36 @@ export default function PasswordsPage() {
554 574 </div>
555 575
556 576 {analysis.score < 5 && (
557 − <button
558 − onClick={() => setTab("generator")}
559 − className="w-full flex items-center justify-center gap-2 py-2.5 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-xl text-sm font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors"
560 − >
577 + <Button variant="accent" onClick={() => setTab("generator")} className="w-full">
561 578 <Zap className="w-4 h-4" /> Générer un mot de passe sécurisé
562 − </button>
579 + </Button>
563 580 )}
564 581 </motion.div>
565 582 </AnimatePresence>
566 583 )}
567 584
568 585 {!checkerPwd && (
569 − <div className="text-center py-12">
570 − <ShieldAlert className="w-12 h-12 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
571 − <p className="text-gray-500 dark:text-gray-400 text-sm">Entrez un mot de passe pour l&apos;analyser</p>
572 − </div>
586 + <EmptyState icon={ShieldAlert} title="Analysez un mot de passe" description="Le mot de passe n'est jamais envoyé au serveur : l'analyse se fait dans votre navigateur." />
573 587 )}
574 588 </div>
575 589 </div>
576 590 )}
577 − </div>
591 + </PageTransition>
578 592
579 593 {/* ── Add / Edit Modal ── */}
580 594 <Modal
581 595 isOpen={showAdd}
582 596 onClose={() => { setShowAdd(false); setEditEntry(null); setFormShowPw(false); }}
583 597 title={editEntry ? "Modifier l'identifiant" : "Nouvel identifiant"}
598 + description="Chiffré avant enregistrement."
599 + footer={
600 + <>
601 + <Button variant="ghost" onClick={() => { setShowAdd(false); setEditEntry(null); }}>Annuler</Button>
602 + <Button variant="accent" onClick={handleSave} loading={saving} disabled={!form.siteName.trim() || !form.password.trim()}>
603 + {editEntry ? "Enregistrer" : "Ajouter"}
604 + </Button>
605 + </>
606 + }
584 607 >
585 608 <div className="space-y-3">
586 609 <div>
@@ -610,7 +633,7 @@ export default function PasswordsPage() {
610 633 <button
611 634 type="button"
612 635 onClick={() => { const p = generatePassword(genOpts); setForm({ ...form, password: p }); setFormShowPw(true); }}
613 − className="text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1"
636 + className="text-xs text-blue-600 dark:text-blue-300 hover:underline flex items-center gap-1"
614 637 >
615 638 <Zap className="w-3 h-3" /> Générer
616 639 </button>
@@ -622,10 +645,10 @@ export default function PasswordsPage() {
622 645 value={form.password}
623 646 onChange={(e) => setForm({ ...form, password: e.target.value })}
624 647 placeholder="••••••••"
625 − className={`${inputCls} pl-9 pr-9`}
648 + className={`${inputCls} pl-9 pr-11`}
626 649 autoComplete="new-password"
627 650 />
628 − <button type="button" onClick={() => setFormShowPw(!formShowPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
651 + <button type="button" onClick={() => setFormShowPw(!formShowPw)} className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200/60 dark:hover:bg-white/10 transition-colors" aria-label="Afficher">
629 652 {formShowPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
630 653 </button>
631 654 </div>
@@ -645,16 +668,28 @@ export default function PasswordsPage() {
645 668 <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes (optionnel)</label>
646 669 <div className="relative">
647 670 <FileText className="absolute left-3 top-3 w-4 h-4 text-gray-400" />
648 − <textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder="Compte personnel, code de récupération..." rows={2} className={`${inputCls} pl-9 resize-none`} />
671 + <textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder="Compte personnel, code de récupération..." rows={2} className={`${inputCls} pl-9 resize-none !h-auto`} />
649 672 </div>
650 673 </div>
651 674 </div>
652 675
653 − <div className="flex gap-2 justify-end mt-4">
654 − <Button variant="ghost" onClick={() => { setShowAdd(false); setEditEntry(null); }}>Annuler</Button>
655 − <Button onClick={handleSave} loading={saving} disabled={!form.siteName.trim() || !form.password.trim()}>
656 − {editEntry ? "Enregistrer" : "Ajouter"}
657 − </Button>
676 + </Modal>
677 +
678 + <Modal
679 + isOpen={!!deleteTarget}
680 + onClose={() => setDeleteTarget(null)}
681 + title="Supprimer cet identifiant ?"
682 + description={deleteTarget?.siteName}
683 + footer={
684 + <>
685 + <Button variant="ghost" onClick={() => setDeleteTarget(null)}>Annuler</Button>
686 + <Button variant="danger" onClick={handleDelete} loading={deleting}><Trash2 className="w-4 h-4" /> Supprimer</Button>
687 + </>
688 + }
689 + >
690 + <div className="flex items-start gap-3 p-3 rounded-2xl bg-rose-500/[0.07] text-[13.5px] text-rose-700 dark:text-rose-300">
691 + <AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
692 + <p>Le mot de passe sera effacé définitivement du coffre.</p>
658 693 </div>
659 694 </Modal>
660 695 </AppShell>
modified app/dashboard/recent/page.tsx +91 −115
@@ -1,43 +1,52 @@
1 1 "use client";
2 2
3 3 import { useState, useEffect, useCallback } from "react";
4 −import { Clock } from "lucide-react";
5 −import { motion, AnimatePresence } from "framer-motion";
4 +import { Clock, LayoutGrid, List } from "lucide-react";
5 +import { motion } from "framer-motion";
6 6 import AppShell from "@/components/layout/AppShell";
7 7 import FileCard from "@/components/files/FileCard";
8 8 import PreviewEngine from "@/components/preview/PreviewEngine";
9 +import PageHeader, { EmptyState } from "@/components/ui/PageHeader";
10 +import PageTransition from "@/components/ui/PageTransition";
9 11 import type { FileData } from "@/types/files";
10 12
11 13 function getDateGroup(dateStr: string): string {
12 14 const date = new Date(dateStr);
13 15 const now = new Date();
14 16 const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
15 − const yesterday = new Date(today);
16 − yesterday.setDate(yesterday.getDate() - 1);
17 − const weekAgo = new Date(today);
18 − weekAgo.setDate(weekAgo.getDate() - 7);
19 − const monthAgo = new Date(today);
20 − monthAgo.setMonth(monthAgo.getMonth() - 1);
21 −
17 + const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1);
18 + const weekAgo = new Date(today); weekAgo.setDate(weekAgo.getDate() - 7);
19 + const monthAgo = new Date(today); monthAgo.setMonth(monthAgo.getMonth() - 1);
22 20 if (date >= today) return "Aujourd'hui";
23 21 if (date >= yesterday) return "Hier";
24 22 if (date >= weekAgo) return "Cette semaine";
25 − if (date >= monthAgo) return "Ce mois";
23 + if (date >= monthAgo) return "Ce mois-ci";
26 24 return "Plus ancien";
27 25 }
28 26
29 −const groupOrder = ["Aujourd'hui", "Hier", "Cette semaine", "Ce mois", "Plus ancien"];
27 +const groupOrder = ["Aujourd'hui", "Hier", "Cette semaine", "Ce mois-ci", "Plus ancien"];
30 28
31 29 export default function RecentPage() {
32 30 const [files, setFiles] = useState<FileData[]>([]);
33 31 const [previewFile, setPreviewFile] = useState<FileData | null>(null);
34 32 const [loading, setLoading] = useState(true);
35 − const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
33 + const [viewMode, setViewModeState] = useState<"grid" | "list">("grid");
34 +
35 + useEffect(() => {
36 + try {
37 + const v = localStorage.getItem("spb-recent-view");
38 + if (v === "grid" || v === "list") setViewModeState(v);
39 + } catch {}
40 + }, []);
41 + const setViewMode = (v: "grid" | "list") => {
42 + setViewModeState(v);
43 + try { localStorage.setItem("spb-recent-view", v); } catch {}
44 + };
36 45
37 46 const fetchRecent = useCallback(async () => {
38 47 setLoading(true);
39 48 try {
40 − const res = await fetch("/api/files?sortBy=updatedAt&order=desc&limit=50");
49 + const res = await fetch("/api/files?sortBy=updatedAt&order=desc&limit=60");
41 50 if (res.ok) {
42 51 const data = await res.json();
43 52 setFiles(Array.isArray(data) ? data : data.files || []);
@@ -46,130 +55,97 @@ export default function RecentPage() {
46 55 setLoading(false);
47 56 }, []);
48 57
49 − useEffect(() => {
50 − fetchRecent();
51 − }, [fetchRecent]);
58 + useEffect(() => { fetchRecent(); }, [fetchRecent]);
52 59
53 − // Group files by date category
54 60 const grouped = files.reduce<Record<string, FileData[]>>((acc, file) => {
55 − const group = getDateGroup(file.updatedAt || file.createdAt);
56 − if (!acc[group]) acc[group] = [];
57 − acc[group].push(file);
61 + const g = getDateGroup(file.updatedAt || file.createdAt);
62 + (acc[g] ||= []).push(file);
58 63 return acc;
59 64 }, {});
60 65
61 66 return (
62 67 <AppShell>
63 68 <div className="flex flex-col h-full lg:flex-row">
64 − <div className="flex-1 flex flex-col min-w-0">
65 − {/* Header */}
66 − <div className="px-3 sm:px-4 lg:px-6 py-3 sm:py-4 border-b border-gray-200 dark:border-white/10 flex items-center justify-between">
67 − <div>
68 − <h1 className="text-lg font-medium text-gray-900 dark:text-white flex items-center gap-2">
69 − <Clock className="w-5 h-5 text-blue-500" />
70 − Fichiers recents
71 − </h1>
72 − <p className="text-xs text-gray-500 mt-1">
73 − {files.length} fichier{files.length !== 1 ? "s" : ""}
74 − </p>
75 − </div>
76 − <div className="flex items-center gap-1">
77 − <button
78 − onClick={() => setViewMode("grid")}
79 − className={`p-2 rounded-lg transition-colors ${
80 − viewMode === "grid"
81 − ? "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white"
82 − : "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
83 − }`}
84 − >
85 − <svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
86 − <rect x="1" y="1" width="6" height="6" rx="1" />
87 − <rect x="9" y="1" width="6" height="6" rx="1" />
88 − <rect x="1" y="9" width="6" height="6" rx="1" />
89 − <rect x="9" y="9" width="6" height="6" rx="1" />
90 − </svg>
91 − </button>
92 − <button
93 − onClick={() => setViewMode("list")}
94 − className={`p-2 rounded-lg transition-colors ${
95 − viewMode === "list"
96 − ? "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white"
97 − : "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
98 − }`}
99 − >
100 − <svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
101 − <rect x="1" y="2" width="14" height="2" rx="0.5" />
102 − <rect x="1" y="7" width="14" height="2" rx="0.5" />
103 − <rect x="1" y="12" width="14" height="2" rx="0.5" />
104 − </svg>
105 − </button>
106 − </div>
69 + <PageTransition className="flex-1 flex flex-col min-w-0">
70 + <div className="px-4 lg:px-6">
71 + <PageHeader
72 + title="Récents"
73 + subtitle={loading ? "Chargement…" : `${files.length} fichier${files.length !== 1 ? "s" : ""} modifié${files.length !== 1 ? "s" : ""} récemment`}
74 + icon={Clock}
75 + compact
76 + actions={
77 + <div className="flex items-center gap-0.5 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06]">
78 + {([["grid", LayoutGrid], ["list", List]] as const).map(([m, Icon]) => (
79 + <button
80 + key={m}
81 + onClick={() => setViewMode(m)}
82 + className={`w-9 h-8 flex items-center justify-center rounded-lg transition-colors ${viewMode === m ? "bg-surface dark:bg-white/10 text-gray-900 dark:text-white shadow-card dark:shadow-none" : "text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"}`}
83 + aria-label={m === "grid" ? "Vue grille" : "Vue liste"}
84 + aria-pressed={viewMode === m}
85 + >
86 + <Icon className="w-4 h-4" />
87 + </button>
88 + ))}
89 + </div>
90 + }
91 + />
107 92 </div>
108 93
109 − {/* Content */}
110 − <div className="flex-1 overflow-auto px-3 sm:px-4 lg:px-6 py-3 sm:py-4">
94 + <div className="flex-1 overflow-auto px-4 lg:px-6 pb-6">
111 95 {loading ? (
112 − <div className="flex items-center justify-center h-32">
113 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
96 + <div className="space-y-6">
97 + {[0, 1].map((g) => (
98 + <div key={g}>
99 + <div className="h-3 w-24 rounded bg-gray-200 dark:bg-white/10 mb-3 animate-pulse" />
100 + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
101 + {Array.from({ length: 5 }).map((_, i) => (
102 + <div key={i} className="card p-0 overflow-hidden animate-pulse">
103 + <div className="aspect-square bg-gray-100 dark:bg-white/[0.06]" />
104 + <div className="p-2.5 space-y-1.5"><div className="h-3 w-3/4 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-1/2 rounded bg-gray-200 dark:bg-white/10" /></div>
105 + </div>
106 + ))}
107 + </div>
108 + </div>
109 + ))}
114 110 </div>
115 111 ) : files.length === 0 ? (
116 − <div className="flex flex-col items-center justify-center h-64 text-gray-500">
117 − <Clock className="w-12 h-12 mb-4 text-gray-300 dark:text-gray-600" />
118 − <p className="text-lg">Aucun fichier recent</p>
119 − <p className="text-sm mt-1 text-center px-4">
120 − Vos fichiers recemment modifies apparaitront ici
121 − </p>
122 − </div>
112 + <EmptyState icon={Clock} title="Aucun fichier récent" description="Vos fichiers récemment ajoutés ou modifiés apparaîtront ici, regroupés par date." />
123 113 ) : (
124 − <div className="space-y-6">
125 − <AnimatePresence>
126 − {groupOrder.map((groupName) => {
127 − const groupFiles = grouped[groupName];
128 − if (!groupFiles || groupFiles.length === 0) return null;
129 − return (
130 − <motion.div
131 − key={groupName}
132 − initial={{ opacity: 0, y: 8 }}
133 − animate={{ opacity: 1, y: 0 }}
134 − transition={{ duration: 0.2 }}
135 − >
136 − <h2 className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
137 − {groupName}
138 − </h2>
139 − <div
140 − className={
141 − viewMode === "grid"
142 − ? "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 sm:gap-3"
143 − : "space-y-1"
144 − }
145 − >
114 + <div className="space-y-7">
115 + {groupOrder.map((groupName, gi) => {
116 + const groupFiles = grouped[groupName];
117 + if (!groupFiles?.length) return null;
118 + return (
119 + <motion.section key={groupName} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, delay: gi * 0.04 }}>
120 + <div className="flex items-center gap-3 mb-3">
121 + <h2 className="text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-[0.12em]">{groupName}</h2>
122 + <span className="text-[11px] text-gray-400 tabular-nums">{groupFiles.length}</span>
123 + <span className="flex-1 h-px bg-line dark:bg-white/[0.06]" />
124 + </div>
125 + {viewMode === "grid" ? (
126 + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
146 127 {groupFiles.map((file) => (
147 − <FileCard
148 − key={file.id}
149 − file={file}
150 − onPreview={setPreviewFile}
151 − onRefresh={fetchRecent}
152 − viewMode={viewMode}
153 − />
128 + <FileCard key={file.id} file={file} onPreview={setPreviewFile} onRefresh={fetchRecent} viewMode="grid" />
154 129 ))}
155 130 </div>
156 − </motion.div>
157 − );
158 − })}
159 − </AnimatePresence>
131 + ) : (
132 + <div className="card p-0 overflow-hidden divide-y divide-line dark:divide-white/[0.06]">
133 + {groupFiles.map((file) => (
134 + <FileCard key={file.id} file={file} onPreview={setPreviewFile} onRefresh={fetchRecent} viewMode="list" />
135 + ))}
136 + </div>
137 + )}
138 + </motion.section>
139 + );
140 + })}
160 141 </div>
161 142 )}
162 143 </div>
163 − </div>
144 + </PageTransition>
164 145
165 146 {previewFile && (
166 − <div className="fixed inset-0 z-50 lg:relative lg:inset-auto lg:w-[400px] lg:border-l lg:border-gray-200 dark:lg:border-white/10">
167 − <PreviewEngine
168 − file={previewFile}
169 − files={files}
170 − onClose={() => setPreviewFile(null)}
171 − onNavigate={setPreviewFile}
172 − />
147 + <div className="fixed inset-0 z-50 lg:relative lg:inset-auto lg:w-[420px] lg:border-l lg:border-line dark:lg:border-white/[0.07]">
148 + <PreviewEngine file={previewFile} files={files} onClose={() => setPreviewFile(null)} onNavigate={setPreviewFile} />
173 149 </div>
174 150 )}
175 151 </div>
modified app/dashboard/security/page.tsx +51 −20
@@ -1,33 +1,64 @@
1 1 "use client";
2 2
3 +import { useEffect, useState } from "react";
3 4 import AppShell from "@/components/layout/AppShell";
4 5 import DevicesPanel from "@/components/auth/DevicesPanel";
5 −import { Shield } from "lucide-react";
6 +import TwoFactorSetup from "@/components/admin/TwoFactorSetup";
7 +import ChangePasswordModal from "@/components/auth/ChangePasswordModal";
8 +import PageHeader from "@/components/ui/PageHeader";
9 +import Button from "@/components/ui/Button";
10 +import PageTransition from "@/components/ui/PageTransition";
11 +import { Shield, KeyRound, Monitor } from "lucide-react";
6 12
7 13 export default function SecurityPage() {
14 + const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
15 + const [showChangePassword, setShowChangePassword] = useState(false);
16 +
17 + const fetchStatus = async () => {
18 + try {
19 + const res = await fetch("/api/auth/session");
20 + if (res.ok) {
21 + const data = await res.json();
22 + setTwoFactorEnabled(!!data.twoFactorEnabled);
23 + }
24 + } catch {}
25 + };
26 +
27 + useEffect(() => { fetchStatus(); }, []);
28 +
8 29 return (
9 30 <AppShell>
10 − <div className="p-4 lg:p-6 space-y-6">
11 − <div className="flex items-center gap-3">
12 − <div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center">
13 − <Shield className="w-5 h-5 text-emerald-400" />
14 − </div>
15 − <div>
16 − <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Sécurité</h1>
17 − <p className="text-sm text-gray-500 mt-0.5">Appareils et sessions connectés à votre compte</p>
18 − </div>
19 − </div>
31 + <PageTransition className="px-4 lg:px-6 pb-8 max-w-5xl">
32 + <PageHeader
33 + title="Sécurité"
34 + subtitle="Mot de passe, double authentification et appareils connectés"
35 + icon={Shield}
36 + tone="text-emerald-600 bg-emerald-500/10 dark:text-emerald-400"
37 + actions={
38 + <Button variant="secondary" size="sm" onClick={() => setShowChangePassword(true)}>
39 + <KeyRound className="w-4 h-4" /> <span className="hidden sm:inline">Changer le mot de passe</span><span className="sm:hidden">Mot de passe</span>
40 + </Button>
41 + }
42 + />
43 +
44 + <div className="space-y-6">
45 + <TwoFactorSetup enabled={twoFactorEnabled} onStatusChange={fetchStatus} />
20 46
21 − <div className="bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-4 lg:p-6">
22 − <div className="mb-5">
23 − <h2 className="text-base font-medium text-gray-900 dark:text-white">Appareils connectés</h2>
24 − <p className="text-sm text-gray-500 mt-0.5">
25 − IP, localisation et navigateur enregistrés à chaque connexion.
26 − </p>
27 − </div>
28 − <DevicesPanel />
47 + <section className="card p-4 sm:p-5">
48 + <div className="flex items-center gap-3 mb-5">
49 + <div className="w-10 h-10 rounded-2xl bg-blue-500/10 text-blue-600 dark:text-blue-300 flex items-center justify-center shrink-0">
50 + <Monitor className="w-5 h-5" />
51 + </div>
52 + <div>
53 + <h2 className="text-[15px] font-semibold text-gray-900 dark:text-white">Appareils et sessions</h2>
54 + <p className="text-[13px] text-gray-500 dark:text-gray-400">IP, localisation et navigateur enregistrés à chaque connexion.</p>
55 + </div>
56 + </div>
57 + <DevicesPanel />
58 + </section>
29 59 </div>
30 − </div>
60 + </PageTransition>
61 + <ChangePasswordModal isOpen={showChangePassword} onClose={() => setShowChangePassword(false)} />
31 62 </AppShell>
32 63 );
33 64 }
modified app/dashboard/shared-space/[id]/page.tsx +126 −125
@@ -1,17 +1,21 @@
1 1 "use client";
2 2
3 −import { useState, useEffect, useCallback } from "react";
3 +import { useState, useEffect, useCallback, useRef } from "react";
4 4 import { useParams } from "next/navigation";
5 5 import AppShell from "@/components/layout/AppShell";
6 6 import FolderCard from "@/components/folders/FolderCard";
7 7 import FileCard from "@/components/files/FileCard";
8 −import FileUpload from "@/components/files/FileUpload";
8 +import FileUpload, { type FileUploadHandle } from "@/components/files/FileUpload";
9 9 import PreviewEngine from "@/components/preview/PreviewEngine";
10 10 import Button from "@/components/ui/Button";
11 11 import SharedBadge from "@/components/ui/SharedBadge";
12 12 import Modal from "@/components/ui/Modal";
13 +import PageHeader, { EmptyState } from "@/components/ui/PageHeader";
14 +import { useShellHeader } from "@/components/layout/ShellContext";
13 15 import { formatFileSize } from "@/lib/utils";
14 −import { Grid, List, Upload, Plus, Users } from "lucide-react";
16 +import { notifyFoldersChanged } from "@/components/files/dnd";
17 +import type { FileData } from "@/types/files";
18 +import { LayoutGrid, List, Upload, FolderPlus, Users, ChevronRight, Home } from "lucide-react";
15 19 import { motion, AnimatePresence } from "framer-motion";
16 20 import toast from "react-hot-toast";
17 21
@@ -24,15 +28,6 @@ interface SpaceData {
24 28 folders: { id: string; name: string; parentId: string | null }[];
25 29 }
26 30
27 −interface FileData {
28 − id: string;
29 − name: string;
30 − size: number;
31 − mimeType: string;
32 − createdAt: string;
33 − folderId?: string | null;
34 −}
35 −
36 31 interface FolderData {
37 32 id: string;
38 33 name: string;
@@ -49,13 +44,18 @@ export default function SharedSpacePage() {
49 44 const [space, setSpace] = useState<SpaceData | null>(null);
50 45 const [folders, setFolders] = useState<FolderData[]>([]);
51 46 const [files, setFiles] = useState<FileData[]>([]);
52 − const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
47 + const [path, setPath] = useState<{ id: string; name: string }[]>([]);
53 48 const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
54 49 const [showUpload, setShowUpload] = useState(false);
55 50 const [showCreateFolder, setShowCreateFolder] = useState(false);
56 51 const [newFolderName, setNewFolderName] = useState("");
57 52 const [previewFile, setPreviewFile] = useState<FileData | null>(null);
58 53 const [loading, setLoading] = useState(true);
54 + const uploadRef = useRef<FileUploadHandle>(null);
55 +
56 + const currentFolderId = path[path.length - 1]?.id ?? null;
57 +
58 + useShellHeader({ title: space?.name ? `Espace · ${space.name}` : "Espace partagé" });
59 59
60 60 const fetchData = useCallback(async () => {
61 61 setLoading(true);
@@ -91,6 +91,7 @@ export default function SharedSpacePage() {
91 91 toast.success("Dossier créé");
92 92 setShowCreateFolder(false);
93 93 setNewFolderName("");
94 + notifyFoldersChanged();
94 95 fetchData();
95 96 } else {
96 97 toast.error("Erreur");
@@ -99,141 +100,141 @@ export default function SharedSpacePage() {
99 100
100 101 const percent = space ? Math.min(Math.round((space.usedBytes / space.quotaBytes) * 100), 100) : 0;
101 102
102 − const inputClasses = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none mb-4";
103 −
104 103 return (
105 104 <AppShell>
106 − <div className="flex flex-col h-full overflow-x-hidden">
107 − {/* Space header */}
108 − <div className="px-4 lg:px-6 py-4 border-b border-gray-200 dark:border-white/10">
109 − <div className="flex items-center gap-3 mb-3">
110 − <div className="w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-500/20 flex items-center justify-center flex-shrink-0">
111 − <Users className="w-4 h-4 text-purple-600 dark:text-purple-400" />
112 − </div>
113 − <div className="flex items-center gap-2 min-w-0">
114 − <h1 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
115 − {space?.name || "Espace partagé"}
116 − </h1>
117 − <SharedBadge />
118 − </div>
105 + <div className="flex flex-col h-full lg:flex-row overflow-x-hidden">
106 + <div className="flex-1 flex flex-col min-w-0 overflow-x-hidden">
107 + {/* En-tête de l'espace */}
108 + <div className="px-3 sm:px-4 lg:px-6 border-b border-line dark:border-white/[0.07]">
109 + <PageHeader
110 + compact
111 + icon={Users}
112 + tone="text-purple-600 bg-purple-500/10 dark:text-purple-300"
113 + title={space?.name || "Espace partagé"}
114 + subtitle={space ? `${space.members.length} membre${space.members.length !== 1 ? "s" : ""} · ${formatFileSize(space.usedBytes)} sur ${formatFileSize(space.quotaBytes)}` : undefined}
115 + actions={<SharedBadge />}
116 + />
117 + {space && (
118 + <div className="pb-3">
119 + <div className="w-full bg-gray-100 dark:bg-white/10 rounded-full h-1.5 overflow-hidden">
120 + <motion.div
121 + initial={{ width: 0 }}
122 + animate={{ width: `${Math.max(1, percent)}%` }}
123 + className={`h-full rounded-full ${percent > 90 ? "bg-rose-500" : percent > 70 ? "bg-amber-500" : "bg-purple-500"}`}
124 + />
125 + </div>
126 + </div>
127 + )}
119 128 </div>
120 129
121 − {space && (
122 − <div>
123 − <div className="flex justify-between text-xs text-gray-500 mb-1">
124 − <span>{space.members.length} membre(s)</span>
125 − <span>{formatFileSize(space.usedBytes)} / {formatFileSize(space.quotaBytes)}</span>
126 − </div>
127 − <div className="w-full bg-gray-100 dark:bg-white/10 rounded-full h-1.5">
128 − <div
129 − className={`h-1.5 rounded-full transition-all ${percent > 90 ? "bg-red-500" : percent > 70 ? "bg-yellow-500" : "bg-purple-500"}`}
130 − style={{ width: `${percent}%` }}
131 − />
132 − </div>
130 + {/* Fil d'Ariane local + outils */}
131 + <div className="px-3 sm:px-4 lg:px-6 py-2.5 flex items-center gap-2 border-b border-line dark:border-white/[0.07]">
132 + <nav className="flex-1 min-w-0 flex items-center gap-1 text-[13px] overflow-x-auto scrollbar-none whitespace-nowrap" aria-label="Fil d'Ariane">
133 + <button onClick={() => setPath([])} className={`flex items-center gap-1.5 px-1.5 h-8 rounded-lg transition-colors ${path.length === 0 ? "font-semibold text-gray-900 dark:text-white" : "text-gray-500 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/5"}`}>
134 + <Home className="w-4 h-4" /> Espace
135 + </button>
136 + {path.map((p, i) => (
137 + <span key={p.id} className="flex items-center gap-1">
138 + <ChevronRight className="w-3.5 h-3.5 text-gray-300 dark:text-gray-600" />
139 + <button onClick={() => setPath(path.slice(0, i + 1))} className={`px-1.5 h-8 rounded-lg truncate max-w-[10rem] transition-colors ${i === path.length - 1 ? "font-semibold text-gray-900 dark:text-white" : "text-gray-500 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/5"}`}>
140 + {p.name}
141 + </button>
142 + </span>
143 + ))}
144 + </nav>
145 + <div className="flex items-center h-10 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06] shrink-0" role="radiogroup">
146 + {(["list", "grid"] as const).map((v) => (
147 + <button key={v} role="radio" aria-checked={viewMode === v} onClick={() => setViewMode(v)} className={`relative w-9 h-8 flex items-center justify-center rounded-lg transition-colors ${viewMode === v ? "text-gray-900 dark:text-white" : "text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"}`} title={v === "list" ? "Liste" : "Grille"}>
148 + {viewMode === v && <motion.span layoutId="ss-view-seg" className="absolute inset-0 rounded-lg bg-surface dark:bg-white/10 shadow-card dark:shadow-none" />}
149 + <span className="relative">{v === "list" ? <List className="w-4 h-4" /> : <LayoutGrid className="w-4 h-4" />}</span>
150 + </button>
151 + ))}
133 152 </div>
134 − )}
135 − </div>
153 + <Button variant="ghost" size="sm" onClick={() => setShowCreateFolder(true)} className="h-10 shrink-0">
154 + <FolderPlus className="w-4 h-4" /><span className="hidden sm:inline">Dossier</span>
155 + </Button>
156 + <Button variant="accent" size="sm" onClick={() => setShowUpload(!showUpload)} className="h-10 shrink-0" disabled={!currentFolderId} title={currentFolderId ? "Envoyer des fichiers" : "Ouvrez un dossier pour envoyer"}>
157 + <Upload className="w-4 h-4" /><span className="hidden sm:inline">Envoyer</span>
158 + </Button>
159 + </div>
136 160
137 − {/* Toolbar */}
138 − <div className="px-4 lg:px-6 py-3 border-b border-gray-200 dark:border-white/10 flex items-center gap-2 justify-end">
139 − <div className="flex items-center gap-1 bg-gray-100 dark:bg-white/5 rounded-lg p-1">
140 − <button onClick={() => setViewMode("grid")} className={`min-w-[36px] min-h-[36px] flex items-center justify-center rounded transition-colors ${viewMode === "grid" ? "bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm" : "text-gray-400"}`}>
141 − <Grid className="w-4 h-4" />
142 − </button>
143 − <button onClick={() => setViewMode("list")} className={`min-w-[36px] min-h-[36px] flex items-center justify-center rounded transition-colors ${viewMode === "list" ? "bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm" : "text-gray-400"}`}>
144 − <List className="w-4 h-4" />
145 − </button>
161 + {/* Upload */}
162 + <AnimatePresence>
163 + {showUpload && currentFolderId && (
164 + <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="px-3 sm:px-4 lg:px-6 pt-3 overflow-hidden">
165 + <FileUpload ref={uploadRef} folderId={currentFolderId} onUploadComplete={fetchData} folderName={path[path.length - 1]?.name} />
166 + </motion.div>
167 + )}
168 + </AnimatePresence>
169 +
170 + {/* Contenu */}
171 + <div className="flex-1 overflow-auto px-3 sm:px-4 lg:px-6 py-4">
172 + {loading ? (
173 + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
174 + {Array.from({ length: 6 }).map((_, i) => <div key={i} className="h-[68px] card animate-pulse" />)}
175 + </div>
176 + ) : (
177 + <>
178 + {folders.length > 0 && (
179 + <section className="mb-6">
180 + <h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-2.5">Dossiers</h3>
181 + <div className={viewMode === "grid" ? "grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 xl:grid-cols-5" : "card overflow-clip divide-y divide-line/70 dark:divide-white/5"}>
182 + {folders.map((folder) => (
183 + <FolderCard key={folder.id} folder={folder} onRefresh={fetchData} viewMode={viewMode} onOpen={(f) => setPath([...path, { id: f.id, name: f.name }])} />
184 + ))}
185 + </div>
186 + </section>
187 + )}
188 + {files.length > 0 && (
189 + <section>
190 + <h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-2.5">Fichiers ({files.length})</h3>
191 + <div className={viewMode === "grid" ? "grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6" : "card overflow-clip divide-y divide-line/70 dark:divide-white/5"}>
192 + {files.map((file) => (
193 + <FileCard key={file.id} file={file} onPreview={setPreviewFile} onRefresh={fetchData} viewMode={viewMode} />
194 + ))}
195 + </div>
196 + </section>
197 + )}
198 + {folders.length === 0 && files.length === 0 && (
199 + <EmptyState
200 + icon={Users}
201 + title={currentFolderId ? "Dossier vide" : "Espace vide"}
202 + description={currentFolderId ? "Envoyez des fichiers ou créez un sous-dossier." : "Créez un dossier pour commencer à partager."}
203 + action={
204 + <div className="flex items-center gap-2">
205 + <Button variant="secondary" size="sm" onClick={() => setShowCreateFolder(true)}><FolderPlus className="w-4 h-4" /> Nouveau dossier</Button>
206 + {currentFolderId && <Button variant="accent" size="sm" onClick={() => { setShowUpload(true); setTimeout(() => uploadRef.current?.openPicker(), 50); }}><Upload className="w-4 h-4" /> Envoyer</Button>}
207 + </div>
208 + }
209 + />
210 + )}
211 + </>
212 + )}
146 213 </div>
147 − <Button variant="ghost" size="sm" onClick={() => setShowCreateFolder(true)}>
148 − <Plus className="w-4 h-4" /><span className="hidden sm:inline">Dossier</span>
149 − </Button>
150 − <Button size="sm" onClick={() => setShowUpload(!showUpload)}>
151 − <Upload className="w-4 h-4" /><span className="hidden sm:inline">Upload</span>
152 − </Button>
153 214 </div>
154 215
155 − {/* Upload */}
156 − <AnimatePresence>
157 − {showUpload && currentFolderId && (
158 − <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="px-4 lg:px-6 pt-4">
159 − <FileUpload folderId={currentFolderId} onUploadComplete={fetchData} />
160 − </motion.div>
161 − )}
162 − {showUpload && !currentFolderId && (
163 − <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="px-4 lg:px-6 pt-4">
164 − <p className="text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 rounded-lg px-4 py-3">
165 − Ouvre un dossier pour uploader des fichiers dans cet espace.
166 − </p>
167 − </motion.div>
168 − )}
169 − </AnimatePresence>
170 −
171 − {/* Content */}
172 − <div className="flex-1 overflow-auto px-4 lg:px-6 py-4">
173 − {currentFolderId && (
174 − <button onClick={() => setCurrentFolderId(null)} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white mb-4 transition-colors">
175 − ← Retour à l&apos;espace
176 − </button>
177 − )}
178 −
179 − {loading ? (
180 − <div className="flex items-center justify-center h-32">
181 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
182 − </div>
183 − ) : (
184 − <>
185 − {folders.length > 0 && (
186 − <div className="mb-6">
187 − <h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Dossiers</h3>
188 − <div className={viewMode === "grid" ? "grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 xl:grid-cols-5" : "space-y-1"}>
189 − {folders.map((folder) => (
190 − <FolderCard key={folder.id} folder={folder} onRefresh={fetchData} viewMode={viewMode} />
191 − ))}
192 − </div>
193 − </div>
194 − )}
195 − {files.length > 0 && (
196 − <div>
197 − <h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Fichiers ({files.length})</h3>
198 − <div className={viewMode === "grid" ? "grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 xl:grid-cols-5" : "space-y-1"}>
199 − {files.map((file) => (
200 − <FileCard key={file.id} file={file} onPreview={setPreviewFile} onRefresh={fetchData} viewMode={viewMode} />
201 − ))}
202 − </div>
203 − </div>
204 − )}
205 − {folders.length === 0 && files.length === 0 && (
206 − <div className="flex flex-col items-center justify-center h-40 text-center">
207 − <Users className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" />
208 − <p className="text-gray-500 dark:text-gray-400 text-sm">Espace vide. Créez un dossier pour commencer.</p>
209 − </div>
210 − )}
211 − </>
212 − )}
213 − </div>
216 + {previewFile && (
217 + <div className="fixed inset-0 z-50 lg:relative lg:inset-auto lg:z-auto lg:w-[420px] xl:w-[460px] lg:shrink-0 lg:border-l lg:border-line dark:lg:border-white/[0.07]">
218 + <PreviewEngine file={previewFile} files={files} onClose={() => setPreviewFile(null)} onNavigate={setPreviewFile} onChanged={fetchData} />
219 + </div>
220 + )}
214 221 </div>
215 222
216 − {/* Create folder modal */}
217 − <Modal isOpen={showCreateFolder} onClose={() => { setShowCreateFolder(false); setNewFolderName(""); }} title="Nouveau dossier">
223 + <Modal isOpen={showCreateFolder} onClose={() => { setShowCreateFolder(false); setNewFolderName(""); }} title="Nouveau dossier" description={currentFolderId ? "Dans le dossier courant" : "À la racine de l'espace"}>
218 224 <input
219 225 type="text"
220 226 placeholder="Nom du dossier"
221 227 value={newFolderName}
222 228 onChange={(e) => setNewFolderName(e.target.value)}
223 229 onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
224 − className={inputClasses}
230 + className="field mb-4 h-11"
225 231 autoFocus
226 232 />
227 233 <div className="flex gap-2 justify-end">
228 234 <Button variant="ghost" onClick={() => { setShowCreateFolder(false); setNewFolderName(""); }}>Annuler</Button>
229 − <Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>Créer</Button>
235 + <Button variant="accent" onClick={handleCreateFolder} disabled={!newFolderName.trim()}><FolderPlus className="w-4 h-4" /> Créer</Button>
230 236 </div>
231 237 </Modal>
232 −
233 − {/* Preview */}
234 − {previewFile && (
235 − <PreviewEngine file={previewFile} onClose={() => setPreviewFile(null)} />
236 − )}
237 238 </AppShell>
238 239 );
239 240 }
modified app/dashboard/shared-with-me/page.tsx +74 −107
@@ -1,25 +1,19 @@
1 1 "use client";
2 2
3 3 import { useState, useEffect, useCallback } from "react";
4 −import { Users, FolderOpen } from "lucide-react";
5 −import { motion, AnimatePresence } from "framer-motion";
4 +import { Users, FolderOpen, ArrowRight, HardDrive } from "lucide-react";
5 +import { motion } from "framer-motion";
6 6 import AppShell from "@/components/layout/AppShell";
7 7 import FileCard from "@/components/files/FileCard";
8 8 import PreviewEngine from "@/components/preview/PreviewEngine";
9 +import PageHeader, { EmptyState } from "@/components/ui/PageHeader";
10 +import PageTransition from "@/components/ui/PageTransition";
9 11 import { formatFileSize } from "@/lib/utils";
10 12 import type { FileData } from "@/types/files";
11 13 import { useRouter } from "next/navigation";
12 14
13 −interface SharedSpace {
14 − id: string;
15 − name: string;
16 − quotaBytes: number;
17 −}
18 −
19 −interface SpaceWithFiles extends SharedSpace {
20 − files: FileData[];
21 − memberCount: number;
22 −}
15 +interface SharedSpace { id: string; name: string; quotaBytes: number }
16 +interface SpaceWithFiles extends SharedSpace { files: FileData[]; memberCount: number; usedBytes: number }
23 17
24 18 export default function SharedWithMePage() {
25 19 const router = useRouter();
@@ -33,34 +27,22 @@ export default function SharedWithMePage() {
33 27 const spacesRes = await fetch("/api/shared-spaces/mine");
34 28 if (!spacesRes.ok) return;
35 29 const spacesData: SharedSpace[] = await spacesRes.json();
30 + if (!Array.isArray(spacesData) || spacesData.length === 0) { setSpaces([]); return; }
36 31
37 − if (!Array.isArray(spacesData) || spacesData.length === 0) {
38 − setSpaces([]);
39 − return;
40 − }
41 −
42 − // Fetch details for each space
43 32 const detailed = await Promise.all(
44 33 spacesData.map(async (space) => {
45 34 try {
46 35 const res = await fetch(`/api/shared-spaces/${space.id}`);
47 − if (!res.ok) return { ...space, files: [], memberCount: 0 };
48 − const data = await res.json();
49 − // files are nested in the space response or we fetch them separately
36 + const data = res.ok ? await res.json() : {};
50 37 const filesRes = await fetch(`/api/files?folderId=root&sharedSpaceId=${space.id}`);
51 38 const filesRaw = filesRes.ok ? await filesRes.json() : [];
52 − const files = Array.isArray(filesRaw) ? filesRaw : filesRaw.files || [];
53 − return {
54 − ...space,
55 − files,
56 − memberCount: data.members?.length || 0,
57 − };
39 + const files: FileData[] = Array.isArray(filesRaw) ? filesRaw : filesRaw.files || [];
40 + return { ...space, files, memberCount: data.members?.length || 0, usedBytes: Number(data.usedBytes || 0) };
58 41 } catch {
59 − return { ...space, files: [], memberCount: 0 };
42 + return { ...space, files: [], memberCount: 0, usedBytes: 0 };
60 43 }
61 44 })
62 45 );
63 −
64 46 setSpaces(detailed);
65 47 } catch {
66 48 setSpaces([]);
@@ -69,117 +51,102 @@ export default function SharedWithMePage() {
69 51 }
70 52 }, []);
71 53
72 − useEffect(() => {
73 − fetchData();
74 − }, [fetchData]);
54 + useEffect(() => { fetchData(); }, [fetchData]);
75 55
76 56 const allFiles = spaces.flatMap((s) => s.files);
77 57
78 58 return (
79 59 <AppShell>
80 60 <div className="flex flex-col h-full lg:flex-row">
81 − <div className="flex-1 flex flex-col min-w-0">
82 − {/* Header */}
83 − <div className="px-3 sm:px-4 lg:px-6 py-3 sm:py-4 border-b border-gray-200 dark:border-white/10">
84 − <h1 className="text-lg font-medium text-gray-900 dark:text-white flex items-center gap-2">
85 − <Users className="w-5 h-5 text-purple-500" />
86 − Partage avec moi
87 − </h1>
88 − <p className="text-xs text-gray-500 mt-1">
89 − {spaces.length} espace{spaces.length !== 1 ? "s" : ""} partage{spaces.length !== 1 ? "s" : ""}
90 − </p>
61 + <PageTransition className="flex-1 flex flex-col min-w-0">
62 + <div className="px-4 lg:px-6">
63 + <PageHeader
64 + title="Partagé avec moi"
65 + subtitle={loading ? "Chargement…" : `${spaces.length} espace${spaces.length !== 1 ? "s" : ""} partagé${spaces.length !== 1 ? "s" : ""}`}
66 + icon={Users}
67 + tone="text-violet-600 bg-violet-500/10 dark:text-violet-300"
68 + compact
69 + />
91 70 </div>
92 71
93 − {/* Content */}
94 − <div className="flex-1 overflow-auto px-3 sm:px-4 lg:px-6 py-3 sm:py-4">
72 + <div className="flex-1 overflow-auto px-4 lg:px-6 pb-6">
95 73 {loading ? (
96 − <div className="flex items-center justify-center h-32">
97 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
74 + <div className="space-y-6">
75 + {[0, 1].map((i) => (
76 + <div key={i} className="card p-5 animate-pulse space-y-4">
77 + <div className="flex items-center gap-3"><div className="w-10 h-10 rounded-2xl bg-gray-200 dark:bg-white/10" /><div className="space-y-2 flex-1"><div className="h-3 w-40 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-56 rounded bg-gray-200 dark:bg-white/10" /></div></div>
78 + <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">{Array.from({ length: 4 }).map((_, j) => <div key={j} className="aspect-square rounded-xl bg-gray-100 dark:bg-white/[0.06]" />)}</div>
79 + </div>
80 + ))}
98 81 </div>
99 82 ) : spaces.length === 0 ? (
100 − <div className="flex flex-col items-center justify-center h-64 text-gray-500">
101 − <Users className="w-12 h-12 mb-4 text-gray-300 dark:text-gray-600" />
102 − <p className="text-lg">Aucun espace partage</p>
103 − <p className="text-sm mt-1 text-center px-4">
104 − Les espaces auxquels vous avez acces apparaitront ici
105 − </p>
106 − </div>
83 + <EmptyState icon={Users} title="Aucun espace partagé" description="Les espaces auxquels un administrateur vous donne accès apparaîtront ici." />
107 84 ) : (
108 − <div className="space-y-6">
109 − <AnimatePresence>
110 − {spaces.map((space, i) => (
111 − <motion.div
112 − key={space.id}
113 − initial={{ opacity: 0, y: 8 }}
114 − animate={{ opacity: 1, y: 0 }}
115 − transition={{ duration: 0.2, delay: 0.05 * i }}
116 − >
117 − {/* Space Header */}
118 − <button
119 − onClick={() => router.push(`/dashboard/shared-space/${space.id}`)}
120 − className="flex items-center gap-3 mb-3 group w-full text-left"
121 − >
122 − <div className="w-8 h-8 rounded-xl bg-purple-500/10 dark:bg-purple-500/20 flex items-center justify-center flex-shrink-0">
123 − <FolderOpen className="w-4 h-4 text-purple-500" />
85 + <div className="space-y-5">
86 + {spaces.map((space, i) => {
87 + const pct = space.quotaBytes > 0 ? Math.min(100, (space.usedBytes / space.quotaBytes) * 100) : 0;
88 + return (
89 + <motion.section key={space.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, delay: 0.05 * i }} className="card p-4 sm:p-5">
90 + <div className="flex items-start gap-3 mb-4">
91 + <div className="w-10 h-10 rounded-2xl bg-violet-500/10 flex items-center justify-center shrink-0">
92 + <FolderOpen className="w-5 h-5 text-violet-600 dark:text-violet-300" />
124 93 </div>
125 94 <div className="min-w-0 flex-1">
126 − <h2 className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500 transition-colors truncate">
127 − {space.name}
128 − </h2>
129 − <p className="text-xs text-gray-400 dark:text-gray-500">
130 − {space.memberCount} membre{space.memberCount !== 1 ? "s" : ""}
131 − {" / "}
132 − {space.files.length} fichier{space.files.length !== 1 ? "s" : ""}
133 − {space.quotaBytes > 0 && (
134 − <> / Quota: {formatFileSize(space.quotaBytes)}</>
135 − )}
95 + <h2 className="text-[15px] font-semibold text-gray-900 dark:text-white truncate">{space.name}</h2>
96 + <p className="text-[12.5px] text-gray-500 dark:text-gray-400 flex flex-wrap gap-x-2">
97 + <span>{space.memberCount} membre{space.memberCount !== 1 ? "s" : ""}</span>
98 + <span>·</span>
99 + <span>{space.files.length} fichier{space.files.length !== 1 ? "s" : ""}</span>
100 + {space.quotaBytes > 0 && (<><span>·</span><span className="inline-flex items-center gap-1"><HardDrive className="w-3 h-3" />{formatFileSize(space.usedBytes)} / {formatFileSize(space.quotaBytes)}</span></>)}
136 101 </p>
102 + {space.quotaBytes > 0 && (
103 + <div className="mt-2 h-1 w-full max-w-xs rounded-full bg-gray-200 dark:bg-white/10 overflow-hidden">
104 + <div className={`h-full rounded-full ${pct > 90 ? "bg-rose-500" : pct > 70 ? "bg-amber-500" : "bg-violet-500"}`} style={{ width: `${Math.max(1, pct)}%` }} />
105 + </div>
106 + )}
137 107 </div>
138 − </button>
108 + <button
109 + onClick={() => router.push(`/dashboard/shared-space/${space.id}`)}
110 + className="hidden sm:inline-flex items-center gap-1.5 h-9 px-3 rounded-xl text-[13px] font-medium text-violet-700 dark:text-violet-300 bg-violet-500/10 hover:bg-violet-500/15 transition-colors shrink-0"
111 + >
112 + Ouvrir <ArrowRight className="w-3.5 h-3.5" />
113 + </button>
114 + </div>
139 115
140 − {/* Files Grid */}
141 116 {space.files.length > 0 ? (
142 − <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 sm:gap-3">
117 + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
143 118 {space.files.slice(0, 10).map((file) => (
144 − <FileCard
145 − key={file.id}
146 − file={file}
147 − onPreview={setPreviewFile}
148 − onRefresh={fetchData}
149 − viewMode="grid"
150 − />
119 + <FileCard key={file.id} file={file} onPreview={setPreviewFile} onRefresh={fetchData} viewMode="grid" />
151 120 ))}
152 121 </div>
153 122 ) : (
154 − <div className="p-4 text-center text-sm text-gray-400 dark:text-gray-500 rounded-xl bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/5">
155 − Aucun fichier dans cet espace
123 + <div className="p-5 text-center text-[13px] text-gray-400 rounded-2xl bg-gray-50 dark:bg-white/[0.03] border border-dashed border-line dark:border-white/10">
124 + Aucun fichier à la racine de cet espace
156 125 </div>
157 126 )}
158 127
128 + <button
129 + onClick={() => router.push(`/dashboard/shared-space/${space.id}`)}
130 + className="mt-3 inline-flex items-center gap-1 text-[13px] font-medium text-violet-700 dark:text-violet-300 hover:underline sm:hidden"
131 + >
132 + {space.files.length > 10 ? `Voir les ${space.files.length} fichiers` : "Ouvrir l'espace"} <ArrowRight className="w-3.5 h-3.5" />
133 + </button>
159 134 {space.files.length > 10 && (
160 − <button
161 − onClick={() => router.push(`/dashboard/shared-space/${space.id}`)}
162 − className="mt-2 text-xs text-purple-500 hover:text-purple-400 transition-colors"
163 − >
164 − Voir les {space.files.length} fichiers
135 + <button onClick={() => router.push(`/dashboard/shared-space/${space.id}`)} className="mt-3 hidden sm:inline-flex items-center gap-1 text-[13px] font-medium text-violet-700 dark:text-violet-300 hover:underline">
136 + Voir les {space.files.length} fichiers <ArrowRight className="w-3.5 h-3.5" />
165 137 </button>
166 138 )}
167 − </motion.div>
168 − ))}
169 − </AnimatePresence>
139 + </motion.section>
140 + );
141 + })}
170 142 </div>
171 143 )}
172 144 </div>
173 − </div>
145 + </PageTransition>
174 146
175 147 {previewFile && (
176 − <div className="fixed inset-0 z-50 lg:relative lg:inset-auto lg:w-[400px] lg:border-l lg:border-gray-200 dark:lg:border-white/10">
177 − <PreviewEngine
178 − file={previewFile}
179 − files={allFiles}
180 − onClose={() => setPreviewFile(null)}
181 − onNavigate={setPreviewFile}
182 − />
148 + <div className="fixed inset-0 z-50 lg:relative lg:inset-auto lg:w-[420px] lg:border-l lg:border-line dark:lg:border-white/[0.07]">
149 + <PreviewEngine file={previewFile} files={allFiles} onClose={() => setPreviewFile(null)} onNavigate={setPreviewFile} />
183 150 </div>
184 151 )}
185 152 </div>
modified app/dashboard/trash/page.tsx +158 −327
@@ -2,21 +2,14 @@
2 2
3 3 import { useState, useEffect, useCallback } from "react";
4 4 import { motion, AnimatePresence } from "framer-motion";
5 −import {
6 − Trash2,
7 − RotateCcw,
8 − AlertTriangle,
9 − FileText,
10 − Image,
11 − Film,
12 − Music,
13 − FileCode,
14 − File,
15 − Check,
16 − Clock,
17 − X,
18 −} from "lucide-react";
5 +import { Trash2, RotateCcw, AlertTriangle, FileText, Image, Film, Music, FileCode, File, Check, Clock, X, Archive, FileSpreadsheet } from "lucide-react";
19 6 import AppShell from "@/components/layout/AppShell";
7 +import PageHeader, { EmptyState } from "@/components/ui/PageHeader";
8 +import PageTransition from "@/components/ui/PageTransition";
9 +import Button from "@/components/ui/Button";
10 +import Modal from "@/components/ui/Modal";
11 +import toast from "react-hot-toast";
12 +import { formatFileSize, getFileIcon } from "@/lib/utils";
20 13
21 14 interface TrashedFile {
22 15 id: string;
@@ -27,37 +20,20 @@ interface TrashedFile {
27 20 createdAt: string;
28 21 }
29 22
30 −function formatSize(bytes: number): string {
31 − if (bytes < 1024) return `${bytes} o`;
32 − if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
33 − if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
34 − return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} Go`;
35 −}
23 +const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
24 + image: Image, video: Film, audio: Music, pdf: FileText, code: FileCode, spreadsheet: FileSpreadsheet,
25 + archive: Archive, document: FileText, presentation: FileText, file: File,
26 +};
27 +const toneMap: Record<string, string> = {
28 + image: "text-emerald-500 bg-emerald-500/10", video: "text-purple-500 bg-purple-500/10", audio: "text-pink-500 bg-pink-500/10",
29 + pdf: "text-rose-500 bg-rose-500/10", code: "text-amber-500 bg-amber-500/10", spreadsheet: "text-green-600 bg-green-500/10",
30 + archive: "text-orange-500 bg-orange-500/10", document: "text-blue-500 bg-blue-500/10", presentation: "text-orange-500 bg-orange-500/10",
31 + file: "text-gray-400 bg-gray-100 dark:bg-white/[0.06]",
32 +};
36 33
37 34 function daysUntilPurge(deletedAt: string): number {
38 − const deleted = new Date(deletedAt);
39 − const purgeDate = new Date(deleted.getTime() + 30 * 24 * 60 * 60 * 1000);
40 − const now = new Date();
41 − const diff = Math.ceil((purgeDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
42 − return Math.max(0, diff);
43 −}
44 −
45 −function getTypeIcon(mimeType: string) {
46 − if (mimeType.startsWith("image/")) return Image;
47 − if (mimeType.startsWith("video/")) return Film;
48 − if (mimeType.startsWith("audio/")) return Music;
49 − if (mimeType === "application/pdf") return FileText;
50 − if (
51 − mimeType.includes("javascript") ||
52 − mimeType.includes("typescript") ||
53 − mimeType.includes("json") ||
54 − mimeType.includes("xml") ||
55 − mimeType.includes("html") ||
56 − mimeType.includes("css")
57 − )
58 − return FileCode;
59 − if (mimeType.startsWith("text/")) return FileText;
60 − return File;
35 + const purge = new Date(deletedAt).getTime() + 30 * 86400000;
36 + return Math.max(0, Math.ceil((purge - Date.now()) / 86400000));
61 37 }
62 38
63 39 export default function TrashPage() {
@@ -73,47 +49,38 @@ export default function TrashPage() {
73 49 try {
74 50 const res = await fetch("/api/trash");
75 51 if (res.ok) setFiles(await res.json());
76 − } catch {
77 − /* ignore */
78 − }
52 + } catch {}
79 53 setLoading(false);
80 54 }, []);
81 55
82 − useEffect(() => {
83 − fetchTrash();
84 − }, [fetchTrash]);
56 + useEffect(() => { fetchTrash(); }, [fetchTrash]);
85 57
86 58 const toggleSelect = (id: string) => {
87 59 setSelected((prev) => {
88 60 const next = new Set(prev);
89 − if (next.has(id)) next.delete(id);
90 − else next.add(id);
61 + if (next.has(id)) next.delete(id); else next.add(id);
91 62 return next;
92 63 });
93 64 };
94 65
95 66 const toggleAll = () => {
96 − if (selected.size === files.length) {
97 − setSelected(new Set());
98 − } else {
99 − setSelected(new Set(files.map((f) => f.id)));
100 − }
67 + setSelected(selected.size === files.length ? new Set() : new Set(files.map((f) => f.id)));
68 + };
69 +
70 + const post = async (body: Record<string, unknown>) => {
71 + const res = await fetch("/api/trash", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
72 + if (!res.ok) throw new Error("trash action failed");
101 73 };
102 74
103 75 const handleRestore = async () => {
104 76 if (selected.size === 0) return;
105 77 setActing(true);
106 78 try {
107 − await fetch("/api/trash", {
108 − method: "POST",
109 − headers: { "Content-Type": "application/json" },
110 − body: JSON.stringify({ action: "restore", fileIds: Array.from(selected) }),
111 − });
79 + await post({ action: "restore", fileIds: Array.from(selected) });
80 + toast.success(`${selected.size} fichier${selected.size > 1 ? "s" : ""} restauré${selected.size > 1 ? "s" : ""}`);
112 81 setSelected(new Set());
113 82 await fetchTrash();
114 − } catch {
115 − /* ignore */
116 − }
83 + } catch { toast.error("Restauration impossible"); }
117 84 setActing(false);
118 85 };
119 86
@@ -121,213 +88,114 @@ export default function TrashPage() {
121 88 if (selected.size === 0) return;
122 89 setActing(true);
123 90 try {
124 − await fetch("/api/trash", {
125 − method: "POST",
126 − headers: { "Content-Type": "application/json" },
127 − body: JSON.stringify({ action: "purge", fileIds: Array.from(selected) }),
128 − });
91 + await post({ action: "purge", fileIds: Array.from(selected) });
92 + toast.success("Supprimé définitivement");
129 93 setSelected(new Set());
130 94 setShowPurgeConfirm(false);
131 95 await fetchTrash();
132 − } catch {
133 − /* ignore */
134 − }
96 + } catch { toast.error("Suppression impossible"); }
135 97 setActing(false);
136 98 };
137 99
138 100 const handleEmpty = async () => {
139 101 setActing(true);
140 102 try {
141 − await fetch("/api/trash", {
142 − method: "POST",
143 − headers: { "Content-Type": "application/json" },
144 − body: JSON.stringify({ action: "empty" }),
145 − });
103 + await post({ action: "empty" });
104 + toast.success("Corbeille vidée");
146 105 setSelected(new Set());
147 106 setShowEmptyConfirm(false);
148 107 await fetchTrash();
149 − } catch {
150 − /* ignore */
151 − }
108 + } catch { toast.error("Impossible de vider la corbeille"); }
152 109 setActing(false);
153 110 };
154 111
112 + const totalSize = files.reduce((s, f) => s + f.size, 0);
113 + const allSelected = files.length > 0 && selected.size === files.length;
114 +
155 115 return (
156 116 <AppShell>
157 − <div className="flex flex-col h-full">
158 − {/* Header */}
159 − <div className="px-3 sm:px-4 lg:px-6 py-3 sm:py-4 border-b border-gray-200 dark:border-white/10 shrink-0">
160 − <div className="flex items-center justify-between gap-3">
161 − <div className="flex items-center gap-2 min-w-0">
162 − <Trash2 className="w-5 h-5 text-red-400 shrink-0" />
163 − <h1 className="text-lg font-medium text-gray-900 dark:text-white truncate">
164 − Corbeille
165 − </h1>
166 − <span className="text-xs text-gray-500 dark:text-gray-400 tabular-nums shrink-0">
167 − {files.length} fichier{files.length !== 1 ? "s" : ""}
168 − </span>
169 − </div>
170 − {files.length > 0 && (
171 − <button
172 − onClick={() => setShowEmptyConfirm(true)}
173 − disabled={acting}
174 − className="text-xs sm:text-sm px-3 py-2 rounded-lg bg-red-500/10 text-red-500 hover:bg-red-500/20 active:bg-red-500/30 transition-colors font-medium min-h-[44px] min-w-[44px] disabled:opacity-50 shrink-0"
175 − >
176 − Vider la corbeille
177 − </button>
178 − )}
179 − </div>
180 −
181 − {/* Bulk actions */}
182 − <AnimatePresence>
183 − {selected.size > 0 && (
184 − <motion.div
185 − initial={{ height: 0, opacity: 0 }}
186 − animate={{ height: "auto", opacity: 1 }}
187 − exit={{ height: 0, opacity: 0 }}
188 − className="overflow-hidden"
189 − >
190 − <div className="flex items-center gap-2 mt-3 flex-wrap">
191 − <span className="text-sm text-gray-500 dark:text-gray-400">
192 − {selected.size} selectionne{selected.size !== 1 ? "s" : ""}
193 − </span>
194 − <button
195 − onClick={handleRestore}
196 − disabled={acting}
197 − className="flex items-center gap-1.5 text-sm px-3 py-2 rounded-lg bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20 active:bg-emerald-500/30 transition-colors font-medium min-h-[44px] disabled:opacity-50"
198 − >
199 − <RotateCcw className="w-4 h-4" />
200 − Restaurer
201 − </button>
202 − <button
203 − onClick={() => setShowPurgeConfirm(true)}
204 − disabled={acting}
205 − className="flex items-center gap-1.5 text-sm px-3 py-2 rounded-lg bg-red-500/10 text-red-500 hover:bg-red-500/20 active:bg-red-500/30 transition-colors font-medium min-h-[44px] disabled:opacity-50"
206 − >
207 − <Trash2 className="w-4 h-4" />
208 − Supprimer definitivement
209 − </button>
210 − <button
211 − onClick={() => setSelected(new Set())}
212 − className="text-sm px-2 py-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
213 − >
214 − <X className="w-4 h-4" />
215 − </button>
216 − </div>
217 − </motion.div>
218 − )}
219 − </AnimatePresence>
117 + <PageTransition className="flex flex-col h-full">
118 + <div className="px-4 lg:px-6">
119 + <PageHeader
120 + title="Corbeille"
121 + subtitle={loading ? "Chargement…" : files.length === 0 ? "Aucun fichier supprimé" : `${files.length} fichier${files.length !== 1 ? "s" : ""} · ${formatFileSize(totalSize)} · purge automatique après 30 jours`}
122 + icon={Trash2}
123 + tone="text-rose-500 bg-rose-500/10"
124 + compact
125 + actions={
126 + files.length > 0 ? (
127 + <Button variant="danger" size="sm" onClick={() => setShowEmptyConfirm(true)} disabled={acting}>
128 + <Trash2 className="w-4 h-4" /> <span className="hidden sm:inline">Vider la corbeille</span><span className="sm:hidden">Vider</span>
129 + </Button>
130 + ) : undefined
131 + }
132 + />
220 133 </div>
221 134
222 − {/* Content */}
223 − <div className="flex-1 overflow-auto px-3 sm:px-4 lg:px-6 py-3 sm:py-4">
135 + <div className="flex-1 overflow-auto px-4 lg:px-6 pb-28">
224 136 {loading ? (
225 − <div className="flex items-center justify-center h-32">
226 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
137 + <div className="space-y-2">
138 + {Array.from({ length: 6 }).map((_, i) => (
139 + <div key={i} className="card p-3 flex items-center gap-3 animate-pulse">
140 + <div className="w-5 h-5 rounded-md bg-gray-200 dark:bg-white/10" />
141 + <div className="w-10 h-10 rounded-xl bg-gray-200 dark:bg-white/10" />
142 + <div className="flex-1 space-y-2"><div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-1/5 rounded bg-gray-200 dark:bg-white/10" /></div>
143 + </div>
144 + ))}
227 145 </div>
228 146 ) : files.length === 0 ? (
229 − <div className="flex flex-col items-center justify-center h-64 text-gray-500">
230 − <Trash2 className="w-16 h-16 mb-4 text-gray-300 dark:text-gray-600" />
231 − <p className="text-lg font-medium text-gray-700 dark:text-gray-300">
232 − La corbeille est vide
233 − </p>
234 − <p className="text-sm mt-1 text-center px-4 text-gray-400 dark:text-gray-500">
235 − Les fichiers supprimes apparaitront ici pendant 30 jours
236 − </p>
237 − </div>
147 + <EmptyState icon={Trash2} title="La corbeille est vide" description="Les fichiers supprimés sont conservés ici pendant 30 jours avant d'être effacés définitivement." />
238 148 ) : (
239 149 <>
240 − {/* Select all */}
241 − <div className="flex items-center gap-3 mb-3 px-1">
242 − <button
243 − onClick={toggleAll}
244 − className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors min-h-[44px]"
245 − >
246 − <div
247 − className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
248 − selected.size === files.length
249 − ? "bg-blue-500 border-blue-500"
250 − : "border-gray-300 dark:border-gray-600"
251 − }`}
252 − >
253 − {selected.size === files.length && (
254 − <Check className="w-3.5 h-3.5 text-white" />
255 − )}
256 − </div>
257 − Tout selectionner
150 + <div className="flex items-center justify-between mb-2 px-1">
151 + <button onClick={toggleAll} className="flex items-center gap-2.5 text-[13px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors h-10">
152 + <span className={`w-5 h-5 rounded-md border-2 flex items-center justify-center transition-colors ${allSelected ? "bg-blue-500 border-blue-500" : selected.size > 0 ? "border-blue-500" : "border-gray-300 dark:border-white/25"}`}>
153 + {allSelected && <Check className="w-3.5 h-3.5 text-white" />}
154 + {!allSelected && selected.size > 0 && <span className="w-2.5 h-0.5 bg-blue-500 rounded" />}
155 + </span>
156 + {allSelected ? "Tout désélectionner" : "Tout sélectionner"}
258 157 </button>
158 + <span className="text-[12px] text-gray-400 hidden sm:block">Cliquez sur une ligne pour la sélectionner</span>
259 159 </div>
260 160
261 − {/* File list */}
262 − <div className="space-y-1">
263 − <AnimatePresence>
161 + <div className="card p-0 overflow-hidden divide-y divide-line dark:divide-white/[0.06]">
162 + <AnimatePresence initial={false}>
264 163 {files.map((file) => {
265 − const Icon = getTypeIcon(file.mimeType);
164 + const kind = getFileIcon(file.mimeType);
165 + const Icon = iconMap[kind] || File;
266 166 const days = daysUntilPurge(file.deletedAt);
267 167 const isSelected = selected.has(file.id);
268 −
269 168 return (
270 169 <motion.div
271 170 key={file.id}
272 171 layout
273 − initial={{ opacity: 0, y: 10 }}
274 − animate={{ opacity: 1, y: 0 }}
275 − exit={{ opacity: 0, x: -50 }}
172 + initial={{ opacity: 0 }}
173 + animate={{ opacity: 1 }}
174 + exit={{ opacity: 0, x: -40 }}
276 175 onClick={() => toggleSelect(file.id)}
277 − className={`flex items-center gap-3 px-3 py-3 rounded-xl cursor-pointer transition-colors min-h-[60px] ${
278 − isSelected
279 − ? "bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30"
280 − : "bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-white/10 active:bg-gray-100 dark:active:bg-white/15"
281 − }`}
176 + role="checkbox"
177 + aria-checked={isSelected}
178 + tabIndex={0}
179 + onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); toggleSelect(file.id); } }}
180 + className={`flex items-center gap-3 px-3 density-row cursor-pointer transition-colors min-h-[56px] outline-none focus-visible:bg-blue-500/5 ${isSelected ? "bg-blue-500/[0.08]" : "hover:bg-gray-50 dark:hover:bg-white/[0.04]"}`}
282 181 >
283 − {/* Checkbox */}
284 − <div
285 − className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
286 − isSelected
287 − ? "bg-blue-500 border-blue-500"
288 − : "border-gray-300 dark:border-gray-600"
289 − }`}
290 − >
182 + <span className={`w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors ${isSelected ? "bg-blue-500 border-blue-500" : "border-gray-300 dark:border-white/25"}`}>
291 183 {isSelected && <Check className="w-3.5 h-3.5 text-white" />}
292 − </div>
293 −
294 − {/* Icon */}
295 − <div className="w-10 h-10 rounded-lg bg-gray-100 dark:bg-white/10 flex items-center justify-center shrink-0">
296 − <Icon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
297 − </div>
298 −
299 − {/* Info */}
184 + </span>
185 + <span className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${toneMap[kind] || toneMap.file}`}>
186 + <Icon className="w-[18px] h-[18px]" />
187 + </span>
300 188 <div className="flex-1 min-w-0">
301 − <p className="text-sm font-medium text-gray-900 dark:text-white truncate">
302 − {file.name}
189 + <p className="text-[14px] font-medium text-gray-900 dark:text-white truncate">{file.name}</p>
190 + <p className="text-[12px] text-gray-500 dark:text-gray-400 tabular-nums">
191 + {formatFileSize(file.size)} · supprimé le {new Date(file.deletedAt).toLocaleDateString("fr-CA", { day: "numeric", month: "short" })}
303 192 </p>
304 − <div className="flex items-center gap-2 mt-0.5 flex-wrap">
305 − <span className="text-xs text-gray-500 dark:text-gray-400">
306 − {formatSize(file.size)}
307 − </span>
308 − <span className="text-xs text-gray-400 dark:text-gray-500">
309 − {new Date(file.deletedAt).toLocaleDateString("fr-FR", {
310 − day: "numeric",
311 − month: "short",
312 − })}
313 − </span>
314 − </div>
315 − </div>
316 −
317 − {/* Auto-purge badge */}
318 − <div
319 − className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs shrink-0 ${
320 − days <= 3
321 − ? "bg-red-100 dark:bg-red-500/15 text-red-600 dark:text-red-400"
322 − : days <= 7
323 − ? "bg-amber-100 dark:bg-amber-500/15 text-amber-600 dark:text-amber-400"
324 − : "bg-gray-100 dark:bg-white/10 text-gray-500 dark:text-gray-400"
325 − }`}
326 − >
327 − <Clock className="w-3 h-3" />
328 − <span className="hidden sm:inline">{days}j</span>
329 − <span className="sm:hidden">{days}</span>
330 193 </div>
194 + <span className={`inline-flex items-center gap-1 px-2 h-6 rounded-full text-[11px] font-medium tabular-nums shrink-0 ${
195 + days <= 3 ? "bg-rose-500/10 text-rose-600 dark:text-rose-400" : days <= 7 ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" : "bg-gray-100 dark:bg-white/[0.06] text-gray-500 dark:text-gray-400"
196 + }`} title={`Suppression définitive dans ${days} jour${days !== 1 ? "s" : ""}`}>
197 + <Clock className="w-3 h-3" />{days} j
198 + </span>
331 199 </motion.div>
332 200 );
333 201 })}
@@ -336,107 +204,70 @@ export default function TrashPage() {
336 204 </>
337 205 )}
338 206 </div>
339 − </div>
340 207
341 − {/* Empty trash confirmation modal */}
342 − <AnimatePresence>
343 − {showEmptyConfirm && (
344 − <motion.div
345 − initial={{ opacity: 0 }}
346 − animate={{ opacity: 1 }}
347 − exit={{ opacity: 0 }}
348 − className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
349 − onClick={() => setShowEmptyConfirm(false)}
350 − >
208 + {/* Barre d'actions flottante */}
209 + <AnimatePresence>
210 + {selected.size > 0 && (
351 211 <motion.div
352 − initial={{ scale: 0.95, opacity: 0 }}
353 − animate={{ scale: 1, opacity: 1 }}
354 − exit={{ scale: 0.95, opacity: 0 }}
355 − onClick={(e) => e.stopPropagation()}
356 − className="bg-white dark:bg-[#1a1a1a] rounded-2xl p-6 max-w-sm w-full shadow-xl border border-gray-200 dark:border-white/10"
212 + initial={{ opacity: 0, y: 24, scale: 0.98 }}
213 + animate={{ opacity: 1, y: 0, scale: 1 }}
214 + exit={{ opacity: 0, y: 24, scale: 0.98 }}
215 + transition={{ type: "spring", stiffness: 380, damping: 32 }}
216 + className="fixed left-1/2 -translate-x-1/2 bottom-[calc(4.5rem+env(safe-area-inset-bottom))] lg:bottom-6 z-40 glass rounded-2xl shadow-pop px-2.5 py-2 flex items-center gap-1.5 max-w-[calc(100vw-1.5rem)]"
357 217 >
358 − <div className="flex items-center gap-3 mb-4">
359 − <div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-500/15 flex items-center justify-center">
360 − <AlertTriangle className="w-5 h-5 text-red-500" />
361 − </div>
362 − <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
363 − Vider la corbeille
364 − </h3>
365 − </div>
366 − <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
367 − Cette action supprimera definitivement {files.length} fichier
368 − {files.length !== 1 ? "s" : ""}. Cette action est irreversible.
369 − </p>
370 − <div className="flex gap-3">
371 − <button
372 − onClick={() => setShowEmptyConfirm(false)}
373 − className="flex-1 px-4 py-3 rounded-xl text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-white/10 hover:bg-gray-200 dark:hover:bg-white/15 transition-colors min-h-[44px]"
374 − >
375 − Annuler
376 − </button>
377 − <button
378 − onClick={handleEmpty}
379 − disabled={acting}
380 − className="flex-1 px-4 py-3 rounded-xl text-sm font-medium text-white bg-red-500 hover:bg-red-600 active:bg-red-700 transition-colors min-h-[44px] disabled:opacity-50"
381 − >
382 − {acting ? "Suppression..." : "Supprimer tout"}
383 − </button>
384 − </div>
218 + <span className="px-2.5 text-[13px] font-medium text-gray-900 dark:text-white whitespace-nowrap tabular-nums">
219 + {selected.size} sélectionné{selected.size > 1 ? "s" : ""}
220 + </span>
221 + <span className="w-px h-6 bg-line dark:bg-white/10" />
222 + <Button size="sm" variant="ghost" onClick={handleRestore} loading={acting} className="text-emerald-600 dark:text-emerald-400 hover:!bg-emerald-500/10">
223 + <RotateCcw className="w-4 h-4" /> Restaurer
224 + </Button>
225 + <Button size="sm" variant="ghost" onClick={() => setShowPurgeConfirm(true)} disabled={acting} className="text-rose-600 dark:text-rose-400 hover:!bg-rose-500/10">
226 + <Trash2 className="w-4 h-4" /> <span className="hidden sm:inline">Supprimer</span>
227 + </Button>
228 + <button onClick={() => setSelected(new Set())} className="w-9 h-9 flex items-center justify-center rounded-xl text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" aria-label="Annuler la sélection">
229 + <X className="w-4 h-4" />
230 + </button>
385 231 </motion.div>
386 − </motion.div>
387 − )}
388 − </AnimatePresence>
232 + )}
233 + </AnimatePresence>
234 + </PageTransition>
389 235
390 − {/* Purge selected confirmation modal */}
391 − <AnimatePresence>
392 − {showPurgeConfirm && (
393 − <motion.div
394 − initial={{ opacity: 0 }}
395 − animate={{ opacity: 1 }}
396 − exit={{ opacity: 0 }}
397 − className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
398 − onClick={() => setShowPurgeConfirm(false)}
399 − >
400 − <motion.div
401 − initial={{ scale: 0.95, opacity: 0 }}
402 − animate={{ scale: 1, opacity: 1 }}
403 − exit={{ scale: 0.95, opacity: 0 }}
404 − onClick={(e) => e.stopPropagation()}
405 − className="bg-white dark:bg-[#1a1a1a] rounded-2xl p-6 max-w-sm w-full shadow-xl border border-gray-200 dark:border-white/10"
406 − >
407 − <div className="flex items-center gap-3 mb-4">
408 − <div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-500/15 flex items-center justify-center">
409 − <AlertTriangle className="w-5 h-5 text-red-500" />
410 − </div>
411 − <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
412 − Supprimer definitivement
413 − </h3>
414 − </div>
415 − <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
416 − {selected.size} fichier{selected.size !== 1 ? "s" : ""} sera
417 − {selected.size !== 1 ? "ont" : ""} supprime
418 − {selected.size !== 1 ? "s" : ""} definitivement. Cette action est
419 − irreversible.
420 − </p>
421 − <div className="flex gap-3">
422 − <button
423 − onClick={() => setShowPurgeConfirm(false)}
424 − className="flex-1 px-4 py-3 rounded-xl text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-white/10 hover:bg-gray-200 dark:hover:bg-white/15 transition-colors min-h-[44px]"
425 − >
426 − Annuler
427 − </button>
428 − <button
429 − onClick={handlePurge}
430 − disabled={acting}
431 − className="flex-1 px-4 py-3 rounded-xl text-sm font-medium text-white bg-red-500 hover:bg-red-600 active:bg-red-700 transition-colors min-h-[44px] disabled:opacity-50"
432 − >
433 − {acting ? "Suppression..." : "Supprimer"}
434 − </button>
435 − </div>
436 − </motion.div>
437 − </motion.div>
438 − )}
439 − </AnimatePresence>
236 + <Modal
237 + isOpen={showEmptyConfirm}
238 + onClose={() => setShowEmptyConfirm(false)}
239 + title="Vider la corbeille ?"
240 + description={`${files.length} fichier${files.length !== 1 ? "s" : ""} · ${formatFileSize(totalSize)}`}
241 + footer={
242 + <>
243 + <Button variant="ghost" onClick={() => setShowEmptyConfirm(false)}>Annuler</Button>
244 + <Button variant="danger" onClick={handleEmpty} loading={acting}><Trash2 className="w-4 h-4" /> Tout supprimer</Button>
245 + </>
246 + }
247 + >
248 + <div className="flex items-start gap-3 p-3 rounded-2xl bg-rose-500/[0.07] text-[13.5px] text-rose-700 dark:text-rose-300">
249 + <AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
250 + <p>Tous les fichiers de la corbeille seront effacés définitivement. Cette action est irréversible.</p>
251 + </div>
252 + </Modal>
253 +
254 + <Modal
255 + isOpen={showPurgeConfirm}
256 + onClose={() => setShowPurgeConfirm(false)}
257 + title="Supprimer définitivement ?"
258 + description={`${selected.size} fichier${selected.size !== 1 ? "s" : ""} sélectionné${selected.size !== 1 ? "s" : ""}`}
259 + footer={
260 + <>
261 + <Button variant="ghost" onClick={() => setShowPurgeConfirm(false)}>Annuler</Button>
262 + <Button variant="danger" onClick={handlePurge} loading={acting}><Trash2 className="w-4 h-4" /> Supprimer</Button>
263 + </>
264 + }
265 + >
266 + <div className="flex items-start gap-3 p-3 rounded-2xl bg-rose-500/[0.07] text-[13.5px] text-rose-700 dark:text-rose-300">
267 + <AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
268 + <p>Ces fichiers ne pourront plus être restaurés.</p>
269 + </div>
270 + </Modal>
440 271 </AppShell>
441 272 );
442 273 }
modified app/globals.css +223 −125
@@ -1,187 +1,285 @@
1 −@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
2 −
3 1 @tailwind base;
4 2 @tailwind components;
5 3 @tailwind utilities;
6 4
7 −/* ===== LIGHT THEME (default) ===== */
5 +/* =====================================================================
6 + SPB Cloud v3 — "Aurora" design tokens
7 + Surfaces sémantiques (triplets RGB pour permettre /alpha) + accent
8 + dynamique (data-accent) + densité (data-density) + mouvement (data-motion)
9 + ===================================================================== */
10 +
11 +/* ---------- Accent presets (RGB triplets) ---------- */
12 +:root,
13 +html[data-accent="aurore"] {
14 + --accent-50: 238 242 255; --accent-100: 224 231 255; --accent-200: 199 210 254;
15 + --accent-300: 165 180 252; --accent-400: 129 140 248; --accent-500: 99 102 241;
16 + --accent-600: 79 70 229; --accent-700: 67 56 202; --accent-800: 55 48 163;
17 + --accent-900: 49 46 129; --accent-950: 30 27 75;
18 +}
19 +html[data-accent="ocean"] {
20 + --accent-50: 239 246 255; --accent-100: 219 234 254; --accent-200: 191 219 254;
21 + --accent-300: 147 197 253; --accent-400: 96 165 250; --accent-500: 59 130 246;
22 + --accent-600: 37 99 235; --accent-700: 29 78 216; --accent-800: 30 64 175;
23 + --accent-900: 30 58 138; --accent-950: 23 37 84;
24 +}
25 +html[data-accent="glacier"] {
26 + --accent-50: 236 254 255; --accent-100: 207 250 254; --accent-200: 165 243 252;
27 + --accent-300: 103 232 249; --accent-400: 34 211 238; --accent-500: 6 182 212;
28 + --accent-600: 8 145 178; --accent-700: 14 116 144; --accent-800: 21 94 117;
29 + --accent-900: 22 78 99; --accent-950: 8 51 68;
30 +}
31 +html[data-accent="foret"] {
32 + --accent-50: 236 253 245; --accent-100: 209 250 229; --accent-200: 167 243 208;
33 + --accent-300: 110 231 183; --accent-400: 52 211 153; --accent-500: 16 185 129;
34 + --accent-600: 5 150 105; --accent-700: 4 120 87; --accent-800: 6 95 70;
35 + --accent-900: 6 78 59; --accent-950: 2 44 34;
36 +}
37 +html[data-accent="corail"] {
38 + --accent-50: 255 241 242; --accent-100: 255 228 230; --accent-200: 254 205 211;
39 + --accent-300: 253 164 175; --accent-400: 251 113 133; --accent-500: 244 63 94;
40 + --accent-600: 225 29 72; --accent-700: 190 18 60; --accent-800: 159 18 57;
41 + --accent-900: 136 19 55; --accent-950: 76 5 25;
42 +}
43 +html[data-accent="ambre"] {
44 + --accent-50: 255 251 235; --accent-100: 254 243 199; --accent-200: 253 230 138;
45 + --accent-300: 252 211 77; --accent-400: 251 191 36; --accent-500: 245 158 11;
46 + --accent-600: 217 119 6; --accent-700: 180 83 9; --accent-800: 146 64 14;
47 + --accent-900: 120 53 15; --accent-950: 69 26 3;
48 +}
49 +html[data-accent="amethyste"] {
50 + --accent-50: 245 243 255; --accent-100: 237 233 254; --accent-200: 221 214 254;
51 + --accent-300: 196 181 253; --accent-400: 167 139 250; --accent-500: 139 92 246;
52 + --accent-600: 124 58 237; --accent-700: 109 40 217; --accent-800: 91 33 182;
53 + --accent-900: 76 29 149; --accent-950: 46 16 101;
54 +}
55 +
56 +/* ---------- LIGHT (défaut) ---------- */
8 57 :root {
9 − --background: #ffffff;
10 − --foreground: #111827;
11 − --bg-sidebar: #f9fafb;
12 − --bg-card: #f3f4f6;
13 − --bg-card-hover: #e5e7eb;
14 − --bg-input: #f3f4f6;
15 − --bg-overlay: rgba(0, 0, 0, 0.4);
16 − --border-primary: #e5e7eb;
17 − --border-secondary: #d1d5db;
18 − --text-primary: #111827;
19 − --text-secondary: #374151;
58 + --canvas: 245 246 248; /* fond de l'app */
59 + --surface: 255 255 255; /* cartes, panneaux */
60 + --elevated: 255 255 255; /* popovers, modales */
61 + --line: 226 230 235; /* bordures */
62 +
63 + --background: rgb(var(--canvas));
64 + --foreground: #0f172a;
65 + --bg-sidebar: #ffffff;
66 + --bg-card: #ffffff;
67 + --bg-card-hover: #f1f3f6;
68 + --bg-input: #f1f3f6;
69 + --bg-overlay: rgba(15, 23, 42, 0.45);
70 + --border-primary: #e2e6eb;
71 + --border-secondary: #c8cdd5;
72 + --text-primary: #0f172a;
73 + --text-secondary: #3a404b;
20 74 --text-muted: #6b7280;
21 − --text-dimmed: #9ca3af;
22 − --scrollbar-track: #f3f4f6;
23 − --scrollbar-thumb: #d1d5db;
24 − --scrollbar-thumb-hover: #9ca3af;
75 + --text-dimmed: #9aa1ad;
25 76 --toast-bg: #ffffff;
26 − --toast-color: #111827;
27 − --toast-border: rgba(0, 0, 0, 0.1);
77 + --toast-color: #0f172a;
78 + --toast-border: rgba(15, 23, 42, 0.08);
79 + --toast-shadow: 0 12px 40px -8px rgba(15, 23, 42, 0.25);
80 +
81 + --row-py: 0.625rem; /* densité confortable */
82 + color-scheme: light;
28 83 }
29 84
30 −/* ===== DARK THEME ===== */
85 +/* ---------- DARK "obsidienne" ---------- */
31 86 .dark {
32 − --background: #0a0a0a;
87 + --canvas: 10 11 16;
88 + --surface: 18 19 26;
89 + --elevated: 24 26 35;
90 + --line: 40 43 54;
91 +
92 + --background: rgb(var(--canvas));
33 93 --foreground: #ededed;
34 − --bg-sidebar: #0a0a0a;
35 − --bg-card: rgba(255, 255, 255, 0.05);
36 − --bg-card-hover: rgba(255, 255, 255, 0.1);
94 + --bg-sidebar: rgb(var(--canvas));
95 + --bg-card: rgba(255, 255, 255, 0.04);
96 + --bg-card-hover: rgba(255, 255, 255, 0.08);
37 97 --bg-input: rgba(255, 255, 255, 0.05);
38 − --bg-overlay: rgba(0, 0, 0, 0.6);
39 − --border-primary: rgba(255, 255, 255, 0.1);
40 − --border-secondary: rgba(255, 255, 255, 0.2);
98 + --bg-overlay: rgba(0, 0, 0, 0.65);
99 + --border-primary: rgba(255, 255, 255, 0.08);
100 + --border-secondary: rgba(255, 255, 255, 0.16);
41 101 --text-primary: #ffffff;
42 102 --text-secondary: #e5e7eb;
43 − --text-muted: #9ca3af;
103 + --text-muted: #9aa1ad;
44 104 --text-dimmed: #6b7280;
45 − --scrollbar-track: #111827;
46 − --scrollbar-thumb: #374151;
47 − --scrollbar-thumb-hover: #4b5563;
48 − --toast-bg: #1f2937;
105 + --toast-bg: #181a23;
49 106 --toast-color: #e5e7eb;
50 − --toast-border: rgba(255, 255, 255, 0.1);
107 + --toast-border: rgba(255, 255, 255, 0.08);
108 + --toast-shadow: 0 12px 40px -8px rgba(0, 0, 0, 0.7);
109 + color-scheme: dark;
110 +}
111 +
112 +html[data-density="compact"] { --row-py: 0.375rem; }
113 +
114 +/* ---------- Base ---------- */
115 +html {
116 + overscroll-behavior: none;
117 + -webkit-text-size-adjust: 100%;
118 + text-rendering: optimizeLegibility;
51 119 }
52 120
53 121 body {
54 122 color: var(--foreground);
55 123 background: var(--background);
56 − font-family: "Inter", system-ui, sans-serif;
124 + font-family: var(--font-geist), "Inter", system-ui, -apple-system, sans-serif;
125 + font-feature-settings: "cv11", "ss01", "ss03";
57 126 transition: background-color 0.3s ease, color 0.3s ease;
58 127 -webkit-tap-highlight-color: transparent;
59 − -webkit-text-size-adjust: 100%;
128 + -webkit-font-smoothing: antialiased;
60 129 }
61 130
62 −/* ===== MOBILE SAFE AREAS ===== */
63 −.safe-top {
64 − padding-top: env(safe-area-inset-top, 0);
131 +::selection {
132 + background: rgb(var(--accent-500) / 0.25);
65 133 }
66 134
67 −.pb-safe {
68 − padding-bottom: env(safe-area-inset-bottom, 0);
135 +/* Réduction des animations (préférence utilisateur ou système) */
136 +html[data-motion="reduced"] *,
137 +html[data-motion="reduced"] *::before,
138 +html[data-motion="reduced"] *::after {
139 + animation-duration: 0.01ms !important;
140 + animation-iteration-count: 1 !important;
141 + transition-duration: 0.01ms !important;
142 + scroll-behavior: auto !important;
69 143 }
70 144
71 −/* ===== TOUCH OPTIMIZATION ===== */
72 −@layer utilities {
73 − .touch-target {
74 − min-width: 44px;
75 − min-height: 44px;
145 +/* ---------- Mobile safe areas ---------- */
146 +.safe-top { padding-top: env(safe-area-inset-top, 0); }
147 +.pb-safe { padding-bottom: env(safe-area-inset-bottom, 0); }
148 +.pb-tabbar { padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0)); }
149 +@media (min-width: 1024px) { .pb-tabbar { padding-bottom: 0; } }
150 +
151 +/* ---------- Composants (surchargeables par les utilitaires Tailwind : pl-10, rounded-*, etc.) ---------- */
152 +@layer components {
153 + /* Carte standard */
154 + .card {
155 + @apply bg-surface border border-line rounded-2xl shadow-card dark:shadow-none dark:border-white/[0.07];
156 + }
157 + .card-hover {
158 + @apply transition-all duration-200 hover:shadow-card-hover hover:-translate-y-px dark:hover:bg-white/[0.03] dark:hover:border-white/[0.12];
76 159 }
77 −}
78 160
79 −/* Prevent text selection on interactive elements on mobile */
80 −button, a, [role="button"] {
81 − -webkit-touch-callout: none;
82 − user-select: none;
83 −}
161 + /* Verre (barres flottantes, popovers, palette) */
162 + .glass {
163 + background: rgb(var(--elevated) / 0.9);
164 + -webkit-backdrop-filter: saturate(160%) blur(18px);
165 + backdrop-filter: saturate(160%) blur(18px);
166 + border: 1px solid rgb(var(--line) / 0.9);
167 + }
168 + .dark .glass { border-color: rgba(255, 255, 255, 0.08); box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05); }
84 169
85 −/* Active state feedback for mobile */
86 −button:active, a:active, [role="button"]:active {
87 − opacity: 0.85;
88 −}
170 + /* Champ de saisie standard */
171 + .field {
172 + @apply w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl px-3.5 py-2.5 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 transition-all duration-200;
173 + @apply focus:outline-none focus:border-blue-500/60 focus:ring-4 focus:ring-blue-500/10 dark:focus:ring-blue-500/15;
174 + }
89 175
90 −/* Custom scrollbar */
91 −::-webkit-scrollbar {
92 − width: 6px;
93 − height: 6px;
94 −}
176 + /* Texte dégradé d'accent */
177 + .text-accent-gradient {
178 + background: linear-gradient(120deg, rgb(var(--accent-500)), rgb(var(--accent-700)));
179 + -webkit-background-clip: text;
180 + background-clip: text;
181 + color: transparent;
182 + }
183 + .dark .text-accent-gradient {
184 + background: linear-gradient(120deg, rgb(var(--accent-300)), rgb(var(--accent-500)));
185 + -webkit-background-clip: text;
186 + background-clip: text;
187 + }
188 +
189 + /* Ligne d'explorateur : padding piloté par la densité */
190 + .density-row { padding-top: var(--row-py); padding-bottom: var(--row-py); }
95 191
96 −@media (max-width: 768px) {
97 − ::-webkit-scrollbar {
98 − width: 3px;
99 − height: 3px;
192 + /* Anneau de cible au dépôt */
193 + .drop-target {
194 + @apply ring-2 ring-blue-500 ring-offset-2 ring-offset-canvas bg-blue-500/10;
100 195 }
101 196 }
102 197
103 −::-webkit-scrollbar-track {
104 − background: transparent;
198 +/* ---------- Utilities ---------- */
199 +@layer utilities {
200 + .touch-target { min-width: 44px; min-height: 44px; }
201 + .scrollbar-none { scrollbar-width: none; }
202 + .scrollbar-none::-webkit-scrollbar { display: none; }
203 + .scrollbar-thin { scrollbar-width: thin; }
105 204 }
106 205
107 −::-webkit-scrollbar-thumb {
108 − background: rgba(128, 128, 128, 0.3);
109 − border-radius: 3px;
110 −}
206 +/* Interactions tactiles */
207 +button, a, [role="button"] { -webkit-touch-callout: none; user-select: none; }
208 +button:active, a:active, [role="button"]:active { opacity: 0.9; }
111 209
112 −::-webkit-scrollbar-thumb:hover {
113 − background: rgba(128, 128, 128, 0.5);
114 −}
210 +/* Scrollbar */
211 +::-webkit-scrollbar { width: 8px; height: 8px; }
212 +@media (max-width: 768px) { ::-webkit-scrollbar { width: 3px; height: 3px; } }
213 +::-webkit-scrollbar-track { background: transparent; }
214 +::-webkit-scrollbar-thumb { background: rgb(128 128 128 / 0.28); border-radius: 999px; border: 2px solid transparent; background-clip: padding-box; }
215 +::-webkit-scrollbar-thumb:hover { background: rgb(128 128 128 / 0.5); border: 2px solid transparent; background-clip: padding-box; }
115 216
116 −/* Better focus rings */
217 +/* Focus visible accent */
117 218 *:focus-visible {
118 − outline: 2px solid rgb(29 78 216);
219 + outline: 2px solid rgb(var(--accent-500));
119 220 outline-offset: 2px;
120 − border-radius: 4px;
221 + border-radius: 6px;
121 222 }
122 223
123 −/* Shimmer animation for skeletons */
124 −@keyframes shimmer {
125 − 0% { background-position: -200% 0; }
126 − 100% { background-position: 200% 0; }
127 −}
224 +/* Skeleton shimmer */
225 +@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
128 226 .animate-shimmer {
129 − background: linear-gradient(90deg, transparent 25%, rgba(255, 255, 255, 0.08) 50%, transparent 75%);
227 + background: linear-gradient(90deg, transparent 25%, rgba(255, 255, 255, 0.35) 50%, transparent 75%);
130 228 background-size: 200% 100%;
131 − animation: shimmer 1.5s infinite;
229 + animation: shimmer 1.6s infinite;
132 230 }
133 −
134 −/* Top loader animation */
135 −@keyframes topload {
136 − 0% { width: 0; }
137 − 50% { width: 70%; }
138 − 100% { width: 100%; }
231 +.dark .animate-shimmer {
232 + background: linear-gradient(90deg, transparent 25%, rgba(255, 255, 255, 0.07) 50%, transparent 75%);
233 + background-size: 200% 100%;
139 234 }
140 235
141 −/* Smooth page transitions */
142 −.page-enter {
143 − opacity: 0;
144 − transform: translateY(8px);
145 −}
146 −.page-enter-active {
147 − opacity: 1;
148 − transform: translateY(0);
149 − transition: all 0.3s ease;
150 −}
236 +/* Page transitions (legacy) */
237 +.page-enter { opacity: 0; transform: translateY(8px); }
238 +.page-enter-active { opacity: 1; transform: translateY(0); transition: all 0.3s ease; }
151 239
152 −/* File drop zone animations */
240 +/* Drop zone */
153 241 .drop-active {
154 − border-color: #3b82f6 !important;
155 − background-color: rgba(59, 130, 246, 0.05) !important;
242 + border-color: rgb(var(--accent-500)) !important;
243 + background-color: rgb(var(--accent-500) / 0.06) !important;
156 244 }
157 245
158 −/* Mobile modal - full viewport */
246 +/* Modale plein écran mobile (legacy) */
159 247 @media (max-width: 640px) {
160 248 .mobile-full-modal {
161 − width: 100vw !important;
162 − max-width: 100vw !important;
163 − min-height: 50vh;
164 − border-radius: 1rem 1rem 0 0 !important;
165 − margin: 0;
166 − position: fixed;
167 − bottom: 0;
168 − left: 0;
169 − right: 0;
249 + width: 100vw !important; max-width: 100vw !important; min-height: 50vh;
250 + border-radius: 1rem 1rem 0 0 !important; margin: 0; position: fixed; bottom: 0; left: 0; right: 0;
170 251 }
171 252 }
172 253
173 −/* Smooth overscroll behavior */
174 −html {
175 − overscroll-behavior: none;
176 −}
177 −
178 −/* Better select inputs on mobile */
254 +/* Selects natifs */
179 255 select {
180 − -webkit-appearance: none;
181 − appearance: none;
256 + -webkit-appearance: none; appearance: none;
182 257 background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
183 − background-position: right 0.5rem center;
184 − background-repeat: no-repeat;
185 − background-size: 1.5em 1.5em;
186 − padding-right: 2.5rem;
258 + background-position: right 0.5rem center; background-repeat: no-repeat; background-size: 1.5em 1.5em; padding-right: 2.5rem;
187 259 }
260 +
261 +/* Kbd */
262 +kbd {
263 + font-family: var(--font-geist), system-ui, sans-serif;
264 + font-size: 11px; line-height: 1;
265 + padding: 3px 6px; border-radius: 6px;
266 + border: 1px solid rgb(var(--line));
267 + background: rgb(var(--surface));
268 + color: var(--text-muted);
269 + box-shadow: 0 1px 0 rgb(var(--line));
270 +}
271 +.dark kbd { border-color: rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); box-shadow: 0 1px 0 rgba(255,255,255,0.08); }
272 +
273 +/* Prose minimal (l'assistant IA rend du Markdown ; pas de plugin typography) */
274 +.prose { color: inherit; line-height: 1.6; }
275 +.prose p { margin: 0.35em 0; }
276 +.prose ul, .prose ol { margin: 0.4em 0; padding-left: 1.25em; }
277 +.prose ul { list-style: disc; } .prose ol { list-style: decimal; }
278 +.prose li { margin: 0.15em 0; }
279 +.prose h1, .prose h2, .prose h3, .prose h4 { font-weight: 600; margin: 0.6em 0 0.3em; line-height: 1.3; }
280 +.prose h1 { font-size: 1.15em; } .prose h2 { font-size: 1.08em; } .prose h3 { font-size: 1.02em; }
281 +.prose blockquote { border-left: 3px solid rgb(var(--accent-500) / 0.5); padding-left: 0.75em; margin: 0.4em 0; color: var(--text-muted); }
282 +.prose table { width: 100%; border-collapse: collapse; font-size: 0.85em; margin: 0.5em 0; }
283 +.prose th, .prose td { border: 1px solid rgb(var(--line)); padding: 4px 8px; text-align: left; }
284 +.prose hr { border-color: rgb(var(--line)); margin: 0.75em 0; }
285 +.prose strong { font-weight: 600; }
modified app/layout.tsx +62 −24
@@ -1,42 +1,80 @@
1 −import type { Metadata } from "next";
1 +import type { Metadata, Viewport } from "next";
2 +import localFont from "next/font/local";
2 3 import "./globals.css";
3 4 import ThemeProvider from "@/components/theme/ThemeProvider";
4 5 import { DynamicToaster } from "@/components/theme/DynamicToaster";
5 6
7 +const geist = localFont({
8 + src: "./fonts/GeistVF.woff",
9 + variable: "--font-geist",
10 + weight: "100 900",
11 + display: "swap",
12 +});
13 +
14 +const geistMono = localFont({
15 + src: "./fonts/GeistMonoVF.woff",
16 + variable: "--font-geist-mono",
17 + weight: "100 900",
18 + display: "swap",
19 +});
20 +
6 21 export const metadata: Metadata = {
7 − title: "SPB Cloud",
8 − description: "Cloud personnel privé",
22 + title: { default: "SPB Cloud", template: "%s · SPB Cloud" },
23 + description: "Cloud personnel privé, chiffré et local-first.",
24 + applicationName: "SPB Cloud",
25 + manifest: "/manifest.webmanifest",
26 + appleWebApp: {
27 + capable: true,
28 + title: "SPB Cloud",
29 + statusBarStyle: "black-translucent",
30 + },
31 + icons: {
32 + icon: [{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }],
33 + apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }],
34 + },
35 + formatDetection: { telephone: false },
9 36 };
10 37
38 +export const viewport: Viewport = {
39 + width: "device-width",
40 + initialScale: 1,
41 + viewportFit: "cover",
42 + themeColor: [
43 + { media: "(prefers-color-scheme: light)", color: "#f5f6f8" },
44 + { media: "(prefers-color-scheme: dark)", color: "#0a0b10" },
45 + ],
46 +};
47 +
48 +// Appliqué avant le premier rendu : thème, accent, densité, mouvement.
49 +// ⚠️ Garder synchronisé avec lib/prefs.ts (clés) et ThemeProvider.
50 +const PREPAINT = `(function(){try{
51 +var d=document.documentElement,ls=localStorage;
52 +var t=ls.getItem('spb-theme');
53 +var dark=t==='dark'||(t==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);
54 +d.classList.toggle('dark',dark);d.classList.toggle('light',!dark);d.style.colorScheme=dark?'dark':'light';
55 +var a=ls.getItem('spb-accent');d.setAttribute('data-accent',a||'aurore');
56 +var de=ls.getItem('spb-density');d.setAttribute('data-density',de==='compact'?'compact':'comfortable');
57 +var m=ls.getItem('spb-motion');d.setAttribute('data-motion',m==='reduced'?'reduced':'normal');
58 +}catch(e){}})();`;
59 +
11 60 export default function RootLayout({
12 61 children,
13 62 }: Readonly<{
14 63 children: React.ReactNode;
15 64 }>) {
16 65 return (
17 − <html lang="fr" className="light" suppressHydrationWarning>
66 + <html
67 + lang="fr"
68 + className={`light ${geist.variable} ${geistMono.variable}`}
69 + data-accent="aurore"
70 + data-density="comfortable"
71 + data-motion="normal"
72 + suppressHydrationWarning
73 + >
18 74 <head>
19 − <script
20 − dangerouslySetInnerHTML={{
21 − __html: `
22 − (function() {
23 − try {
24 − var theme = localStorage.getItem('spb-theme');
25 − // Clair par défaut : on ne passe en sombre que si explicitement choisi
26 − if (theme === 'dark') {
27 − document.documentElement.classList.add('dark');
28 − document.documentElement.classList.remove('light');
29 − } else {
30 − document.documentElement.classList.remove('dark');
31 − document.documentElement.classList.add('light');
32 − }
33 − } catch(e) {}
34 − })();
35 − `,
36 − }}
37 − />
75 + <script dangerouslySetInnerHTML={{ __html: PREPAINT }} />
38 76 </head>
39 − <body className="antialiased">
77 + <body className="antialiased font-sans">
40 78 <ThemeProvider>
41 79 {children}
42 80 <DynamicToaster />
modified app/login/page.tsx +141 −188
@@ -1,11 +1,18 @@
1 1 "use client";
2 2
3 −import { useState } from "react";
3 +import { useState, useEffect } from "react";
4 4 import { useRouter } from "next/navigation";
5 −import { motion } from "framer-motion";
6 −import { Cloud, Lock, Eye, EyeOff, Loader2, User, Shield } from "lucide-react";
7 −import { AnimatePresence } from "framer-motion";
5 +import { motion, AnimatePresence } from "framer-motion";
6 +import { Lock, Eye, EyeOff, Loader2, User, Shield, ShieldCheck, Sparkles, HardDrive, Share2, ArrowRight } from "lucide-react";
8 7 import ThemeToggle from "@/components/theme/ThemeToggle";
8 +import Logo from "@/components/ui/Logo";
9 +
10 +const features = [
11 + { icon: ShieldCheck, title: "Chiffré au repos", text: "AES-256-GCM sur chaque fichier, clés hors du dépôt." },
12 + { icon: Sparkles, title: "IA intégrée", text: "Recherche sémantique, OCR, résumés et agent sur vos documents." },
13 + { icon: HardDrive, title: "Local-first", text: "Hébergé sur votre propre infrastructure, snapshots quotidiens." },
14 + { icon: Share2, title: "Partage maîtrisé", text: "Liens à expiration, mot de passe, aperçu seul ou téléchargement." },
15 +];
9 16
10 17 export default function LoginPage() {
11 18 const [username, setUsername] = useState("");
@@ -15,30 +22,33 @@ export default function LoginPage() {
15 22 const [loading, setLoading] = useState(false);
16 23 const [showPassword, setShowPassword] = useState(false);
17 24 const [needs2FA, setNeeds2FA] = useState(false);
25 + const [now, setNow] = useState<string>("");
18 26 const router = useRouter();
19 27
20 − const inputClasses = "w-full bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl px-10 py-3.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-blue-600 dark:focus:border-blue-700/50 focus:ring-2 focus:ring-blue-700/20 dark:focus:ring-blue-700/10 transition-all duration-200 shadow-sm dark:shadow-none";
28 + useEffect(() => {
29 + const fmt = () => setNow(new Intl.DateTimeFormat("fr-CA", { weekday: "long", day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" }).format(new Date()));
30 + fmt();
31 + const t = setInterval(fmt, 30_000);
32 + return () => clearInterval(t);
33 + }, []);
34 +
35 + const inputClasses =
36 + "w-full bg-white dark:bg-white/[0.05] border border-gray-200 dark:border-white/10 rounded-2xl pl-11 pr-11 h-12 text-[15px] text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-blue-500/60 focus:ring-4 focus:ring-blue-500/10 dark:focus:ring-blue-500/15 transition-all duration-200 shadow-card dark:shadow-none";
21 37
22 38 const handleSubmit = async (e: React.FormEvent) => {
23 39 e.preventDefault();
24 40 setError("");
25 41 setLoading(true);
26 −
27 42 try {
28 43 const res = await fetch("/api/auth/login", {
29 44 method: "POST",
30 45 headers: { "Content-Type": "application/json" },
31 46 body: JSON.stringify({ password, username: username || undefined }),
32 47 });
33 −
34 48 const data = await res.json();
35 −
36 49 if (res.ok) {
37 − if (data.requires2FA) {
38 − setNeeds2FA(true);
39 − } else {
40 − router.push("/dashboard");
41 − }
50 + if (data.requires2FA) setNeeds2FA(true);
51 + else router.push("/dashboard");
42 52 } else {
43 53 setError(data.error || "Erreur de connexion");
44 54 }
@@ -53,17 +63,14 @@ export default function LoginPage() {
53 63 e.preventDefault();
54 64 setError("");
55 65 setLoading(true);
56 −
57 66 try {
58 67 const res = await fetch("/api/auth/2fa/verify", {
59 68 method: "POST",
60 69 headers: { "Content-Type": "application/json" },
61 70 body: JSON.stringify({ token: totpCode }),
62 71 });
63 −
64 − if (res.ok) {
65 − router.push("/dashboard");
66 − } else {
72 + if (res.ok) router.push("/dashboard");
73 + else {
67 74 const data = await res.json();
68 75 setError(data.error || "Code invalide");
69 76 }
@@ -75,187 +82,133 @@ export default function LoginPage() {
75 82 };
76 83
77 84 return (
78 − <div className="min-h-screen bg-gray-50 dark:bg-[#0a0a0a] flex items-center justify-center px-4 py-8 transition-colors relative overflow-hidden">
79 − {/* Animated background orbs */}
80 − <div className="absolute inset-0 pointer-events-none overflow-hidden">
81 − <motion.div
82 − className="absolute w-[400px] h-[400px] rounded-full bg-blue-700/[0.07] dark:bg-blue-700/[0.05] blur-3xl"
83 − animate={{
84 − x: [0, 60, -30, 0],
85 − y: [0, -40, 50, 0],
86 − }}
87 − transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }}
88 − style={{ top: "-10%", left: "-5%" }}
89 − />
90 − <motion.div
91 − className="absolute w-[350px] h-[350px] rounded-full bg-blue-500/[0.06] dark:bg-blue-500/[0.04] blur-3xl"
92 − animate={{
93 − x: [0, -50, 40, 0],
94 − y: [0, 60, -30, 0],
95 − }}
96 − transition={{ duration: 25, repeat: Infinity, ease: "easeInOut" }}
97 − style={{ bottom: "-5%", right: "-5%" }}
98 − />
99 − <motion.div
100 − className="absolute w-[250px] h-[250px] rounded-full bg-pink-500/[0.05] dark:bg-pink-500/[0.03] blur-3xl"
101 − animate={{
102 − x: [0, 30, -20, 0],
103 − y: [0, -30, 20, 0],
104 − }}
105 − transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
106 − style={{ top: "40%", right: "20%" }}
107 − />
108 − </div>
109 −
110 − <div className="fixed top-3 right-3 sm:top-4 sm:right-4 z-10">
111 − <ThemeToggle />
112 − </div>
85 + <div className="min-h-[100dvh] bg-canvas flex transition-colors relative overflow-hidden">
86 + {/* ===== Panneau de marque (desktop) ===== */}
87 + <aside className="hidden lg:flex lg:w-[46%] xl:w-1/2 relative flex-col justify-between p-10 xl:p-14 overflow-hidden text-white">
88 + <div className="absolute inset-0 bg-gray-950" />
89 + <div className="absolute inset-0 bg-aurora opacity-90" />
90 + <div className="absolute inset-0 bg-grid bg-[size:44px_44px] opacity-[0.12] [mask-image:radial-gradient(ellipse_at_center,black_35%,transparent_75%)]" />
91 + <motion.div className="absolute -top-32 -left-24 w-[520px] h-[520px] rounded-full bg-blue-500/30 blur-3xl animate-drift" />
92 + <motion.div className="absolute bottom-[-20%] right-[-10%] w-[560px] h-[560px] rounded-full bg-blue-300/20 blur-3xl animate-drift [animation-delay:-8s]" />
93 +
94 + <div className="relative">
95 + <Logo size={44} wordmark wordmarkClassName="text-xl !text-white" />
96 + </div>
113 97
114 − <motion.div
115 − initial={{ opacity: 0, y: 20 }}
116 − animate={{ opacity: 1, y: 0 }}
117 − transition={{ duration: 0.5 }}
118 − className="w-full max-w-sm px-4 sm:px-0"
119 − >
120 − <div className="text-center mb-8">
121 − <motion.div
122 − initial={{ scale: 0 }}
123 − animate={{ scale: 1 }}
124 − transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
125 − className="inline-flex items-center justify-center w-14 h-14 sm:w-16 sm:h-16 rounded-2xl bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 mb-4"
126 − >
127 − <Cloud className="w-7 h-7 sm:w-8 sm:h-8 text-gray-700 dark:text-white" />
128 − </motion.div>
129 − <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">SPB Cloud</h1>
130 − <p className="text-gray-500 mt-1 text-sm">
131 − {needs2FA ? "Vérification 2FA" : "Cloud personnel privé"}
132 − </p>
98 + <div className="relative max-w-md">
99 + <motion.h2 initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1, duration: 0.5 }} className="text-4xl xl:text-5xl font-semibold tracking-tight leading-[1.05]">
100 + Votre cloud.<br />
101 + <span className="text-white/70">Vos règles.</span>
102 + </motion.h2>
103 + <motion.p initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2, duration: 0.5 }} className="mt-4 text-white/70 text-[15px] leading-relaxed">
104 + Un espace privé, chiffré et hébergé chez vous — avec l&apos;intelligence d&apos;un assistant qui connaît vos documents.
105 + </motion.p>
106 + <ul className="mt-8 space-y-3.5">
107 + {features.map((f, i) => (
108 + <motion.li key={f.title} initial={{ opacity: 0, x: -12 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.3 + i * 0.07, duration: 0.4 }} className="flex items-start gap-3">
109 + <span className="mt-0.5 w-8 h-8 rounded-xl bg-white/10 border border-white/15 flex items-center justify-center shrink-0 backdrop-blur">
110 + <f.icon className="w-4 h-4" />
111 + </span>
112 + <span>
113 + <span className="block text-[14px] font-medium">{f.title}</span>
114 + <span className="block text-[13px] text-white/60">{f.text}</span>
115 + </span>
116 + </motion.li>
117 + ))}
118 + </ul>
133 119 </div>
134 120
135 − <AnimatePresence mode="wait">
136 − {!needs2FA ? (
137 − <motion.form
138 − key="login-form"
139 − initial={{ opacity: 0, x: -20 }}
140 − animate={{ opacity: 1, x: 0 }}
141 − exit={{ opacity: 0, x: 20 }}
142 − transition={{ duration: 0.3 }}
143 − onSubmit={handleSubmit}
144 − className="space-y-4"
145 − >
146 − <div className="relative">
147 − <User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" />
148 − <input
149 − type="text"
150 − value={username}
151 − onChange={(e) => setUsername(e.target.value)}
152 − placeholder="Nom d'utilisateur"
153 − className={inputClasses}
154 − autoComplete="username"
155 − />
156 − </div>
121 + <div className="relative flex items-center justify-between text-[12px] text-white/50">
122 + <span className="capitalize">{now}</span>
123 + <span>SPB Private Cloud · v3</span>
124 + </div>
125 + </aside>
157 126
158 − <div className="relative">
159 − <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" />
160 − <input
161 − type={showPassword ? "text" : "password"}
162 − value={password}
163 − onChange={(e) => setPassword(e.target.value)}
164 − placeholder="Mot de passe"
165 − className={inputClasses}
166 − autoComplete="current-password"
167 − />
168 − <button
169 − type="button"
170 − onClick={() => setShowPassword(!showPassword)}
171 − className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
172 − >
173 − {showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
174 − </button>
175 − </div>
127 + {/* ===== Formulaire ===== */}
128 + <main className="flex-1 flex items-center justify-center px-4 py-8 sm:px-8 relative">
129 + {/* Halo mobile */}
130 + <div className="lg:hidden absolute inset-0 pointer-events-none bg-aurora opacity-60 dark:opacity-40" />
131 + <div className="fixed top-3 right-3 sm:top-5 sm:right-5 z-10"><ThemeToggle /></div>
176 132
177 − {error && (
178 − <motion.p initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} className="text-red-500 dark:text-red-400 text-sm text-center">
179 − {error}
180 − </motion.p>
181 − )}
133 + <motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.45 }} className="relative w-full max-w-[400px]">
134 + <div className="lg:hidden flex justify-center mb-6">
135 + <Logo size={56} />
136 + </div>
182 137
183 − <motion.button
184 − whileTap={{ scale: 0.98 }}
185 − type="submit"
186 − disabled={loading || !password}
187 − className="w-full bg-gray-900 dark:bg-white text-white dark:text-black font-medium py-3.5 rounded-xl hover:bg-gray-800 dark:hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
188 − >
189 − {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Connexion"}
190 − </motion.button>
191 − </motion.form>
192 − ) : (
193 − <motion.form
194 − key="2fa-form"
195 − initial={{ opacity: 0, x: 20 }}
196 − animate={{ opacity: 1, x: 0 }}
197 − exit={{ opacity: 0, x: -20 }}
198 − transition={{ duration: 0.3 }}
199 − onSubmit={handleVerify2FA}
200 − className="space-y-4"
201 − >
202 − <motion.div
203 − initial={{ scale: 0.8, opacity: 0 }}
204 − animate={{ scale: 1, opacity: 1 }}
205 − transition={{ delay: 0.1, type: "spring", stiffness: 200 }}
206 − className="flex justify-center mb-4"
207 − >
208 − <div className="w-16 h-16 rounded-2xl bg-blue-800/10 dark:bg-blue-800/20 flex items-center justify-center">
209 − <Shield className="w-8 h-8 text-blue-600" />
210 − </div>
211 − </motion.div>
212 − <p className="text-sm text-gray-500 dark:text-gray-400 text-center">
213 − Entrez le code a 6 chiffres de votre app authenticator
138 + <div className="mb-7 text-center lg:text-left">
139 + <h1 className="text-[26px] sm:text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
140 + {needs2FA ? "Vérification en deux étapes" : "Bon retour"}
141 + </h1>
142 + <p className="text-gray-500 dark:text-gray-400 mt-1.5 text-[14.5px]">
143 + {needs2FA ? "Entrez le code de votre application d'authentification." : "Connectez-vous à votre cloud personnel."}
214 144 </p>
215 − <input
216 − type="text"
217 − value={totpCode}
218 − onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
219 − placeholder="000000"
220 − maxLength={6}
221 − className="w-full bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl px-4 py-3.5 text-gray-900 dark:text-white text-center text-2xl tracking-[0.3em] sm:tracking-[0.5em] font-mono focus:outline-none focus:border-blue-600 dark:focus:border-blue-700/50 focus:ring-2 focus:ring-blue-700/20 dark:focus:ring-blue-700/10 transition-all duration-200 shadow-sm dark:shadow-none"
222 − autoFocus
223 − />
145 + </div>
224 146
225 − {error && (
226 − <motion.p initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} className="text-red-500 dark:text-red-400 text-sm text-center">
227 − {error}
228 − </motion.p>
147 + <AnimatePresence mode="wait">
148 + {!needs2FA ? (
149 + <motion.form key="login-form" initial={{ opacity: 0, x: -16 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 16 }} transition={{ duration: 0.25 }} onSubmit={handleSubmit} className="space-y-3.5">
150 + <div className="relative">
151 + <User className="absolute left-4 top-1/2 -translate-y-1/2 w-[18px] h-[18px] text-gray-400 dark:text-gray-500" />
152 + <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Nom d'utilisateur" className={inputClasses} autoComplete="username" autoCapitalize="none" autoCorrect="off" />
153 + </div>
154 + <div className="relative">
155 + <Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-[18px] h-[18px] text-gray-400 dark:text-gray-500" />
156 + <input type={showPassword ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Mot de passe" className={inputClasses} autoComplete="current-password" />
157 + <button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-2.5 top-1/2 -translate-y-1/2 w-9 h-9 flex items-center justify-center rounded-xl text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" aria-label={showPassword ? "Masquer" : "Afficher"}>
158 + {showPassword ? <EyeOff className="w-[18px] h-[18px]" /> : <Eye className="w-[18px] h-[18px]" />}
159 + </button>
160 + </div>
161 +
162 + <AnimatePresence>
163 + {error && (
164 + <motion.p initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="text-rose-600 dark:text-rose-400 text-[13px] px-1 flex items-center gap-1.5" role="alert">
165 + <Shield className="w-3.5 h-3.5" /> {error}
166 + </motion.p>
167 + )}
168 + </AnimatePresence>
169 +
170 + <motion.button whileTap={{ scale: 0.98 }} type="submit" disabled={loading || !password} className="group w-full h-12 rounded-2xl text-white font-medium bg-accent-gradient shadow-[inset_0_1px_0_rgb(255_255_255/.2)] hover:brightness-110 hover:shadow-glow transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none flex items-center justify-center gap-2 text-[15px]">
171 + {loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <>Se connecter <ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" /></>}
172 + </motion.button>
173 + </motion.form>
174 + ) : (
175 + <motion.form key="2fa-form" initial={{ opacity: 0, x: 16 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -16 }} transition={{ duration: 0.25 }} onSubmit={handleVerify2FA} className="space-y-4">
176 + <motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ delay: 0.05, type: "spring", stiffness: 220 }} className="flex justify-center">
177 + <div className="w-16 h-16 rounded-3xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center">
178 + <ShieldCheck className="w-8 h-8 text-blue-600 dark:text-blue-300" />
179 + </div>
180 + </motion.div>
181 + <input
182 + type="text"
183 + inputMode="numeric"
184 + pattern="[0-9]*"
185 + value={totpCode}
186 + onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
187 + placeholder="000000"
188 + maxLength={6}
189 + className="w-full bg-white dark:bg-white/[0.05] border border-gray-200 dark:border-white/10 rounded-2xl h-14 text-gray-900 dark:text-white text-center text-[28px] tracking-[0.4em] font-mono focus:outline-none focus:border-blue-500/60 focus:ring-4 focus:ring-blue-500/10 transition-all shadow-card dark:shadow-none"
190 + autoFocus
191 + autoComplete="one-time-code"
192 + />
193 + <AnimatePresence>
194 + {error && <motion.p initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="text-rose-600 dark:text-rose-400 text-[13px] text-center" role="alert">{error}</motion.p>}
195 + </AnimatePresence>
196 + <motion.button whileTap={{ scale: 0.98 }} type="submit" disabled={loading || totpCode.length !== 6} className="w-full h-12 rounded-2xl text-white font-medium bg-accent-gradient hover:brightness-110 hover:shadow-glow transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-[15px]">
197 + {loading ? <Loader2 className="w-5 h-5 animate-spin" /> : "Vérifier"}
198 + </motion.button>
199 + <button type="button" onClick={() => { setNeeds2FA(false); setTotpCode(""); setError(""); }} className="w-full text-[13px] text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 py-2 transition-colors">
200 + ← Retour
201 + </button>
202 + </motion.form>
229 203 )}
204 + </AnimatePresence>
230 205
231 − <motion.button
232 − whileTap={{ scale: 0.98 }}
233 − type="submit"
234 − disabled={loading || totpCode.length !== 6}
235 − className="w-full bg-gray-900 dark:bg-white text-white dark:text-black font-medium py-3.5 rounded-xl hover:bg-gray-800 dark:hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
236 − >
237 − {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Verifier"}
238 − </motion.button>
239 −
240 − <button
241 − type="button"
242 − onClick={() => { setNeeds2FA(false); setTotpCode(""); setError(""); }}
243 − className="w-full text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 py-2 transition-colors"
244 − >
245 − Retour
246 − </button>
247 − </motion.form>
248 − )}
249 − </AnimatePresence>
250 −
251 − <div className="mt-6 flex flex-col items-center gap-1.5">
252 − <div className="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
253 − <Shield className="w-3 h-3" />
254 − <span>Connexions analysées par <span className="text-gray-500 dark:text-gray-400 font-medium">Claude Opus 4.6</span></span>
206 + <div className="mt-8 flex flex-col items-center gap-1.5 text-[12px] text-gray-400 dark:text-gray-500">
207 + <span className="flex items-center gap-1.5"><Shield className="w-3 h-3" /> Connexions analysées par IA · appareils de confiance</span>
208 + <span>SPB Private Cloud · v3.0</span>
255 209 </div>
256 − <p className="text-gray-400 dark:text-gray-600 text-xs">SPB Private Cloud v2.0</p>
257 − </div>
258 − </motion.div>
210 + </motion.div>
211 + </main>
259 212 </div>
260 213 );
261 214 }
modified app/shared/[token]/page.tsx +121 −150
@@ -4,7 +4,6 @@ import { useEffect, useState, useCallback } from "react";
4 4 import { useParams } from "next/navigation";
5 5 import { motion } from "framer-motion";
6 6 import {
7 − Cloud,
8 7 Download,
9 8 Lock,
10 9 AlertCircle,
@@ -20,6 +19,8 @@ import {
20 19 Clock,
21 20 } from "lucide-react";
22 21 import Button from "@/components/ui/Button";
22 +import Logo from "@/components/ui/Logo";
23 +import ThemeToggle from "@/components/theme/ThemeToggle";
23 24
24 25 interface FileInfo {
25 26 id: string;
@@ -43,7 +44,7 @@ function formatExpiry(expiresAt: string | null): string | null {
43 44 const date = new Date(expiresAt);
44 45 const now = new Date();
45 46 const diff = date.getTime() - now.getTime();
46 − if (diff <= 0) return "Expire";
47 + if (diff <= 0) return "Expiré";
47 48 const hours = Math.floor(diff / (1000 * 60 * 60));
48 49 const days = Math.floor(hours / 24);
49 50 if (days > 0) return `Expire dans ${days}j`;
@@ -73,17 +74,17 @@ function getFileCategory(mimeType: string): string {
73 74 function getFileIcon(category: string) {
74 75 switch (category) {
75 76 case "image":
76 − return <ImageIcon className="w-16 h-16 text-blue-400" />;
77 + return <ImageIcon className="w-12 h-12 text-emerald-500" />;
77 78 case "pdf":
78 − return <FileText className="w-16 h-16 text-red-400" />;
79 + return <FileText className="w-12 h-12 text-rose-500" />;
79 80 case "video":
80 − return <Film className="w-16 h-16 text-purple-400" />;
81 + return <Film className="w-12 h-12 text-purple-500" />;
81 82 case "audio":
82 − return <Music className="w-16 h-16 text-green-400" />;
83 + return <Music className="w-12 h-12 text-pink-500" />;
83 84 case "text":
84 − return <FileCode className="w-16 h-16 text-yellow-400" />;
85 + return <FileCode className="w-12 h-12 text-amber-500" />;
85 86 default:
86 − return <FileIcon className="w-16 h-16 text-gray-400" />;
87 + return <FileIcon className="w-12 h-12 text-gray-400" />;
87 88 }
88 89 }
89 90
@@ -154,7 +155,7 @@ export default function SharedPage() {
154 155 const data = await res.json();
155 156 if (res.status === 410) {
156 157 setErrorType("expired");
157 − setError(data.error || "Ce lien a expire");
158 + setError(data.error || "Ce lien a expiré");
158 159 } else {
159 160 setErrorType("invalid");
160 161 setError(data.error || "Lien invalide");
@@ -188,7 +189,7 @@ export default function SharedPage() {
188 189 })
189 190 .catch(() => {
190 191 setErrorType("network");
191 − setError("Erreur reseau");
192 + setError("Erreur réseau");
192 193 setState("error");
193 194 });
194 195 }, [token]);
@@ -229,7 +230,7 @@ export default function SharedPage() {
229 230 }
230 231 setPasswordLoading(false);
231 232 } catch {
232 − setError("Erreur reseau");
233 + setError("Erreur réseau");
233 234 setPasswordLoading(false);
234 235 }
235 236 };
@@ -246,7 +247,7 @@ export default function SharedPage() {
246 247 a.click();
247 248 URL.revokeObjectURL(url);
248 249 } catch {
249 − setError("Erreur lors du telechargement");
250 + setError("Erreur lors du téléchargement");
250 251 }
251 252 } else {
252 253 window.open(contentUrl(true), "_blank");
@@ -256,102 +257,75 @@ export default function SharedPage() {
256 257 // Loading state
257 258 if (state === "loading") {
258 259 return (
259 − <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
260 − <motion.div
261 − initial={{ opacity: 0 }}
262 − animate={{ opacity: 1 }}
263 − className="flex flex-col items-center gap-4"
264 − >
265 − <Loader2 className="w-8 h-8 text-white animate-spin" />
266 − <p className="text-sm text-gray-500">Chargement...</p>
260 + <Shell centered>
261 + <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4">
262 + <Logo size={56} />
263 + <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
264 + <p className="text-sm text-gray-500">Chargement du partage…</p>
267 265 </motion.div>
268 − </div>
266 + </Shell>
269 267 );
270 268 }
271 269
272 270 // Error state
273 271 if (state === "error") {
274 272 return (
275 − <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center p-4">
276 − <motion.div
277 − initial={{ opacity: 0, y: 20 }}
278 − animate={{ opacity: 1, y: 0 }}
279 − className="text-center max-w-sm"
280 − >
281 − {errorType === "expired" ? (
282 − <Clock className="w-16 h-16 text-yellow-400 mx-auto mb-4" />
283 − ) : (
284 − <AlertCircle className="w-16 h-16 text-red-400 mx-auto mb-4" />
285 − )}
286 − <h1 className="text-xl font-semibold text-white mb-2">
273 + <Shell centered>
274 + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-8 text-center max-w-sm w-full">
275 + <div className={`w-16 h-16 rounded-3xl mx-auto mb-5 flex items-center justify-center ${errorType === "expired" ? "bg-amber-500/10 text-amber-500" : "bg-rose-500/10 text-rose-500"}`}>
276 + {errorType === "expired" ? <Clock className="w-8 h-8" /> : <AlertCircle className="w-8 h-8" />}
277 + </div>
278 + <h1 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
287 279 {errorType === "expired"
288 − ? "Lien expire"
280 + ? "Lien expiré"
289 281 : errorType === "network"
290 282 ? "Erreur de connexion"
291 283 : "Lien invalide"}
292 284 </h1>
293 − <p className="text-gray-400 text-sm">{error}</p>
285 + <p className="text-gray-500 dark:text-gray-400 text-sm">{error}</p>
294 286 {errorType === "expired" && (
295 − <p className="text-gray-500 text-xs mt-3">
296 − Demandez un nouveau lien au proprietaire du fichier.
297 − </p>
287 + <p className="text-gray-400 text-xs mt-3">Demandez un nouveau lien au propriétaire du fichier.</p>
298 288 )}
299 289 </motion.div>
300 − </div>
290 + </Shell>
301 291 );
302 292 }
303 293
304 294 // Password state
305 295 if (state === "password") {
306 296 return (
307 − <div className="min-h-screen bg-[#0a0a0a] flex flex-col">
308 − <header className="flex items-center gap-2 px-4 py-3 border-b border-white/10">
309 − <Cloud className="w-5 h-5 text-white" />
310 − <span className="text-sm text-white font-medium">SPB Cloud</span>
311 − </header>
297 + <Shell>
298 + <Header />
312 299 <div className="flex-1 flex items-center justify-center p-4">
313 − <motion.div
314 − initial={{ opacity: 0, y: 20 }}
315 − animate={{ opacity: 1, y: 0 }}
316 − className="w-full max-w-sm"
317 − >
318 − <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
300 + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="w-full max-w-sm">
301 + <div className="card p-6 sm:p-8">
319 302 <div className="text-center mb-6">
320 − <div className="w-16 h-16 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mx-auto mb-4">
321 − <Lock className="w-8 h-8 text-gray-400" />
303 + <div className="w-16 h-16 rounded-3xl bg-blue-500/10 text-blue-600 dark:text-blue-300 flex items-center justify-center mx-auto mb-4">
304 + <Lock className="w-8 h-8" />
322 305 </div>
323 − <h1 className="text-lg font-semibold text-white">
324 − Fichier protege
325 − </h1>
326 − <p className="text-sm text-gray-500 mt-1 truncate max-w-[280px] mx-auto">
327 − {fileInfo?.name}
328 − </p>
306 + <h1 className="text-lg font-semibold text-gray-900 dark:text-white">Fichier protégé</h1>
307 + <p className="text-sm text-gray-500 mt-1 truncate max-w-[280px] mx-auto">{fileInfo?.name}</p>
329 308 </div>
330 309
331 − <form onSubmit={handlePasswordSubmit} className="space-y-4">
310 + <form onSubmit={handlePasswordSubmit} className="space-y-3">
332 311 <input
333 312 type="password"
334 313 value={password}
335 − onChange={(e) => {
336 − setPassword(e.target.value);
337 − setError("");
338 − }}
339 − placeholder="Mot de passe"
340 − className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-white/20 transition-colors"
314 + onChange={(e) => { setPassword(e.target.value); setError(""); }}
315 + placeholder="Mot de passe du partage"
316 + className="field h-12 text-center"
341 317 autoFocus
342 318 />
343 − {error && (
344 − <p className="text-red-400 text-sm text-center">{error}</p>
345 − )}
346 − <Button type="submit" className="w-full" loading={passwordLoading}>
347 − Acceder
319 + {error && <p className="text-rose-500 text-sm text-center" role="alert">{error}</p>}
320 + <Button type="submit" variant="accent" size="lg" className="w-full" loading={passwordLoading} disabled={!password}>
321 + Accéder
348 322 </Button>
349 323 </form>
350 324 </div>
351 325 </motion.div>
352 326 </div>
353 327 <Footer expiresAt={fileInfo?.expiresAt || null} />
354 − </div>
328 + </Shell>
355 329 );
356 330 }
357 331
@@ -359,31 +333,23 @@ export default function SharedPage() {
359 333 if (state === "download") {
360 334 const category = fileInfo ? getFileCategory(fileInfo.mimeType) : "other";
361 335 return (
362 − <div className="min-h-screen bg-[#0a0a0a] flex flex-col">
336 + <Shell>
363 337 <Header fileName={fileInfo?.name} fileSize={fileInfo?.size} onDownload={handleDownload} />
364 338 <div className="flex-1 flex items-center justify-center p-4">
365 − <motion.div
366 − initial={{ opacity: 0, scale: 0.95 }}
367 − animate={{ opacity: 1, scale: 1 }}
368 − className="text-center"
369 − >
370 − <div className="w-24 h-24 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mx-auto mb-6">
339 + <motion.div initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} className="card p-8 sm:p-10 text-center max-w-md w-full">
340 + <div className="w-24 h-24 rounded-3xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center mx-auto mb-6">
371 341 {getFileIcon(category)}
372 342 </div>
373 − <h2 className="text-lg font-semibold text-white mb-1">
374 − {fileInfo?.name}
375 − </h2>
376 − <p className="text-sm text-gray-500 mb-6">
377 − {fileInfo ? formatSize(fileInfo.size) : ""}
378 − </p>
379 − <Button onClick={handleDownload} size="lg">
343 + <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1 break-words">{fileInfo?.name}</h2>
344 + <p className="text-sm text-gray-500 mb-6 tabular-nums">{fileInfo ? formatSize(fileInfo.size) : ""}</p>
345 + <Button onClick={handleDownload} variant="accent" size="lg" className="w-full sm:w-auto">
380 346 <Download className="w-5 h-5" />
381 − Telecharger
347 + Télécharger
382 348 </Button>
383 349 </motion.div>
384 350 </div>
385 351 <Footer expiresAt={fileInfo?.expiresAt || null} />
386 − </div>
352 + </Shell>
387 353 );
388 354 }
389 355
@@ -391,10 +357,10 @@ export default function SharedPage() {
391 357 const category = fileInfo ? getFileCategory(fileInfo.mimeType) : "other";
392 358
393 359 return (
394 − <div className="min-h-screen bg-[#0a0a0a] flex flex-col">
360 + <Shell>
395 361 <Header fileName={fileInfo?.name} fileSize={fileInfo?.size} onDownload={handleDownload} />
396 362
397 − <main className="flex-1 flex items-center justify-center p-2 sm:p-4 overflow-auto">
363 + <main className="flex-1 flex items-center justify-center p-3 sm:p-6 overflow-auto">
398 364 <motion.div
399 365 initial={{ opacity: 0 }}
400 366 animate={{ opacity: 1 }}
@@ -403,7 +369,7 @@ export default function SharedPage() {
403 369 >
404 370 {category === "image" && fileInfo && (
405 371 <div className="relative flex flex-col items-center gap-3 max-w-full">
406 − <div className="overflow-auto max-h-[75vh] max-w-full rounded-lg">
372 + <div className="overflow-auto max-h-[75vh] max-w-full rounded-2xl shadow-pop">
407 373 {passwordVerified ? (
408 374 <ProtectedImage
409 375 token={token}
@@ -416,27 +382,17 @@ export default function SharedPage() {
416 382 <img
417 383 src={contentUrl()}
418 384 alt={fileInfo.name}
419 − className="max-w-full object-contain rounded-lg transition-transform duration-200"
385 + className="max-w-full object-contain rounded-2xl transition-transform duration-200"
420 386 style={{ transform: `scale(${imageZoom})`, transformOrigin: "center" }}
421 387 />
422 388 )}
423 389 </div>
424 − <div className="flex items-center gap-2">
425 − <button
426 − onClick={() => setImageZoom((z) => Math.max(0.25, z - 0.25))}
427 − className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-white transition-colors"
428 − aria-label="Zoom out"
429 − >
390 + <div className="flex items-center gap-1 glass rounded-full px-1.5 py-1">
391 + <button onClick={() => setImageZoom((z) => Math.max(0.25, z - 0.25))} className="w-9 h-9 rounded-full flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-white/10 transition-colors" aria-label="Réduire">
430 392 <ZoomOut className="w-4 h-4" />
431 393 </button>
432 − <span className="text-xs text-gray-400 min-w-[3rem] text-center">
433 − {Math.round(imageZoom * 100)}%
434 − </span>
435 − <button
436 − onClick={() => setImageZoom((z) => Math.min(4, z + 0.25))}
437 − className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-white transition-colors"
438 − aria-label="Zoom in"
439 − >
394 + <span className="text-xs text-gray-600 dark:text-gray-300 min-w-[3rem] text-center tabular-nums">{Math.round(imageZoom * 100)}%</span>
395 + <button onClick={() => setImageZoom((z) => Math.min(4, z + 0.25))} className="w-9 h-9 rounded-full flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-white/10 transition-colors" aria-label="Agrandir">
440 396 <ZoomIn className="w-4 h-4" />
441 397 </button>
442 398 </div>
@@ -446,7 +402,7 @@ export default function SharedPage() {
446 402 {category === "pdf" && fileInfo && (
447 403 <iframe
448 404 src={passwordVerified ? undefined : contentUrl()}
449 − className="w-full h-[80vh] border-0 rounded-lg bg-white"
405 + className="w-full h-[80vh] border-0 rounded-2xl bg-white shadow-pop"
450 406 title={fileInfo.name}
451 407 />
452 408 )}
@@ -454,23 +410,23 @@ export default function SharedPage() {
454 410 {category === "video" && fileInfo && (
455 411 <video
456 412 controls
457 − className="max-w-full max-h-[75vh] rounded-lg"
413 + className="max-w-full max-h-[75vh] rounded-2xl shadow-pop bg-black"
458 414 preload="metadata"
459 415 >
460 416 {!passwordVerified && (
461 417 <source src={contentUrl()} type={fileInfo.mimeType} />
462 418 )}
463 − Votre navigateur ne supporte pas la lecture video.
419 + Votre navigateur ne supporte pas la lecture vidéo.
464 420 </video>
465 421 )}
466 422
467 423 {category === "audio" && fileInfo && (
468 424 <div className="w-full max-w-lg">
469 − <div className="bg-white/5 border border-white/10 rounded-2xl p-8 text-center">
470 − <div className="w-20 h-20 rounded-2xl bg-green-500/10 border border-green-500/20 flex items-center justify-center mx-auto mb-6">
471 − <Music className="w-10 h-10 text-green-400" />
425 + <div className="card p-8 text-center">
426 + <div className="w-20 h-20 rounded-3xl bg-pink-500/10 flex items-center justify-center mx-auto mb-6">
427 + <Music className="w-10 h-10 text-pink-500" />
472 428 </div>
473 − <h3 className="text-white font-medium mb-4 truncate">
429 + <h3 className="text-gray-900 dark:text-white font-medium mb-4 truncate">
474 430 {fileInfo.name}
475 431 </h3>
476 432 <audio
@@ -489,13 +445,13 @@ export default function SharedPage() {
489 445
490 446 {category === "text" && (
491 447 <div className="w-full max-w-4xl max-h-[75vh] overflow-auto">
492 − <div className="bg-white/5 border border-white/10 rounded-2xl p-4 sm:p-6">
448 + <div className="card p-4 sm:p-6">
493 449 {textContent === null ? (
494 450 <div className="flex items-center justify-center py-12">
495 − <Loader2 className="w-6 h-6 text-white animate-spin" />
451 + <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
496 452 </div>
497 453 ) : (
498 − <pre className="text-sm text-gray-300 whitespace-pre-wrap break-words font-mono leading-relaxed">
454 + <pre className="text-[13px] text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words font-mono leading-relaxed">
499 455 {textContent}
500 456 </pre>
501 457 )}
@@ -504,22 +460,16 @@ export default function SharedPage() {
504 460 )}
505 461
506 462 {category === "other" && fileInfo && (
507 − <div className="text-center">
508 − <div className="w-24 h-24 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mx-auto mb-6">
463 + <div className="card p-8 sm:p-10 text-center max-w-md w-full">
464 + <div className="w-24 h-24 rounded-3xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center mx-auto mb-6">
509 465 {getFileIcon(category)}
510 466 </div>
511 − <h2 className="text-lg font-semibold text-white mb-1">
512 − {fileInfo.name}
513 − </h2>
514 − <p className="text-sm text-gray-500 mb-6">
515 − {formatSize(fileInfo.size)}
516 − </p>
517 − <p className="text-xs text-gray-600 mb-4">
518 − Apercu non disponible pour ce type de fichier.
519 − </p>
520 − <Button onClick={handleDownload}>
467 + <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1 break-words">{fileInfo.name}</h2>
468 + <p className="text-sm text-gray-500 mb-2 tabular-nums">{formatSize(fileInfo.size)}</p>
469 + <p className="text-xs text-gray-400 mb-6">Aperçu non disponible pour ce type de fichier.</p>
470 + <Button onClick={handleDownload} variant="accent">
521 471 <Download className="w-4 h-4" />
522 − Telecharger
472 + Télécharger
523 473 </Button>
524 474 </div>
525 475 )}
@@ -527,12 +477,28 @@ export default function SharedPage() {
527 477 </main>
528 478
529 479 <Footer expiresAt={fileInfo?.expiresAt || null} />
530 − </div>
480 + </Shell>
531 481 );
532 482 }
533 483
534 484 // --- Sub-components ---
535 485
486 +function Shell({ children, centered }: { children: React.ReactNode; centered?: boolean }) {
487 + return (
488 + <div className={`min-h-[100dvh] bg-canvas relative flex flex-col ${centered ? "items-center justify-center p-4" : ""}`}>
489 + <div className="absolute inset-0 pointer-events-none bg-aurora opacity-50 dark:opacity-35" />
490 + <div className="absolute inset-0 pointer-events-none bg-grid bg-[size:40px_40px] opacity-[0.25] dark:opacity-[0.12] [mask-image:radial-gradient(ellipse_at_top,black_20%,transparent_70%)]" />
491 + {centered && (
492 + <div className="absolute top-4 left-4 right-4 flex items-center justify-between">
493 + <Logo size={32} wordmark wordmarkClassName="text-[15px]" />
494 + <ThemeToggle />
495 + </div>
496 + )}
497 + <div className={`relative flex flex-col ${centered ? "w-full items-center" : "flex-1 min-h-[100dvh]"}`}>{children}</div>
498 + </div>
499 + );
500 +}
501 +
536 502 function Header({
537 503 fileName,
538 504 fileSize,
@@ -540,25 +506,30 @@ function Header({
540 506 }: {
541 507 fileName?: string;
542 508 fileSize?: number;
543 − onDownload: () => void;
509 + onDownload?: () => void;
544 510 }) {
545 511 return (
546 − <header className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-[#0a0a0a]/80 backdrop-blur-sm sticky top-0 z-10">
547 − <div className="flex items-center gap-3 min-w-0">
548 − <Cloud className="w-5 h-5 text-white flex-shrink-0" />
549 − <div className="min-w-0">
550 − <p className="text-sm text-white font-medium truncate max-w-[200px] sm:max-w-[400px]">
551 − {fileName || "Fichier"}
552 − </p>
553 − {fileSize !== undefined && fileSize > 0 && (
554 − <p className="text-xs text-gray-500">{formatSize(fileSize)}</p>
512 + <header className="sticky top-0 z-10 glass border-x-0 border-t-0 rounded-none">
513 + <div className="max-w-6xl mx-auto flex items-center justify-between gap-3 px-4 h-16">
514 + <div className="flex items-center gap-3 min-w-0">
515 + <Logo size={34} wordmark={!fileName} wordmarkClassName="text-[15px]" />
516 + {fileName && (
517 + <div className="min-w-0 border-l border-line dark:border-white/10 pl-3">
518 + <p className="text-[14px] text-gray-900 dark:text-white font-medium truncate max-w-[200px] sm:max-w-[420px]">{fileName}</p>
519 + {fileSize !== undefined && fileSize > 0 && <p className="text-[11.5px] text-gray-500 tabular-nums">{formatSize(fileSize)}</p>}
520 + </div>
521 + )}
522 + </div>
523 + <div className="flex items-center gap-1.5">
524 + <ThemeToggle />
525 + {onDownload && (
526 + <Button size="sm" variant="accent" onClick={onDownload}>
527 + <Download className="w-4 h-4" />
528 + <span className="hidden sm:inline">Télécharger</span>
529 + </Button>
555 530 )}
556 531 </div>
557 532 </div>
558 − <Button size="sm" onClick={onDownload}>
559 − <Download className="w-4 h-4" />
560 − <span className="hidden sm:inline">Telecharger</span>
561 − </Button>
562 533 </header>
563 534 );
564 535 }
@@ -566,12 +537,12 @@ function Header({
566 537 function Footer({ expiresAt }: { expiresAt: string | null }) {
567 538 const expiryText = formatExpiry(expiresAt);
568 539 return (
569 − <footer className="px-4 py-3 border-t border-white/10 text-center">
570 − <p className="text-xs text-gray-600">
571 − Partage via SPB Cloud
540 + <footer className="px-4 py-4 text-center pb-safe">
541 + <p className="text-[12px] text-gray-400 dark:text-gray-500 flex items-center justify-center gap-2 flex-wrap">
542 + <span>Propulsé par <span className="font-medium text-gray-600 dark:text-gray-300">SPB Cloud</span> · partage chiffré</span>
572 543 {expiryText && (
573 − <span className="ml-2 text-yellow-500/70">
574 − {" "}-- {expiryText}
544 + <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400">
545 + <Clock className="w-3 h-3" /> {expiryText}
575 546 </span>
576 547 )}
577 548 </p>
@@ -609,7 +580,7 @@ function ProtectedImage({
609 580 if (!src) {
610 581 return (
611 582 <div className="flex items-center justify-center py-24">
612 − <Loader2 className="w-6 h-6 text-white animate-spin" />
583 + <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
613 584 </div>
614 585 );
615 586 }
@@ -618,7 +589,7 @@ function ProtectedImage({
618 589 <img
619 590 src={src}
620 591 alt={alt}
621 − className="max-w-full object-contain rounded-lg transition-transform duration-200"
592 + className="max-w-full object-contain rounded-2xl transition-transform duration-200"
622 593 style={{ transform: `scale(${zoom})`, transformOrigin: "center" }}
623 594 />
624 595 );
modified components/admin/ActivityLog.tsx +6 −92
@@ -1,99 +1,13 @@
1 1 "use client";
2 2
3 −import { motion } from "framer-motion";
4 −import { formatDate } from "@/lib/utils";
5 −import {
6 − Upload,
7 − Download,
8 − Trash2,
9 − Pencil,
10 − FolderInput,
11 − Share2,
12 − LogIn,
13 − LogOut,
14 − FolderPlus,
15 − FolderMinus,
16 − Link2Off,
17 −} from "lucide-react";
18 −
19 −interface Activity {
20 − id: string;
21 − action: string;
22 − details: string | null;
23 − createdAt: string;
24 − file: { name: string } | null;
25 −}
3 +import ActivityTimeline, { type ActivityItem } from "@/components/dashboard/ActivityTimeline";
26 4
27 5 interface ActivityLogProps {
28 − activities: Activity[];
6 + activities: ActivityItem[];
7 + compact?: boolean;
29 8 }
30 9
31 −const actionIcons: Record<string, React.ComponentType<{ className?: string }>> = {
32 − UPLOAD: Upload,
33 − DOWNLOAD: Download,
34 − DELETE: Trash2,
35 − RENAME: Pencil,
36 − MOVE: FolderInput,
37 − SHARE: Share2,
38 − UNSHARE: Link2Off,
39 − LOGIN: LogIn,
40 − LOGOUT: LogOut,
41 − CREATE_FOLDER: FolderPlus,
42 − DELETE_FOLDER: FolderMinus,
43 −};
44 −
45 −const actionLabels: Record<string, string> = {
46 − UPLOAD: "Upload",
47 − DOWNLOAD: "Téléchargement",
48 − DELETE: "Suppression",
49 − RENAME: "Renommage",
50 − MOVE: "Déplacement",
51 − SHARE: "Partage",
52 − UNSHARE: "Partage retiré",
53 − LOGIN: "Connexion",
54 − LOGOUT: "Déconnexion",
55 − CREATE_FOLDER: "Dossier créé",
56 − DELETE_FOLDER: "Dossier supprimé",
57 −};
58 −
59 −export default function ActivityLog({ activities }: ActivityLogProps) {
60 − if (activities.length === 0) {
61 − return (
62 − <div className="text-center text-gray-500 py-8">
63 − Aucune activité récente
64 − </div>
65 − );
66 − }
67 −
68 − return (
69 − <div className="space-y-1">
70 − {activities.map((activity, i) => {
71 − const Icon = actionIcons[activity.action] || Upload;
72 − return (
73 − <motion.div
74 − key={activity.id}
75 − initial={{ opacity: 0, x: -10 }}
76 − animate={{ opacity: 1, x: 0 }}
77 − transition={{ delay: i * 0.03 }}
78 − className="flex items-center gap-3 px-3 py-3.5 hover:bg-gray-100 dark:hover:bg-white/5 rounded-lg transition-colors"
79 − >
80 − <Icon className="w-4 h-4 text-gray-400 dark:text-gray-500 flex-shrink-0" />
81 − <div className="flex-1 min-w-0">
82 − <p className="text-sm text-gray-600 dark:text-gray-300">
83 − <span className="text-gray-500 dark:text-gray-400">
84 − {actionLabels[activity.action] || activity.action}
85 − </span>
86 − {activity.details && (
87 − <span className="text-gray-400 dark:text-gray-500 ml-1">— {activity.details}</span>
88 − )}
89 − </p>
90 − </div>
91 − <span className="text-xs text-gray-400 dark:text-gray-600 flex-shrink-0 hidden sm:block">
92 − {formatDate(activity.createdAt)}
93 − </span>
94 − </motion.div>
95 − );
96 − })}
97 − </div>
98 − );
10 +/** Journal d'activité (timeline partagée avec l'accueil). */
11 +export default function ActivityLog({ activities, compact }: ActivityLogProps) {
12 + return <ActivityTimeline activities={activities} compact={compact} />;
99 13 }
modified components/admin/ApiKeyManager.tsx +97 −72
@@ -2,9 +2,10 @@
2 2
3 3 import { useState, useEffect } from "react";
4 4 import { motion } from "framer-motion";
5 −import { Key, Trash2, Copy, Plus } from "lucide-react";
5 +import { Key, Trash2, Copy, Plus, Check, Terminal } from "lucide-react";
6 6 import Button from "../ui/Button";
7 7 import Modal from "../ui/Modal";
8 +import { EmptyState } from "../ui/PageHeader";
8 9 import { formatDate } from "@/lib/utils";
9 10 import toast from "react-hot-toast";
10 11
@@ -18,11 +19,21 @@ interface ApiKeyData {
18 19 createdAt: string;
19 20 }
20 21
22 +const permLabels: Record<string, string> = { read: "Lecture", write: "Lecture + écriture", admin: "Admin" };
23 +const permColors: Record<string, string> = {
24 + read: "bg-blue-500/10 text-blue-700 dark:text-blue-300 ring-blue-500/20",
25 + write: "bg-amber-500/10 text-amber-700 dark:text-amber-300 ring-amber-500/20",
26 + admin: "bg-rose-500/10 text-rose-600 dark:text-rose-400 ring-rose-500/20",
27 +};
28 +
21 29 export default function ApiKeyManager() {
22 30 const [keys, setKeys] = useState<ApiKeyData[]>([]);
23 31 const [loading, setLoading] = useState(true);
24 32 const [showCreate, setShowCreate] = useState(false);
25 33 const [newKey, setNewKey] = useState<string | null>(null);
34 + const [copied, setCopied] = useState(false);
35 + const [creating, setCreating] = useState(false);
36 + const [toDelete, setToDelete] = useState<ApiKeyData | null>(null);
26 37 const [form, setForm] = useState({ name: "", permissions: "read" });
27 38
28 39 const fetchKeys = async () => {
@@ -34,80 +45,88 @@ export default function ApiKeyManager() {
34 45 useEffect(() => { fetchKeys(); }, []);
35 46
36 47 const createKey = async () => {
37 − const res = await fetch("/api/keys", {
38 − method: "POST",
39 − headers: { "Content-Type": "application/json" },
40 − body: JSON.stringify(form),
41 − });
48 + setCreating(true);
49 + const res = await fetch("/api/keys", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
50 + setCreating(false);
42 51 if (res.ok) {
43 52 const data = await res.json();
44 53 setNewKey(data.key);
45 54 setForm({ name: "", permissions: "read" });
46 55 fetchKeys();
47 − }
56 + } else toast.error("Création impossible");
48 57 };
49 58
50 − const deleteKey = async (id: string) => {
51 − if (!confirm("Supprimer cette clé API ?")) return;
52 − await fetch(`/api/keys/${id}`, { method: "DELETE" });
53 − toast.success("Clé supprimée");
59 + const deleteKey = async () => {
60 + if (!toDelete) return;
61 + await fetch(`/api/keys/${toDelete.id}`, { method: "DELETE" });
62 + setToDelete(null);
63 + toast.success("Clé révoquée");
54 64 fetchKeys();
55 65 };
56 66
57 67 const copyKey = (key: string) => {
58 − navigator.clipboard.writeText(key);
59 − toast.success("Clé copiée");
68 + navigator.clipboard.writeText(key).then(() => { setCopied(true); toast.success("Clé copiée"); setTimeout(() => setCopied(false), 2000); });
60 69 };
61 70
62 − const inputClasses = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none";
71 + const closeCreate = () => { setShowCreate(false); setNewKey(null); setCopied(false); };
63 72
64 73 return (
65 74 <div className="space-y-4">
66 − <div className="flex items-center justify-between">
67 − <h2 className="text-lg font-medium text-gray-900 dark:text-white flex items-center gap-2">
68 − <Key className="w-5 h-5" /> Clés API
69 − </h2>
70 − <Button size="sm" onClick={() => setShowCreate(true)}>
75 + <div className="card p-4 flex flex-col sm:flex-row sm:items-center gap-3">
76 + <div className="w-10 h-10 rounded-2xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center shrink-0 text-gray-500 dark:text-gray-300">
77 + <Terminal className="w-5 h-5" />
78 + </div>
79 + <div className="flex-1 min-w-0 text-[13px] text-gray-600 dark:text-gray-300">
80 + <p className="font-medium text-gray-900 dark:text-white mb-1">Utilisation</p>
81 + <code className="block overflow-x-auto whitespace-nowrap rounded-lg bg-gray-100 dark:bg-white/[0.06] px-3 py-2 text-[12px] font-mono text-gray-700 dark:text-gray-200">
82 + curl -H &quot;Authorization: Bearer spb_…&quot; https://cloud.spboucher.ai/api/v1/files
83 + </code>
84 + </div>
85 + <Button variant="accent" size="sm" onClick={() => setShowCreate(true)} className="shrink-0">
71 86 <Plus className="w-4 h-4" /> Nouvelle clé
72 87 </Button>
73 88 </div>
74 89
75 − <p className="text-xs text-gray-500 break-words">
76 − Utilisez les clés API pour accéder à l&apos;API REST : <code className="bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded inline-block overflow-x-auto max-w-full">GET /api/v1/files</code> avec header <code className="bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded inline-block overflow-x-auto max-w-full">Authorization: Bearer spb_...</code>
77 − </p>
78 −
79 90 {loading ? (
80 − <div className="flex justify-center py-8">
81 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
91 + <div className="space-y-2">
92 + {[0, 1].map((i) => (
93 + <div key={i} className="card p-4 flex items-center gap-3 animate-pulse">
94 + <div className="w-10 h-10 rounded-xl bg-gray-200 dark:bg-white/10" />
95 + <div className="flex-1 space-y-2"><div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-1/2 rounded bg-gray-200 dark:bg-white/10" /></div>
96 + </div>
97 + ))}
82 98 </div>
83 99 ) : keys.length === 0 ? (
84 − <div className="text-center text-gray-500 py-8">
85 − <Key className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
86 − <p>Aucune clé API</p>
87 − </div>
100 + <EmptyState icon={Key} title="Aucune clé API" description="Créez une clé pour accéder à vos fichiers depuis des scripts ou des intégrations." action={<Button variant="accent" onClick={() => setShowCreate(true)}><Plus className="w-4 h-4" /> Créer une clé</Button>} />
88 101 ) : (
89 − <div className="space-y-2">
102 + <div className="card p-0 overflow-hidden divide-y divide-line dark:divide-white/[0.06]">
90 103 {keys.map((k, i) => (
91 104 <motion.div
92 105 key={k.id}
93 106 initial={{ opacity: 0, y: 5 }}
94 107 animate={{ opacity: 1, y: 0 }}
95 − transition={{ delay: i * 0.03 }}
96 − className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl transition-colors"
108 + transition={{ delay: Math.min(i, 10) * 0.03 }}
109 + className="flex flex-col sm:flex-row sm:items-center gap-3 px-4 py-3.5 hover:bg-gray-50 dark:hover:bg-white/[0.03] transition-colors"
97 110 >
98 − <div className="flex items-start sm:items-center gap-3 flex-1 min-w-0">
99 − <Key className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5 sm:mt-0" />
100 − <div className="flex-1 min-w-0">
101 − <p className="text-sm font-medium text-gray-900 dark:text-white">{k.name}</p>
102 − <p className="text-xs text-gray-500 break-all">
103 − <code>{k.keyPrefix}...</code> • {k.permissions}
104 − {k.lastUsedAt && ` • Dernier usage: ${formatDate(k.lastUsedAt)}`}
105 − </p>
111 + <div className="w-10 h-10 rounded-xl bg-amber-500/10 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0">
112 + <Key className="w-[18px] h-[18px]" />
113 + </div>
114 + <div className="flex-1 min-w-0">
115 + <div className="flex items-center gap-2 flex-wrap">
116 + <p className="text-[14px] font-medium text-gray-900 dark:text-white">{k.name}</p>
117 + <span className={`inline-flex px-2 py-0.5 rounded-full text-[11px] font-medium ring-1 ring-inset ${permColors[k.permissions] || permColors.read}`}>{permLabels[k.permissions] || k.permissions}</span>
106 118 </div>
119 + <p className="text-[12px] text-gray-500 dark:text-gray-400 mt-0.5 flex flex-wrap gap-x-2">
120 + <code className="font-mono">{k.keyPrefix}…</code>
121 + <span>· créée le {formatDate(k.createdAt)}</span>
122 + {k.lastUsedAt ? <span>· dernier usage {formatDate(k.lastUsedAt)}</span> : <span>· jamais utilisée</span>}
123 + {k.expiresAt && <span>· expire le {formatDate(k.expiresAt)}</span>}
124 + </p>
107 125 </div>
108 126 <button
109 − onClick={() => deleteKey(k.id)}
110 − className="min-w-[44px] min-h-[44px] flex items-center justify-center p-2.5 text-gray-400 hover:text-red-500 rounded-lg transition-colors self-end sm:self-auto"
127 + onClick={() => setToDelete(k)}
128 + className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-rose-500 rounded-xl hover:bg-rose-500/10 transition-colors self-end sm:self-auto"
129 + title="Révoquer"
111 130 >
112 131 <Trash2 className="w-4 h-4" />
113 132 </button>
@@ -116,43 +135,49 @@ export default function ApiKeyManager() {
116 135 </div>
117 136 )}
118 137
119 − {/* Create Modal */}
120 − <Modal isOpen={showCreate} onClose={() => { setShowCreate(false); setNewKey(null); }} title="Nouvelle clé API">
138 + <Modal
139 + isOpen={showCreate}
140 + onClose={closeCreate}
141 + title={newKey ? "Clé créée" : "Nouvelle clé API"}
142 + description={newKey ? "Copiez-la maintenant : elle ne sera plus jamais affichée." : "Nommez la clé et choisissez ses permissions."}
143 + footer={newKey
144 + ? <Button onClick={closeCreate} className="w-full sm:w-auto">Terminé</Button>
145 + : <><Button variant="ghost" onClick={closeCreate}>Annuler</Button><Button variant="accent" onClick={createKey} loading={creating} disabled={!form.name}>Créer</Button></>}
146 + >
121 147 {newKey ? (
122 − <div className="space-y-4">
123 − <p className="text-sm text-gray-600 dark:text-gray-400">
124 − Voici votre clé API. Copiez-la maintenant, elle ne sera plus affichée.
125 − </p>
126 − <div className="flex items-center gap-2">
127 − <code className="flex-1 bg-gray-100 dark:bg-white/10 px-3 py-2 rounded-lg text-xs font-mono text-gray-700 dark:text-gray-300 break-all">
128 − {newKey}
129 − </code>
130 − <button
131 − onClick={() => copyKey(newKey)}
132 − className="min-w-[44px] min-h-[44px] flex items-center justify-center p-2.5 text-gray-400 hover:text-gray-700 dark:hover:text-white rounded-lg transition-colors"
133 − >
134 − <Copy className="w-4 h-4" />
135 − </button>
136 − </div>
137 − <Button onClick={() => { setShowCreate(false); setNewKey(null); }} className="w-full">
138 − Fermer
139 − </Button>
148 + <div className="flex items-center gap-2 rounded-xl bg-gray-100 dark:bg-white/[0.06] border border-line dark:border-white/10 pl-3 pr-1 h-12">
149 + <code className="flex-1 text-[12.5px] font-mono text-gray-800 dark:text-gray-100 truncate">{newKey}</code>
150 + <button onClick={() => copyKey(newKey)} className={`w-10 h-10 flex items-center justify-center rounded-lg transition-colors ${copied ? "text-emerald-500" : "text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200/60 dark:hover:bg-white/10"}`} title="Copier">
151 + {copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
152 + </button>
140 153 </div>
141 154 ) : (
142 − <div className="space-y-3">
143 − <input type="text" placeholder="Nom de la clé" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClasses} />
144 − <select value={form.permissions} onChange={(e) => setForm({ ...form, permissions: e.target.value })} className={inputClasses}>
145 − <option value="read">Lecture seule</option>
146 − <option value="write">Lecture + Écriture</option>
147 − <option value="admin">Admin</option>
148 − </select>
149 − <div className="flex gap-2 justify-end pt-2">
150 − <Button variant="ghost" onClick={() => setShowCreate(false)}>Annuler</Button>
151 − <Button onClick={createKey} disabled={!form.name}>Créer</Button>
155 + <div className="space-y-3.5">
156 + <div>
157 + <label className="block text-[12px] font-medium text-gray-500 dark:text-gray-400 mb-1.5">Nom</label>
158 + <input type="text" placeholder="ex. Script de sauvegarde" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="field h-11" autoFocus />
159 + </div>
160 + <div>
161 + <label className="block text-[12px] font-medium text-gray-500 dark:text-gray-400 mb-1.5">Permissions</label>
162 + <select value={form.permissions} onChange={(e) => setForm({ ...form, permissions: e.target.value })} className="field h-11">
163 + <option value="read">Lecture seule</option>
164 + <option value="write">Lecture + écriture</option>
165 + <option value="admin">Admin</option>
166 + </select>
152 167 </div>
153 168 </div>
154 169 )}
155 170 </Modal>
171 +
172 + <Modal
173 + isOpen={!!toDelete}
174 + onClose={() => setToDelete(null)}
175 + title="Révoquer cette clé ?"
176 + description={toDelete?.name}
177 + footer={<><Button variant="ghost" onClick={() => setToDelete(null)}>Annuler</Button><Button variant="danger" onClick={deleteKey}><Trash2 className="w-4 h-4" /> Révoquer</Button></>}
178 + >
179 + <p className="text-sm text-gray-600 dark:text-gray-300">Les intégrations qui utilisent cette clé cesseront de fonctionner immédiatement.</p>
180 + </Modal>
156 181 </div>
157 182 );
158 183 }
modified components/admin/BackupPanel.tsx +62 −55
@@ -2,8 +2,10 @@
2 2
3 3 import { useState, useEffect } from "react";
4 4 import { motion } from "framer-motion";
5 −import { HardDrive, Download, Trash2, RefreshCw, CheckCircle, XCircle, Loader2 } from "lucide-react";
5 +import { HardDrive, Download, Trash2, RefreshCw, CheckCircle, XCircle, Loader2, Layers } from "lucide-react";
6 6 import Button from "../ui/Button";
7 +import Modal from "../ui/Modal";
8 +import { EmptyState } from "../ui/PageHeader";
7 9 import { formatFileSize, formatDate } from "@/lib/utils";
8 10 import toast from "react-hot-toast";
9 11
@@ -20,7 +22,8 @@ interface BackupData {
20 22 export default function BackupPanel() {
21 23 const [backups, setBackups] = useState<BackupData[]>([]);
22 24 const [loading, setLoading] = useState(true);
23 − const [creating, setCreating] = useState(false);
25 + const [creating, setCreating] = useState<"full" | "incremental" | null>(null);
26 + const [toDelete, setToDelete] = useState<BackupData | null>(null);
24 27
25 28 const fetchBackups = async () => {
26 29 const res = await fetch("/api/backup");
@@ -31,102 +34,96 @@ export default function BackupPanel() {
31 34 useEffect(() => { fetchBackups(); }, []);
32 35
33 36 const createBackup = async (type: "full" | "incremental") => {
34 − setCreating(true);
37 + setCreating(type);
35 38 try {
36 − const res = await fetch("/api/backup", {
37 − method: "POST",
38 − headers: { "Content-Type": "application/json" },
39 − body: JSON.stringify({ type }),
40 − });
39 + const res = await fetch("/api/backup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type }) });
41 40 if (res.ok) {
42 − toast.success("Backup créé avec succès");
41 + toast.success(`Backup ${type === "full" ? "complet" : "incrémental"} créé`);
43 42 fetchBackups();
44 − } else {
45 − toast.error("Échec du backup");
46 − }
43 + } else toast.error("Échec du backup");
47 44 } catch {
48 − toast.error("Erreur");
45 + toast.error("Erreur réseau");
49 46 }
50 − setCreating(false);
47 + setCreating(null);
51 48 };
52 49
53 − const deleteBackup = async (id: string) => {
54 − if (!confirm("Supprimer ce backup ?")) return;
55 − const res = await fetch("/api/backup", {
56 − method: "DELETE",
57 − headers: { "Content-Type": "application/json" },
58 − body: JSON.stringify({ id }),
59 − });
50 + const deleteBackup = async () => {
51 + if (!toDelete) return;
52 + const res = await fetch("/api/backup", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: toDelete.id }) });
53 + setToDelete(null);
60 54 if (res.ok) {
61 55 toast.success("Backup supprimé");
62 56 fetchBackups();
63 − }
57 + } else toast.error("Suppression impossible");
64 58 };
65 59
66 60 const statusIcon = (status: string) => {
67 − if (status === "completed") return <CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />;
68 − if (status === "failed") return <XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />;
69 − return <Loader2 className="w-4 h-4 text-blue-500 animate-spin flex-shrink-0" />;
61 + if (status === "completed") return <CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />;
62 + if (status === "failed") return <XCircle className="w-4 h-4 text-rose-500 shrink-0" />;
63 + return <Loader2 className="w-4 h-4 text-blue-500 animate-spin shrink-0" />;
70 64 };
71 65
66 + const totalSize = backups.reduce((s, b) => s + (b.size || 0), 0);
67 +
72 68 return (
73 − <div className="space-y-4">
69 + <div className="space-y-5">
74 70 <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
75 − <h2 className="text-lg font-medium text-gray-900 dark:text-white flex items-center gap-2">
76 − <HardDrive className="w-5 h-5" /> Backups
77 − </h2>
78 − <div className="flex flex-col sm:flex-row gap-2">
79 − <Button size="sm" variant="secondary" onClick={() => createBackup("incremental")} loading={creating}>
80 − Incrémental
71 + <div>
72 + <h2 className="text-[15px] font-semibold text-gray-900 dark:text-white flex items-center gap-2"><HardDrive className="w-[18px] h-[18px] text-cyan-500" /> Sauvegardes manuelles</h2>
73 + <p className="text-[13px] text-gray-500 dark:text-gray-400 mt-0.5">{backups.length} backup{backups.length !== 1 ? "s" : ""} · {formatFileSize(totalSize)} au total</p>
74 + </div>
75 + <div className="flex gap-2">
76 + <Button size="sm" variant="secondary" onClick={() => createBackup("incremental")} loading={creating === "incremental"} disabled={!!creating}>
77 + <Layers className="w-4 h-4" /> Incrémental
81 78 </Button>
82 − <Button size="sm" onClick={() => createBackup("full")} loading={creating}>
79 + <Button size="sm" variant="accent" onClick={() => createBackup("full")} loading={creating === "full"} disabled={!!creating}>
83 80 <Download className="w-4 h-4" /> Backup complet
84 81 </Button>
85 82 </div>
86 83 </div>
87 84
88 85 {loading ? (
89 − <div className="flex justify-center py-8">
90 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
86 + <div className="space-y-2">
87 + {[0, 1, 2].map((i) => (
88 + <div key={i} className="rounded-2xl border border-line dark:border-white/[0.07] p-4 flex items-center gap-3 animate-pulse">
89 + <div className="w-4 h-4 rounded-full bg-gray-200 dark:bg-white/10" />
90 + <div className="flex-1 space-y-2"><div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-1/4 rounded bg-gray-200 dark:bg-white/10" /></div>
91 + </div>
92 + ))}
91 93 </div>
92 94 ) : backups.length === 0 ? (
93 − <div className="text-center text-gray-500 py-8">
94 − <HardDrive className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
95 − <p>Aucun backup</p>
96 − </div>
95 + <EmptyState icon={HardDrive} title="Aucun backup" description="Lancez un backup complet pour créer une première archive de vos fichiers et de la base de données." />
97 96 ) : (
98 − <div className="space-y-2">
97 + <div className="rounded-2xl border border-line dark:border-white/[0.07] overflow-hidden divide-y divide-line dark:divide-white/[0.06]">
99 98 {backups.map((backup, i) => (
100 99 <motion.div
101 100 key={backup.id}
102 101 initial={{ opacity: 0, y: 5 }}
103 102 animate={{ opacity: 1, y: 0 }}
104 − transition={{ delay: i * 0.03 }}
105 − className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl transition-colors"
103 + transition={{ delay: Math.min(i, 10) * 0.03 }}
104 + className="flex flex-col sm:flex-row sm:items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/[0.03] transition-colors"
106 105 >
107 − <div className="flex items-start sm:items-center gap-3 flex-1 min-w-0">
106 + <div className="flex items-center gap-3 flex-1 min-w-0">
108 107 {statusIcon(backup.status)}
109 108 <div className="flex-1 min-w-0">
110 − <p className="text-sm text-gray-900 dark:text-white break-words">
111 − Backup {backup.type} - {formatDate(backup.startedAt)}
109 + <p className="text-[14px] text-gray-900 dark:text-white flex items-center gap-2 flex-wrap">
110 + <span className="font-medium">{formatDate(backup.startedAt)}</span>
111 + <span className={`inline-flex px-2 py-0.5 rounded-full text-[11px] font-medium ring-1 ring-inset ${backup.type === "full" ? "bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 ring-cyan-500/20" : "bg-gray-100 dark:bg-white/[0.06] text-gray-600 dark:text-gray-300 ring-gray-500/15"}`}>
112 + {backup.type === "full" ? "Complet" : "Incrémental"}
113 + </span>
112 114 </p>
113 − <p className="text-xs text-gray-500 break-words">
115 + <p className="text-[12px] text-gray-500 dark:text-gray-400 tabular-nums">
114 116 {backup.size > 0 ? formatFileSize(backup.size) : "—"}
115 − {backup.completedAt && ` • Terminé: ${formatDate(backup.completedAt)}`}
117 + {backup.completedAt && ` · terminé à ${new Date(backup.completedAt).toLocaleTimeString("fr-CA", { hour: "2-digit", minute: "2-digit" })}`}
118 + {backup.status === "failed" && " · échec"}
116 119 </p>
117 120 </div>
118 121 </div>
119 122 <div className="flex gap-1 pl-7 sm:pl-0">
120 − <button
121 − onClick={() => fetchBackups()}
122 − className="min-w-[44px] min-h-[44px] flex items-center justify-center p-2.5 text-gray-400 hover:text-gray-700 dark:hover:text-white rounded-lg transition-colors"
123 − >
123 + <button onClick={fetchBackups} className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-xl hover:bg-gray-100 dark:hover:bg-white/[0.08] transition-colors" title="Actualiser">
124 124 <RefreshCw className="w-4 h-4" />
125 125 </button>
126 − <button
127 − onClick={() => deleteBackup(backup.id)}
128 − className="min-w-[44px] min-h-[44px] flex items-center justify-center p-2.5 text-gray-400 hover:text-red-500 rounded-lg transition-colors"
129 − >
126 + <button onClick={() => setToDelete(backup)} className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-rose-500 rounded-xl hover:bg-rose-500/10 transition-colors" title="Supprimer">
130 127 <Trash2 className="w-4 h-4" />
131 128 </button>
132 129 </div>
@@ -134,6 +131,16 @@ export default function BackupPanel() {
134 131 ))}
135 132 </div>
136 133 )}
134 +
135 + <Modal
136 + isOpen={!!toDelete}
137 + onClose={() => setToDelete(null)}
138 + title="Supprimer ce backup ?"
139 + description={toDelete ? `${formatDate(toDelete.startedAt)} · ${formatFileSize(toDelete.size || 0)}` : undefined}
140 + footer={<><Button variant="ghost" onClick={() => setToDelete(null)}>Annuler</Button><Button variant="danger" onClick={deleteBackup}><Trash2 className="w-4 h-4" /> Supprimer</Button></>}
141 + >
142 + <p className="text-sm text-gray-600 dark:text-gray-300">L&apos;archive sera effacée du disque. Cette action est irréversible.</p>
143 + </Modal>
137 144 </div>
138 145 );
139 146 }
modified components/admin/SharedSpaceManagement.tsx +63 −43
@@ -5,6 +5,8 @@ import { motion } from "framer-motion";
5 5 import { Plus, Trash2, Edit2, Users, UserPlus, UserMinus } from "lucide-react";
6 6 import Button from "../ui/Button";
7 7 import Modal from "../ui/Modal";
8 +import UserAvatar from "../ui/UserAvatar";
9 +import { EmptyState } from "../ui/PageHeader";
8 10 import toast from "react-hot-toast";
9 11 import { formatFileSize } from "@/lib/utils";
10 12
@@ -29,7 +31,7 @@ interface UserOption {
29 31 username: string | null;
30 32 }
31 33
32 −const inputClasses = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none mb-4";
34 +const inputClasses = "field h-11 mb-4";
33 35
34 36 export default function SharedSpaceManagement() {
35 37 const [spaces, setSpaces] = useState<SpaceData[]>([]);
@@ -38,6 +40,7 @@ export default function SharedSpaceManagement() {
38 40 const [showCreate, setShowCreate] = useState(false);
39 41 const [editSpace, setEditSpace] = useState<SpaceData | null>(null);
40 42 const [form, setForm] = useState({ name: "", memberIds: [] as string[] });
43 + const [toDelete, setToDelete] = useState<SpaceData | null>(null);
41 44
42 45 const fetchData = async () => {
43 46 const [spacesRes, usersRes] = await Promise.all([
@@ -67,9 +70,10 @@ export default function SharedSpaceManagement() {
67 70 }
68 71 };
69 72
70 − const handleDelete = async (id: string) => {
71 − if (!confirm("Supprimer cet espace partagé ? Les dossiers seront conservés mais retirés de l'espace.")) return;
72 − const res = await fetch(`/api/shared-spaces/${id}`, { method: "DELETE" });
73 + const handleDelete = async () => {
74 + if (!toDelete) return;
75 + const res = await fetch(`/api/shared-spaces/${toDelete.id}`, { method: "DELETE" });
76 + setToDelete(null);
73 77 if (res.ok) { toast.success("Espace supprimé"); fetchData(); }
74 78 else toast.error("Erreur");
75 79 };
@@ -114,24 +118,34 @@ export default function SharedSpaceManagement() {
114 118 }));
115 119 };
116 120
117 − if (loading) return <div className="text-gray-500 text-sm p-4">Chargement...</div>;
121 + 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 + }
118 134
119 135 return (
120 136 <div className="space-y-4">
121 − <div className="flex justify-end">
122 − <Button onClick={() => setShowCreate(true)}>
123 − <Plus className="w-4 h-4 mr-1" /> Nouvel espace
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 espace
124 141 </Button>
125 142 </div>
126 143
127 144 {spaces.length === 0 && (
128 − <div className="text-center py-12 text-gray-500 dark:text-gray-400">
129 − <Users className="w-10 h-10 mx-auto mb-3 opacity-40" />
130 − <p className="text-sm">Aucun espace partagé. Créez-en un pour commencer.</p>
131 − </div>
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>} />
132 146 )}
133 147
134 − <div className="space-y-4">
148 + <div className="grid gap-4 lg:grid-cols-2">
135 149 {spaces.map((space) => {
136 150 const percent = space.quotaBytes > 0 ? Math.min(Math.round((space.usedBytes / space.quotaBytes) * 100), 100) : 0;
137 151 const memberUserIds = space.members.map((m) => m.user.id);
@@ -142,24 +156,24 @@ export default function SharedSpaceManagement() {
142 156 key={space.id}
143 157 initial={{ opacity: 0, y: 10 }}
144 158 animate={{ opacity: 1, y: 0 }}
145 − className="bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl p-5"
159 + className="card p-5"
146 160 >
147 161 {/* Header */}
148 162 <div className="flex items-start justify-between gap-3 mb-4">
149 163 <div className="flex items-center gap-2 min-w-0">
150 − <div className="w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-600/20 flex items-center justify-center flex-shrink-0">
151 − <Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
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" />
152 166 </div>
153 167 <div className="min-w-0">
154 − <p className="font-medium text-gray-900 dark:text-white truncate">{space.name}</p>
155 − <p className="text-xs text-gray-500">{space._count.members} membre(s) · {space._count.folders} dossier(s)</p>
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>
156 170 </div>
157 171 </div>
158 172 <div className="flex items-center gap-1 flex-shrink-0">
159 − <button onClick={() => setEditSpace(space)} className="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-white transition-colors">
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">
160 174 <Edit2 className="w-4 h-4" />
161 175 </button>
162 − <button onClick={() => handleDelete(space.id)} className="p-2 text-gray-400 hover:text-red-500 transition-colors">
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">
163 177 <Trash2 className="w-4 h-4" />
164 178 </button>
165 179 </div>
@@ -171,10 +185,10 @@ export default function SharedSpaceManagement() {
171 185 <span>Stockage utilisé</span>
172 186 <span>{formatFileSize(space.usedBytes)} / {formatFileSize(space.quotaBytes)}</span>
173 187 </div>
174 − <div className="w-full bg-gray-100 dark:bg-white/10 rounded-full h-1.5">
188 + <div className="w-full bg-gray-200 dark:bg-white/10 rounded-full h-1.5 overflow-hidden">
175 189 <div
176 − className={`h-1.5 rounded-full transition-all ${percent > 90 ? "bg-red-500" : percent > 70 ? "bg-yellow-500" : "bg-blue-700"}`}
177 − style={{ width: `${percent}%` }}
190 + 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)}%` }}
178 192 />
179 193 </div>
180 194 <p className="text-xs text-gray-400 mt-1">{percent}% utilisé</p>
@@ -182,19 +196,15 @@ export default function SharedSpaceManagement() {
182 196
183 197 {/* Members */}
184 198 <div>
185 − <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Membres</p>
186 − <div className="space-y-1 mb-3">
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">
187 201 {space.members.map((m) => (
188 − <div key={m.id} className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5">
189 − <div className="flex items-center gap-2">
190 − <div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-white/10 flex items-center justify-center">
191 − <span className="text-[10px] font-medium text-gray-600 dark:text-gray-300">
192 − {(m.user.name || m.user.username || "?")[0].toUpperCase()}
193 − </span>
194 − </div>
195 − <span className="text-sm text-gray-700 dark:text-gray-300">{m.user.name || m.user.username}</span>
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>
196 206 </div>
197 − <button onClick={() => handleRemoveMember(space.id, m.user.id)} className="p-1 text-gray-400 hover:text-red-500 transition-colors">
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">
198 208 <UserMinus className="w-3.5 h-3.5" />
199 209 </button>
200 210 </div>
@@ -208,7 +218,7 @@ export default function SharedSpaceManagement() {
208 218 {nonMembers.length > 0 && (
209 219 <div className="flex gap-2">
210 220 <select
211 − className="flex-1 bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-300 focus:outline-none"
221 + className="field h-10 flex-1"
212 222 defaultValue=""
213 223 onChange={(e) => {
214 224 if (e.target.value) { handleAddMember(space.id, e.target.value); e.target.value = ""; }
@@ -219,9 +229,9 @@ export default function SharedSpaceManagement() {
219 229 <option key={u.id} value={u.id}>{u.name || u.username}</option>
220 230 ))}
221 231 </select>
222 − <button className="p-2 text-blue-600 hover:text-blue-700 transition-colors">
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>
223 233 <UserPlus className="w-5 h-5" />
224 − </button>
234 + </span>
225 235 </div>
226 236 )}
227 237 </div>
@@ -242,17 +252,17 @@ export default function SharedSpaceManagement() {
242 252 />
243 253 {users.length > 0 && (
244 254 <div className="mb-4">
245 − <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Membres</p>
246 − <div className="space-y-1 max-h-48 overflow-auto">
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">
247 257 {users.map((u) => (
248 − <label key={u.id} className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5 cursor-pointer">
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">
249 259 <input
250 260 type="checkbox"
251 261 checked={form.memberIds.includes(u.id)}
252 262 onChange={() => toggleMember(u.id)}
253 − className="rounded"
263 + className="w-4 h-4 rounded accent-[rgb(var(--accent-500))]"
254 264 />
255 − <span className="text-sm text-gray-700 dark:text-gray-300">{u.name || u.username}</span>
265 + <span className="text-[13.5px] text-gray-700 dark:text-gray-200">{u.name || u.username}</span>
256 266 </label>
257 267 ))}
258 268 </div>
@@ -261,10 +271,20 @@ export default function SharedSpaceManagement() {
261 271 <p className="text-xs text-gray-400 mb-4">Quota : 500 Go par défaut</p>
262 272 <div className="flex gap-2 justify-end">
263 273 <Button variant="ghost" onClick={() => { setShowCreate(false); setForm({ name: "", memberIds: [] }); }}>Annuler</Button>
264 − <Button onClick={handleCreate} disabled={!form.name.trim()}>Créer</Button>
274 + <Button variant="accent" onClick={handleCreate} disabled={!form.name.trim()}>Créer</Button>
265 275 </div>
266 276 </Modal>
267 277
278 + <Modal
279 + 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>
287 +
268 288 {/* Edit modal */}
269 289 <Modal isOpen={!!editSpace} onClose={() => setEditSpace(null)} title="Modifier l'espace">
270 290 {editSpace && (
modified components/admin/SnapshotPanel.tsx +45 −72
@@ -1,7 +1,7 @@
1 1 "use client";
2 2
3 3 import { useState, useEffect } from "react";
4 −import { motion, AnimatePresence } from "framer-motion";
4 +import { motion } from "framer-motion";
5 5 import {
6 6 Clock,
7 7 Camera,
@@ -14,6 +14,8 @@ import {
14 14 CalendarDays,
15 15 } from "lucide-react";
16 16 import Button from "../ui/Button";
17 +import Modal from "../ui/Modal";
18 +import { EmptyState } from "../ui/PageHeader";
17 19 import { formatFileSize } from "@/lib/utils";
18 20 import toast from "react-hot-toast";
19 21
@@ -30,7 +32,7 @@ interface SnapshotData {
30 32
31 33 function StatusIcon({ status }: { status: string }) {
32 34 if (status === "completed") return <CheckCircle className="w-4 h-4 text-emerald-500 flex-shrink-0" />;
33 − if (status === "failed") return <XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />;
35 + if (status === "failed") return <XCircle className="w-4 h-4 text-rose-500 flex-shrink-0" />;
34 36 return <Loader2 className="w-4 h-4 text-blue-400 animate-spin flex-shrink-0" />;
35 37 }
36 38
@@ -116,19 +118,19 @@ export default function SnapshotPanel() {
116 118 {/* Header */}
117 119 <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
118 120 <div>
119 − <h2 className="text-lg font-medium text-gray-900 dark:text-white flex items-center gap-2">
120 − <Clock className="w-5 h-5 text-blue-400" />
121 + <h2 className="text-[15px] font-semibold text-gray-900 dark:text-white flex items-center gap-2">
122 + <Clock className="w-[18px] h-[18px] text-blue-500" />
121 123 Snapshots quotidiens
122 124 </h2>
123 − <p className="text-sm text-gray-500 mt-0.5">
124 − 7 jours conservés · restauration en un clic
125 + <p className="text-[13px] text-gray-500 dark:text-gray-400 mt-0.5">
126 + {snapshots.length} instantané{snapshots.length !== 1 ? "s" : ""} · 7 jours conservés · restauration en un clic
125 127 </p>
126 128 </div>
127 129 <Button
128 130 size="sm"
131 + variant="accent"
129 132 onClick={handleCreate}
130 133 loading={creating}
131 − className="gap-2"
132 134 >
133 135 <Camera className="w-4 h-4" />
134 136 {todayHasSnapshot ? "Re-snapshot maintenant" : "Snapshot maintenant"}
@@ -136,7 +138,7 @@ export default function SnapshotPanel() {
136 138 </div>
137 139
138 140 {/* Auto info banner */}
139 − <div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/20 rounded-xl text-sm text-blue-400">
141 + <div className="flex items-start gap-2.5 p-3 rounded-2xl bg-blue-500/[0.07] text-[13px] text-blue-800 dark:text-blue-200">
140 142 <CalendarDays className="w-4 h-4 mt-0.5 flex-shrink-0" />
141 143 <span>
142 144 Un snapshot automatique est créé chaque jour au premier démarrage du serveur.
@@ -146,15 +148,15 @@ export default function SnapshotPanel() {
146 148
147 149 {/* Snapshot list */}
148 150 {loading ? (
149 − <div className="flex justify-center py-10">
150 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
151 + <div className="space-y-2">
152 + {[0, 1, 2].map((i) => (
153 + <div key={i} className="rounded-2xl border border-line dark:border-white/[0.07] p-3 pl-12 flex items-center gap-3 animate-pulse">
154 + <div className="flex-1 space-y-2"><div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-1/4 rounded bg-gray-200 dark:bg-white/10" /></div>
155 + </div>
156 + ))}
151 157 </div>
152 158 ) : snapshots.length === 0 ? (
153 − <div className="text-center py-12 text-gray-500">
154 − <Clock className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
155 − <p className="font-medium">Aucun snapshot</p>
156 − <p className="text-sm mt-1">Créez votre premier snapshot manuellement ou attendez le démarrage automatique.</p>
157 − </div>
159 + <EmptyState icon={Camera} title="Aucun snapshot" description="Créez votre premier snapshot manuellement ou attendez le démarrage automatique quotidien." />
158 160 ) : (
159 161 <div className="relative space-y-2">
160 162 {/* Vertical timeline line */}
@@ -168,10 +170,10 @@ export default function SnapshotPanel() {
168 170 initial={{ opacity: 0, x: -8 }}
169 171 animate={{ opacity: 1, x: 0 }}
170 172 transition={{ delay: i * 0.04 }}
171 − className={`relative flex flex-col sm:flex-row sm:items-center gap-3 p-3 pl-12 rounded-xl border transition-colors ${
173 + className={`relative flex flex-col sm:flex-row sm:items-center gap-3 p-3 pl-12 rounded-2xl border transition-colors ${
172 174 today
173 − ? "bg-blue-500/10 border-blue-500/30 dark:border-blue-500/30"
174 − : "bg-white dark:bg-white/5 border-gray-200 dark:border-white/10"
175 + ? "bg-blue-500/[0.07] border-blue-500/25"
176 + : "bg-surface dark:bg-white/[0.03] border-line dark:border-white/[0.07] hover:bg-gray-50 dark:hover:bg-white/[0.05]"
175 177 }`}
176 178 >
177 179 {/* Timeline dot */}
@@ -179,10 +181,10 @@ export default function SnapshotPanel() {
179 181 className={`absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 flex items-center justify-center ${
180 182 snapshot.status === "completed"
181 183 ? today
182 − ? "bg-blue-500 border-blue-400"
183 − : "bg-emerald-500 border-emerald-400"
184 + ? "bg-blue-500 border-blue-300 shadow-glow"
185 + : "bg-emerald-500 border-emerald-300"
184 186 : snapshot.status === "failed"
185 − ? "bg-red-500 border-red-400"
187 + ? "bg-rose-500 border-rose-300"
186 188 : "bg-gray-400 border-gray-300"
187 189 }`}
188 190 />
@@ -194,7 +196,7 @@ export default function SnapshotPanel() {
194 196 {snapshot.label}
195 197 </p>
196 198 {today && (
197 − <span className="text-[10px] font-semibold px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded-full uppercase tracking-wide">
199 + <span className="text-[10px] font-semibold px-1.5 py-0.5 bg-blue-500/15 text-blue-700 dark:text-blue-300 rounded-full uppercase tracking-wide">
198 200 Aujourd&apos;hui
199 201 </span>
200 202 )}
@@ -219,7 +221,7 @@ export default function SnapshotPanel() {
219 221 onClick={() => setConfirmRestore(snapshot)}
220 222 disabled={restoringId === snapshot.id}
221 223 title="Restaurer"
222 − className="min-w-[44px] min-h-[44px] flex items-center justify-center p-2.5 text-gray-400 hover:text-blue-400 dark:hover:text-blue-400 rounded-lg transition-colors disabled:opacity-50"
224 + className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 rounded-xl hover:bg-blue-500/10 transition-colors disabled:opacity-50"
223 225 >
224 226 {restoringId === snapshot.id ? (
225 227 <Loader2 className="w-4 h-4 animate-spin" />
@@ -231,7 +233,7 @@ export default function SnapshotPanel() {
231 233 onClick={() => handleDelete(snapshot.id)}
232 234 disabled={deletingId === snapshot.id}
233 235 title="Supprimer"
234 − className="min-w-[44px] min-h-[44px] flex items-center justify-center p-2.5 text-gray-400 hover:text-red-500 rounded-lg transition-colors disabled:opacity-50"
236 + className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-rose-500 rounded-xl hover:bg-rose-500/10 transition-colors disabled:opacity-50"
235 237 >
236 238 {deletingId === snapshot.id ? (
237 239 <Loader2 className="w-4 h-4 animate-spin" />
@@ -248,54 +250,25 @@ export default function SnapshotPanel() {
248 250 )}
249 251
250 252 {/* Restore confirmation modal */}
251 − <AnimatePresence>
252 − {confirmRestore && (
253 − <motion.div
254 − initial={{ opacity: 0 }}
255 − animate={{ opacity: 1 }}
256 − exit={{ opacity: 0 }}
257 − className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
258 − onClick={() => setConfirmRestore(null)}
259 − >
260 − <motion.div
261 − initial={{ scale: 0.95, opacity: 0 }}
262 − animate={{ scale: 1, opacity: 1 }}
263 − exit={{ scale: 0.95, opacity: 0 }}
264 − onClick={(e) => e.stopPropagation()}
265 − className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl"
266 − >
267 − <div className="flex items-center gap-3 mb-4">
268 − <div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center flex-shrink-0">
269 − <AlertTriangle className="w-5 h-5 text-amber-400" />
270 − </div>
271 − <div>
272 − <h3 className="font-semibold text-gray-900 dark:text-white">Restaurer le snapshot ?</h3>
273 − <p className="text-sm text-gray-500 capitalize">{confirmRestore.label}</p>
274 − </div>
275 − </div>
276 − <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
277 − Tous vos fichiers et la base de données seront remplacés par ceux du snapshot sélectionné.
278 − <strong className="text-gray-900 dark:text-white"> Cette action est irréversible.</strong>
279 − </p>
280 − <div className="flex gap-3">
281 − <Button
282 − variant="secondary"
283 − className="flex-1"
284 − onClick={() => setConfirmRestore(null)}
285 − >
286 − Annuler
287 − </Button>
288 − <button
289 − onClick={() => handleRestore(confirmRestore)}
290 − className="flex-1 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-xl font-medium transition-colors text-sm"
291 − >
292 − Oui, restaurer
293 − </button>
294 − </div>
295 − </motion.div>
296 − </motion.div>
297 − )}
298 − </AnimatePresence>
253 + <Modal
254 + isOpen={!!confirmRestore}
255 + onClose={() => setConfirmRestore(null)}
256 + title="Restaurer ce snapshot ?"
257 + description={confirmRestore?.label}
258 + footer={
259 + <>
260 + <Button variant="ghost" onClick={() => setConfirmRestore(null)}>Annuler</Button>
261 + <Button onClick={() => confirmRestore && handleRestore(confirmRestore)} className="!bg-amber-500 hover:!bg-amber-600 !text-white dark:!bg-amber-500 dark:!text-white">
262 + <RotateCcw className="w-4 h-4" /> Oui, restaurer
263 + </Button>
264 + </>
265 + }
266 + >
267 + <div className="flex items-start gap-3 p-3 rounded-2xl bg-amber-500/[0.08] text-[13.5px] text-amber-800 dark:text-amber-300">
268 + <AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
269 + <p>Tous vos fichiers et la base de données seront remplacés par ceux du snapshot sélectionné. <strong>Cette action est irréversible.</strong></p>
270 + </div>
271 + </Modal>
299 272 </div>
300 273 );
301 274 }
modified components/admin/StatsCards.tsx +53 −52
@@ -1,79 +1,80 @@
1 1 "use client";
2 2
3 3 import { motion } from "framer-motion";
4 −import { HardDrive, FileText, Share2, Folder } from "lucide-react";
4 +import { HardDrive, FileText, Share2, Folder, Star, Trash2, Layers, type LucideIcon } from "lucide-react";
5 5 import { formatFileSize } from "@/lib/utils";
6 +import { STORAGE_QUOTA_BYTES } from "@/lib/prefs";
6 7
7 8 interface StatsData {
8 9 fileCount: number;
9 10 folderCount: number;
10 11 shareCount: number;
11 12 storageUsed: number;
13 + favoriteCount?: number;
14 + trashCount?: number;
15 + versionCount?: number;
12 16 }
13 17
14 18 interface StatsCardsProps {
15 19 stats: StatsData;
16 20 }
17 21
18 −const cards = [
19 − {
20 − key: "storageUsed",
21 − label: "Stockage utilisé",
22 − icon: HardDrive,
23 − format: (v: number) => formatFileSize(v),
24 − color: "text-blue-500 dark:text-blue-400",
25 − bgColor: "bg-blue-100 dark:bg-blue-500/10",
26 − },
27 − {
28 − key: "fileCount",
29 − label: "Fichiers",
30 − icon: FileText,
31 − format: (v: number) => String(v),
32 − color: "text-green-500 dark:text-green-400",
33 − bgColor: "bg-green-100 dark:bg-green-500/10",
34 − },
35 − {
36 − key: "folderCount",
37 − label: "Dossiers",
38 − icon: Folder,
39 − format: (v: number) => String(v),
40 − color: "text-blue-600 dark:text-blue-400",
41 − bgColor: "bg-blue-100 dark:bg-blue-500/10",
42 − },
22 +interface CardDef {
23 + key: keyof StatsData;
24 + label: string;
25 + icon: LucideIcon;
26 + format: (v: number) => string;
27 + tone: string;
28 + sub?: (s: StatsData) => string | undefined;
29 +}
30 +
31 +const cards: CardDef[] = [
43 32 {
44 − key: "shareCount",
45 − label: "Liens actifs",
46 − icon: Share2,
47 − format: (v: number) => String(v),
48 − color: "text-orange-500 dark:text-orange-400",
49 − bgColor: "bg-orange-100 dark:bg-orange-500/10",
33 + key: "storageUsed", label: "Stockage utilisé", icon: HardDrive, format: (v) => formatFileSize(v),
34 + tone: "text-blue-600 bg-blue-500/10 dark:text-blue-300",
35 + sub: (s) => `${Math.min(100, (s.storageUsed / STORAGE_QUOTA_BYTES) * 100).toFixed(1)} % de ${formatFileSize(STORAGE_QUOTA_BYTES)}`,
50 36 },
37 + { key: "fileCount", label: "Fichiers", icon: FileText, format: (v) => (v ?? 0).toLocaleString("fr-CA"), tone: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-300", sub: (s) => (typeof s.versionCount === "number" ? `${s.versionCount.toLocaleString("fr-CA")} versions` : undefined) },
38 + { key: "folderCount", label: "Dossiers", icon: Folder, format: (v) => (v ?? 0).toLocaleString("fr-CA"), tone: "text-amber-600 bg-amber-500/10 dark:text-amber-300" },
39 + { key: "shareCount", label: "Liens actifs", icon: Share2, format: (v) => (v ?? 0).toLocaleString("fr-CA"), tone: "text-purple-600 bg-purple-500/10 dark:text-purple-300" },
40 + { key: "favoriteCount", label: "Favoris", icon: Star, format: (v) => (v ?? 0).toLocaleString("fr-CA"), tone: "text-amber-600 bg-amber-500/10 dark:text-amber-300" },
41 + { key: "trashCount", label: "Corbeille", icon: Trash2, format: (v) => (v ?? 0).toLocaleString("fr-CA"), tone: "text-rose-600 bg-rose-500/10 dark:text-rose-300" },
51 42 ];
52 43
53 44 export default function StatsCards({ stats }: StatsCardsProps) {
45 + const visible = cards.filter((c) => typeof stats[c.key] === "number");
54 46 return (
55 − <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
56 − {cards.map((card, i) => (
57 − <motion.div
58 − key={card.key}
59 − initial={{ opacity: 0, y: 10 }}
60 − animate={{ opacity: 1, y: 0 }}
61 − transition={{ delay: i * 0.1 }}
62 − className="bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl p-3.5 sm:p-4 shadow-sm dark:shadow-none transition-colors"
63 − >
64 − <div className="flex items-center gap-3">
65 − <div className={`p-2 sm:p-2.5 rounded-lg ${card.bgColor}`}>
66 − <card.icon className={`w-4 h-4 sm:w-5 sm:h-5 ${card.color}`} />
47 + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2 sm:gap-3">
48 + {visible.map((card, i) => {
49 + const value = stats[card.key] as number;
50 + const sub = card.sub?.(stats);
51 + const Icon = card.icon;
52 + return (
53 + <motion.div
54 + key={card.key}
55 + initial={{ opacity: 0, y: 10 }}
56 + animate={{ opacity: 1, y: 0 }}
57 + transition={{ delay: i * 0.05, duration: 0.3 }}
58 + className="card card-hover p-3.5 sm:p-4"
59 + >
60 + <div className="flex items-center gap-3">
61 + <span className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${card.tone}`}>
62 + <Icon className="w-[18px] h-[18px]" />
63 + </span>
64 + <div className="min-w-0">
65 + <p className="text-[20px] sm:text-[22px] font-semibold tracking-tight text-gray-900 dark:text-white tabular-nums leading-none truncate">{card.format(value)}</p>
66 + <p className="text-[11.5px] text-gray-500 dark:text-gray-400 mt-1 truncate">{card.label}</p>
67 + </div>
67 68 </div>
68 − <div>
69 − <p className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-white">
70 − {card.format(stats[card.key as keyof StatsData] as number)}
69 + {sub && (
70 + <p className="mt-2.5 text-[11px] text-gray-400 flex items-center gap-1 truncate">
71 + {card.key === "fileCount" && <Layers className="w-3 h-3 shrink-0" />}
72 + {sub}
71 73 </p>
72 − <p className="text-[11px] sm:text-xs text-gray-500">{card.label}</p>
73 − </div>
74 − </div>
75 − </motion.div>
76 − ))}
74 + )}
75 + </motion.div>
76 + );
77 + })}
77 78 </div>
78 79 );
79 80 }
modified components/admin/StorageChart.tsx +10 −82
@@ -1,7 +1,9 @@
1 1 "use client";
2 2
3 −import { motion } from "framer-motion";
4 −import { formatFileSize } from "@/lib/utils";
3 +import { useMemo } from "react";
4 +import StorageDonut from "@/components/dashboard/StorageDonut";
5 +import { aggregateByCategory, type CategoryStat } from "@/components/dashboard/categories";
6 +import { STORAGE_QUOTA_BYTES } from "@/lib/prefs";
5 7
6 8 interface TypeBreakdown {
7 9 type: string;
@@ -12,86 +14,12 @@ interface TypeBreakdown {
12 14 interface StorageChartProps {
13 15 data: TypeBreakdown[];
14 16 totalSize: number;
17 + /** Si fourni par l'API (v3), utilisé tel quel ; sinon agrégé côté client. */
18 + byCategory?: CategoryStat[];
15 19 }
16 20
17 −const typeColors: Record<string, string> = {
18 − "image/": "bg-blue-500",
19 − "video/": "bg-purple-500",
20 − "audio/": "bg-pink-500",
21 − "application/pdf": "bg-red-500",
22 − "text/": "bg-green-500",
23 − "application/": "bg-yellow-500",
24 −};
25 −
26 −function getColor(type: string): string {
27 − for (const [prefix, color] of Object.entries(typeColors)) {
28 − if (type.startsWith(prefix)) return color;
29 − }
30 − return "bg-gray-500";
31 −}
32 −
33 −function getTypeLabel(type: string): string {
34 − if (type.startsWith("image/")) return "Images";
35 − if (type.startsWith("video/")) return "Vidéos";
36 − if (type.startsWith("audio/")) return "Audio";
37 − if (type === "application/pdf") return "PDF";
38 − if (type.startsWith("text/")) return "Texte";
39 − return type.split("/")[1]?.toUpperCase() || "Autre";
40 −}
41 −
42 −export default function StorageChart({ data, totalSize }: StorageChartProps) {
43 − // Group by category
44 − const grouped = data.reduce<Record<string, { size: number; count: number }>>(
45 − (acc, item) => {
46 − const label = getTypeLabel(item.type);
47 − if (!acc[label]) acc[label] = { size: 0, count: 0 };
48 − acc[label].size += item.size;
49 − acc[label].count += item.count;
50 − return acc;
51 − },
52 − {}
53 − );
54 −
55 − const entries = Object.entries(grouped).sort(([, a], [, b]) => b.size - a.size);
56 −
57 − return (
58 − <div className="space-y-4">
59 − {/* Bar */}
60 − <div className="h-4 rounded-full bg-gray-200 dark:bg-white/5 overflow-hidden flex">
61 − {entries.map(([label, { size }]) => {
62 − const pct = totalSize > 0 ? (size / totalSize) * 100 : 0;
63 − if (pct < 1) return null;
64 − const matchType = data.find((d) => getTypeLabel(d.type) === label)?.type || "";
65 − return (
66 − <motion.div
67 − key={label}
68 − initial={{ width: 0 }}
69 − animate={{ width: `${pct}%` }}
70 − transition={{ duration: 0.5, delay: 0.2 }}
71 − className={`h-full ${getColor(matchType)}`}
72 − title={`${label}: ${formatFileSize(size)}`}
73 − />
74 − );
75 − })}
76 − </div>
77 −
78 − {/* Legend */}
79 − <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
80 − {entries.map(([label, { size, count }]) => {
81 − const matchType = data.find((d) => getTypeLabel(d.type) === label)?.type || "";
82 − return (
83 − <div key={label} className="flex items-center gap-2">
84 − <div className={`w-3 h-3 rounded-full ${getColor(matchType)}`} />
85 − <div>
86 − <p className="text-xs text-gray-600 dark:text-gray-300">{label}</p>
87 − <p className="text-xs text-gray-500">
88 − {formatFileSize(size)} ({count})
89 − </p>
90 − </div>
91 − </div>
92 − );
93 − })}
94 − </div>
95 − </div>
96 − );
21 +/** Répartition du stockage par catégorie — même donut que l'accueil. */
22 +export default function StorageChart({ data, totalSize, byCategory }: StorageChartProps) {
23 + const cats = useMemo(() => byCategory && byCategory.length ? byCategory : aggregateByCategory(data), [byCategory, data]);
24 + return <StorageDonut data={cats} totalUsed={totalSize} quota={STORAGE_QUOTA_BYTES} size={184} legend="side" />;
97 25 }
modified components/admin/TwoFactorSetup.tsx +83 −63
@@ -1,8 +1,9 @@
1 1 "use client";
2 2
3 3 import { useState } from "react";
4 −import { Shield, ShieldOff, ShieldCheck } from "lucide-react";
4 +import { Shield, ShieldOff, ShieldCheck, Copy, Smartphone } from "lucide-react";
5 5 import Button from "../ui/Button";
6 +import Modal from "../ui/Modal";
6 7 import toast from "react-hot-toast";
7 8
8 9 interface TwoFactorSetupProps {
@@ -16,6 +17,7 @@ export default function TwoFactorSetup({ enabled, onStatusChange }: TwoFactorSet
16 17 const [token, setToken] = useState("");
17 18 const [loading, setLoading] = useState(false);
18 19 const [step, setStep] = useState<"idle" | "setup" | "verify">("idle");
20 + const [confirmDisable, setConfirmDisable] = useState(false);
19 21
20 22 const startSetup = async () => {
21 23 setLoading(true);
@@ -26,9 +28,9 @@ export default function TwoFactorSetup({ enabled, onStatusChange }: TwoFactorSet
26 28 setQrCode(data.qrCodeUrl);
27 29 setSecret(data.secret);
28 30 setStep("setup");
29 − }
31 + } else toast.error("Impossible de démarrer la configuration");
30 32 } catch {
31 − toast.error("Erreur");
33 + toast.error("Erreur réseau");
32 34 }
33 35 setLoading(false);
34 36 };
@@ -42,7 +44,7 @@ export default function TwoFactorSetup({ enabled, onStatusChange }: TwoFactorSet
42 44 body: JSON.stringify({ token, enable: true }),
43 45 });
44 46 if (res.ok) {
45 − toast.success("2FA activé !");
47 + toast.success("Authentification à deux facteurs activée");
46 48 setStep("idle");
47 49 setQrCode(null);
48 50 setToken("");
@@ -52,98 +54,116 @@ export default function TwoFactorSetup({ enabled, onStatusChange }: TwoFactorSet
52 54 toast.error(data.error || "Code invalide");
53 55 }
54 56 } catch {
55 − toast.error("Erreur");
57 + toast.error("Erreur réseau");
56 58 }
57 59 setLoading(false);
58 60 };
59 61
60 62 const disable2FA = async () => {
61 − if (!confirm("Désactiver l'authentification à deux facteurs ?")) return;
63 + setLoading(true);
62 64 const res = await fetch("/api/auth/2fa/disable", { method: "POST" });
65 + setLoading(false);
66 + setConfirmDisable(false);
63 67 if (res.ok) {
64 − toast.success("2FA désactivé");
68 + toast.success("2FA désactivée");
65 69 onStatusChange();
66 − }
70 + } else toast.error("Erreur");
67 71 };
68 72
69 − const inputClasses = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none text-center tracking-[0.3em] sm:tracking-[0.5em] text-xl";
70 −
71 73 return (
72 − <div className="bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl p-4 sm:p-6 transition-colors">
73 − <div className="flex items-center gap-3 mb-4">
74 − {enabled ? (
75 − <ShieldCheck className="w-6 h-6 text-green-500 flex-shrink-0" />
76 − ) : (
77 − <Shield className="w-6 h-6 text-gray-400 flex-shrink-0" />
78 − )}
79 − <div>
80 − <h3 className="text-sm font-medium text-gray-900 dark:text-white">
81 − Authentification à deux facteurs (2FA)
82 − </h3>
83 − <p className="text-xs text-gray-500 mt-0.5">
84 − {enabled ? "Activé - Votre compte est protégé" : "Désactivé - Activez pour plus de sécurité"}
74 + <div className="card p-4 sm:p-5">
75 + <div className="flex items-start gap-3">
76 + <div className={`w-10 h-10 rounded-2xl flex items-center justify-center shrink-0 ${enabled ? "bg-emerald-500/10 text-emerald-500" : "bg-gray-100 dark:bg-white/[0.06] text-gray-400"}`}>
77 + {enabled ? <ShieldCheck className="w-5 h-5" /> : <Shield className="w-5 h-5" />}
78 + </div>
79 + <div className="flex-1 min-w-0">
80 + <div className="flex items-center gap-2 flex-wrap">
81 + <h3 className="text-[15px] font-semibold text-gray-900 dark:text-white">Authentification à deux facteurs</h3>
82 + <span className={`text-[10.5px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide ${enabled ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-gray-100 dark:bg-white/10 text-gray-500"}`}>
83 + {enabled ? "Activée" : "Désactivée"}
84 + </span>
85 + </div>
86 + <p className="text-[13px] text-gray-500 dark:text-gray-400 mt-0.5">
87 + {enabled ? "Un code temporaire est demandé à chaque connexion." : "Ajoutez un code temporaire (TOTP) en plus de votre mot de passe."}
85 88 </p>
86 89 </div>
90 + {step === "idle" && (
91 + enabled ? (
92 + <Button size="sm" variant="danger" onClick={() => setConfirmDisable(true)} className="shrink-0">
93 + <ShieldOff className="w-4 h-4" /> Désactiver
94 + </Button>
95 + ) : (
96 + <Button size="sm" variant="accent" onClick={startSetup} loading={loading} className="shrink-0">
97 + <Smartphone className="w-4 h-4" /> Activer
98 + </Button>
99 + )
100 + )}
87 101 </div>
88 102
89 − {step === "idle" && !enabled && (
90 − <Button size="sm" onClick={startSetup} loading={loading} className="w-full sm:w-auto">
91 − <Shield className="w-4 h-4" /> Activer 2FA
92 − </Button>
93 − )}
94 −
95 − {step === "idle" && enabled && (
96 − <Button size="sm" variant="danger" onClick={disable2FA} className="w-full sm:w-auto">
97 − <ShieldOff className="w-4 h-4" /> Désactiver 2FA
98 − </Button>
99 − )}
100 −
101 103 {step === "setup" && qrCode && (
102 − <div className="space-y-4">
103 − <p className="text-sm text-gray-600 dark:text-gray-400">
104 − Scannez ce QR code avec votre app authenticator (Google Authenticator, Authy, etc.)
105 − </p>
106 − <div className="flex justify-center">
107 − <img src={qrCode} alt="QR Code 2FA" className="w-44 h-44 sm:w-48 sm:h-48 rounded-lg" />
104 + <div className="mt-5 grid sm:grid-cols-[auto_1fr] gap-5 items-center">
105 + <div className="mx-auto p-3 rounded-2xl bg-white shadow-card border border-line">
106 + <img src={qrCode} alt="QR Code 2FA" className="w-44 h-44 rounded-lg" />
108 107 </div>
109 − {secret && (
110 − <div className="text-center">
111 − <p className="text-xs text-gray-500 mb-1">Clé manuelle :</p>
112 − <code className="text-xs bg-gray-100 dark:bg-white/10 px-3 py-1 rounded font-mono text-gray-700 dark:text-gray-300 break-all inline-block max-w-full">
113 − {secret}
114 − </code>
108 + <div className="space-y-3">
109 + <ol className="text-[13px] text-gray-600 dark:text-gray-300 space-y-1.5 list-decimal pl-4">
110 + <li>Ouvrez votre application d&apos;authentification (Google Authenticator, Authy, 1Password…).</li>
111 + <li>Scannez le QR code ou saisissez la clé manuelle.</li>
112 + <li>Entrez le code à 6 chiffres pour confirmer.</li>
113 + </ol>
114 + {secret && (
115 + <button
116 + onClick={() => { navigator.clipboard.writeText(secret); toast.success("Clé copiée"); }}
117 + className="flex items-center gap-2 max-w-full px-3 py-2 rounded-xl bg-gray-100 dark:bg-white/[0.06] text-[12px] font-mono text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-white/10 transition-colors"
118 + title="Copier la clé manuelle"
119 + >
120 + <span className="truncate">{secret}</span>
121 + <Copy className="w-3.5 h-3.5 shrink-0 text-gray-400" />
122 + </button>
123 + )}
124 + <div className="flex gap-2">
125 + <Button variant="ghost" size="sm" onClick={() => { setStep("idle"); setQrCode(null); }}>Annuler</Button>
126 + <Button size="sm" onClick={() => setStep("verify")}>Continuer</Button>
115 127 </div>
116 − )}
117 − <Button size="sm" onClick={() => setStep("verify")} className="w-full">
118 − Suivant
119 − </Button>
128 + </div>
120 129 </div>
121 130 )}
122 131
123 132 {step === "verify" && (
124 − <div className="space-y-4">
125 − <p className="text-sm text-gray-600 dark:text-gray-400">
126 − Entrez le code à 6 chiffres de votre app authenticator
127 − </p>
133 + <div className="mt-5 max-w-sm space-y-3">
134 + <p className="text-[13px] text-gray-600 dark:text-gray-300">Entrez le code à 6 chiffres affiché dans votre application.</p>
128 135 <input
129 136 type="text"
137 + inputMode="numeric"
130 138 value={token}
131 139 onChange={(e) => setToken(e.target.value.replace(/\D/g, "").slice(0, 6))}
132 140 placeholder="000000"
133 141 maxLength={6}
134 − className={inputClasses}
142 + className="field h-14 text-center text-2xl tracking-[0.4em] font-mono"
135 143 autoFocus
144 + autoComplete="one-time-code"
136 145 />
137 − <div className="flex flex-col sm:flex-row gap-2">
138 − <Button variant="ghost" onClick={() => { setStep("idle"); setQrCode(null); }} className="w-full sm:flex-1">
139 − Annuler
140 − </Button>
141 − <Button onClick={verifyAndEnable} loading={loading} disabled={token.length !== 6} className="w-full sm:flex-1">
142 − Vérifier & Activer
143 − </Button>
146 + <div className="flex gap-2">
147 + <Button variant="ghost" onClick={() => { setStep("setup"); setToken(""); }} className="flex-1">Retour</Button>
148 + <Button variant="accent" onClick={verifyAndEnable} loading={loading} disabled={token.length !== 6} className="flex-1">Vérifier et activer</Button>
144 149 </div>
145 150 </div>
146 151 )}
152 +
153 + <Modal
154 + isOpen={confirmDisable}
155 + onClose={() => setConfirmDisable(false)}
156 + title="Désactiver la 2FA ?"
157 + description="Votre compte ne sera plus protégé que par votre mot de passe."
158 + footer={
159 + <>
160 + <Button variant="ghost" onClick={() => setConfirmDisable(false)}>Annuler</Button>
161 + <Button variant="danger" onClick={disable2FA} loading={loading}><ShieldOff className="w-4 h-4" /> Désactiver</Button>
162 + </>
163 + }
164 + >
165 + <p className="text-sm text-gray-600 dark:text-gray-300">Vous pourrez la réactiver à tout moment depuis cette page.</p>
166 + </Modal>
147 167 </div>
148 168 );
149 169 }
modified components/admin/UserManagement.tsx +137 −125
@@ -2,9 +2,11 @@
2 2
3 3 import { useState, useEffect } from "react";
4 4 import { motion } from "framer-motion";
5 −import { UserPlus, Trash2, Shield, Edit2, Users } from "lucide-react";
5 +import { UserPlus, Trash2, ShieldCheck, Edit2, Users, FileText, Folder } from "lucide-react";
6 6 import Button from "../ui/Button";
7 7 import Modal from "../ui/Modal";
8 +import UserAvatar from "../ui/UserAvatar";
9 +import { EmptyState } from "../ui/PageHeader";
8 10 import toast from "react-hot-toast";
9 11
10 12 interface UserData {
@@ -18,12 +20,50 @@ interface UserData {
18 20 _count?: { files: number; folders: number };
19 21 }
20 22
23 +const roleLabels: Record<string, string> = { admin: "Admin", user: "Utilisateur", viewer: "Lecteur" };
24 +const roleColors: Record<string, string> = {
25 + admin: "bg-rose-500/10 text-rose-600 dark:text-rose-400 ring-rose-500/20",
26 + user: "bg-blue-500/10 text-blue-700 dark:text-blue-300 ring-blue-500/20",
27 + viewer: "bg-gray-100 text-gray-600 dark:bg-white/[0.06] dark:text-gray-300 ring-gray-500/15",
28 +};
29 +
30 +const emptyForm = { username: "", name: "", password: "", role: "user" };
31 +
32 +function UserForm({ create, form, setForm }: { create: boolean; form: typeof emptyForm; setForm: (f: typeof emptyForm) => void }) {
33 +return (
34 + <div className="space-y-3.5">
35 + <div>
36 + <label className="block text-[12px] font-medium text-gray-500 dark:text-gray-400 mb-1.5">Nom d&apos;utilisateur{create ? " *" : ""}</label>
37 + <input type="text" placeholder="ex. simon, marie42…" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase().replace(/\s/g, "") })} className="field h-11" autoFocus={create} autoComplete="off" />
38 + </div>
39 + <div>
40 + <label className="block text-[12px] font-medium text-gray-500 dark:text-gray-400 mb-1.5">Nom affiché</label>
41 + <input type="text" placeholder="ex. Simon Boucher" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="field h-11" />
42 + </div>
43 + <div>
44 + <label className="block text-[12px] font-medium text-gray-500 dark:text-gray-400 mb-1.5">{create ? "Mot de passe *" : "Nouveau mot de passe"}</label>
45 + <input type="password" placeholder={create ? "Mot de passe" : "Laisser vide pour ne pas changer"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className="field h-11" autoComplete="new-password" />
46 + </div>
47 + <div>
48 + <label className="block text-[12px] font-medium text-gray-500 dark:text-gray-400 mb-1.5">Rôle</label>
49 + <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className="field h-11">
50 + <option value="user">Utilisateur</option>
51 + <option value="viewer">Lecteur</option>
52 + <option value="admin">Admin</option>
53 + </select>
54 + </div>
55 + </div>
56 +);
57 +}
58 +
21 59 export default function UserManagement() {
22 60 const [users, setUsers] = useState<UserData[]>([]);
23 61 const [loading, setLoading] = useState(true);
24 62 const [showCreate, setShowCreate] = useState(false);
25 63 const [editUser, setEditUser] = useState<UserData | null>(null);
26 − const [form, setForm] = useState({ username: "", name: "", password: "", role: "user" });
64 + const [deleteUser, setDeleteUser] = useState<UserData | null>(null);
65 + const [saving, setSaving] = useState(false);
66 + const [form, setForm] = useState(emptyForm);
27 67
28 68 const fetchUsers = async () => {
29 69 const res = await fetch("/api/users");
@@ -34,15 +74,13 @@ export default function UserManagement() {
34 74 useEffect(() => { fetchUsers(); }, []);
35 75
36 76 const handleCreate = async () => {
37 − const res = await fetch("/api/users", {
38 − method: "POST",
39 − headers: { "Content-Type": "application/json" },
40 − body: JSON.stringify(form),
41 − });
77 + setSaving(true);
78 + const res = await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
79 + setSaving(false);
42 80 if (res.ok) {
43 81 toast.success("Utilisateur créé");
44 82 setShowCreate(false);
45 − setForm({ username: "", name: "", password: "", role: "user" });
83 + setForm(emptyForm);
46 84 fetchUsers();
47 85 } else {
48 86 const data = await res.json();
@@ -57,22 +95,22 @@ export default function UserManagement() {
57 95 if (form.username) data.username = form.username;
58 96 if (form.role) data.role = form.role;
59 97 if (form.password) data.password = form.password;
60 −
61 − const res = await fetch(`/api/users/${editUser.id}`, {
62 − method: "PATCH",
63 − headers: { "Content-Type": "application/json" },
64 − body: JSON.stringify(data),
65 − });
98 + setSaving(true);
99 + const res = await fetch(`/api/users/${editUser.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
100 + setSaving(false);
66 101 if (res.ok) {
67 102 toast.success("Utilisateur modifié");
68 103 setEditUser(null);
69 104 fetchUsers();
70 − }
105 + } else toast.error("Erreur lors de la modification");
71 106 };
72 107
73 − const handleDelete = async (id: string) => {
74 − if (!confirm("Supprimer cet utilisateur ?")) return;
75 − const res = await fetch(`/api/users/${id}`, { method: "DELETE" });
108 + const handleDelete = async () => {
109 + if (!deleteUser) return;
110 + setSaving(true);
111 + const res = await fetch(`/api/users/${deleteUser.id}`, { method: "DELETE" });
112 + setSaving(false);
113 + setDeleteUser(null);
76 114 if (res.ok) {
77 115 toast.success("Utilisateur supprimé");
78 116 fetchUsers();
@@ -82,139 +120,113 @@ export default function UserManagement() {
82 120 }
83 121 };
84 122
85 − const inputClasses = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none focus:border-gray-400 dark:focus:border-white/20";
86 −
87 − const roleColors: Record<string, string> = {
88 − admin: "bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400",
89 − user: "bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400",
90 − viewer: "bg-gray-100 text-gray-700 dark:bg-gray-500/20 dark:text-gray-400",
91 − };
92 −
93 123 return (
94 124 <div className="space-y-4">
95 − <div className="flex items-center justify-between">
96 − <h2 className="text-lg font-medium text-gray-900 dark:text-white flex items-center gap-2">
97 − <Users className="w-5 h-5" /> Utilisateurs
98 − </h2>
99 − <Button size="sm" onClick={() => setShowCreate(true)}>
125 + <div className="flex items-center justify-between gap-3">
126 + <p className="text-[13px] text-gray-500 dark:text-gray-400">{loading ? "Chargement…" : `${users.length} compte${users.length !== 1 ? "s" : ""}`}</p>
127 + <Button variant="accent" size="sm" onClick={() => { setForm(emptyForm); setShowCreate(true); }}>
100 128 <UserPlus className="w-4 h-4" /> Ajouter
101 129 </Button>
102 130 </div>
103 131
104 132 {loading ? (
105 − <div className="flex justify-center py-8">
106 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
133 + <div className="space-y-2">
134 + {[0, 1, 2].map((i) => (
135 + <div key={i} className="card p-4 flex items-center gap-3 animate-pulse">
136 + <div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-white/10" />
137 + <div className="flex-1 space-y-2"><div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-1/4 rounded bg-gray-200 dark:bg-white/10" /></div>
138 + </div>
139 + ))}
107 140 </div>
141 + ) : users.length === 0 ? (
142 + <EmptyState icon={Users} title="Aucun utilisateur" description="Créez un premier compte pour partager votre cloud." />
108 143 ) : (
109 − <div className="space-y-2">
110 − {users.map((user, i) => (
111 − <motion.div
112 − key={user.id}
113 − initial={{ opacity: 0, y: 5 }}
114 − animate={{ opacity: 1, y: 0 }}
115 − transition={{ delay: i * 0.05 }}
116 − className="flex flex-col sm:flex-row sm:items-center gap-3 p-4 bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl transition-colors"
117 − >
118 − {/* First line: avatar + name/email */}
119 − <div className="flex items-center gap-3 flex-1 min-w-0">
120 − <div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-white/10 flex items-center justify-center text-sm font-medium text-gray-600 dark:text-gray-300 flex-shrink-0">
121 − {(user.name || user.username || "U")[0].toUpperCase()}
144 + <div className="card p-0 overflow-hidden">
145 + {/* En-tête desktop */}
146 + <div className="hidden md:grid grid-cols-[1fr_120px_140px_110px_96px] items-center gap-3 px-4 h-10 border-b border-line dark:border-white/[0.07] text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-400 sticky top-0 bg-surface dark:bg-canvas z-10">
147 + <span>Utilisateur</span><span>Rôle</span><span>Contenu</span><span>2FA</span><span />
148 + </div>
149 + <div className="divide-y divide-line dark:divide-white/[0.06]">
150 + {users.map((user, i) => (
151 + <motion.div
152 + key={user.id}
153 + initial={{ opacity: 0, y: 5 }}
154 + animate={{ opacity: 1, y: 0 }}
155 + transition={{ delay: Math.min(i, 10) * 0.03 }}
156 + className="grid grid-cols-1 md:grid-cols-[1fr_120px_140px_110px_96px] items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/[0.03] transition-colors"
157 + >
158 + <div className="flex items-center gap-3 min-w-0">
159 + <UserAvatar userId={user.id} name={user.name || user.username} size="md" />
160 + <div className="min-w-0">
161 + <p className="text-[14px] font-medium text-gray-900 dark:text-white truncate">{user.name || user.username || user.id}</p>
162 + <p className="text-[12px] text-gray-500 truncate">@{user.username}{user.email ? ` · ${user.email}` : ""}</p>
163 + </div>
164 + </div>
165 + <div className="flex items-center gap-2 md:block">
166 + <span className={`inline-flex px-2 py-0.5 rounded-full text-[11px] font-medium ring-1 ring-inset ${roleColors[user.role] || roleColors.user}`}>{roleLabels[user.role] || user.role}</span>
167 + <span className="md:hidden text-[12px] text-gray-500">{user._count?.files || 0} fichiers · {user._count?.folders || 0} dossiers</span>
168 + {user.twoFactorEnabled && <ShieldCheck className="md:hidden w-4 h-4 text-emerald-500" aria-label="2FA activée" />}
122 169 </div>
123 − <div className="flex-1 min-w-0">
124 − <p className="text-sm font-medium text-gray-900 dark:text-white truncate">
125 − {user.name || user.username || user.id}
126 − </p>
127 − <p className="text-xs text-gray-500 truncate">@{user.username}</p>
170 + <div className="hidden md:flex items-center gap-3 text-[12.5px] text-gray-500 tabular-nums">
171 + <span className="inline-flex items-center gap-1"><FileText className="w-3.5 h-3.5" />{user._count?.files || 0}</span>
172 + <span className="inline-flex items-center gap-1"><Folder className="w-3.5 h-3.5" />{user._count?.folders || 0}</span>
128 173 </div>
129 − </div>
130 − {/* Second line on mobile: role badge + file count + actions */}
131 − <div className="flex items-center gap-2 sm:gap-3 pl-13 sm:pl-0">
132 − <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${roleColors[user.role] || roleColors.user}`}>
133 − {user.role}
134 − </span>
135 − {user.twoFactorEnabled && (
136 − <Shield className="w-4 h-4 text-green-500 flex-shrink-0" aria-label="2FA activé" />
137 − )}
138 − <span className="text-xs text-gray-500 hidden sm:block">
139 − {user._count?.files || 0} fichiers
140 − </span>
141 − <div className="flex gap-1 ml-auto">
174 + <div className="hidden md:block">
175 + {user.twoFactorEnabled ? (
176 + <span className="inline-flex items-center gap-1 text-[12px] text-emerald-600 dark:text-emerald-400"><ShieldCheck className="w-3.5 h-3.5" /> Activée</span>
177 + ) : (
178 + <span className="text-[12px] text-gray-400">—</span>
179 + )}
180 + </div>
181 + <div className="flex gap-1 md:justify-end -ml-2 md:ml-0">
142 182 <button
143 183 onClick={() => { setEditUser(user); setForm({ username: user.username || "", name: user.name || "", password: "", role: user.role }); }}
144 − className="min-w-[44px] min-h-[44px] flex items-center justify-center p-2.5 text-gray-400 hover:text-gray-700 dark:hover:text-white rounded-lg transition-colors"
184 + className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-xl hover:bg-gray-100 dark:hover:bg-white/[0.08] transition-colors"
185 + title="Modifier"
145 186 >
146 187 <Edit2 className="w-4 h-4" />
147 188 </button>
148 189 <button
149 − onClick={() => handleDelete(user.id)}
150 − className="min-w-[44px] min-h-[44px] flex items-center justify-center p-2.5 text-gray-400 hover:text-red-500 rounded-lg transition-colors"
190 + onClick={() => setDeleteUser(user)}
191 + className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-rose-500 rounded-xl hover:bg-rose-500/10 transition-colors"
192 + title="Supprimer"
151 193 >
152 194 <Trash2 className="w-4 h-4" />
153 195 </button>
154 196 </div>
155 − </div>
156 − </motion.div>
157 − ))}
197 + </motion.div>
198 + ))}
199 + </div>
158 200 </div>
159 201 )}
160 202
161 − {/* Create Modal */}
162 − <Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Nouvel utilisateur">
163 − <div className="space-y-3">
164 − <div>
165 − <label className="block text-xs text-gray-500 mb-1 ml-1">Nom d&apos;utilisateur *</label>
166 − <input type="text" placeholder="ex: simon, marie42..." value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase().replace(/\s/g, "") })} className={inputClasses} autoFocus autoComplete="off" />
167 − </div>
168 − <div>
169 − <label className="block text-xs text-gray-500 mb-1 ml-1">Nom affiché</label>
170 − <input type="text" placeholder="ex: Simon Boucher" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClasses} />
171 − </div>
172 − <div>
173 − <label className="block text-xs text-gray-500 mb-1 ml-1">Mot de passe *</label>
174 − <input type="password" placeholder="Mot de passe" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className={inputClasses} />
175 − </div>
176 − <div>
177 − <label className="block text-xs text-gray-500 mb-1 ml-1">Rôle</label>
178 − <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className={inputClasses}>
179 − <option value="user">Utilisateur</option>
180 − <option value="viewer">Lecteur</option>
181 − <option value="admin">Admin</option>
182 − </select>
183 − </div>
184 − <div className="flex gap-2 justify-end pt-2">
185 − <Button variant="ghost" onClick={() => setShowCreate(false)}>Annuler</Button>
186 − <Button onClick={handleCreate} disabled={!form.username || !form.password}>Créer</Button>
187 − </div>
188 − </div>
203 + <Modal
204 + isOpen={showCreate}
205 + onClose={() => setShowCreate(false)}
206 + title="Nouvel utilisateur"
207 + footer={<><Button variant="ghost" onClick={() => setShowCreate(false)}>Annuler</Button><Button variant="accent" onClick={handleCreate} loading={saving} disabled={!form.username || !form.password}>Créer</Button></>}
208 + >
209 + <UserForm create form={form} setForm={setForm} />
189 210 </Modal>
190 211
191 − <Modal isOpen={!!editUser} onClose={() => setEditUser(null)} title="Modifier utilisateur">
192 − <div className="space-y-3">
193 − <div>
194 − <label className="block text-xs text-gray-500 mb-1 ml-1">Nom d&apos;utilisateur</label>
195 − <input type="text" placeholder="Nom d'utilisateur" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase().replace(/\s/g, "") })} className={inputClasses} />
196 − </div>
197 − <div>
198 − <label className="block text-xs text-gray-500 mb-1 ml-1">Nom affiché</label>
199 − <input type="text" placeholder="ex: Simon Boucher" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClasses} />
200 − </div>
201 − <div>
202 − <label className="block text-xs text-gray-500 mb-1 ml-1">Nouveau mot de passe</label>
203 − <input type="password" placeholder="Laisser vide pour ne pas changer" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className={inputClasses} />
204 − </div>
205 − <div>
206 − <label className="block text-xs text-gray-500 mb-1 ml-1">Rôle</label>
207 − <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className={inputClasses}>
208 − <option value="user">Utilisateur</option>
209 − <option value="viewer">Lecteur</option>
210 − <option value="admin">Admin</option>
211 − </select>
212 − </div>
213 − <div className="flex gap-2 justify-end pt-2">
214 − <Button variant="ghost" onClick={() => setEditUser(null)}>Annuler</Button>
215 − <Button onClick={handleUpdate}>Sauvegarder</Button>
216 − </div>
217 − </div>
212 + <Modal
213 + isOpen={!!editUser}
214 + onClose={() => setEditUser(null)}
215 + title="Modifier l'utilisateur"
216 + description={editUser ? `@${editUser.username}` : undefined}
217 + footer={<><Button variant="ghost" onClick={() => setEditUser(null)}>Annuler</Button><Button variant="accent" onClick={handleUpdate} loading={saving}>Enregistrer</Button></>}
218 + >
219 + <UserForm create={false} form={form} setForm={setForm} />
220 + </Modal>
221 +
222 + <Modal
223 + isOpen={!!deleteUser}
224 + onClose={() => setDeleteUser(null)}
225 + title="Supprimer cet utilisateur ?"
226 + description={deleteUser ? `${deleteUser.name || deleteUser.username}` : undefined}
227 + footer={<><Button variant="ghost" onClick={() => setDeleteUser(null)}>Annuler</Button><Button variant="danger" onClick={handleDelete} loading={saving}><Trash2 className="w-4 h-4" /> Supprimer</Button></>}
228 + >
229 + <p className="text-sm text-gray-600 dark:text-gray-300">Le compte sera supprimé et ne pourra plus se connecter.</p>
218 230 </Modal>
219 231 </div>
220 232 );
modified components/ai/AiPanel.tsx +153 −115
@@ -5,7 +5,6 @@ import { motion, AnimatePresence } from "framer-motion";
5 5 import {
6 6 Brain,
7 7 Search,
8 − FileText,
9 8 MessageSquare,
10 9 Sparkles,
11 10 X,
@@ -66,9 +65,9 @@ interface AnalyserFile {
66 65 type Tab = "search" | "chat" | "analyse";
67 66
68 67 const relevanceColors: Record<string, string> = {
69 − high: "bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400",
70 − medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400",
71 − low: "bg-gray-100 text-gray-600 dark:bg-gray-500/20 dark:text-gray-400",
68 + high: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
69 + medium: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
70 + low: "bg-gray-100 text-gray-600 dark:bg-white/[0.08] dark:text-gray-400",
72 71 };
73 72
74 73 const mimeIcon = (mime: string) => {
@@ -83,8 +82,7 @@ const mimeIcon = (mime: string) => {
83 82 return "📁";
84 83 };
85 84
86 −const inputClasses =
87 − "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none focus:border-blue-600 dark:focus:border-blue-700 placeholder-gray-400 dark:placeholder-gray-500";
85 +const inputClasses = "field h-11";
88 86
89 87 // Markdown renderer with code syntax highlighting
90 88 function MarkdownContent({ content }: { content: string }) {
@@ -112,7 +110,7 @@ function MarkdownContent({ content }: { content: string }) {
112 110 }
113 111 return (
114 112 <code
115 − className="bg-gray-200 dark:bg-white/15 px-1.5 py-0.5 rounded text-[11px] font-mono text-blue-700 dark:text-blue-300"
113 + className="bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded-md text-[11.5px] font-mono text-blue-700 dark:text-blue-300"
116 114 {...props}
117 115 >
118 116 {children}
@@ -124,7 +122,7 @@ function MarkdownContent({ content }: { content: string }) {
124 122 },
125 123 a({ href, children }) {
126 124 return (
127 − <a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 underline underline-offset-2 hover:text-purple-400">
125 + <a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-300 underline underline-offset-2 hover:opacity-80">
128 126 {children}
129 127 </a>
130 128 );
@@ -162,29 +160,30 @@ function ToolCard({ event }: { event: ToolEvent }) {
162 160 const isError = resultObj && "error" in resultObj;
163 161
164 162 return (
165 − <div className="my-1 rounded-lg border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5 overflow-hidden text-[11px]">
166 − <div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-gray-100 dark:bg-white/10 border-b border-gray-200 dark:border-white/10">
167 − <Wrench className="w-3 h-3 text-blue-600 flex-shrink-0" />
168 − <span className="font-medium text-gray-700 dark:text-gray-300 flex-1">{label}</span>
163 + <details className="my-1 rounded-xl border border-line dark:border-white/10 bg-surface/60 dark:bg-white/[0.04] overflow-hidden text-[11px] group/tool" open={isError || !hasResult}>
164 + <summary className="flex items-center gap-1.5 px-2.5 h-8 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
165 + <Wrench className="w-3 h-3 text-blue-500 flex-shrink-0" />
166 + <span className="font-medium text-gray-700 dark:text-gray-200 flex-1 truncate">{label}</span>
169 167 {hasResult ? (
170 168 isError ? (
171 − <span className="text-red-500 text-[10px]">Erreur</span>
169 + <span className="text-rose-500 text-[10px] font-medium">Erreur</span>
172 170 ) : (
173 − <CheckCircle2 className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />
171 + <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500 flex-shrink-0" />
174 172 )
175 173 ) : (
176 − <Loader2 className="w-3 h-3 animate-spin text-purple-400 flex-shrink-0" />
174 + <Loader2 className="w-3 h-3 animate-spin text-blue-400 flex-shrink-0" />
177 175 )}
178 − </div>
176 + <ChevronDown className="w-3 h-3 text-gray-400 transition-transform group-open/tool:rotate-180" />
177 + </summary>
179 178 {Object.keys(event.input).length > 0 && (
180 − <div className="px-2.5 py-1 font-mono text-[10px] text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-white/5">
179 + <div className="px-2.5 py-1.5 font-mono text-[10px] text-gray-500 dark:text-gray-400 border-t border-line dark:border-white/[0.06] break-all">
181 180 {Object.entries(event.input)
182 181 .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
183 182 .join(" · ")}
184 183 </div>
185 184 )}
186 185 {hasResult && resultObj && (
187 − <div className={`px-2.5 py-1.5 text-[10px] font-mono ${isError ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
186 + <div className={`px-2.5 py-1.5 text-[10px] font-mono border-t border-line dark:border-white/[0.06] ${isError ? "text-rose-500" : "text-emerald-600 dark:text-emerald-400"}`}>
188 187 {isError
189 188 ? String((resultObj as { error: unknown }).error)
190 189 : resultObj.success
@@ -198,7 +197,7 @@ function ToolCard({ event }: { event: ToolEvent }) {
198 197 : JSON.stringify(resultObj).slice(0, 120)}
199 198 </div>
200 199 )}
201 − </div>
200 + </details>
202 201 );
203 202 }
204 203
@@ -237,6 +236,21 @@ export default function AiPanel() {
237 236 chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
238 237 }, [chatHistory, streamingContent]);
239 238
239 + // Ouverture/fermeture depuis la TopBar, la barre latérale et la palette ⌘K
240 + useEffect(() => {
241 + const onToggle = () => setOpen((v) => !v);
242 + window.addEventListener("spb:ai-toggle", onToggle);
243 + return () => window.removeEventListener("spb:ai-toggle", onToggle);
244 + }, []);
245 +
246 + // Échap ferme le panneau
247 + useEffect(() => {
248 + if (!open) return;
249 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
250 + window.addEventListener("keydown", onKey);
251 + return () => window.removeEventListener("keydown", onKey);
252 + }, [open]);
253 +
240 254 // Fetch files for analyser
241 255 const fetchAnalyserFiles = useCallback(async (q = "") => {
242 256 setAnalyserLoading(true);
@@ -479,13 +493,24 @@ export default function AiPanel() {
479 493 return (
480 494 <>
481 495 {/* Toggle Button */}
482 − <button
483 − onClick={() => setOpen(true)}
484 − className="fixed bottom-20 right-4 sm:bottom-6 sm:right-6 z-40 w-14 h-14 rounded-full bg-blue-800 hover:bg-blue-900 text-white shadow-lg flex items-center justify-center transition-colors"
485 − title="Assistant IA"
486 − >
487 − <Brain className="w-6 h-6" />
488 − </button>
496 + <AnimatePresence>
497 + {!open && (
498 + <motion.button
499 + initial={{ opacity: 0, scale: 0.8 }}
500 + animate={{ opacity: 1, scale: 1 }}
501 + exit={{ opacity: 0, scale: 0.8 }}
502 + whileHover={{ scale: 1.06 }}
503 + whileTap={{ scale: 0.94 }}
504 + onClick={() => setOpen(true)}
505 + className="fixed right-4 bottom-[calc(4.5rem+env(safe-area-inset-bottom))] lg:right-6 lg:bottom-6 z-40 w-14 h-14 rounded-full bg-accent-gradient text-white shadow-glow flex items-center justify-center"
506 + title="Assistant IA"
507 + aria-label="Ouvrir l'assistant IA"
508 + >
509 + <span className="absolute inset-0 rounded-full bg-blue-500/40 animate-pulse-ring pointer-events-none" />
510 + <Sparkles className="relative w-6 h-6" />
511 + </motion.button>
512 + )}
513 + </AnimatePresence>
489 514
490 515 {/* Panel */}
491 516 <AnimatePresence>
@@ -495,31 +520,38 @@ export default function AiPanel() {
495 520 initial={{ opacity: 0 }}
496 521 animate={{ opacity: 1 }}
497 522 exit={{ opacity: 0 }}
498 − className="fixed inset-0 bg-black/30 z-40 lg:hidden"
523 + className="fixed inset-0 bg-gray-950/40 dark:bg-black/60 backdrop-blur-sm z-[55] lg:hidden"
499 524 onClick={() => setOpen(false)}
500 525 />
501 526 <motion.div
502 − initial={{ x: 420, opacity: 0 }}
527 + initial={{ x: 40, opacity: 0 }}
503 528 animate={{ x: 0, opacity: 1 }}
504 − exit={{ x: 420, opacity: 0 }}
505 − transition={{ type: "spring", damping: 25, stiffness: 300 }}
506 − className="fixed right-0 top-0 bottom-0 w-full sm:w-[420px] z-50 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-white/10 flex flex-col shadow-2xl pb-[env(safe-area-inset-bottom)]"
529 + exit={{ x: 40, opacity: 0 }}
530 + transition={{ type: "spring", damping: 30, stiffness: 340 }}
531 + className="fixed inset-x-0 bottom-0 top-[max(2.5rem,env(safe-area-inset-top))] lg:inset-auto lg:top-3 lg:right-3 lg:bottom-3 w-full lg:w-[420px] z-[56] glass rounded-t-3xl lg:rounded-3xl shadow-pop flex flex-col overflow-hidden"
532 + role="dialog"
533 + aria-label="Assistant IA"
507 534 >
535 + <div className="lg:hidden flex justify-center pt-2.5"><div className="w-10 h-1 rounded-full bg-gray-300 dark:bg-white/20" /></div>
508 536 {/* Header */}
509 − <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-white/10 pt-[max(0.75rem,env(safe-area-inset-top))]">
510 − <h2 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
511 − <Sparkles className="w-4 h-4 text-purple-500" /> Assistant IA
537 + <div className="flex items-center justify-between pl-4 pr-2 h-14">
538 + <h2 className="text-[15px] font-semibold text-gray-900 dark:text-white flex items-center gap-2.5">
539 + <span className="w-8 h-8 rounded-xl bg-accent-gradient text-white flex items-center justify-center shadow-[inset_0_1px_0_rgb(255_255_255/.25)]">
540 + <Sparkles className="w-4 h-4" />
541 + </span>
542 + Assistant IA
512 543 </h2>
513 544 <button
514 545 onClick={() => setOpen(false)}
515 − className="min-w-[44px] min-h-[44px] flex items-center justify-center p-2 text-gray-400 hover:text-gray-700 dark:hover:text-white"
546 + className="w-10 h-10 flex items-center justify-center rounded-xl text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
547 + aria-label="Fermer"
516 548 >
517 549 <X className="w-5 h-5" />
518 550 </button>
519 551 </div>
520 552
521 553 {/* Tabs */}
522 − <div className="flex border-b border-gray-200 dark:border-white/10">
554 + <div className="mx-4 mb-2 flex gap-0.5 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06]">
523 555 {(["chat", "search", "analyse"] as Tab[]).map((t) => {
524 556 const icons = { chat: MessageSquare, search: Search, analyse: ScanText };
525 557 const labels = { chat: "Chat", search: "Recherche", analyse: "Analyser" };
@@ -528,11 +560,12 @@ export default function AiPanel() {
528 560 <button
529 561 key={t}
530 562 onClick={() => setTab(t)}
531 − className={`flex-1 px-2 py-3 text-xs font-medium transition-colors flex items-center justify-center gap-1.5 ${
563 + className={`flex-1 h-9 text-[12.5px] font-medium transition-colors flex items-center justify-center gap-1.5 rounded-lg ${
532 564 tab === t
533 − ? "text-blue-700 dark:text-blue-400 border-b-2 border-blue-700"
534 − : "text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
565 + ? "bg-surface dark:bg-white/10 text-gray-900 dark:text-white shadow-card dark:shadow-none"
566 + : "text-gray-500 hover:text-gray-800 dark:hover:text-gray-200"
535 567 }`}
568 + aria-pressed={tab === t}
536 569 >
537 570 <Icon className="w-3.5 h-3.5" />
538 571 {labels[t]}
@@ -544,15 +577,13 @@ export default function AiPanel() {
544 577 {/* ── CHAT TAB ── */}
545 578 {tab === "chat" && (
546 579 <div className="flex flex-col flex-1 min-h-0">
547 − <div className="flex-1 overflow-auto p-4 space-y-3">
580 + <div className="flex-1 overflow-auto px-4 pb-4 pt-2 space-y-3 scrollbar-thin">
548 581 {chatHistory.length === 0 && !isStreaming && (
549 582 <div className="text-center py-6">
550 − {agentMode ? (
551 − <Bot className="w-8 h-8 mx-auto mb-3 text-violet-400" />
552 − ) : (
553 − <Brain className="w-8 h-8 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
554 − )}
555 − <p className="text-sm text-gray-500 mb-4">
583 + <div className={`w-14 h-14 mx-auto mb-3 rounded-3xl flex items-center justify-center ${agentMode ? "bg-violet-500/10 text-violet-500" : "bg-blue-500/10 text-blue-600 dark:text-blue-300"}`}>
584 + {agentMode ? <Bot className="w-7 h-7" /> : <Brain className="w-7 h-7" />}
585 + </div>
586 + <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
556 587 {agentMode
557 588 ? "Mode Agent — donnez un ordre, l'IA agit sur vos fichiers"
558 589 : "Posez une question sur vos fichiers"}
@@ -573,16 +604,16 @@ export default function AiPanel() {
573 604 <button
574 605 key={prompt}
575 606 onClick={() => setChatInput(prompt)}
576 − className={`w-full text-left px-3 py-2 rounded-lg border text-xs transition-colors ${
607 + className={`w-full text-left px-3 h-10 rounded-xl border text-[12.5px] transition-colors ${
577 608 agentMode
578 − ? "bg-violet-50 dark:bg-violet-500/5 border-violet-200 dark:border-violet-500/20 text-violet-700 dark:text-violet-300 hover:bg-violet-100 dark:hover:bg-violet-500/15"
579 − : "bg-gray-50 dark:bg-white/5 border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-400 hover:bg-blue-50 dark:hover:bg-blue-500/10 hover:border-blue-200 dark:hover:border-blue-500/30"
609 + ? "bg-violet-500/[0.06] border-violet-500/20 text-violet-700 dark:text-violet-300 hover:bg-violet-500/10"
610 + : "bg-surface/60 dark:bg-white/[0.04] border-line dark:border-white/10 text-gray-600 dark:text-gray-300 hover:bg-blue-500/[0.07] hover:border-blue-500/30"
580 611 }`}
581 612 >
582 613 {agentMode ? (
583 614 <Wrench className="w-3 h-3 inline mr-1.5 text-violet-400" />
584 615 ) : (
585 − <Zap className="w-3 h-3 inline mr-1.5 text-purple-400" />
616 + <Zap className="w-3 h-3 inline mr-1.5 text-blue-400" />
586 617 )}
587 618 {prompt}
588 619 </button>
@@ -594,15 +625,15 @@ export default function AiPanel() {
594 625 {chatHistory.map((msg, i) => (
595 626 <div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
596 627 {msg.role === "assistant" && (
597 − <div className="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-800/30 flex items-center justify-center flex-shrink-0 mr-2 mt-0.5">
598 − <Brain className="w-3 h-3 text-blue-700 dark:text-blue-400" />
628 + <div className="w-7 h-7 rounded-xl bg-accent-gradient flex items-center justify-center flex-shrink-0 mr-2 mt-0.5 text-white">
629 + <Sparkles className="w-3.5 h-3.5" />
599 630 </div>
600 631 )}
601 632 <div
602 − className={`max-w-[88%] px-3 py-2.5 rounded-2xl text-sm leading-relaxed ${
633 + className={`max-w-[88%] px-3.5 py-2.5 rounded-2xl text-[13.5px] leading-relaxed ${
603 634 msg.role === "user"
604 − ? "bg-blue-800 text-white rounded-tr-sm"
605 − : "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-gray-100 rounded-tl-sm"
635 + ? "bg-accent-gradient text-white rounded-tr-md shadow-[inset_0_1px_0_rgb(255_255_255/.18)]"
636 + : "bg-surface dark:bg-white/[0.06] border border-line dark:border-white/[0.07] text-gray-900 dark:text-gray-100 rounded-tl-md"
606 637 }`}
607 638 >
608 639 {msg.role === "assistant" ? (
@@ -625,10 +656,10 @@ export default function AiPanel() {
625 656
626 657 {isStreaming && (
627 658 <div className="flex justify-start">
628 − <div className="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-800/30 flex items-center justify-center flex-shrink-0 mr-2 mt-0.5">
629 − <Brain className="w-3 h-3 text-blue-700 dark:text-blue-400" />
659 + <div className="w-7 h-7 rounded-xl bg-accent-gradient flex items-center justify-center flex-shrink-0 mr-2 mt-0.5 text-white">
660 + <Sparkles className="w-3.5 h-3.5" />
630 661 </div>
631 − <div className="max-w-[88%] px-3 py-2.5 rounded-2xl rounded-tl-sm bg-gray-100 dark:bg-white/10 text-sm text-gray-900 dark:text-gray-100 leading-relaxed">
662 + <div className="max-w-[88%] px-3.5 py-2.5 rounded-2xl rounded-tl-md bg-surface dark:bg-white/[0.06] border border-line dark:border-white/[0.07] text-[13.5px] text-gray-900 dark:text-gray-100 leading-relaxed">
632 663 {streamingEvents.length > 0 && (
633 664 <div className="mb-2 space-y-1">
634 665 {streamingEvents.map((e) => (
@@ -641,7 +672,7 @@ export default function AiPanel() {
641 672 ) : (
642 673 <span className="flex items-center gap-1">
643 674 {[0, 150, 300].map((d) => (
644 − <span key={d} className="w-1.5 h-1.5 bg-blue-700 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
675 + <span key={d} className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
645 676 ))}
646 677 </span>
647 678 )}
@@ -651,12 +682,12 @@ export default function AiPanel() {
651 682 <div ref={chatEndRef} />
652 683 </div>
653 684
654 − <div className="p-4 border-t border-gray-200 dark:border-white/10">
685 + <div className="p-3 sm:p-4 border-t border-line dark:border-white/10 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
655 686 <div className="flex items-center justify-between mb-2">
656 687 {chatHistory.length > 0 ? (
657 688 <button
658 689 onClick={() => setChatHistory([])}
659 − className="flex items-center gap-1 text-xs text-gray-400 hover:text-red-500 transition-colors"
690 + className="flex items-center gap-1 text-xs text-gray-400 hover:text-rose-500 transition-colors h-7 px-1"
660 691 >
661 692 <Trash2 className="w-3 h-3" /> Effacer
662 693 </button>
@@ -665,11 +696,13 @@ export default function AiPanel() {
665 696 )}
666 697 <button
667 698 onClick={() => setAgentMode((v) => !v)}
668 − className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
699 + className={`flex items-center gap-1.5 px-2.5 h-7 rounded-full text-[11px] font-medium transition-colors ring-1 ring-inset ${
669 700 agentMode
670 − ? "bg-violet-600 text-white"
671 − : "bg-gray-100 dark:bg-white/10 text-gray-500 dark:text-gray-400 hover:bg-violet-50 dark:hover:bg-violet-500/10 hover:text-violet-600 dark:hover:text-violet-400"
701 + ? "bg-violet-600 text-white ring-violet-600"
702 + : "bg-gray-100 dark:bg-white/[0.06] text-gray-500 dark:text-gray-400 ring-transparent hover:bg-violet-500/10 hover:text-violet-600 dark:hover:text-violet-300"
672 703 }`}
704 + role="switch"
705 + aria-checked={agentMode}
673 706 title={agentMode ? "Mode Agent actif — l'IA peut agir sur vos fichiers" : "Activer le Mode Agent"}
674 707 >
675 708 <Bot className="w-3 h-3" />
@@ -682,20 +715,24 @@ export default function AiPanel() {
682 715 L&apos;agent peut lister, créer, renommer, déplacer et supprimer des fichiers.
683 716 </p>
684 717 )}
685 − <div className="flex gap-2">
686 − <input
687 − type="text"
718 + <div className="flex items-end gap-2 rounded-2xl border border-line dark:border-white/10 bg-surface dark:bg-white/[0.04] p-1.5 focus-within:border-blue-500/60 focus-within:ring-4 focus-within:ring-blue-500/10 transition-all">
719 + <textarea
688 720 value={chatInput}
689 721 onChange={(e) => setChatInput(e.target.value)}
690 − onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleChat()}
691 − placeholder={agentMode ? "Ex: crée un dossier Images et mets-y tous les .jpg..." : "Votre message..."}
692 − className={`${inputClasses} flex-1`}
722 + onKeyDown={(e) => {
723 + if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) { e.preventDefault(); handleChat(); }
724 + }}
725 + placeholder={agentMode ? "Ex. crée un dossier Images et mets-y tous les .jpg…" : "Posez une question sur vos fichiers…"}
726 + className="flex-1 bg-transparent outline-none resize-none text-[13.5px] text-gray-900 dark:text-white placeholder-gray-400 px-2.5 py-2 max-h-32 min-h-[40px]"
727 + rows={1}
693 728 disabled={isStreaming}
694 729 />
695 730 <button
696 731 onClick={handleChat}
697 732 disabled={isStreaming || !chatInput.trim()}
698 − className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg bg-blue-800 hover:bg-blue-900 disabled:opacity-50 disabled:cursor-not-allowed text-white transition-colors"
733 + className="w-10 h-10 shrink-0 flex items-center justify-center rounded-xl bg-accent-gradient text-white disabled:opacity-40 disabled:cursor-not-allowed hover:brightness-110 transition-all"
734 + title="Envoyer (↵)"
735 + aria-label="Envoyer"
699 736 >
700 737 {isStreaming ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
701 738 </button>
@@ -706,20 +743,21 @@ export default function AiPanel() {
706 743
707 744 {/* ── SEARCH TAB ── */}
708 745 {tab === "search" && (
709 − <div className="flex-1 overflow-auto p-4 space-y-4">
746 + <div className="flex-1 overflow-auto px-4 pb-4 pt-2 space-y-4 scrollbar-thin">
710 747 <div className="flex gap-2">
711 748 <input
712 749 type="text"
713 750 value={query}
714 751 onChange={(e) => setQuery(e.target.value)}
715 752 onKeyDown={(e) => e.key === "Enter" && handleSearch()}
716 − placeholder="Ex: rapports financiers 2024..."
753 + placeholder="Ex. rapports financiers 2024…"
717 754 className={`${inputClasses} flex-1`}
718 755 />
719 756 <button
720 757 onClick={() => handleSearch()}
721 758 disabled={searching || !query.trim()}
722 − className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg bg-blue-800 hover:bg-blue-900 disabled:opacity-50 text-white transition-colors"
759 + className="w-11 h-11 shrink-0 flex items-center justify-center rounded-xl bg-accent-gradient text-white disabled:opacity-40 hover:brightness-110 transition-all"
760 + aria-label="Rechercher"
723 761 >
724 762 {searching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
725 763 </button>
@@ -728,7 +766,7 @@ export default function AiPanel() {
728 766 {/* Quick suggestions */}
729 767 {!answer && results.length === 0 && !searching && (
730 768 <div className="space-y-2">
731 − <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Suggestions</p>
769 + <p className="text-[11px] font-semibold text-gray-400 uppercase tracking-[0.12em]">Suggestions</p>
732 770 <div className="grid grid-cols-2 gap-2">
733 771 {[
734 772 { label: "Documents récents", query: "documents récents" },
@@ -739,9 +777,9 @@ export default function AiPanel() {
739 777 <button
740 778 key={s.query}
741 779 onClick={() => quickSearch(s.query)}
742 − className="text-left p-3 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-white/5 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-500/10 border border-transparent hover:border-purple-200 dark:hover:border-purple-500/30 transition-colors"
780 + className="text-left p-3 text-[12.5px] text-gray-600 dark:text-gray-300 card card-hover hover:!border-blue-500/30"
743 781 >
744 − <Search className="w-3 h-3 mb-1 text-purple-400" />
782 + <Search className="w-3.5 h-3.5 mb-1.5 text-blue-500" />
745 783 {s.label}
746 784 </button>
747 785 ))}
@@ -750,11 +788,11 @@ export default function AiPanel() {
750 788 )}
751 789
752 790 {answer && (
753 − <div className="bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20 rounded-lg p-3.5">
754 − <p className="text-xs font-medium text-blue-700 dark:text-blue-400 mb-1 flex items-center gap-1">
755 − <Brain className="w-3 h-3" /> Synthèse IA
791 + <div className="rounded-2xl p-3.5 bg-blue-500/[0.07] border border-blue-500/15">
792 + <p className="text-[11px] font-semibold text-blue-700 dark:text-blue-300 mb-1 flex items-center gap-1 uppercase tracking-[0.1em]">
793 + <Sparkles className="w-3 h-3" /> Synthèse IA
756 794 </p>
757 − <p className="text-sm text-purple-800 dark:text-purple-300">{answer}</p>
795 + <p className="text-[13.5px] text-gray-800 dark:text-gray-100 leading-relaxed">{answer}</p>
758 796 </div>
759 797 )}
760 798
@@ -764,7 +802,7 @@ export default function AiPanel() {
764 802 {results.map((r) => (
765 803 <div
766 804 key={r.fileId}
767 − className="p-3.5 bg-gray-50 dark:bg-white/5 rounded-lg border border-gray-200 dark:border-white/10"
805 + className="card p-3.5"
768 806 >
769 807 <div className="flex items-start gap-3">
770 808 <span className="text-base flex-shrink-0 mt-0.5">{mimeIcon(r.mimeType || "")}</span>
@@ -780,7 +818,7 @@ export default function AiPanel() {
780 818 </div>
781 819 </div>
782 820 {/* Actions on search results */}
783 − <div className="flex gap-2 mt-2.5 pt-2.5 border-t border-gray-100 dark:border-white/10">
821 + <div className="flex gap-1.5 mt-2.5 pt-2.5 border-t border-line dark:border-white/[0.07] flex-wrap">
784 822 <button
785 823 onClick={() => openInAnalyser({
786 824 id: r.fileId,
@@ -788,13 +826,13 @@ export default function AiPanel() {
788 826 mimeType: r.mimeType || "",
789 827 size: 0,
790 828 })}
791 − className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-blue-700 dark:text-blue-400 bg-purple-50 dark:bg-purple-500/10 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-500/20 transition-colors"
829 + className="flex items-center gap-1.5 px-2.5 h-8 text-[11.5px] font-medium text-blue-700 dark:text-blue-300 bg-blue-500/10 rounded-lg hover:bg-blue-500/15 transition-colors"
792 830 >
793 831 <FlaskConical className="w-3 h-3" /> Analyser
794 832 </button>
795 833 <button
796 834 onClick={() => window.open(`/api/files/${r.fileId}/download`, "_blank")}
797 − className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-white/10 rounded-lg hover:bg-gray-200 dark:hover:bg-white/20 transition-colors"
835 + className="flex items-center gap-1.5 px-2.5 h-8 text-[11.5px] font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-white/[0.08] rounded-lg hover:bg-gray-200 dark:hover:bg-white/[0.14] transition-colors"
798 836 >
799 837 <Download className="w-3 h-3" /> Télécharger
800 838 </button>
@@ -803,7 +841,7 @@ export default function AiPanel() {
803 841 setChatInput(`Résume le fichier ${r.name}`);
804 842 setTab("chat");
805 843 }}
806 − className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-white/10 rounded-lg hover:bg-gray-200 dark:hover:bg-white/20 transition-colors"
844 + className="flex items-center gap-1.5 px-2.5 h-8 text-[11.5px] font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-white/[0.08] rounded-lg hover:bg-gray-200 dark:hover:bg-white/[0.14] transition-colors"
807 845 >
808 846 <MessageSquare className="w-3 h-3" /> Résumer
809 847 </button>
@@ -817,13 +855,13 @@ export default function AiPanel() {
817 855
818 856 {/* ── ANALYSE TAB ── */}
819 857 {tab === "analyse" && (
820 − <div className="flex-1 overflow-auto p-4 space-y-4">
858 + <div className="flex-1 overflow-auto px-4 pb-4 pt-2 space-y-4 scrollbar-thin">
821 859 {/* File picker */}
822 860 <div>
823 − <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Fichier à analyser</p>
861 + <p className="text-[11px] font-semibold text-gray-400 uppercase tracking-[0.12em] mb-2">Fichier à analyser</p>
824 862 <button
825 863 onClick={() => setShowFilePicker(!showFilePicker)}
826 − className="w-full flex items-center justify-between px-3 py-2.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm text-left hover:border-purple-300 dark:hover:border-purple-500/40 transition-colors"
864 + className="field h-11 flex items-center justify-between text-left hover:border-blue-500/40"
827 865 >
828 866 <span className={selectedFile ? "text-gray-900 dark:text-white" : "text-gray-400"}>
829 867 {selectedFile ? (
@@ -837,7 +875,7 @@ export default function AiPanel() {
837 875 </button>
838 876
839 877 {showFilePicker && (
840 − <div className="mt-1 border border-gray-200 dark:border-white/10 rounded-lg overflow-hidden bg-white dark:bg-gray-800">
878 + <div className="mt-1.5 card p-0 overflow-hidden">
841 879 <div className="p-2 border-b border-gray-100 dark:border-white/10">
842 880 <input
843 881 type="text"
@@ -847,7 +885,7 @@ export default function AiPanel() {
847 885 fetchAnalyserFiles(e.target.value);
848 886 }}
849 887 placeholder="Filtrer par nom..."
850 − className="w-full bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded px-2 py-1.5 text-xs text-gray-900 dark:text-white focus:outline-none"
888 + className="field h-9 !py-1.5 text-xs"
851 889 autoFocus
852 890 />
853 891 </div>
@@ -876,7 +914,7 @@ export default function AiPanel() {
876 914 }}
877 915 className={`w-full flex items-center gap-2 px-3 py-2.5 text-sm text-left transition-colors border-b border-gray-50 dark:border-white/5 last:border-0 ${
878 916 selectedFile?.id === f.id
879 − ? "bg-purple-50 dark:bg-purple-500/10"
917 + ? "bg-blue-500/10"
880 918 : "hover:bg-gray-50 dark:hover:bg-white/5"
881 919 }`}
882 920 >
@@ -886,7 +924,7 @@ export default function AiPanel() {
886 924 {f.folder && <p className="text-[10px] text-gray-400">📁 {f.folder.name}</p>}
887 925 </div>
888 926 {f.aiDescription && (
889 − <span className="w-2 h-2 rounded-full bg-purple-400 flex-shrink-0" title="Déjà analysé" />
927 + <span className="w-2 h-2 rounded-full bg-blue-400 flex-shrink-0" title="Déjà analysé" />
890 928 )}
891 929 </button>
892 930 ))}
@@ -898,23 +936,23 @@ export default function AiPanel() {
898 936 {/* Analysis actions */}
899 937 {selectedFile && (
900 938 <div className="space-y-3">
901 − <div className="p-3 bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20 rounded-lg">
939 + <div className="p-3.5 rounded-2xl bg-blue-500/[0.07] border border-blue-500/15">
902 940 <div className="flex items-start justify-between gap-2">
903 941 <div className="min-w-0">
904 − <p className="text-xs text-blue-700 dark:text-blue-400 font-medium mb-0.5">Fichier sélectionné</p>
905 − <p className="text-sm font-medium text-purple-900 dark:text-purple-200 truncate">{selectedFile.name}</p>
906 − {selectedFile.folder && <p className="text-xs text-purple-400 mt-0.5">📁 {selectedFile.folder.name}</p>}
942 + <p className="text-[11px] font-semibold text-blue-700 dark:text-blue-300 uppercase tracking-[0.1em] mb-0.5">Fichier sélectionné</p>
943 + <p className="text-sm font-medium text-gray-900 dark:text-white truncate">{selectedFile.name}</p>
944 + {selectedFile.folder && <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">📁 {selectedFile.folder.name}</p>}
907 945 </div>
908 946 <button
909 947 onClick={() => window.open(`/api/files/${selectedFile.id}/download`, "_blank")}
910 − className="flex-shrink-0 p-1.5 rounded-lg text-purple-400 hover:text-purple-600 hover:bg-purple-100 dark:hover:bg-purple-500/20 transition-colors"
948 + className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-blue-500 hover:bg-blue-500/15 transition-colors"
911 949 title="Télécharger"
912 950 >
913 951 <Download className="w-3.5 h-3.5" />
914 952 </button>
915 953 </div>
916 954 {selectedFile.aiDescription && (
917 − <p className="text-xs text-blue-700 dark:text-blue-400 mt-2 border-t border-purple-200 dark:border-purple-500/30 pt-2">{selectedFile.aiDescription}</p>
955 + <p className="text-xs text-gray-700 dark:text-gray-300 mt-2 border-t border-blue-500/15 pt-2">{selectedFile.aiDescription}</p>
918 956 )}
919 957 {selectedFile.aiTags && (() => {
920 958 try {
@@ -922,7 +960,7 @@ export default function AiPanel() {
922 960 return (
923 961 <div className="flex flex-wrap gap-1 mt-1.5">
924 962 {tags.map((t) => (
925 − <span key={t} className="px-1.5 py-0.5 bg-purple-200 dark:bg-purple-500/30 text-blue-700 dark:text-blue-300 rounded text-[10px]">{t}</span>
963 + <span key={t} className="px-2 py-0.5 bg-blue-500/10 text-blue-700 dark:text-blue-300 rounded-full text-[10.5px] font-medium">{t}</span>
926 964 ))}
927 965 </div>
928 966 );
@@ -935,29 +973,29 @@ export default function AiPanel() {
935 973 <button
936 974 onClick={() => handleAnalyse("ocr")}
937 975 disabled={analysing}
938 − className="flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-xs font-medium text-gray-700 dark:text-gray-300 hover:border-purple-300 dark:hover:border-purple-500/40 disabled:opacity-50 transition-colors"
976 + className="flex items-center justify-center gap-2 h-11 card card-hover text-xs font-medium text-gray-700 dark:text-gray-200 hover:!border-blue-500/30 disabled:opacity-50"
939 977 >
940 − <ScanText className="w-3.5 h-3.5 text-purple-500" /> OCR
978 + <ScanText className="w-3.5 h-3.5 text-blue-500" /> OCR
941 979 </button>
942 980 )}
943 981 <button
944 982 onClick={() => handleAnalyse("describe")}
945 983 disabled={analysing}
946 − className="flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-xs font-medium text-gray-700 dark:text-gray-300 hover:border-purple-300 dark:hover:border-purple-500/40 disabled:opacity-50 transition-colors"
984 + className="flex items-center justify-center gap-2 h-11 card card-hover text-xs font-medium text-gray-700 dark:text-gray-200 hover:!border-blue-500/30 disabled:opacity-50"
947 985 >
948 − <Info className="w-3.5 h-3.5 text-purple-500" /> Décrire
986 + <Info className="w-3.5 h-3.5 text-blue-500" /> Décrire
949 987 </button>
950 988 <button
951 989 onClick={() => handleAnalyse("tag")}
952 990 disabled={analysing}
953 − className="flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-xs font-medium text-gray-700 dark:text-gray-300 hover:border-purple-300 dark:hover:border-purple-500/40 disabled:opacity-50 transition-colors"
991 + className="flex items-center justify-center gap-2 h-11 card card-hover text-xs font-medium text-gray-700 dark:text-gray-200 hover:!border-blue-500/30 disabled:opacity-50"
954 992 >
955 − <Tag className="w-3.5 h-3.5 text-purple-500" /> Auto-tagger
993 + <Tag className="w-3.5 h-3.5 text-blue-500" /> Auto-tagger
956 994 </button>
957 995 <button
958 996 onClick={handleAnalyseAll}
959 997 disabled={analysing}
960 − className="flex items-center justify-center gap-2 px-3 py-2.5 bg-blue-800 hover:bg-blue-900 border border-transparent rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors col-span-full"
998 + className="flex items-center justify-center gap-2 h-11 rounded-xl bg-accent-gradient text-white text-xs font-medium disabled:opacity-50 hover:brightness-110 transition-all col-span-full"
961 999 >
962 1000 {analysing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5" />}
963 1001 Analyse complète
@@ -970,7 +1008,7 @@ export default function AiPanel() {
970 1008 setChatInput(`Résume le fichier ${selectedFile.name}`);
971 1009 setTab("chat");
972 1010 }}
973 − className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs text-blue-700 dark:text-blue-400 border border-purple-200 dark:border-purple-500/30 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-500/10 transition-colors"
1011 + className="w-full flex items-center justify-center gap-2 h-10 text-xs font-medium text-blue-700 dark:text-blue-300 border border-dashed border-blue-500/30 rounded-xl hover:bg-blue-500/[0.07] transition-colors"
974 1012 >
975 1013 <MessageSquare className="w-3.5 h-3.5" />
976 1014 Demander à Claude de résumer ce fichier
@@ -978,13 +1016,13 @@ export default function AiPanel() {
978 1016
979 1017 {analysing && !analyserResult && (
980 1018 <div className="flex items-center justify-center py-6 gap-2 text-sm text-gray-500">
981 − <Loader2 className="w-4 h-4 animate-spin text-purple-500" /> Analyse en cours...
1019 + <Loader2 className="w-4 h-4 animate-spin text-blue-500" /> Analyse en cours…
982 1020 </div>
983 1021 )}
984 1022
985 1023 {analyserResult && (
986 − <div className="bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg p-3.5">
987 − <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">{analyserResult.type}</p>
1024 + <div className="card p-3.5">
1025 + <p className="text-[11px] font-semibold text-gray-400 uppercase tracking-[0.12em] mb-2">{analyserResult.type}</p>
988 1026 <MarkdownContent content={analyserResult.content} />
989 1027 </div>
990 1028 )}
@@ -993,10 +1031,10 @@ export default function AiPanel() {
993 1031
994 1032 {!selectedFile && !showFilePicker && (
995 1033 <div className="text-center py-8">
996 − <File className="w-10 h-10 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
997 − <p className="text-sm text-gray-500 dark:text-gray-400">
998 − Sélectionnez un fichier ci-dessus pour l&apos;analyser
999 − </p>
1034 + <div className="w-14 h-14 mx-auto mb-3 rounded-3xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center">
1035 + <File className="w-7 h-7 text-gray-400" />
1036 + </div>
1037 + <p className="text-sm text-gray-600 dark:text-gray-300">Sélectionnez un fichier ci-dessus pour l&apos;analyser</p>
1000 1038 <p className="text-xs text-gray-400 mt-1">OCR, description, tags automatiques</p>
1001 1039 </div>
1002 1040 )}
modified components/auth/ChangePasswordModal.tsx +95 −78
@@ -1,7 +1,7 @@
1 1 "use client";
2 2
3 3 import { useState } from "react";
4 −import { Eye, EyeOff, Lock, Check } from "lucide-react";
4 +import { Eye, EyeOff, Lock, Check, KeyRound } from "lucide-react";
5 5 import Modal from "../ui/Modal";
6 6 import Button from "../ui/Button";
7 7 import toast from "react-hot-toast";
@@ -11,6 +11,56 @@ interface ChangePasswordModalProps {
11 11 onClose: () => void;
12 12 }
13 13
14 +function strength(pwd: string): { score: number; label: string; bar: string } {
15 + if (!pwd) return { score: 0, label: "", bar: "bg-gray-300" };
16 + let s = 0;
17 + if (pwd.length >= 8) s++;
18 + if (pwd.length >= 12) s++;
19 + if (/[A-Z]/.test(pwd) && /[a-z]/.test(pwd)) s++;
20 + if (/\d/.test(pwd)) s++;
21 + if (/[^A-Za-z0-9]/.test(pwd)) s++;
22 + const labels = ["", "Très faible", "Faible", "Moyen", "Bon", "Excellent"];
23 + const bars = ["bg-gray-300", "bg-rose-500", "bg-orange-500", "bg-amber-500", "bg-emerald-500", "bg-emerald-500"];
24 + return { score: s, label: labels[s], bar: bars[s] };
25 +}
26 +
27 +function Field({
28 + label, value, onChange, show, onToggle, placeholder, autoFocus, extraClass = "", onEnter, showToggle = true, trailing,
29 +}: {
30 + label: string; value: string; onChange: (v: string) => void; show?: boolean; onToggle?: () => void;
31 + placeholder: string; autoFocus?: boolean; extraClass?: string; onEnter?: () => void; showToggle?: boolean; trailing?: React.ReactNode;
32 +}): React.ReactElement {
33 + return (
34 + <div>
35 + <label className="block text-[12px] font-medium text-gray-500 dark:text-gray-400 mb-1.5">{label}</label>
36 + <div className="relative">
37 + <Lock className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
38 + <input
39 + type={show ? "text" : "password"}
40 + value={value}
41 + onChange={(e) => onChange(e.target.value)}
42 + onKeyDown={(e) => e.key === "Enter" && onEnter?.()}
43 + placeholder={placeholder}
44 + className={`field pl-10 pr-11 h-11 ${extraClass}`}
45 + autoFocus={autoFocus}
46 + autoComplete={label.includes("actuel") ? "current-password" : "new-password"}
47 + />
48 + {trailing}
49 + {showToggle && onToggle && (
50 + <button
51 + type="button"
52 + onClick={onToggle}
53 + className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200/60 dark:hover:bg-white/10 transition-colors"
54 + aria-label={show ? "Masquer" : "Afficher"}
55 + >
56 + {show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
57 + </button>
58 + )}
59 + </div>
60 + </div>
61 +);
62 +}
63 +
14 64 export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProps) {
15 65 const [currentPassword, setCurrentPassword] = useState("");
16 66 const [newPassword, setNewPassword] = useState("");
@@ -31,6 +81,7 @@ export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordM
31 81 const passwordsMatch = newPassword && confirmPassword && newPassword === confirmPassword;
32 82 const newPasswordValid = newPassword.length >= 6;
33 83 const canSubmit = currentPassword && newPasswordValid && passwordsMatch;
84 + const st = strength(newPassword);
34 85
35 86 const handleSubmit = async () => {
36 87 if (!canSubmit) return;
@@ -55,94 +106,60 @@ export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordM
55 106 }
56 107 };
57 108
58 − const inputBase =
59 − "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg pl-10 pr-10 py-2.5 text-gray-900 dark:text-white text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-gray-400 dark:focus:border-white/20";
60 −
61 109 return (
62 − <Modal isOpen={isOpen} onClose={handleClose} title="Changer le mot de passe">
63 − {/* Current password */}
64 − <div className="mb-3">
65 − <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
66 − Mot de passe actuel
67 − </label>
68 − <div className="relative">
69 − <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
70 − <input
71 − type={showCurrent ? "text" : "password"}
72 − value={currentPassword}
73 − onChange={(e) => setCurrentPassword(e.target.value)}
74 − placeholder="••••••••"
75 − className={inputBase}
76 − autoFocus
77 − />
78 − <button
79 − type="button"
80 − onClick={() => setShowCurrent(!showCurrent)}
81 − className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
82 − >
83 − {showCurrent ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
84 − </button>
85 − </div>
86 − </div>
110 + <Modal
111 + isOpen={isOpen}
112 + onClose={handleClose}
113 + title="Changer le mot de passe"
114 + description="Choisissez un mot de passe long et unique."
115 + footer={
116 + <>
117 + <Button variant="ghost" onClick={handleClose}>Annuler</Button>
118 + <Button variant="accent" onClick={handleSubmit} loading={loading} disabled={!canSubmit}>
119 + <KeyRound className="w-4 h-4" /> Mettre à jour
120 + </Button>
121 + </>
122 + }
123 + >
124 + <div className="space-y-4">
125 + <Field label="Mot de passe actuel" value={currentPassword} onChange={setCurrentPassword} show={showCurrent} onToggle={() => setShowCurrent(!showCurrent)} placeholder="••••••••" autoFocus />
87 126
88 − {/* New password */}
89 − <div className="mb-3">
90 − <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
91 − Nouveau mot de passe
92 − </label>
93 − <div className="relative">
94 − <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
95 − <input
96 − type={showNew ? "text" : "password"}
127 + <div>
128 + <Field
129 + label="Nouveau mot de passe"
97 130 value={newPassword}
98 − onChange={(e) => setNewPassword(e.target.value)}
131 + onChange={setNewPassword}
132 + show={showNew}
133 + onToggle={() => setShowNew(!showNew)}
99 134 placeholder="Min. 6 caractères"
100 − className={`${inputBase} ${newPassword && !newPasswordValid ? "border-red-400 dark:border-red-500" : ""}`}
135 + extraClass={newPassword && !newPasswordValid ? "!border-rose-400" : ""}
101 136 />
102 − <button
103 − type="button"
104 − onClick={() => setShowNew(!showNew)}
105 − className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
106 − >
107 − {showNew ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
108 − </button>
137 + {newPassword && (
138 + <div className="mt-2 flex items-center gap-2">
139 + <div className="flex gap-1 flex-1">
140 + {Array.from({ length: 5 }).map((_, i) => (
141 + <span key={i} className={`h-1 flex-1 rounded-full transition-colors ${i < st.score ? st.bar : "bg-gray-200 dark:bg-white/10"}`} />
142 + ))}
143 + </div>
144 + <span className="text-[11px] text-gray-500 min-w-[4.5rem] text-right">{st.label}</span>
145 + </div>
146 + )}
147 + {newPassword && !newPasswordValid && <p className="text-xs text-rose-500 mt-1.5">Au moins 6 caractères requis</p>}
109 148 </div>
110 − {newPassword && !newPasswordValid && (
111 − <p className="text-xs text-red-500 mt-1">Au moins 6 caractères requis</p>
112 − )}
113 − </div>
114 149
115 − {/* Confirm new password */}
116 − <div className="mb-5">
117 − <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
118 − Confirmer le nouveau mot de passe
119 − </label>
120 − <div className="relative">
121 − <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
122 − <input
123 − type="password"
150 + <div>
151 + <Field
152 + label="Confirmer le nouveau mot de passe"
124 153 value={confirmPassword}
125 − onChange={(e) => setConfirmPassword(e.target.value)}
126 − onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
154 + onChange={setConfirmPassword}
127 155 placeholder="••••••••"
128 − className={`${inputBase} ${confirmPassword && !passwordsMatch ? "border-red-400 dark:border-red-500" : confirmPassword && passwordsMatch ? "border-green-400 dark:border-green-500" : ""}`}
156 + onEnter={handleSubmit}
157 + showToggle={false}
158 + extraClass={confirmPassword && !passwordsMatch ? "!border-rose-400" : confirmPassword && passwordsMatch ? "!border-emerald-400" : ""}
159 + trailing={confirmPassword && passwordsMatch ? <Check className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-emerald-500" /> : undefined}
129 160 />
130 − {confirmPassword && passwordsMatch && (
131 − <Check className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-green-500" />
132 − )}
161 + {confirmPassword && !passwordsMatch && <p className="text-xs text-rose-500 mt-1.5">Les mots de passe ne correspondent pas</p>}
133 162 </div>
134 − {confirmPassword && !passwordsMatch && (
135 − <p className="text-xs text-red-500 mt-1">Les mots de passe ne correspondent pas</p>
136 − )}
137 − </div>
138 −
139 − <div className="flex gap-2 justify-end">
140 − <Button variant="ghost" onClick={handleClose}>
141 − Annuler
142 − </Button>
143 − <Button onClick={handleSubmit} loading={loading} disabled={!canSubmit}>
144 − Changer le mot de passe
145 − </Button>
146 163 </div>
147 164 </Modal>
148 165 );
modified components/auth/DevicesPanel.tsx +125 −142
@@ -3,24 +3,13 @@
3 3 import { useState, useEffect } from "react";
4 4 import { motion, AnimatePresence } from "framer-motion";
5 5 import {
6 − Monitor,
7 − Smartphone,
8 − Tablet,
9 − Globe,
10 − MapPin,
11 − Clock,
12 − ShieldCheck,
13 − ShieldX,
14 − LogOut,
15 − Loader2,
16 − Wifi,
17 − ChevronDown,
18 − ChevronUp,
19 − Bot,
20 − AlertTriangle,
21 − Shield,
6 + Monitor, Smartphone, Tablet, Globe, MapPin, Clock, ShieldCheck, ShieldX, LogOut, Wifi,
7 + ChevronDown, Bot, AlertTriangle, Shield,
22 8 } from "lucide-react";
23 9 import toast from "react-hot-toast";
10 +import Modal from "../ui/Modal";
11 +import Button from "../ui/Button";
12 +import { EmptyState } from "../ui/PageHeader";
24 13
25 14 interface DeviceSession {
26 15 id: string;
@@ -40,21 +29,19 @@ interface DeviceSession {
40 29 isCurrent: boolean;
41 30 }
42 31
43 −function DeviceIcon({ device }: { device: string }) {
44 − const cls = "w-5 h-5";
45 − if (device === "Mobile") return <Smartphone className={cls} />;
46 − if (device === "Tablet") return <Tablet className={cls} />;
47 − return <Monitor className={cls} />;
32 +function DeviceIcon({ device, className = "w-5 h-5" }: { device: string; className?: string }) {
33 + if (device === "Mobile") return <Smartphone className={className} />;
34 + if (device === "Tablet") return <Tablet className={className} />;
35 + return <Monitor className={className} />;
48 36 }
49 37
50 38 function RiskBadge({ level }: { level: string | null }) {
51 39 if (!level || level === "low") return null;
52 − const config = {
53 − medium: "bg-amber-500/15 text-amber-400 border-amber-500/20",
54 − high: "bg-red-500/15 text-red-400 border-red-500/20",
55 − }[level] || "";
40 + const cls = level === "high"
41 + ? "bg-rose-500/10 text-rose-600 dark:text-rose-400 ring-rose-500/20"
42 + : "bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20";
56 43 return (
57 − <span className={`inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full border uppercase tracking-wide ${config}`}>
44 + <span className={`inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full ring-1 ring-inset uppercase tracking-wide ${cls}`}>
58 45 <AlertTriangle className="w-2.5 h-2.5" />
59 46 {level === "high" ? "Risque élevé" : "Risque moyen"}
60 47 </span>
@@ -67,12 +54,28 @@ function formatRelative(dateStr: string): string {
67 54 const mins = Math.floor(diff / 60000);
68 55 const hours = Math.floor(diff / 3600000);
69 56 const days = Math.floor(diff / 86400000);
70 − if (mins < 1) return "À l'instant";
71 − if (mins < 60) return `Il y a ${mins} min`;
72 − if (hours < 24) return `Il y a ${hours}h`;
73 − if (days === 1) return "Hier";
74 − if (days < 7) return `Il y a ${days} jours`;
75 − return date.toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
57 + if (mins < 1) return "à l'instant";
58 + if (mins < 60) return `il y a ${mins} min`;
59 + if (hours < 24) return `il y a ${hours} h`;
60 + if (days === 1) return "hier";
61 + if (days < 7) return `il y a ${days} jours`;
62 + return date.toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" });
63 +}
64 +
65 +function SessionSkeleton() {
66 + return (
67 + <div className="space-y-2">
68 + {[0, 1, 2].map((i) => (
69 + <div key={i} className="card p-4 flex items-center gap-3 animate-pulse">
70 + <div className="w-10 h-10 rounded-xl bg-gray-200 dark:bg-white/10" />
71 + <div className="flex-1 space-y-2">
72 + <div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-white/10" />
73 + <div className="h-2.5 w-1/2 rounded bg-gray-200 dark:bg-white/10" />
74 + </div>
75 + </div>
76 + ))}
77 + </div>
78 + );
76 79 }
77 80
78 81 export default function DevicesPanel() {
@@ -80,10 +83,13 @@ export default function DevicesPanel() {
80 83 const [loading, setLoading] = useState(true);
81 84 const [revokingId, setRevokingId] = useState<string | null>(null);
82 85 const [expandedId, setExpandedId] = useState<string | null>(null);
86 + const [toRevoke, setToRevoke] = useState<DeviceSession | null>(null);
83 87
84 88 const fetchSessions = async () => {
85 − const res = await fetch("/api/auth/devices");
86 − if (res.ok) setSessions(await res.json());
89 + try {
90 + const res = await fetch("/api/auth/devices");
91 + if (res.ok) setSessions(await res.json());
92 + } catch {}
87 93 setLoading(false);
88 94 };
89 95
@@ -92,38 +98,26 @@ export default function DevicesPanel() {
92 98 const handleRevoke = async (session: DeviceSession) => {
93 99 if (session.isCurrent) { toast.error("Impossible de révoquer la session active"); return; }
94 100 setRevokingId(session.id);
101 + setToRevoke(null);
95 102 try {
96 103 const res = await fetch(`/api/auth/devices/${session.id}`, { method: "DELETE" });
97 104 if (res.ok) {
98 105 toast.success("Session révoquée");
99 − setSessions((prev) => prev.map((s) => s.id === session.id ? { ...s, endedAt: new Date().toISOString() } : s));
100 − } else {
101 − toast.error("Erreur lors de la révocation");
102 − }
106 + setSessions((prev) => prev.map((s) => (s.id === session.id ? { ...s, endedAt: new Date().toISOString() } : s)));
107 + } else toast.error("Erreur lors de la révocation");
103 108 } catch { toast.error("Erreur réseau"); }
104 109 setRevokingId(null);
105 110 };
106 111
107 112 const refused = sessions.filter((s) => s.status === "refused_by_ai");
108 − const active = sessions.filter((s) => s.status === "active" && !s.endedAt);
109 − const past = sessions.filter((s) => s.status === "active" && s.endedAt || s.status === "ended");
113 + const current = sessions.find((s) => s.isCurrent && s.status === "active" && !s.endedAt);
114 + const active = sessions.filter((s) => s.status === "active" && !s.endedAt && !s.isCurrent);
115 + const past = sessions.filter((s) => (s.status === "active" && s.endedAt) || s.status === "ended");
110 116
111 − if (loading) {
112 − return (
113 − <div className="flex justify-center py-10">
114 − <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
115 − </div>
116 − );
117 − }
117 + if (loading) return <SessionSkeleton />;
118 118
119 119 if (sessions.length === 0) {
120 − return (
121 − <div className="text-center py-12 text-gray-500">
122 − <Monitor className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
123 − <p>Aucune session enregistrée</p>
124 − <p className="text-sm mt-1">Les connexions futures apparaîtront ici.</p>
125 − </div>
126 − );
120 + return <EmptyState icon={Monitor} title="Aucune session enregistrée" description="Les connexions futures apparaîtront ici avec leur appareil, leur emplacement et l'analyse de sécurité." />;
127 121 }
128 122
129 123 const SessionCard = ({ s, i }: { s: DeviceSession; i: number }) => {
@@ -136,109 +130,65 @@ export default function DevicesPanel() {
136 130 key={s.id}
137 131 initial={{ opacity: 0, y: 6 }}
138 132 animate={{ opacity: 1, y: 0 }}
139 − transition={{ delay: i * 0.04 }}
140 − className={`rounded-xl border transition-colors overflow-hidden ${
141 − isRefused
142 − ? "bg-red-500/5 border-red-500/25"
143 − : s.isCurrent
144 − ? "bg-emerald-500/10 border-emerald-500/30"
145 − : isActive
146 − ? "bg-white dark:bg-white/5 border-gray-200 dark:border-white/10"
147 − : "bg-gray-50 dark:bg-white/[0.02] border-gray-100 dark:border-white/5 opacity-70"
148 − }`}
133 + transition={{ delay: Math.min(i, 8) * 0.04 }}
134 + className={`card overflow-hidden ${isRefused ? "!border-rose-500/30 bg-rose-500/[0.03]" : !isActive ? "opacity-70" : ""}`}
149 135 >
150 136 <div className="flex flex-col sm:flex-row sm:items-center gap-3 p-4">
151 − {/* Icon */}
152 − <div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
153 − isRefused ? "bg-red-500/15 text-red-400"
154 − : s.isCurrent ? "bg-emerald-500/20 text-emerald-400"
155 − : isActive ? "bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300"
156 − : "bg-gray-100 dark:bg-white/5 text-gray-400"
137 + <div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${
138 + isRefused ? "bg-rose-500/10 text-rose-500" : isActive ? "bg-blue-500/10 text-blue-600 dark:text-blue-300" : "bg-gray-100 dark:bg-white/[0.06] text-gray-400"
157 139 }`}>
158 140 {isRefused ? <ShieldX className="w-5 h-5" /> : <DeviceIcon device={s.device} />}
159 141 </div>
160 142
161 − {/* Info */}
162 143 <div className="flex-1 min-w-0 space-y-1">
163 144 <div className="flex items-center gap-2 flex-wrap">
164 − <span className="text-sm font-medium text-gray-900 dark:text-white">
165 − {s.browser} · {s.os}
166 − </span>
145 + <span className="text-[14px] font-medium text-gray-900 dark:text-white">{s.browser} · {s.os}</span>
167 146 {isRefused && (
168 − <span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 bg-red-500/15 text-red-400 border border-red-500/20 rounded-full uppercase tracking-wide">
147 + <span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 bg-rose-500/10 text-rose-600 dark:text-rose-400 ring-1 ring-inset ring-rose-500/20 rounded-full uppercase tracking-wide">
169 148 <Bot className="w-2.5 h-2.5" /> Refusé par IA
170 149 </span>
171 150 )}
172 − {s.isCurrent && (
173 − <span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 bg-emerald-500/20 text-emerald-400 rounded-full uppercase tracking-wide">
174 − <Wifi className="w-2.5 h-2.5" /> Session active
175 − </span>
176 − )}
177 151 {!isRefused && <RiskBadge level={s.aiRiskLevel} />}
178 152 </div>
179 −
180 − <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
181 − <span className="flex items-center gap-1"><Globe className="w-3 h-3" />{s.ip}</span>
153 + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-gray-500 dark:text-gray-400">
154 + <span className="flex items-center gap-1 font-mono"><Globe className="w-3 h-3" />{s.ip}</span>
182 155 {(s.country || s.city) && (
183 − <span className="flex items-center gap-1">
184 − <MapPin className="w-3 h-3" />
185 − {[s.city, s.country].filter(Boolean).join(", ")}
186 − </span>
156 + <span className="flex items-center gap-1"><MapPin className="w-3 h-3" />{[s.city, s.country].filter(Boolean).join(", ")}</span>
187 157 )}
188 158 <span className="flex items-center gap-1"><Clock className="w-3 h-3" />{formatRelative(s.createdAt)}</span>
189 − {s.endedAt && (
190 − <span className="flex items-center gap-1 text-gray-400">
191 − <LogOut className="w-3 h-3" />Déconnecté {formatRelative(s.endedAt)}
192 − </span>
193 − )}
159 + {s.endedAt && <span className="flex items-center gap-1 text-gray-400"><LogOut className="w-3 h-3" />déconnecté {formatRelative(s.endedAt)}</span>}
194 160 </div>
195 161 </div>
196 162
197 − {/* Actions */}
198 − <div className="flex items-center gap-1 flex-shrink-0">
163 + <div className="flex items-center gap-1 shrink-0">
199 164 {s.aiReason && (
200 165 <button
201 166 onClick={() => setExpandedId(isExpanded ? null : s.id)}
202 − className="min-w-[36px] min-h-[36px] flex items-center justify-center p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg transition-colors"
203 − title="Voir analyse IA"
167 + className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-xl hover:bg-gray-100 dark:hover:bg-white/[0.06] transition-colors"
168 + title="Voir l'analyse IA"
169 + aria-expanded={isExpanded}
204 170 >
205 − {isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
171 + <ChevronDown className={`w-4 h-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
206 172 </button>
207 173 )}
208 174 {isActive && !s.isCurrent && (
209 − <button
210 − onClick={() => handleRevoke(s)}
211 − disabled={revokingId === s.id}
212 − title="Déconnecter"
213 − className="flex items-center gap-1 px-2.5 py-1.5 text-xs text-red-500 hover:bg-red-500/10 rounded-lg transition-colors disabled:opacity-50"
214 − >
215 − {revokingId === s.id ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <LogOut className="w-3.5 h-3.5" />}
216 − Révoquer
217 − </button>
175 + <Button variant="danger" size="sm" onClick={() => setToRevoke(s)} loading={revokingId === s.id}>
176 + <LogOut className="w-3.5 h-3.5" /> Révoquer
177 + </Button>
218 178 )}
219 − {s.isCurrent && <ShieldCheck className="w-4 h-4 text-emerald-400 mr-1" />}
220 179 </div>
221 180 </div>
222 181
223 − {/* AI reason expandable */}
224 182 <AnimatePresence>
225 183 {isExpanded && s.aiReason && (
226 − <motion.div
227 − initial={{ height: 0, opacity: 0 }}
228 − animate={{ height: "auto", opacity: 1 }}
229 − exit={{ height: 0, opacity: 0 }}
230 − transition={{ duration: 0.2 }}
231 − className="overflow-hidden"
232 − >
233 − <div className={`mx-4 mb-4 p-3 rounded-lg flex items-start gap-2 text-xs ${
234 − isRefused
235 − ? "bg-red-500/10 border border-red-500/20 text-red-300"
236 − : "bg-blue-500/10 border border-blue-500/20 text-blue-300"
184 + <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }} className="overflow-hidden">
185 + <div className={`mx-4 mb-4 p-3 rounded-xl flex items-start gap-2.5 text-[12.5px] ${
186 + isRefused ? "bg-rose-500/10 text-rose-700 dark:text-rose-300" : "bg-blue-500/10 text-blue-800 dark:text-blue-200"
237 187 }`}>
238 − <Bot className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
188 + <Bot className="w-4 h-4 mt-0.5 shrink-0" />
239 189 <div>
240 190 <span className="font-semibold block mb-0.5">
241 − Analyse IA Claude Opus 4.6 — {isRefused ? "Connexion refusée" : `Risque ${s.aiRiskLevel || "faible"}`}
191 + Analyse IA — {isRefused ? "connexion refusée" : `risque ${s.aiRiskLevel || "faible"}`}
242 192 </span>
243 193 <span>{s.aiReason}</span>
244 194 </div>
@@ -252,41 +202,74 @@ export default function DevicesPanel() {
252 202
253 203 return (
254 204 <div className="space-y-6">
255 − {/* AI protection info */}
256 − <div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/20 rounded-xl text-sm text-blue-400">
257 − <Shield className="w-4 h-4 mt-0.5 flex-shrink-0" />
258 − <span>Chaque connexion est analysée en temps réel par <strong>Claude Opus 4.6</strong> pour détecter toute activité suspecte.</span>
205 + {/* Appareil courant */}
206 + {current && (
207 + <div className="relative overflow-hidden rounded-3xl border border-emerald-500/25 bg-gradient-to-br from-emerald-500/10 via-emerald-500/5 to-transparent p-4 sm:p-5">
208 + <div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-emerald-500/15 blur-3xl pointer-events-none" />
209 + <div className="relative flex flex-col sm:flex-row sm:items-center gap-4">
210 + <div className="w-12 h-12 rounded-2xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center shrink-0">
211 + <DeviceIcon device={current.device} className="w-6 h-6" />
212 + </div>
213 + <div className="flex-1 min-w-0">
214 + <div className="flex items-center gap-2 flex-wrap">
215 + <span className="text-[15px] font-semibold text-gray-900 dark:text-white">{current.browser} · {current.os}</span>
216 + <span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 rounded-full uppercase tracking-wide">
217 + <Wifi className="w-2.5 h-2.5" /> Cet appareil
218 + </span>
219 + </div>
220 + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-gray-600 dark:text-gray-400 mt-1">
221 + <span className="flex items-center gap-1 font-mono"><Globe className="w-3 h-3" />{current.ip}</span>
222 + {(current.country || current.city) && <span className="flex items-center gap-1"><MapPin className="w-3 h-3" />{[current.city, current.country].filter(Boolean).join(", ")}</span>}
223 + <span className="flex items-center gap-1"><Clock className="w-3 h-3" />connecté {formatRelative(current.createdAt)}</span>
224 + </div>
225 + </div>
226 + <ShieldCheck className="w-6 h-6 text-emerald-500 shrink-0 hidden sm:block" />
227 + </div>
228 + </div>
229 + )}
230 +
231 + <div className="flex items-start gap-2.5 p-3 rounded-2xl bg-blue-500/[0.07] text-[13px] text-blue-800 dark:text-blue-200">
232 + <Shield className="w-4 h-4 mt-0.5 shrink-0" />
233 + <span>Chaque connexion est analysée en temps réel par l&apos;IA (appareil, emplacement, historique) pour détecter toute activité suspecte.</span>
259 234 </div>
260 235
261 − {/* Refused sessions */}
262 236 {refused.length > 0 && (
263 − <div className="space-y-2">
264 − <h3 className="text-xs font-semibold uppercase tracking-wider text-red-400 px-1 flex items-center gap-1.5">
265 − <ShieldX className="w-3.5 h-3.5" /> Connexions refusées par IA ({refused.length})
237 + <section className="space-y-2">
238 + <h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-rose-500 px-1 flex items-center gap-1.5">
239 + <ShieldX className="w-3.5 h-3.5" /> Connexions refusées ({refused.length})
266 240 </h3>
267 241 {refused.map((s, i) => <SessionCard key={s.id} s={s} i={i} />)}
268 − </div>
242 + </section>
269 243 )}
270 244
271 − {/* Active sessions */}
272 245 {active.length > 0 && (
273 − <div className="space-y-2">
274 − <h3 className="text-xs font-semibold uppercase tracking-wider text-gray-400 px-1">
275 − Sessions actives ({active.length})
276 − </h3>
246 + <section className="space-y-2">
247 + <h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-400 px-1">Autres sessions actives ({active.length})</h3>
277 248 {active.map((s, i) => <SessionCard key={s.id} s={s} i={i} />)}
278 − </div>
249 + </section>
279 250 )}
280 251
281 − {/* Past sessions */}
282 252 {past.length > 0 && (
283 − <div className="space-y-2">
284 − <h3 className="text-xs font-semibold uppercase tracking-wider text-gray-400 px-1">
285 − Historique ({past.length})
286 − </h3>
253 + <section className="space-y-2">
254 + <h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-400 px-1">Historique ({past.length})</h3>
287 255 {past.map((s, i) => <SessionCard key={s.id} s={s} i={i} />)}
288 − </div>
256 + </section>
289 257 )}
258 +
259 + <Modal
260 + isOpen={!!toRevoke}
261 + onClose={() => setToRevoke(null)}
262 + title="Révoquer cette session ?"
263 + description={toRevoke ? `${toRevoke.browser} · ${toRevoke.os} — ${toRevoke.ip}` : undefined}
264 + footer={
265 + <>
266 + <Button variant="ghost" onClick={() => setToRevoke(null)}>Annuler</Button>
267 + <Button variant="danger" onClick={() => toRevoke && handleRevoke(toRevoke)}><LogOut className="w-4 h-4" /> Révoquer</Button>
268 + </>
269 + }
270 + >
271 + <p className="text-sm text-gray-600 dark:text-gray-300">L&apos;appareil sera déconnecté immédiatement et devra se reconnecter.</p>
272 + </Modal>
290 273 </div>
291 274 );
292 275 }
added components/dashboard/ActivityBars.tsx +148 −0
@@ -0,0 +1,148 @@
1 +"use client";
2 +
3 +import { useMemo, useState } from "react";
4 +import { motion } from "framer-motion";
5 +
6 +export interface DayActivity {
7 + date: string; // YYYY-MM-DD
8 + count: number;
9 + uploads: number;
10 +}
11 +
12 +interface ActivityBarsProps {
13 + data: DayActivity[];
14 + height?: number;
15 + className?: string;
16 +}
17 +
18 +const DAY_SHORT = ["di", "lu", "ma", "me", "je", "ve", "sa"];
19 +
20 +function parseLocal(date: string): Date {
21 + const [y, m, d] = date.split("-").map(Number);
22 + return new Date(y, m - 1, d);
23 +}
24 +
25 +function fmtLong(date: string): string {
26 + return new Intl.DateTimeFormat("fr-CA", { weekday: "long", day: "numeric", month: "long" }).format(parseLocal(date));
27 +}
28 +
29 +/**
30 + * Barres « activité 14 jours » (SVG). Un seul axe, barres fines à extrémités
31 + * arrondies ancrées à la ligne de base, grille discrète, tooltip au survol/tap.
32 + * Série unique (total) en accent + sous-segment « téléversements » direct-labelé
33 + * dans le tooltip — pas de légende nécessaire pour une série.
34 + */
35 +export default function ActivityBars({ data, height = 140, className = "" }: ActivityBarsProps) {
36 + const [hover, setHover] = useState<number | null>(null);
37 + const max = useMemo(() => Math.max(1, ...data.map((d) => d.count)), [data]);
38 + const total = useMemo(() => data.reduce((a, d) => a + d.count, 0), [data]);
39 + const totalUp = useMemo(() => data.reduce((a, d) => a + d.uploads, 0), [data]);
40 +
41 + const ticks = useMemo(() => {
42 + const nice = max <= 4 ? max : max <= 10 ? 5 : max <= 20 ? 10 : Math.ceil(max / 4);
43 + const arr: number[] = [];
44 + for (let v = 0; v <= max; v += Math.max(1, Math.round(max / (nice || 1)))) arr.push(v);
45 + if (arr[arr.length - 1] !== max) arr.push(max);
46 + return arr.slice(-4);
47 + }, [max]);
48 +
49 + const todayKey = (() => {
50 + const d = new Date();
51 + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
52 + })();
53 +
54 + const h = hover !== null ? data[hover] : null;
55 +
56 + return (
57 + <div className={`relative ${className}`}>
58 + {/* En-tête chiffres */}
59 + <div className="flex items-baseline gap-4 mb-3">
60 + <div>
61 + <span className="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white tabular-nums">{total}</span>
62 + <span className="text-[12px] text-gray-500 dark:text-gray-400 ml-1.5">actions</span>
63 + </div>
64 + <div>
65 + <span className="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white tabular-nums">{totalUp}</span>
66 + <span className="text-[12px] text-gray-500 dark:text-gray-400 ml-1.5">téléversements</span>
67 + </div>
68 + <span className="ml-auto text-[11px] text-gray-400">14 derniers jours</span>
69 + </div>
70 +
71 + <div className="relative" style={{ height }} onMouseLeave={() => setHover(null)}>
72 + {/* Grille + axe Y (discret) */}
73 + <div className="absolute inset-0 pointer-events-none">
74 + {ticks.map((t) => (
75 + <div key={t} className="absolute left-0 right-0 flex items-center gap-2" style={{ bottom: `${(t / max) * 100}%` }}>
76 + <span className="w-6 text-right text-[10px] text-gray-400 tabular-nums -translate-y-1/2">{t}</span>
77 + <span className="flex-1 border-t border-dashed border-gray-200 dark:border-white/[0.08] -translate-y-1/2" />
78 + </div>
79 + ))}
80 + </div>
81 +
82 + {/* Barres */}
83 + <div className="absolute inset-y-0 left-8 right-0 flex items-end gap-[3px] sm:gap-1.5" role="img" aria-label={`Activité des 14 derniers jours : ${total} actions, ${totalUp} téléversements`}>
84 + {data.map((d, i) => {
85 + const pct = (d.count / max) * 100;
86 + const upPct = d.count > 0 ? (d.uploads / d.count) * 100 : 0;
87 + const on = hover === i;
88 + return (
89 + <button
90 + key={d.date}
91 + type="button"
92 + onMouseEnter={() => setHover(i)}
93 + onFocus={() => setHover(i)}
94 + onBlur={() => setHover(null)}
95 + onClick={() => setHover(on ? null : i)}
96 + className="group relative flex-1 h-full flex items-end justify-center outline-none"
97 + aria-label={`${fmtLong(d.date)} : ${d.count} actions, ${d.uploads} téléversements`}
98 + >
99 + {/* zone de survol élargie */}
100 + <span className={`absolute inset-x-0 inset-y-0 rounded-md transition-colors ${on ? "bg-gray-100/70 dark:bg-white/[0.04]" : ""}`} />
101 + <motion.span
102 + initial={{ height: 0 }}
103 + animate={{ height: `${Math.max(d.count > 0 ? 3 : 1.5, pct)}%` }}
104 + transition={{ duration: 0.6, delay: 0.05 + i * 0.03, ease: [0.2, 0.8, 0.2, 1] }}
105 + className={`relative w-full max-w-[22px] rounded-t-[4px] overflow-hidden ${d.count === 0 ? "bg-gray-200 dark:bg-white/10" : "bg-blue-500/30 dark:bg-blue-400/25"} ${on ? "ring-2 ring-blue-500/40" : ""}`}
106 + >
107 + {/* sous-segment téléversements (même teinte, plus dense) — ancré à la base */}
108 + <span className="absolute bottom-0 left-0 right-0 bg-blue-500 dark:bg-blue-400" style={{ height: `${upPct}%` }} />
109 + </motion.span>
110 + </button>
111 + );
112 + })}
113 + </div>
114 +
115 + {/* Tooltip (ancré dans la zone des barres) */}
116 + {h && hover !== null && (
117 + <div
118 + className="absolute left-8 right-0 top-0 pointer-events-none z-10"
119 + aria-hidden
120 + >
121 + <div
122 + className="absolute -top-2 glass rounded-xl px-3 py-2 shadow-pop text-[12px] whitespace-nowrap"
123 + style={{ left: `${Math.min(88, Math.max(12, ((hover + 0.5) / data.length) * 100))}%`, transform: "translate(-50%, -100%)" }}
124 + >
125 + <p className="font-medium text-gray-900 dark:text-white capitalize">{fmtLong(h.date)}</p>
126 + <p className="text-gray-600 dark:text-gray-300 tabular-nums"><span className="inline-block w-2 h-2 rounded-full bg-blue-500/40 mr-1.5 align-middle" />{h.count} action{h.count > 1 ? "s" : ""}</p>
127 + <p className="text-gray-600 dark:text-gray-300 tabular-nums"><span className="inline-block w-2 h-2 rounded-full bg-blue-500 mr-1.5 align-middle" />{h.uploads} téléversement{h.uploads > 1 ? "s" : ""}</p>
128 + </div>
129 + </div>
130 + )}
131 + </div>
132 +
133 + {/* Axe X */}
134 + <div className="flex gap-[3px] sm:gap-1.5 mt-2 ml-8">
135 + {data.map((d, i) => {
136 + const dt = parseLocal(d.date);
137 + const isToday = d.date === todayKey;
138 + const showLabel = i % 2 === 1 || data.length <= 7;
139 + return (
140 + <span key={d.date} className={`flex-1 text-center text-[10px] tabular-nums ${isToday ? "text-blue-600 dark:text-blue-300 font-semibold" : "text-gray-400"}`}>
141 + <span className={showLabel ? "" : "hidden sm:inline"}>{isToday ? "auj." : `${DAY_SHORT[dt.getDay()]} ${dt.getDate()}`}</span>
142 + </span>
143 + );
144 + })}
145 + </div>
146 + </div>
147 + );
148 +}
added components/dashboard/ActivityTimeline.tsx +116 −0
@@ -0,0 +1,116 @@
1 +"use client";
2 +
3 +import { motion } from "framer-motion";
4 +import {
5 + Upload, Download, Trash2, Pencil, FolderInput, Share2, LogIn, LogOut, FolderPlus, FolderMinus, Link2Off,
6 + Star, Tag, Edit3, Users, RotateCcw, Eye, Activity as ActivityIcon, type LucideIcon,
7 +} from "lucide-react";
8 +import { EmptyState } from "@/components/ui/PageHeader";
9 +
10 +export interface ActivityItem {
11 + id: string;
12 + action: string;
13 + details: string | null;
14 + createdAt: string;
15 + file: { name: string } | null;
16 +}
17 +
18 +interface Meta { label: string; icon: LucideIcon; tone: string }
19 +
20 +/** Actions réelles observées en base : UPLOAD, LOGIN, file_renamed, DOWNLOAD, LOGOUT, DELETE, CREATE_FOLDER, DELETE_FOLDER, shared_space_renamed, SHARE, MOVE, FAVORITE, EDIT, RENAME, TAG_ADD, TAG_REMOVE… */
21 +const META: Record<string, Meta> = {
22 + UPLOAD: { label: "a téléversé", icon: Upload, tone: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-300" },
23 + DOWNLOAD: { label: "a téléchargé", icon: Download, tone: "text-blue-600 bg-blue-500/10 dark:text-blue-300" },
24 + DELETE: { label: "a supprimé", icon: Trash2, tone: "text-rose-600 bg-rose-500/10 dark:text-rose-300" },
25 + RESTORE: { label: "a restauré", icon: RotateCcw, tone: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-300" },
26 + RENAME: { label: "a renommé", icon: Pencil, tone: "text-amber-600 bg-amber-500/10 dark:text-amber-300" },
27 + FILE_RENAMED: { label: "a renommé", icon: Pencil, tone: "text-amber-600 bg-amber-500/10 dark:text-amber-300" },
28 + MOVE: { label: "a déplacé", icon: FolderInput, tone: "text-blue-600 bg-blue-500/10 dark:text-blue-300" },
29 + SHARE: { label: "a partagé", icon: Share2, tone: "text-purple-600 bg-purple-500/10 dark:text-purple-300" },
30 + UNSHARE: { label: "a retiré le partage de", icon: Link2Off, tone: "text-gray-600 bg-gray-500/10 dark:text-gray-300" },
31 + LOGIN: { label: "connexion", icon: LogIn, tone: "text-gray-600 bg-gray-500/10 dark:text-gray-300" },
32 + LOGOUT: { label: "déconnexion", icon: LogOut, tone: "text-gray-600 bg-gray-500/10 dark:text-gray-300" },
33 + CREATE_FOLDER: { label: "a créé un dossier", icon: FolderPlus, tone: "text-blue-600 bg-blue-500/10 dark:text-blue-300" },
34 + DELETE_FOLDER: { label: "a supprimé un dossier", icon: FolderMinus, tone: "text-rose-600 bg-rose-500/10 dark:text-rose-300" },
35 + SHARED_SPACE_RENAMED: { label: "a renommé un espace partagé", icon: Users, tone: "text-purple-600 bg-purple-500/10 dark:text-purple-300" },
36 + FAVORITE: { label: "a mis en favori", icon: Star, tone: "text-amber-600 bg-amber-500/10 dark:text-amber-300" },
37 + UNFAVORITE: { label: "a retiré des favoris", icon: Star, tone: "text-gray-600 bg-gray-500/10 dark:text-gray-300" },
38 + EDIT: { label: "a modifié", icon: Edit3, tone: "text-blue-600 bg-blue-500/10 dark:text-blue-300" },
39 + PREVIEW: { label: "a consulté", icon: Eye, tone: "text-gray-600 bg-gray-500/10 dark:text-gray-300" },
40 + TAG_ADD: { label: "a étiqueté", icon: Tag, tone: "text-purple-600 bg-purple-500/10 dark:text-purple-300" },
41 + TAG_REMOVE: { label: "a retiré une étiquette de", icon: Tag, tone: "text-gray-600 bg-gray-500/10 dark:text-gray-300" },
42 +};
43 +
44 +export function activityMeta(action: string): Meta {
45 + const key = (action || "").toUpperCase();
46 + return META[key] || { label: action.toLowerCase().replace(/_/g, " "), icon: ActivityIcon, tone: "text-gray-600 bg-gray-500/10 dark:text-gray-300" };
47 +}
48 +
49 +export function timeAgo(dateStr: string): string {
50 + const diff = Date.now() - new Date(dateStr).getTime();
51 + const m = Math.floor(diff / 60000);
52 + if (m < 1) return "à l'instant";
53 + if (m < 60) return `il y a ${m} min`;
54 + const h = Math.floor(m / 60);
55 + if (h < 24) return `il y a ${h} h`;
56 + const d = Math.floor(h / 24);
57 + if (d < 30) return `il y a ${d} j`;
58 + return new Intl.DateTimeFormat("fr-CA", { day: "numeric", month: "short" }).format(new Date(dateStr));
59 +}
60 +
61 +/** Nettoie les détails techniques (« Uploaded X », « Login from ... ») pour un libellé lisible. */
62 +function prettyDetails(a: ActivityItem): string | null {
63 + if (!a.details) return null;
64 + let d = a.details;
65 + if (a.file?.name && d.includes(a.file.name)) return null; // redondant avec le nom
66 + d = d.replace(/^Uploaded\s+/i, "").replace(/^Created folder\s+/i, "").replace(/^Deleted\s+/i, "").replace(/^Login from\s+/i, "depuis ");
67 + return d;
68 +}
69 +
70 +interface ActivityTimelineProps {
71 + activities: ActivityItem[];
72 + /** Compact : sans ligne verticale, padding réduit */
73 + compact?: boolean;
74 + limit?: number;
75 + emptyTitle?: string;
76 +}
77 +
78 +export default function ActivityTimeline({ activities, compact, limit, emptyTitle = "Aucune activité récente" }: ActivityTimelineProps) {
79 + const list = limit ? activities.slice(0, limit) : activities;
80 + if (list.length === 0) {
81 + return <EmptyState icon={ActivityIcon} title={emptyTitle} description="Vos actions (téléversements, partages, modifications) apparaîtront ici." />;
82 + }
83 + return (
84 + <ol className="relative" role="list">
85 + {!compact && <span className="absolute left-[19px] top-4 bottom-4 w-px bg-line dark:bg-white/[0.08]" aria-hidden />}
86 + {list.map((a, i) => {
87 + const meta = activityMeta(a.action);
88 + const Icon = meta.icon;
89 + const details = prettyDetails(a);
90 + return (
91 + <motion.li
92 + key={a.id}
93 + initial={{ opacity: 0, x: -8 }}
94 + animate={{ opacity: 1, x: 0 }}
95 + transition={{ delay: Math.min(i, 12) * 0.03, duration: 0.25 }}
96 + className={`relative flex items-start gap-3 ${compact ? "py-2" : "py-2.5"} px-1 rounded-xl hover:bg-gray-50 dark:hover:bg-white/[0.03] transition-colors`}
97 + >
98 + <span className={`relative z-10 w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ring-4 ring-surface dark:ring-[#12131a] ${meta.tone}`}>
99 + <Icon className="w-4 h-4" />
100 + </span>
101 + <div className="flex-1 min-w-0 pt-0.5">
102 + <p className="text-[13.5px] text-gray-700 dark:text-gray-200 leading-snug">
103 + <span className="text-gray-500 dark:text-gray-400">{meta.label}</span>
104 + {a.file?.name && <> <span className="font-medium text-gray-900 dark:text-white break-all">{a.file.name}</span></>}
105 + </p>
106 + {details && <p className="text-[12px] text-gray-400 dark:text-gray-500 truncate mt-0.5">{details}</p>}
107 + </div>
108 + <time dateTime={a.createdAt} className="text-[11.5px] text-gray-400 dark:text-gray-500 shrink-0 whitespace-nowrap pt-1 tabular-nums" title={new Date(a.createdAt).toLocaleString("fr-CA")}>
109 + {timeAgo(a.createdAt)}
110 + </time>
111 + </motion.li>
112 + );
113 + })}
114 + </ol>
115 + );
116 +}
modified components/dashboard/DashboardHome.tsx +301 −288
@@ -1,29 +1,26 @@
1 1 "use client";
2 2
3 −import { useState, useEffect } from "react";
3 +import { useState, useEffect, useMemo } from "react";
4 +import Link from "next/link";
4 5 import { useRouter } from "next/navigation";
5 6 import { motion } from "framer-motion";
6 7 import {
7 − Upload,
8 − FolderPlus,
9 − Search,
10 − Trash2,
11 − HardDrive,
12 − FileText,
13 − FolderOpen,
14 − Share2,
15 − Clock,
16 − ArrowRight,
17 − Image,
18 − Video,
19 − Music,
20 − FileCode,
21 − File,
8 + Upload, FolderPlus, Search, Sparkles, HardDrive, FileText, FolderOpen, Share2, Clock, ArrowRight,
9 + Image as ImageIcon, Video, Music, FileCode, File, FileSpreadsheet, Archive, Star, Trash2, Users, Layers, Scale,
10 + type LucideIcon,
22 11 } from "lucide-react";
23 −import { formatFileSize, formatDate, getFileIcon } from "@/lib/utils";
12 +import { formatFileSize, getFileIcon } from "@/lib/utils";
13 +import { STORAGE_QUOTA_BYTES } from "@/lib/prefs";
24 14 import type { FileData } from "@/types/files";
25 15 import PageTransition from "@/components/ui/PageTransition";
16 +import Card, { CardTitle } from "@/components/ui/Card";
17 +import { EmptyState } from "@/components/ui/PageHeader";
26 18 import { DashboardSkeleton } from "@/components/ui/Skeleton";
19 +import { useShell, openCommandPalette, toggleAiPanel } from "@/components/layout/ShellContext";
20 +import StorageDonut from "./StorageDonut";
21 +import ActivityBars, { type DayActivity } from "./ActivityBars";
22 +import ActivityTimeline, { type ActivityItem } from "./ActivityTimeline";
23 +import type { CategoryStat } from "./categories";
27 24
28 25 interface Stats {
29 26 fileCount: number;
@@ -31,319 +28,335 @@ interface Stats {
31 28 shareCount: number;
32 29 storageUsed: number;
33 30 diskFileCount: number;
31 + byCategory?: CategoryStat[];
32 + activityByDay?: DayActivity[];
33 + favoriteCount?: number;
34 + trashCount?: number;
35 + versionCount?: number;
36 + largestFiles?: { id: string; name: string; size: number; mimeType: string; folderId: string | null }[];
34 37 }
35 38
36 −interface Activity {
37 − id: string;
38 − action: string;
39 − details: string | null;
40 − createdAt: string;
41 − file: { name: string } | null;
42 −}
43 −
44 −interface SessionInfo {
45 − name?: string;
46 − username?: string;
47 − email?: string;
48 −}
39 +interface SharedSpace { id: string; name: string; quotaBytes: number }
49 40
50 −const actionLabels: Record<string, string> = {
51 − upload: "a mis en ligne",
52 − delete: "a supprime",
53 − download: "a telecharge",
54 − rename: "a renomme",
55 − move: "a deplace",
56 − share: "a partage",
57 − preview: "a consulte",
58 − restore: "a restaure",
59 − favorite: "a ajoute aux favoris",
60 − unfavorite: "a retire des favoris",
41 +const iconMap: Record<string, LucideIcon> = {
42 + image: ImageIcon, video: Video, audio: Music, pdf: FileText, code: FileCode, document: FileText,
43 + spreadsheet: FileSpreadsheet, file: File, archive: Archive, presentation: FileText,
61 44 };
62 −
63 −const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
64 − image: Image,
65 − video: Video,
66 − audio: Music,
67 − pdf: FileText,
68 − code: FileCode,
69 − document: FileText,
70 − spreadsheet: FileText,
71 − file: File,
72 − archive: File,
73 − presentation: FileText,
45 +const toneMap: Record<string, string> = {
46 + image: "text-emerald-500 bg-emerald-500/10", video: "text-purple-500 bg-purple-500/10", audio: "text-pink-500 bg-pink-500/10",
47 + pdf: "text-rose-500 bg-rose-500/10", code: "text-amber-500 bg-amber-500/10", spreadsheet: "text-green-600 bg-green-500/10",
48 + archive: "text-orange-500 bg-orange-500/10", document: "text-blue-500 bg-blue-500/10", presentation: "text-orange-500 bg-orange-500/10",
49 + file: "text-gray-400 bg-gray-500/10",
74 50 };
75 51
76 −function getGreeting(): string {
77 − const hour = new Date().getHours();
78 − if (hour < 12) return "Bonjour";
79 − if (hour < 18) return "Bon apres-midi";
52 +function greeting(d: Date): string {
53 + const h = d.getHours();
54 + if (h < 5) return "Bonne nuit";
55 + if (h < 12) return "Bonjour";
56 + if (h < 18) return "Bon après-midi";
80 57 return "Bonsoir";
81 58 }
82 59
83 −function formatFrenchDate(): string {
84 − return new Intl.DateTimeFormat("fr-FR", {
85 − weekday: "long",
86 − year: "numeric",
87 − month: "long",
88 − day: "numeric",
89 − }).format(new Date());
60 +function fileHref(f: { id: string; folderId?: string | null }): string {
61 + return f.folderId ? `/dashboard/folder/${f.folderId}?file=${f.id}` : `/dashboard/files?file=${f.id}`;
90 62 }
91 63
64 +const stagger = (i: number) => ({
65 + initial: { opacity: 0, y: 10 },
66 + animate: { opacity: 1, y: 0 },
67 + transition: { duration: 0.3, delay: 0.04 * i, ease: [0.2, 0.8, 0.2, 1] as [number, number, number, number] },
68 +});
69 +
92 70 export default function DashboardHome() {
93 71 const router = useRouter();
72 + const { session } = useShell();
94 73 const [stats, setStats] = useState<Stats | null>(null);
95 74 const [recentFiles, setRecentFiles] = useState<FileData[]>([]);
96 − const [activities, setActivities] = useState<Activity[]>([]);
97 − const [session, setSession] = useState<SessionInfo | null>(null);
75 + const [favorites, setFavorites] = useState<FileData[]>([]);
76 + const [activities, setActivities] = useState<ActivityItem[]>([]);
77 + const [spaces, setSpaces] = useState<SharedSpace[]>([]);
98 78 const [loading, setLoading] = useState(true);
79 + const [now, setNow] = useState<Date>(() => new Date());
99 80
100 81 useEffect(() => {
82 + const t = setInterval(() => setNow(new Date()), 15_000);
83 + return () => clearInterval(t);
84 + }, []);
85 +
86 + useEffect(() => {
87 + const j = (r: Response) => (r.ok ? r.json() : null);
101 88 Promise.all([
102 − fetch("/api/admin/stats").then((r) => r.json()),
103 − fetch("/api/files?sortBy=updatedAt&order=desc&limit=8").then((r) => r.json()),
104 − fetch("/api/admin/activity?limit=5").then((r) => r.json()),
105 − fetch("/api/auth/session").then((r) => r.json()),
89 + fetch("/api/admin/stats").then(j),
90 + fetch("/api/files?sortBy=updatedAt&order=desc&limit=10").then(j),
91 + fetch("/api/files?favorite=true&sortBy=updatedAt&order=desc&limit=6").then(j),
92 + fetch("/api/admin/activity?limit=8").then(j),
93 + fetch("/api/shared-spaces/mine").then(j),
106 94 ])
107 − .then(([statsData, filesData, activityData, sessionData]) => {
108 − setStats(statsData);
109 − setRecentFiles(Array.isArray(filesData) ? filesData : filesData?.files || []);
110 − setActivities(activityData.activities || []);
111 − setSession(sessionData);
95 + .then(([s, files, favs, act, sp]) => {
96 + if (s) setStats(s);
97 + setRecentFiles(Array.isArray(files) ? files : files?.files || []);
98 + setFavorites(Array.isArray(favs) ? favs : favs?.files || []);
99 + setActivities(act?.activities || []);
100 + if (Array.isArray(sp)) setSpaces(sp);
112 101 })
113 102 .catch(console.error)
114 103 .finally(() => setLoading(false));
115 104 }, []);
116 105
117 − const userName = session?.name || session?.username || "Admin";
106 + const userName = session?.name || "Simon-Pierre";
107 + const storageUsed = stats?.storageUsed || 0;
108 + const storagePct = Math.min((storageUsed / STORAGE_QUOTA_BYTES) * 100, 100);
118 109
119 − if (loading) {
120 − return <DashboardSkeleton />;
121 − }
110 + const kpis = useMemo(() => [
111 + { label: "Fichiers", value: stats?.fileCount ?? 0, icon: FileText, href: "/dashboard/files", tone: "text-blue-600 bg-blue-500/10 dark:text-blue-300" },
112 + { label: "Dossiers", value: stats?.folderCount ?? 0, icon: FolderOpen, href: "/dashboard/files", tone: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-300" },
113 + { label: "Partages actifs", value: stats?.shareCount ?? 0, icon: Share2, href: "/admin/shares", tone: "text-purple-600 bg-purple-500/10 dark:text-purple-300" },
114 + { label: "Favoris", value: stats?.favoriteCount ?? 0, icon: Star, href: "/dashboard/favorites", tone: "text-amber-600 bg-amber-500/10 dark:text-amber-300" },
115 + { label: "Corbeille", value: stats?.trashCount ?? 0, icon: Trash2, href: "/dashboard/trash", tone: "text-rose-600 bg-rose-500/10 dark:text-rose-300" },
116 + ], [stats]);
122 117
123 − const storageTotal = 1024 * 1024 * 1024 * 500; // 500 GB
124 − const storageUsed = stats?.storageUsed || 0;
125 − const storagePercent = Math.min((storageUsed / storageTotal) * 100, 100);
118 + if (loading) return <DashboardSkeleton />;
126 119
127 − return (
128 − <PageTransition>
129 − <div className="p-3 sm:p-4 lg:p-6 space-y-5 sm:space-y-6">
130 − {/* Welcome Header */}
131 − <motion.div
132 − initial={{ opacity: 0, y: 10 }}
133 − animate={{ opacity: 1, y: 0 }}
134 − transition={{ duration: 0.3 }}
135 − className="relative overflow-hidden rounded-2xl p-4 sm:p-6 bg-gradient-to-br from-blue-800/5 via-blue-500/5 to-transparent dark:from-blue-800/10 dark:via-blue-500/5 dark:to-transparent border border-gray-200/50 dark:border-white/5"
136 − >
137 − <div className="absolute top-0 right-0 w-32 h-32 bg-blue-700/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 pointer-events-none" />
138 − <h1 className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-white relative">
139 − {getGreeting()}, {userName}
140 − </h1>
141 − <p className="text-sm text-gray-500 dark:text-gray-400 mt-1 capitalize relative">
142 − {formatFrenchDate()}
143 − </p>
144 − </motion.div>
120 + const dateLabel = new Intl.DateTimeFormat("fr-CA", { weekday: "long", day: "numeric", month: "long" }).format(now);
121 + const timeLabel = new Intl.DateTimeFormat("fr-CA", { hour: "2-digit", minute: "2-digit" }).format(now);
145 122
146 − {/* Quick Actions */}
147 − <motion.div
148 − initial={{ opacity: 0, y: 10 }}
149 − animate={{ opacity: 1, y: 0 }}
150 − transition={{ duration: 0.3, delay: 0.05 }}
151 − className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3"
152 − >
153 − {[
154 − { label: "Upload", icon: Upload, color: "bg-blue-500/10 text-blue-500 dark:bg-blue-500/20 dark:text-blue-400", href: "/dashboard/files" },
155 − { label: "Nouveau dossier", icon: FolderPlus, color: "bg-green-500/10 text-green-500 dark:bg-green-500/20 dark:text-green-400", href: "/dashboard/files" },
156 − { label: "Recherche AI", icon: Search, color: "bg-blue-700/10 text-blue-700 dark:bg-blue-700/20 dark:text-blue-400", href: "/dashboard/files" },
157 − { label: "Corbeille", icon: Trash2, color: "bg-red-500/10 text-red-500 dark:bg-red-500/20 dark:text-red-400", href: "/dashboard/trash" },
158 − ].map((action) => (
159 − <button
160 − key={action.label}
161 − onClick={() => router.push(action.href)}
162 − className="flex flex-col items-center gap-2 p-4 rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-white/10 transition-all active:scale-95 min-h-[88px] justify-center"
163 − >
164 − <div className={`w-10 h-10 rounded-xl flex items-center justify-center ${action.color}`}>
165 − <action.icon className="w-5 h-5" />
166 − </div>
167 − <span className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 text-center leading-tight">
168 − {action.label}
169 − </span>
170 − </button>
171 − ))}
172 − </motion.div>
123 + const quick = [
124 + { label: "Envoyer", hint: "Fichiers ou dossiers", icon: Upload, accent: true, onClick: () => router.push("/dashboard/files?upload=1") },
125 + { label: "Nouveau dossier", hint: "Organiser", icon: FolderPlus, onClick: () => router.push("/dashboard/files?new=1") },
126 + { label: "Rechercher", hint: "⌘K", icon: Search, onClick: () => openCommandPalette() },
127 + { label: "Assistant IA", hint: "Analyser, résumer", icon: Sparkles, onClick: () => toggleAiPanel() },
128 + ];
173 129
174 − {/* Stats Row */}
175 − <motion.div
176 − initial={{ opacity: 0, y: 10 }}
177 − animate={{ opacity: 1, y: 0 }}
178 − transition={{ duration: 0.3, delay: 0.1 }}
179 − className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-3"
180 − >
181 − {[
182 − { label: "Fichiers", value: stats?.fileCount || 0, icon: FileText, color: "text-blue-500" },
183 − { label: "Dossiers", value: stats?.folderCount || 0, icon: FolderOpen, color: "text-green-500" },
184 − { label: "Partages actifs", value: stats?.shareCount || 0, icon: Share2, color: "text-purple-500" },
185 − { label: "Stockage", value: formatFileSize(storageUsed), icon: HardDrive, color: "text-orange-500" },
186 − ].map((stat) => (
187 − <div
188 − key={stat.label}
189 − className="flex items-center gap-3 p-3 sm:p-4 rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/5 dark:hover:shadow-black/20"
190 − >
191 − <stat.icon className={`w-5 h-5 flex-shrink-0 ${stat.color}`} />
192 − <div className="min-w-0">
193 − <p className="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white truncate">
194 − {typeof stat.value === "number" ? stat.value.toLocaleString() : stat.value}
130 + return (
131 + <PageTransition>
132 + <div className="p-3 sm:p-5 lg:p-6 space-y-4 sm:space-y-5 max-w-[1400px] mx-auto">
133 + {/* ===== Bandeau d'accueil ===== */}
134 + <motion.section {...stagger(0)} className="relative overflow-hidden rounded-3xl border border-line dark:border-white/[0.08] bg-surface dark:bg-white/[0.02]">
135 + <div className="absolute inset-0 bg-aurora opacity-70 dark:opacity-50 pointer-events-none" />
136 + <div className="absolute inset-0 bg-grid bg-[size:36px_36px] opacity-[0.35] dark:opacity-[0.25] [mask-image:radial-gradient(ellipse_at_top_left,black_20%,transparent_70%)] pointer-events-none" />
137 + <div className="relative p-5 sm:p-7 flex flex-col lg:flex-row lg:items-end gap-5 lg:gap-8">
138 + <div className="flex-1 min-w-0">
139 + <p className="text-[12px] font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400 capitalize">{dateLabel}</p>
140 + <h2 className="mt-1 text-[26px] sm:text-[32px] font-semibold tracking-tight text-gray-900 dark:text-white leading-tight">
141 + {greeting(now)}, <span className="text-accent-gradient">{userName}</span>
142 + </h2>
143 + <p className="mt-1.5 text-[14px] text-gray-600 dark:text-gray-300">
144 + Il est <span className="font-medium tabular-nums text-gray-900 dark:text-white">{timeLabel}</span>
145 + {" · "}{stats?.fileCount?.toLocaleString("fr-CA") ?? 0} fichiers protégés
146 + {" · "}{formatFileSize(storageUsed)} utilisés
195 147 </p>
196 − <p className="text-xs text-gray-500 dark:text-gray-400 truncate">{stat.label}</p>
148 + </div>
149 + <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 lg:w-[560px] shrink-0">
150 + {quick.map((q) => (
151 + <button
152 + key={q.label}
153 + onClick={q.onClick}
154 + className={`group flex items-center gap-3 p-3 rounded-2xl text-left transition-all active:scale-[0.98] min-h-[64px] ${
155 + q.accent
156 + ? "text-white bg-accent-gradient shadow-[inset_0_1px_0_rgb(255_255_255/.2)] hover:brightness-110 hover:shadow-glow"
157 + : "bg-surface/80 dark:bg-white/[0.05] border border-line dark:border-white/10 hover:border-blue-500/40 hover:bg-surface dark:hover:bg-white/[0.08] backdrop-blur"
158 + }`}
159 + >
160 + <span className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${q.accent ? "bg-white/20" : "bg-blue-500/10 text-blue-600 dark:text-blue-300"}`}>
161 + <q.icon className="w-[18px] h-[18px]" />
162 + </span>
163 + <span className="min-w-0">
164 + <span className={`block text-[13px] font-medium leading-tight ${q.accent ? "" : "text-gray-900 dark:text-white"}`}>{q.label}</span>
165 + <span className={`block text-[11px] leading-tight mt-0.5 truncate ${q.accent ? "text-white/75" : "text-gray-400"}`}>{q.hint}</span>
166 + </span>
167 + </button>
168 + ))}
197 169 </div>
198 170 </div>
199 − ))}
200 − </motion.div>
171 + </motion.section>
201 172
202 − {/* Storage Usage */}
203 − <motion.div
204 − initial={{ opacity: 0, y: 10 }}
205 − animate={{ opacity: 1, y: 0 }}
206 − transition={{ duration: 0.3, delay: 0.15 }}
207 − className="p-4 rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10"
208 − >
209 − <div className="flex items-center justify-between mb-2">
210 − <div className="flex items-center gap-2">
211 − <HardDrive className="w-4 h-4 text-gray-500" />
212 − <span className="text-sm font-medium text-gray-700 dark:text-gray-300">Stockage</span>
213 − </div>
214 − <span className="text-xs text-gray-500 dark:text-gray-400">
215 − {formatFileSize(storageUsed)} / {formatFileSize(storageTotal)}
216 − </span>
217 − </div>
218 − <div className="w-full h-2.5 bg-gray-100 dark:bg-white/10 rounded-full overflow-hidden">
219 − <motion.div
220 − initial={{ width: 0 }}
221 − animate={{ width: `${storagePercent}%` }}
222 − transition={{ duration: 0.8, ease: "easeOut", delay: 0.3 }}
223 − className={`h-full rounded-full ${
224 − storagePercent > 90
225 − ? "bg-red-500"
226 − : storagePercent > 70
227 − ? "bg-orange-500"
228 − : "bg-blue-500"
229 − }`}
230 − />
231 − </div>
232 − <p className="text-xs text-gray-400 mt-1.5">
233 − {storagePercent.toFixed(1)}% utilise
234 − </p>
235 − </motion.div>
173 + {/* ===== KPI ===== */}
174 + <motion.section {...stagger(1)} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2 sm:gap-3">
175 + {kpis.map((k) => (
176 + <Link key={k.label} href={k.href} className="card card-hover p-3.5 sm:p-4 flex items-center gap-3 group">
177 + <span className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${k.tone}`}>
178 + <k.icon className="w-[18px] h-[18px]" />
179 + </span>
180 + <span className="min-w-0">
181 + <span className="block text-[20px] sm:text-[22px] font-semibold tracking-tight text-gray-900 dark:text-white tabular-nums leading-none">{k.value.toLocaleString("fr-CA")}</span>
182 + <span className="block text-[11.5px] text-gray-500 dark:text-gray-400 mt-1 truncate">{k.label}</span>
183 + </span>
184 + <ArrowRight className="w-4 h-4 text-gray-300 dark:text-gray-600 ml-auto opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all shrink-0 hidden sm:block" />
185 + </Link>
186 + ))}
187 + </motion.section>
236 188
237 − {/* Recent Files */}
238 − <motion.div
239 − initial={{ opacity: 0, y: 10 }}
240 − animate={{ opacity: 1, y: 0 }}
241 − transition={{ duration: 0.3, delay: 0.2 }}
242 − >
243 − <div className="flex items-center justify-between mb-3">
244 − <h2 className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
245 − <Clock className="w-4 h-4" />
246 − Fichiers recents
247 − </h2>
248 − <button
249 − onClick={() => router.push("/dashboard/recent")}
250 − className="text-xs text-blue-500 hover:text-blue-400 flex items-center gap-1 transition-colors"
251 − >
252 − Tout voir <ArrowRight className="w-3 h-3" />
253 − </button>
189 + {/* ===== Stockage + Activité ===== */}
190 + <div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-5">
191 + <motion.div {...stagger(2)} className="lg:col-span-5">
192 + <Card className="h-full">
193 + <CardTitle action={<Link href="/admin" className="text-[12px] text-blue-600 dark:text-blue-300 hover:underline flex items-center gap-1">Détails <ArrowRight className="w-3 h-3" /></Link>}>
194 + <HardDrive className="w-3.5 h-3.5" /> Stockage
195 + </CardTitle>
196 + <StorageDonut data={stats?.byCategory || []} totalUsed={storageUsed} quota={STORAGE_QUOTA_BYTES} size={168} />
197 + <div className="mt-4 pt-4 border-t border-line dark:border-white/[0.07]">
198 + <div className="flex items-center justify-between text-[12px] mb-1.5">
199 + <span className="text-gray-600 dark:text-gray-300">{formatFileSize(storageUsed)} sur {formatFileSize(STORAGE_QUOTA_BYTES)}</span>
200 + <span className="text-gray-400 tabular-nums">{storagePct.toFixed(1)} %</span>
201 + </div>
202 + <div className="h-1.5 rounded-full bg-gray-200 dark:bg-white/10 overflow-hidden">
203 + <motion.div initial={{ width: 0 }} animate={{ width: `${Math.max(1.5, storagePct)}%` }} transition={{ duration: 0.8, ease: "easeOut", delay: 0.3 }} className={`h-full rounded-full ${storagePct > 90 ? "bg-rose-500" : storagePct > 70 ? "bg-amber-500" : "bg-accent-gradient"}`} />
204 + </div>
205 + {typeof stats?.versionCount === "number" && stats.versionCount > 0 && (
206 + <p className="text-[11px] text-gray-400 mt-2 flex items-center gap-1.5"><Layers className="w-3 h-3" /> {stats.versionCount.toLocaleString("fr-CA")} version{stats.versionCount > 1 ? "s" : ""} conservée{stats.versionCount > 1 ? "s" : ""}</p>
207 + )}
208 + </div>
209 + </Card>
210 + </motion.div>
211 +
212 + <motion.div {...stagger(3)} className="lg:col-span-7">
213 + <Card className="h-full">
214 + <CardTitle action={<Link href="/admin/activity" className="text-[12px] text-blue-600 dark:text-blue-300 hover:underline flex items-center gap-1">Journal <ArrowRight className="w-3 h-3" /></Link>}>
215 + <Clock className="w-3.5 h-3.5" /> Activité
216 + </CardTitle>
217 + {stats?.activityByDay && stats.activityByDay.length > 0 ? (
218 + <ActivityBars data={stats.activityByDay} height={150} />
219 + ) : (
220 + <EmptyState icon={Clock} title="Pas encore d'activité" description="Le graphique se remplit au fil de vos actions." />
221 + )}
222 + </Card>
223 + </motion.div>
254 224 </div>
255 225
256 − {recentFiles.length === 0 ? (
257 − <div className="p-8 text-center text-gray-400 dark:text-gray-500 rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10">
258 − <File className="w-10 h-10 mx-auto mb-2 opacity-50" />
259 − <p className="text-sm">Aucun fichier recent</p>
226 + {/* ===== Reprendre ===== */}
227 + <motion.section {...stagger(4)}>
228 + <div className="flex items-center justify-between mb-2.5 px-0.5">
229 + <h2 className="text-[13px] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 flex items-center gap-2"><Clock className="w-3.5 h-3.5" /> Reprendre</h2>
230 + <Link href="/dashboard/recent" className="text-[12px] text-blue-600 dark:text-blue-300 hover:underline flex items-center gap-1">Tout voir <ArrowRight className="w-3 h-3" /></Link>
260 231 </div>
261 − ) : (
262 − <div className="flex gap-2 sm:gap-3 overflow-x-auto pb-2 -mx-3 px-3 sm:-mx-4 sm:px-4 lg:-mx-6 lg:px-6 scrollbar-thin">
263 − {recentFiles.map((file, i) => {
264 − const iconType = getFileIcon(file.mimeType);
265 − const IconComponent = iconMap[iconType] || File;
266 − return (
267 − <motion.button
268 − key={file.id}
269 − initial={{ opacity: 0, scale: 0.95 }}
270 − animate={{ opacity: 1, scale: 1 }}
271 − transition={{ duration: 0.2, delay: 0.05 * i }}
272 − onClick={() => router.push("/dashboard/files")}
273 − className="flex-shrink-0 w-36 sm:w-44 p-3 rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-white/10 transition-all text-left active:scale-95 hover:scale-[1.03] hover:shadow-lg hover:shadow-black/5 dark:hover:shadow-black/20"
274 − >
275 − {file.mimeType.startsWith("image/") ? (
276 − <div className="w-full h-20 rounded-xl bg-gray-100 dark:bg-white/10 mb-2 overflow-hidden">
277 − <img
278 − src={`/api/files/${file.id}/raw`}
279 − alt={file.name}
280 − className="w-full h-full object-cover"
281 − loading="lazy"
282 − />
283 − </div>
284 − ) : (
285 − <div className="w-full h-20 rounded-xl bg-gray-100 dark:bg-white/10 mb-2 flex items-center justify-center">
286 − <IconComponent className="w-8 h-8 text-gray-400 dark:text-gray-500" />
232 + {recentFiles.length === 0 ? (
233 + <Card padding="none"><EmptyState icon={File} title="Aucun fichier récent" description="Envoyez vos premiers fichiers pour commencer." /></Card>
234 + ) : (
235 + <div className="flex gap-2.5 sm:gap-3 overflow-x-auto pb-2 -mx-3 px-3 sm:-mx-5 sm:px-5 lg:-mx-6 lg:px-6 scrollbar-none snap-x">
236 + {recentFiles.map((file, i) => {
237 + const t = getFileIcon(file.mimeType);
238 + const Icon = iconMap[t] || File;
239 + const isImage = file.mimeType.startsWith("image/");
240 + return (
241 + <motion.button
242 + key={file.id}
243 + initial={{ opacity: 0, scale: 0.96 }}
244 + animate={{ opacity: 1, scale: 1 }}
245 + transition={{ duration: 0.22, delay: 0.03 * i }}
246 + onClick={() => router.push(fileHref(file))}
247 + className="card card-hover snap-start shrink-0 w-[150px] sm:w-[176px] p-2 text-left group"
248 + title={file.name}
249 + >
250 + <div className={`relative w-full aspect-[4/3] rounded-xl overflow-hidden flex items-center justify-center ${isImage ? "bg-gray-100 dark:bg-white/5" : toneMap[t] || toneMap.file}`}>
251 + {isImage ? (
252 + // eslint-disable-next-line @next/next/no-img-element
253 + <img src={`/api/files/${file.id}/preview?w=400`} alt="" className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.04]" loading="lazy" decoding="async" />
254 + ) : (
255 + <Icon className="w-9 h-9" />
256 + )}
257 + {file.metadata?.isFavorite && <Star className="absolute top-2 left-2 w-3.5 h-3.5 text-amber-400 fill-amber-400 drop-shadow" />}
287 258 </div>
288 − )}
289 − <p className="text-xs font-medium text-gray-900 dark:text-white truncate">
290 − {file.name}
291 − </p>
292 − <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
293 − {formatFileSize(file.size)}
294 − </p>
295 − </motion.button>
296 − );
297 − })}
298 − </div>
299 − )}
300 − </motion.div>
259 + <p className="mt-2 px-1 text-[12.5px] font-medium text-gray-900 dark:text-white truncate">{file.name}</p>
260 + <p className="px-1 pb-0.5 text-[11px] text-gray-400 truncate">{formatFileSize(file.size)}</p>
261 + </motion.button>
262 + );
263 + })}
264 + </div>
265 + )}
266 + </motion.section>
301 267
302 − {/* Recent Activity */}
303 − <motion.div
304 − initial={{ opacity: 0, y: 10 }}
305 − animate={{ opacity: 1, y: 0 }}
306 − transition={{ duration: 0.3, delay: 0.25 }}
307 − >
308 − <h2 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
309 − <Clock className="w-4 h-4" />
310 − Activite recente
311 − </h2>
268 + {/* ===== Timeline + colonne latérale ===== */}
269 + <div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-5">
270 + <motion.div {...stagger(5)} className="lg:col-span-7">
271 + <Card>
272 + <CardTitle action={<Link href="/admin/activity" className="text-[12px] text-blue-600 dark:text-blue-300 hover:underline flex items-center gap-1">Tout le journal <ArrowRight className="w-3 h-3" /></Link>}>
273 + <Clock className="w-3.5 h-3.5" /> Activité récente
274 + </CardTitle>
275 + <ActivityTimeline activities={activities} limit={8} />
276 + </Card>
277 + </motion.div>
312 278
313 − {activities.length === 0 ? (
314 − <div className="p-8 text-center text-gray-400 dark:text-gray-500 rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10">
315 − <Clock className="w-10 h-10 mx-auto mb-2 opacity-50" />
316 − <p className="text-sm">Aucune activite recente</p>
317 − </div>
318 − ) : (
319 − <div className="rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 divide-y divide-gray-100 dark:divide-white/5">
320 − {activities.map((activity) => (
321 − <div key={activity.id} className="flex items-center gap-3 px-4 py-3">
322 − <div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
323 − <div className="min-w-0 flex-1">
324 − <p className="text-sm text-gray-700 dark:text-gray-300 truncate">
325 − <span className="text-gray-400 dark:text-gray-500">
326 − {actionLabels[activity.action] || activity.action}
327 − </span>{" "}
328 − {activity.file?.name && (
329 − <span className="font-medium text-gray-900 dark:text-white">
330 − {activity.file.name}
331 − </span>
332 − )}
333 − </p>
334 − {activity.details && (
335 − <p className="text-xs text-gray-400 truncate">{activity.details}</p>
336 − )}
337 − </div>
338 − <span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0 whitespace-nowrap">
339 − {formatDate(activity.createdAt)}
340 − </span>
341 − </div>
342 − ))}
279 + <div className="lg:col-span-5 space-y-4 sm:space-y-5">
280 + {/* Favoris */}
281 + <motion.div {...stagger(6)}>
282 + <Card>
283 + <CardTitle action={<Link href="/dashboard/favorites" className="text-[12px] text-blue-600 dark:text-blue-300 hover:underline flex items-center gap-1">Tous <ArrowRight className="w-3 h-3" /></Link>}>
284 + <Star className="w-3.5 h-3.5" /> Favoris
285 + </CardTitle>
286 + {favorites.length === 0 ? (
287 + <p className="text-[13px] text-gray-400 py-3 text-center">Marquez des fichiers d&apos;une étoile pour les retrouver ici.</p>
288 + ) : (
289 + <ul className="-mx-1">
290 + {favorites.map((f) => {
291 + const t = getFileIcon(f.mimeType);
292 + const Icon = iconMap[t] || File;
293 + return (
294 + <li key={f.id}>
295 + <button onClick={() => router.push(fileHref(f))} className="w-full flex items-center gap-3 px-2 h-11 rounded-xl hover:bg-gray-50 dark:hover:bg-white/[0.04] transition-colors text-left">
296 + <span className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${toneMap[t] || toneMap.file}`}><Icon className="w-4 h-4" /></span>
297 + <span className="flex-1 min-w-0 text-[13px] text-gray-800 dark:text-gray-100 truncate">{f.name}</span>
298 + <span className="text-[11px] text-gray-400 tabular-nums shrink-0">{formatFileSize(f.size)}</span>
299 + </button>
300 + </li>
301 + );
302 + })}
303 + </ul>
304 + )}
305 + </Card>
306 + </motion.div>
307 +
308 + {/* Plus volumineux */}
309 + {stats?.largestFiles && stats.largestFiles.length > 0 && (
310 + <motion.div {...stagger(7)}>
311 + <Card>
312 + <CardTitle><Scale className="w-3.5 h-3.5" /> Les plus volumineux</CardTitle>
313 + <ul className="-mx-1">
314 + {stats.largestFiles.map((f) => {
315 + const t = getFileIcon(f.mimeType);
316 + const Icon = iconMap[t] || File;
317 + const pct = storageUsed > 0 ? (f.size / storageUsed) * 100 : 0;
318 + return (
319 + <li key={f.id}>
320 + <button onClick={() => router.push(fileHref(f))} className="w-full flex items-center gap-3 px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-white/[0.04] transition-colors text-left">
321 + <span className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${toneMap[t] || toneMap.file}`}><Icon className="w-4 h-4" /></span>
322 + <span className="flex-1 min-w-0">
323 + <span className="block text-[13px] text-gray-800 dark:text-gray-100 truncate">{f.name}</span>
324 + <span className="block mt-1 h-1 rounded-full bg-gray-200 dark:bg-white/10 overflow-hidden"><span className="block h-full rounded-full bg-blue-500/70" style={{ width: `${Math.max(2, pct)}%` }} /></span>
325 + </span>
326 + <span className="text-[11.5px] text-gray-500 dark:text-gray-400 tabular-nums shrink-0">{formatFileSize(f.size)}</span>
327 + </button>
328 + </li>
329 + );
330 + })}
331 + </ul>
332 + </Card>
333 + </motion.div>
334 + )}
335 +
336 + {/* Espaces partagés */}
337 + {spaces.length > 0 && (
338 + <motion.div {...stagger(8)}>
339 + <Card>
340 + <CardTitle action={<Link href="/dashboard/shared-with-me" className="text-[12px] text-blue-600 dark:text-blue-300 hover:underline flex items-center gap-1">Voir <ArrowRight className="w-3 h-3" /></Link>}>
341 + <Users className="w-3.5 h-3.5" /> Espaces partagés
342 + </CardTitle>
343 + <ul className="-mx-1">
344 + {spaces.map((s) => (
345 + <li key={s.id}>
346 + <Link href={`/dashboard/shared-space/${s.id}`} className="flex items-center gap-3 px-2 h-11 rounded-xl hover:bg-gray-50 dark:hover:bg-white/[0.04] transition-colors">
347 + <span className="w-8 h-8 rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-300 flex items-center justify-center shrink-0"><Users className="w-4 h-4" /></span>
348 + <span className="flex-1 min-w-0 text-[13px] text-gray-800 dark:text-gray-100 truncate">{s.name}</span>
349 + <span className="text-[11px] text-gray-400 tabular-nums shrink-0">{formatFileSize(Number(s.quotaBytes))}</span>
350 + </Link>
351 + </li>
352 + ))}
353 + </ul>
354 + </Card>
355 + </motion.div>
356 + )}
343 357 </div>
344 − )}
345 − </motion.div>
346 − </div>
358 + </div>
359 + </div>
347 360 </PageTransition>
348 361 );
349 362 }
added components/dashboard/StorageDonut.tsx +121 −0
@@ -0,0 +1,121 @@
1 +"use client";
2 +
3 +import { useMemo, useState } from "react";
4 +import { motion } from "framer-motion";
5 +import { formatFileSize } from "@/lib/utils";
6 +import { useTheme } from "@/components/theme/ThemeProvider";
7 +import { CATEGORY_BY_KEY, type CategoryStat } from "./categories";
8 +
9 +interface StorageDonutProps {
10 + data: CategoryStat[];
11 + /** Total utilisé (octets). Si absent, somme des catégories. */
12 + totalUsed?: number;
13 + /** Quota d'affichage (octets) — affiche le % au centre si fourni. */
14 + quota?: number;
15 + /** Taille du donut en px */
16 + size?: number;
17 + /** Légende à droite (desktop) ou en dessous */
18 + legend?: "side" | "below";
19 + className?: string;
20 +}
21 +
22 +/**
23 + * Anneau de stockage par catégorie (SVG, 0 dépendance).
24 + * - Couleur par entité (ordre fixe), jamais par rang.
25 + * - 2 px d'espace surface entre segments (spacer).
26 + * - Légende interactive (survol/tap = mise en avant + valeur au centre).
27 + */
28 +export default function StorageDonut({ data, totalUsed, quota, size = 176, legend = "side", className = "" }: StorageDonutProps) {
29 + const { theme } = useTheme();
30 + const dark = theme === "dark";
31 + const [active, setActive] = useState<string | null>(null);
32 +
33 + const items = useMemo(() => data.filter((d) => d.size > 0), [data]);
34 + const sum = useMemo(() => items.reduce((a, d) => a + d.size, 0), [items]);
35 + const total = totalUsed ?? sum;
36 +
37 + const stroke = 14;
38 + const r = (size - stroke) / 2;
39 + const c = 2 * Math.PI * r;
40 + const gapPx = items.length > 1 ? 2.5 : 0;
41 +
42 + // Segments : proportion du total *utilisé* (le donut représente la répartition)
43 + let offset = 0;
44 + const segments = items.map((d) => {
45 + const frac = sum > 0 ? d.size / sum : 0;
46 + const len = Math.max(0, frac * c - gapPx);
47 + const seg = { ...d, frac, dash: `${len} ${c - len}`, dashOffset: -offset, color: dark ? CATEGORY_BY_KEY[d.key].dark : CATEGORY_BY_KEY[d.key].light };
48 + offset += frac * c;
49 + return seg;
50 + });
51 +
52 + const activeItem = active ? items.find((i) => i.key === active) : null;
53 + const centerMain = activeItem ? formatFileSize(activeItem.size) : formatFileSize(total);
54 + const centerSub = activeItem
55 + ? `${activeItem.label} · ${sum > 0 ? Math.round((activeItem.size / sum) * 100) : 0} %`
56 + : quota
57 + ? `${((total / quota) * 100).toFixed(1)} % de ${formatFileSize(quota)}`
58 + : "utilisés";
59 +
60 + return (
61 + <div className={`flex ${legend === "side" ? "flex-col sm:flex-row sm:items-center" : "flex-col"} gap-5 ${className}`}>
62 + <div className="relative shrink-0 mx-auto sm:mx-0" style={{ width: size, height: size }}>
63 + <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label={`Répartition du stockage : ${items.map((i) => `${i.label} ${formatFileSize(i.size)}`).join(", ")}`}>
64 + <circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-gray-200/80 dark:stroke-white/[0.07]" />
65 + <g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
66 + {segments.map((s, i) => (
67 + <motion.circle
68 + key={s.key}
69 + cx={size / 2}
70 + cy={size / 2}
71 + r={r}
72 + fill="none"
73 + stroke={s.color}
74 + strokeWidth={active && active !== s.key ? stroke - 4 : stroke}
75 + strokeLinecap="butt"
76 + strokeDasharray={s.dash}
77 + initial={{ strokeDashoffset: 0, opacity: 0 }}
78 + animate={{ strokeDashoffset: s.dashOffset, opacity: active && active !== s.key ? 0.35 : 1 }}
79 + transition={{ duration: 0.7, delay: 0.1 + i * 0.05, ease: [0.2, 0.8, 0.2, 1] }}
80 + onMouseEnter={() => setActive(s.key)}
81 + onMouseLeave={() => setActive(null)}
82 + onClick={() => setActive(active === s.key ? null : s.key)}
83 + style={{ cursor: "pointer", transition: "stroke-width .2s" }}
84 + />
85 + ))}
86 + </g>
87 + </svg>
88 + <div className="absolute inset-0 flex flex-col items-center justify-center text-center px-6 pointer-events-none">
89 + <span className="text-[19px] sm:text-[21px] font-semibold tracking-tight text-gray-900 dark:text-white tabular-nums leading-none">{centerMain}</span>
90 + <span className="text-[11px] text-gray-500 dark:text-gray-400 mt-1.5 leading-tight">{centerSub}</span>
91 + </div>
92 + </div>
93 +
94 + <ul className={`min-w-0 flex-1 grid gap-1 ${legend === "side" ? "grid-cols-1" : "grid-cols-2"}`} role="list">
95 + {items.length === 0 && <li className="text-sm text-gray-400">Aucun fichier</li>}
96 + {items.map((d) => {
97 + const color = dark ? CATEGORY_BY_KEY[d.key].dark : CATEGORY_BY_KEY[d.key].light;
98 + const pct = sum > 0 ? (d.size / sum) * 100 : 0;
99 + const on = active === d.key;
100 + return (
101 + <li key={d.key}>
102 + <button
103 + type="button"
104 + onMouseEnter={() => setActive(d.key)}
105 + onMouseLeave={() => setActive(null)}
106 + onClick={() => setActive(on ? null : d.key)}
107 + className={`w-full flex items-center gap-2.5 px-2 h-9 rounded-lg text-left transition-colors ${on ? "bg-gray-100 dark:bg-white/[0.06]" : "hover:bg-gray-50 dark:hover:bg-white/[0.04]"}`}
108 + aria-pressed={on}
109 + >
110 + <span className="w-2.5 h-2.5 rounded-full shrink-0 ring-2 ring-white dark:ring-[#12131a]" style={{ background: color }} />
111 + <span className="flex-1 min-w-0 text-[13px] text-gray-700 dark:text-gray-200 truncate">{d.label}</span>
112 + <span className="text-[12px] text-gray-500 dark:text-gray-400 tabular-nums shrink-0">{formatFileSize(d.size)}</span>
113 + <span className="w-10 text-right text-[11px] text-gray-400 tabular-nums shrink-0">{pct < 1 && pct > 0 ? "<1" : Math.round(pct)} %</span>
114 + </button>
115 + </li>
116 + );
117 + })}
118 + </ul>
119 + </div>
120 + );
121 +}
added components/dashboard/categories.ts +115 −0
@@ -0,0 +1,115 @@
1 +/**
2 + * Catégories de fichiers partagées (API stats + dataviz du tableau de bord).
3 + * Palette catégorielle validée (skill dataviz, ordre fixe, jamais cyclée) :
4 + * clair/sombre sélectionnés séparément, gris neutre réservé à « Autres ».
5 + */
6 +
7 +export type CategoryKey =
8 + | "documents"
9 + | "images"
10 + | "videos"
11 + | "code"
12 + | "audio"
13 + | "spreadsheets"
14 + | "archives"
15 + | "other";
16 +
17 +export interface CategoryDef {
18 + key: CategoryKey;
19 + label: string;
20 + light: string;
21 + dark: string;
22 +}
23 +
24 +/** Ordre = ordre des slots de la palette (fixe). */
25 +export const CATEGORIES: CategoryDef[] = [
26 + { key: "documents", label: "Documents", light: "#2a78d6", dark: "#3987e5" },
27 + { key: "images", label: "Images", light: "#eb6834", dark: "#d95926" },
28 + { key: "videos", label: "Vidéos", light: "#1baf7a", dark: "#199e70" },
29 + { key: "code", label: "Code & texte", light: "#eda100", dark: "#c98500" },
30 + { key: "audio", label: "Audio", light: "#e87ba4", dark: "#d55181" },
31 + { key: "spreadsheets", label: "Tableurs", light: "#008300", dark: "#008300" },
32 + { key: "archives", label: "Archives", light: "#4a3aa7", dark: "#9085e9" },
33 + { key: "other", label: "Autres", light: "#9aa1ad", dark: "#6b7280" },
34 +];
35 +
36 +export const CATEGORY_BY_KEY: Record<CategoryKey, CategoryDef> = Object.fromEntries(
37 + CATEGORIES.map((c) => [c.key, c])
38 +) as Record<CategoryKey, CategoryDef>;
39 +
40 +export function categorize(mimeType: string): CategoryKey {
41 + const m = (mimeType || "").toLowerCase();
42 + if (m.startsWith("image/")) return "images";
43 + if (m.startsWith("video/")) return "videos";
44 + if (m.startsWith("audio/")) return "audio";
45 + if (
46 + m.includes("spreadsheet") ||
47 + m.includes("excel") ||
48 + m === "text/csv" ||
49 + m.includes("ms-excel") ||
50 + m.includes("numbers")
51 + )
52 + return "spreadsheets";
53 + if (
54 + m === "application/pdf" ||
55 + m.includes("word") ||
56 + m.includes("officedocument.wordprocessingml") ||
57 + m.includes("presentation") ||
58 + m.includes("powerpoint") ||
59 + m.includes("rtf") ||
60 + m.includes("opendocument") ||
61 + m.includes("pages") ||
62 + m.includes("epub") ||
63 + m.includes("keynote")
64 + )
65 + return "documents";
66 + if (
67 + m.includes("zip") ||
68 + m.includes("rar") ||
69 + m.includes("7z") ||
70 + m.includes("tar") ||
71 + m.includes("gzip") ||
72 + m.includes("compressed") ||
73 + m.includes("archive") ||
74 + m.includes("x-bzip") ||
75 + m.includes("x-xz")
76 + )
77 + return "archives";
78 + if (
79 + m.startsWith("text/") ||
80 + m.includes("json") ||
81 + m.includes("xml") ||
82 + m.includes("javascript") ||
83 + m.includes("typescript") ||
84 + m.includes("yaml") ||
85 + m.includes("toml") ||
86 + m.includes("x-sh") ||
87 + m.includes("x-python") ||
88 + m.includes("markdown") ||
89 + m.includes("html") ||
90 + m.includes("css") ||
91 + m.includes("sql")
92 + )
93 + return "code";
94 + return "other";
95 +}
96 +
97 +export interface CategoryStat {
98 + key: CategoryKey;
99 + label: string;
100 + count: number;
101 + size: number;
102 +}
103 +
104 +export function aggregateByCategory(rows: { type: string; count: number; size: number }[]): CategoryStat[] {
105 + const acc = new Map<CategoryKey, CategoryStat>();
106 + for (const c of CATEGORIES) acc.set(c.key, { key: c.key, label: c.label, count: 0, size: 0 });
107 + for (const r of rows) {
108 + const k = categorize(r.type);
109 + const s = acc.get(k)!;
110 + s.count += r.count;
111 + s.size += r.size;
112 + }
113 + // Ordre fixe (palette), on ne trie PAS par taille : la couleur suit l'entité.
114 + return Array.from(acc.values());
115 +}
modified components/files/BulkActions.tsx +69 −53
@@ -1,20 +1,26 @@
1 1 "use client";
2 2
3 3 import { useState } from "react";
4 −import { Trash2, FolderInput, Download, Lock, Unlock, X, Sparkles } from "lucide-react";
4 +import { motion, AnimatePresence } from "framer-motion";
5 +import { Trash2, FolderInput, Download, Lock, Unlock, X, Sparkles, CheckSquare } from "lucide-react";
5 6 import Button from "../ui/Button";
6 7 import Modal from "../ui/Modal";
8 +import MoveModal from "./MoveModal";
7 9 import toast from "react-hot-toast";
8 10
9 11 interface BulkActionsProps {
10 12 selectedIds: string[];
13 + totalCount?: number;
11 14 onClear: () => void;
12 15 onRefresh: () => void;
16 + onSelectAll?: () => void;
17 + /** Dossier courant (désactivé comme destination) */
18 + currentFolderId?: string | null;
13 19 }
14 20
15 −export default function BulkActions({ selectedIds, onClear, onRefresh }: BulkActionsProps) {
21 +export default function BulkActions({ selectedIds, totalCount, onClear, onRefresh, onSelectAll, currentFolderId }: BulkActionsProps) {
16 22 const [moveModal, setMoveModal] = useState(false);
17 − const [folders, setFolders] = useState<Array<{ id: string; name: string }>>([]);
23 + const [deleteModal, setDeleteModal] = useState(false);
18 24 const [loading, setLoading] = useState(false);
19 25
20 26 const bulkAction = async (action: string, extra?: Record<string, unknown>) => {
@@ -31,13 +37,13 @@ export default function BulkActions({ selectedIds, onClear, onRefresh }: BulkAct
31 37 const url = URL.createObjectURL(blob);
32 38 const a = document.createElement("a");
33 39 a.href = url;
34 − a.download = "files.zip";
40 + a.download = "fichiers.zip";
35 41 a.click();
36 42 URL.revokeObjectURL(url);
37 43 toast.success("Téléchargement lancé");
38 44 } else if (res.ok) {
39 − const data = await res.json();
40 − toast.success(`Action terminée (${data.count || selectedIds.length} fichiers)`);
45 + const data = await res.json().catch(() => ({}));
46 + toast.success(`Action terminée (${data.count || selectedIds.length} fichier${(data.count || selectedIds.length) > 1 ? "s" : ""})`);
41 47 onRefresh();
42 48 onClear();
43 49 } else {
@@ -49,15 +55,9 @@ export default function BulkActions({ selectedIds, onClear, onRefresh }: BulkAct
49 55 setLoading(false);
50 56 };
51 57
52 − const openMoveModal = async () => {
53 − const res = await fetch("/api/folders?tree=true");
54 − if (res.ok) setFolders(await res.json());
55 − setMoveModal(true);
56 − };
57 −
58 58 const handleMove = async (targetFolderId: string) => {
59 − await bulkAction("move", { targetFolderId });
60 59 setMoveModal(false);
60 + await bulkAction("move", { targetFolderId });
61 61 };
62 62
63 63 const aiTagAll = async () => {
@@ -76,50 +76,66 @@ export default function BulkActions({ selectedIds, onClear, onRefresh }: BulkAct
76 76 onRefresh();
77 77 };
78 78
79 − if (selectedIds.length === 0) return null;
79 + const n = selectedIds.length;
80 80
81 81 return (
82 82 <>
83 − <div className="flex items-center gap-1.5 sm:gap-2 px-4 py-2 bg-blue-50 dark:bg-blue-500/10 border-b border-blue-200 dark:border-blue-500/20 overflow-x-auto">
84 − <span className="text-sm text-blue-700 dark:text-blue-300 font-medium whitespace-nowrap flex-shrink-0">
85 − {selectedIds.length} sélectionné{selectedIds.length > 1 ? "s" : ""}
86 − </span>
87 − <div className="flex-1 min-w-0" />
88 − <div className="flex items-center gap-1.5 flex-nowrap flex-shrink-0">
89 − <Button size="sm" variant="ghost" onClick={() => bulkAction("download")} loading={loading} className="min-h-[40px] whitespace-nowrap">
90 − <Download className="w-3.5 h-3.5" /> <span className="hidden sm:inline">ZIP</span>
91 − </Button>
92 − <Button size="sm" variant="ghost" onClick={openMoveModal} className="min-h-[40px] whitespace-nowrap">
93 − <FolderInput className="w-3.5 h-3.5" /> <span className="hidden sm:inline">Déplacer</span>
94 − </Button>
95 − <Button size="sm" variant="ghost" onClick={() => bulkAction("encrypt")} loading={loading} className="min-h-[40px] whitespace-nowrap">
96 − <Lock className="w-3.5 h-3.5" /> <span className="hidden sm:inline">Chiffrer</span>
97 − </Button>
98 − <Button size="sm" variant="ghost" onClick={() => bulkAction("decrypt")} loading={loading} className="min-h-[40px] whitespace-nowrap">
99 − <Unlock className="w-3.5 h-3.5" /> <span className="hidden sm:inline">Déchiffrer</span>
100 − </Button>
101 − <Button size="sm" variant="ghost" onClick={aiTagAll} loading={loading} className="min-h-[40px] whitespace-nowrap">
102 − <Sparkles className="w-3.5 h-3.5" /> <span className="hidden sm:inline">Tags IA</span>
103 − </Button>
104 − <Button data-bulk-delete size="sm" variant="danger" onClick={() => { if (confirm(`Supprimer ${selectedIds.length} fichier(s) ?`)) bulkAction("delete"); }} loading={loading} className="min-h-[40px] whitespace-nowrap">
105 − <Trash2 className="w-3.5 h-3.5" /> <span className="hidden sm:inline">Supprimer</span>
106 − </Button>
107 − </div>
108 − <button onClick={onClear} className="min-w-[44px] min-h-[44px] flex items-center justify-center text-gray-400 hover:text-gray-700 dark:hover:text-white flex-shrink-0">
109 − <X className="w-4 h-4" />
110 − </button>
111 − </div>
83 + <AnimatePresence>
84 + {n > 0 && (
85 + <motion.div
86 + initial={{ opacity: 0, y: 24, scale: 0.98 }}
87 + animate={{ opacity: 1, y: 0, scale: 1 }}
88 + exit={{ opacity: 0, y: 24, scale: 0.98 }}
89 + transition={{ type: "spring", stiffness: 420, damping: 34 }}
90 + className="fixed z-40 left-1/2 -translate-x-1/2 bottom-[calc(4.5rem+env(safe-area-inset-bottom,0px))] lg:bottom-6 w-[calc(100vw-1.5rem)] sm:w-auto max-w-[calc(100vw-1.5rem)]"
91 + role="toolbar"
92 + aria-label="Actions sur la sélection"
93 + >
94 + <div className="glass rounded-2xl shadow-pop flex items-center gap-1 sm:gap-1.5 px-2 py-1.5 overflow-x-auto scrollbar-none">
95 + <div className="flex items-center gap-2 pl-2 pr-3 shrink-0 border-r border-line dark:border-white/10 mr-1">
96 + <span className="w-7 h-7 rounded-lg bg-blue-500 text-white text-[12px] font-semibold flex items-center justify-center tabular-nums">{n}</span>
97 + <span className="text-[13px] text-gray-700 dark:text-gray-200 whitespace-nowrap hidden sm:inline">sélectionné{n > 1 ? "s" : ""}</span>
98 + </div>
99 + {onSelectAll && totalCount !== undefined && n < totalCount && (
100 + <Button size="sm" variant="ghost" onClick={onSelectAll} className="whitespace-nowrap">
101 + <CheckSquare className="w-4 h-4" /> <span className="hidden md:inline">Tout ({totalCount})</span>
102 + </Button>
103 + )}
104 + <Button size="sm" variant="ghost" onClick={() => bulkAction("download")} loading={loading} title="Télécharger en ZIP">
105 + <Download className="w-4 h-4" /> <span className="hidden sm:inline">ZIP</span>
106 + </Button>
107 + <Button size="sm" variant="ghost" onClick={() => setMoveModal(true)} title="Déplacer">
108 + <FolderInput className="w-4 h-4" /> <span className="hidden sm:inline">Déplacer</span>
109 + </Button>
110 + <Button size="sm" variant="ghost" onClick={() => bulkAction("encrypt")} loading={loading} title="Chiffrer" className="hidden sm:inline-flex">
111 + <Lock className="w-4 h-4" /> <span className="hidden lg:inline">Chiffrer</span>
112 + </Button>
113 + <Button size="sm" variant="ghost" onClick={() => bulkAction("decrypt")} loading={loading} title="Déchiffrer" className="hidden sm:inline-flex">
114 + <Unlock className="w-4 h-4" /> <span className="hidden lg:inline">Déchiffrer</span>
115 + </Button>
116 + <Button size="sm" variant="ghost" onClick={aiTagAll} loading={loading} title="Tags IA">
117 + <Sparkles className="w-4 h-4 text-blue-500" /> <span className="hidden sm:inline">Tags IA</span>
118 + </Button>
119 + <Button data-bulk-delete size="sm" variant="danger" onClick={() => setDeleteModal(true)} loading={loading} title="Supprimer">
120 + <Trash2 className="w-4 h-4" /> <span className="hidden sm:inline">Supprimer</span>
121 + </Button>
122 + <button onClick={onClear} className="w-9 h-9 shrink-0 flex items-center justify-center rounded-xl text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" aria-label="Annuler la sélection" title="Annuler (Échap)">
123 + <X className="w-4 h-4" />
124 + </button>
125 + </div>
126 + </motion.div>
127 + )}
128 + </AnimatePresence>
129 +
130 + <MoveModal isOpen={moveModal} onClose={() => setMoveModal(false)} onSelect={handleMove} title={`Déplacer ${n} fichier${n > 1 ? "s" : ""}`} currentFolderId={currentFolderId ?? "root"} />
112 131
113 − <Modal isOpen={moveModal} onClose={() => setMoveModal(false)} title="Déplacer vers">
114 − <div className="space-y-1 max-h-60 overflow-y-auto">
115 − <button onClick={() => handleMove("root")} className="w-full text-left px-3 py-2.5 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 rounded-lg">
116 − Racine
117 − </button>
118 − {folders.map((f: { id: string; name: string }) => (
119 − <button key={f.id} onClick={() => handleMove(f.id)} className="w-full text-left px-3 py-2.5 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 rounded-lg">
120 − {f.name}
121 − </button>
122 − ))}
132 + <Modal isOpen={deleteModal} onClose={() => setDeleteModal(false)} title={`Supprimer ${n} fichier${n > 1 ? "s" : ""}`}>
133 + <p className="text-gray-600 dark:text-gray-300 text-sm mb-5">
134 + Les fichiers seront déplacés dans la corbeille et restaurables pendant 30 jours.
135 + </p>
136 + <div className="flex gap-2 justify-end">
137 + <Button variant="ghost" onClick={() => setDeleteModal(false)}>Annuler</Button>
138 + <Button variant="danger" onClick={() => { setDeleteModal(false); bulkAction("delete"); }} loading={loading}><Trash2 className="w-4 h-4" /> Supprimer</Button>
123 139 </div>
124 140 </Modal>
125 141 </>
modified components/files/FileActions.tsx +181 −113
@@ -1,15 +1,8 @@
1 1 "use client";
2 2
3 −import { useState } from "react";
3 +import { useEffect, useLayoutEffect, useRef, useState } from "react";
4 4 import { motion } from "framer-motion";
5 −import {
6 − Download,
7 − Pencil,
8 − Trash2,
9 − Copy,
10 − Share2,
11 − FolderInput,
12 −} from "lucide-react";
5 +import { Download, Pencil, Trash2, Copy, Share2, FolderInput, Star, Eye, Link2, Check } from "lucide-react";
13 6 import toast from "react-hot-toast";
14 7 import Modal from "../ui/Modal";
15 8 import Button from "../ui/Button";
@@ -21,15 +14,20 @@ interface FileData {
21 14 size: number;
22 15 mimeType: string;
23 16 folderId?: string | null;
17 + metadata?: { isFavorite: boolean } | null;
24 18 }
25 19
26 20 interface FileActionsProps {
27 21 file: FileData;
28 22 onClose: () => void;
29 23 onRefresh: () => void;
24 + /** Position fixe (menu contextuel) ; sinon ancré sous le déclencheur (absolute). */
25 + anchor?: { x: number; y: number };
26 + /** Ouvrir l'aperçu depuis le menu contextuel */
27 + onPreview?: () => void;
30 28 }
31 29
32 −export default function FileActions({ file, onClose, onRefresh }: FileActionsProps) {
30 +export default function FileActions({ file, onClose, onRefresh, anchor, onPreview }: FileActionsProps) {
33 31 const [renameModal, setRenameModal] = useState(false);
34 32 const [shareModal, setShareModal] = useState(false);
35 33 const [moveModal, setMoveModal] = useState(false);
@@ -39,24 +37,72 @@ export default function FileActions({ file, onClose, onRefresh }: FileActionsPro
39 37 const [shareExpiry, setShareExpiry] = useState("");
40 38 const [shareMode, setShareMode] = useState<"DOWNLOAD" | "PREVIEW">("DOWNLOAD");
41 39 const [loading, setLoading] = useState(false);
40 + const [shareLink, setShareLink] = useState<string | null>(null);
41 + const [copied, setCopied] = useState(false);
42 + const menuRef = useRef<HTMLDivElement>(null);
43 + const [pos, setPos] = useState<{ x: number; y: number } | null>(anchor ?? null);
44 +
45 + // Clamp du menu contextuel dans le viewport
46 + useLayoutEffect(() => {
47 + if (!anchor || !menuRef.current) return;
48 + const r = menuRef.current.getBoundingClientRect();
49 + const pad = 8;
50 + const x = Math.min(anchor.x, window.innerWidth - r.width - pad);
51 + const y = Math.min(anchor.y, window.innerHeight - r.height - pad);
52 + setPos({ x: Math.max(pad, x), y: Math.max(pad, y) });
53 + }, [anchor]);
54 +
55 + // Fermer le menu contextuel : clic ailleurs / Échap / scroll
56 + useEffect(() => {
57 + if (!anchor) return;
58 + const anyModal = renameModal || shareModal || moveModal || deleteModal;
59 + if (anyModal) return;
60 + const onDown = (e: MouseEvent) => {
61 + if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
62 + };
63 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
64 + document.addEventListener("mousedown", onDown);
65 + document.addEventListener("keydown", onKey);
66 + window.addEventListener("scroll", onClose, true);
67 + return () => {
68 + document.removeEventListener("mousedown", onDown);
69 + document.removeEventListener("keydown", onKey);
70 + window.removeEventListener("scroll", onClose, true);
71 + };
72 + }, [anchor, onClose, renameModal, shareModal, moveModal, deleteModal]);
42 73
43 74 const handleDownload = () => {
44 75 window.open(`/api/files/${file.id}/download`, "_blank");
45 76 onClose();
46 77 };
47 78
79 + const handleToggleFavorite = async () => {
80 + try {
81 + const res = await fetch(`/api/files/${file.id}/favorite`, { method: "POST" });
82 + if (res.ok) {
83 + const meta = await res.json().catch(() => null);
84 + toast.success(meta?.isFavorite ? "Ajouté aux favoris" : "Retiré des favoris");
85 + onRefresh();
86 + }
87 + } catch {
88 + toast.error("Erreur");
89 + }
90 + onClose();
91 + };
92 +
48 93 const handleRename = async () => {
94 + if (!newName.trim()) return;
49 95 setLoading(true);
50 96 try {
51 97 const res = await fetch(`/api/files/${file.id}`, {
52 98 method: "PATCH",
53 99 headers: { "Content-Type": "application/json" },
54 − body: JSON.stringify({ name: newName }),
100 + body: JSON.stringify({ name: newName.trim() }),
55 101 });
56 102 if (res.ok) {
57 103 toast.success("Fichier renommé");
58 104 onRefresh();
59 − }
105 + } else toast.error("Renommage impossible");
60 106 } catch {
61 107 toast.error("Erreur");
62 108 } finally {
@@ -68,13 +114,11 @@ export default function FileActions({ file, onClose, onRefresh }: FileActionsPro
68 114
69 115 const handleDuplicate = async () => {
70 116 try {
71 − const res = await fetch(`/api/files/${file.id}/duplicate`, {
72 − method: "POST",
73 − });
117 + const res = await fetch(`/api/files/${file.id}/duplicate`, { method: "POST" });
74 118 if (res.ok) {
75 119 toast.success("Fichier dupliqué");
76 120 onRefresh();
77 − }
121 + } else toast.error("Duplication impossible");
78 122 } catch {
79 123 toast.error("Erreur");
80 124 }
@@ -84,13 +128,11 @@ export default function FileActions({ file, onClose, onRefresh }: FileActionsPro
84 128 const handleDelete = async () => {
85 129 setLoading(true);
86 130 try {
87 − const res = await fetch(`/api/files/${file.id}`, {
88 − method: "DELETE",
89 − });
131 + const res = await fetch(`/api/files/${file.id}`, { method: "DELETE" });
90 132 if (res.ok) {
91 − toast.success("Fichier supprimé");
133 + toast.success("Fichier déplacé dans la corbeille");
92 134 onRefresh();
93 − }
135 + } else toast.error("Suppression impossible");
94 136 } catch {
95 137 toast.error("Erreur");
96 138 } finally {
@@ -100,14 +142,11 @@ export default function FileActions({ file, onClose, onRefresh }: FileActionsPro
100 142 }
101 143 };
102 144
103 − const [shareLink, setShareLink] = useState<string | null>(null);
104 −
105 145 const copyToClipboard = async (text: string) => {
106 146 try {
107 147 await navigator.clipboard.writeText(text);
108 148 return true;
109 149 } catch {
110 − // Fallback for non-HTTPS contexts
111 150 const textarea = document.createElement("textarea");
112 151 textarea.value = text;
113 152 textarea.style.position = "fixed";
@@ -142,18 +181,14 @@ export default function FileActions({ file, onClose, onRefresh }: FileActionsPro
142 181 const data = await res.json();
143 182 const url = `${window.location.origin}/shared/${data.token}`;
144 183 setShareLink(url);
145 − const copied = await copyToClipboard(url);
146 − if (copied) {
147 − toast.success("Lien copié dans le presse-papiers");
148 − } else {
149 − toast.success("Lien de partage créé");
150 − }
184 + const ok = await copyToClipboard(url);
185 + toast.success(ok ? "Lien copié dans le presse-papiers" : "Lien de partage créé");
151 186 } else {
152 − const data = await res.json();
187 + const data = await res.json().catch(() => ({}));
153 188 toast.error(data.error || "Erreur lors du partage");
154 189 }
155 190 } catch (err) {
156 − toast.error("Erreur réseau: " + (err as Error).message);
191 + toast.error("Erreur réseau : " + (err as Error).message);
157 192 } finally {
158 193 setLoading(false);
159 194 }
@@ -169,7 +204,7 @@ export default function FileActions({ file, onClose, onRefresh }: FileActionsPro
169 204 if (res.ok) {
170 205 toast.success("Fichier déplacé");
171 206 onRefresh();
172 − }
207 + } else toast.error("Déplacement impossible");
173 208 } catch {
174 209 toast.error("Erreur");
175 210 }
@@ -177,95 +212,124 @@ export default function FileActions({ file, onClose, onRefresh }: FileActionsPro
177 212 onClose();
178 213 };
179 214
180 − const actions = [
215 + const isFav = !!file.metadata?.isFavorite;
216 +
217 + type Action = { icon: typeof Download; label: string; action: () => void; danger?: boolean; sep?: boolean; hint?: string };
218 + const actions: Action[] = [
219 + ...(onPreview ? [{ icon: Eye, label: "Aperçu", action: () => { onPreview(); onClose(); }, hint: "↵" }] : []),
181 220 { icon: Download, label: "Télécharger", action: handleDownload },
182 − { icon: Pencil, label: "Renommer", action: () => setRenameModal(true) },
221 + { icon: Star, label: isFav ? "Retirer des favoris" : "Ajouter aux favoris", action: handleToggleFavorite },
222 + { icon: Share2, label: "Partager…", action: () => setShareModal(true), sep: true },
223 + { icon: Pencil, label: "Renommer…", action: () => setRenameModal(true) },
183 224 { icon: Copy, label: "Dupliquer", action: handleDuplicate },
184 − {
185 − icon: FolderInput,
186 − label: "Déplacer",
187 − action: () => setMoveModal(true),
188 − },
189 − { icon: Share2, label: "Partager", action: () => setShareModal(true) },
190 − { icon: Trash2, label: "Supprimer", action: () => setDeleteModal(true), danger: true },
225 + { icon: FolderInput, label: "Déplacer vers…", action: () => setMoveModal(true) },
226 + { icon: Trash2, label: "Supprimer", action: () => setDeleteModal(true), danger: true, sep: true, hint: "⌫" },
191 227 ];
192 228
193 − const inputClasses = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none focus:border-gray-400 dark:focus:border-white/20";
229 + const anyModal = renameModal || shareModal || moveModal || deleteModal;
194 230
195 − return (
196 − <>
197 − <motion.div
198 − initial={{ opacity: 0, scale: 0.95 }}
199 − animate={{ opacity: 1, scale: 1 }}
200 − className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-900 border border-gray-200 dark:border-white/10 rounded-xl shadow-2xl z-50 py-1 overflow-hidden max-h-[70vh] overflow-auto"
201 − onClick={(e) => e.stopPropagation()}
202 − >
203 − {actions.map((action) => (
231 + const menu = (
232 + <motion.div
233 + ref={menuRef}
234 + initial={{ opacity: 0, scale: 0.96, y: -4 }}
235 + animate={{ opacity: 1, scale: 1, y: 0 }}
236 + transition={{ duration: 0.14, ease: [0.2, 0.8, 0.2, 1] }}
237 + role="menu"
238 + className={`glass rounded-2xl shadow-pop p-1.5 w-56 max-h-[70vh] overflow-auto ${
239 + anchor ? "fixed z-[75]" : "absolute right-0 top-full mt-1.5 z-50"
240 + } ${anchor && !pos ? "invisible" : ""} ${anyModal ? "pointer-events-none opacity-0" : ""}`}
241 + style={anchor && pos ? { left: pos.x, top: pos.y } : undefined}
242 + onClick={(e) => e.stopPropagation()}
243 + onContextMenu={(e) => e.preventDefault()}
244 + >
245 + {actions.map((a) => (
246 + <div key={a.label}>
247 + {a.sep && <div className="my-1 border-t border-line dark:border-white/10" />}
204 248 <button
205 − key={action.label}
206 − onClick={(e) => {
207 − e.stopPropagation();
208 − action.action();
209 − }}
210 − className={`flex items-center gap-3 w-full px-4 py-3 text-sm transition-colors ${
211 − (action as { danger?: boolean }).danger
212 − ? "text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10"
213 − : "text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5"
249 + role="menuitem"
250 + onClick={(e) => { e.stopPropagation(); a.action(); }}
251 + className={`flex items-center gap-2.5 w-full px-3 h-10 sm:h-9 rounded-xl text-[13px] text-left transition-colors ${
252 + a.danger
253 + ? "text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10"
254 + : "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.08]"
214 255 }`}
215 256 >
216 − <action.icon className="w-4 h-4" />
217 − {action.label}
257 + <a.icon className={`w-4 h-4 shrink-0 ${a.danger ? "" : a.label.includes("favoris") && isFav ? "text-amber-400 fill-amber-400" : "text-gray-400 dark:text-gray-500"}`} />
258 + <span className="flex-1 truncate">{a.label}</span>
259 + {a.hint && <kbd>{a.hint}</kbd>}
218 260 </button>
219 − ))}
220 − </motion.div>
261 + </div>
262 + ))}
263 + </motion.div>
264 + );
221 265
222 − {/* Rename Modal */}
223 − <Modal isOpen={renameModal} onClose={() => setRenameModal(false)} title="Renommer">
266 + return (
267 + <>
268 + {menu}
269 +
270 + <Modal isOpen={renameModal} onClose={() => { setRenameModal(false); onClose(); }} title="Renommer">
224 271 <input
225 272 type="text"
226 273 value={newName}
227 274 onChange={(e) => setNewName(e.target.value)}
228 − className={`${inputClasses} mb-4`}
275 + onKeyDown={(e) => e.key === "Enter" && handleRename()}
276 + onFocus={(e) => {
277 + const dot = file.name.lastIndexOf(".");
278 + e.currentTarget.setSelectionRange(0, dot > 0 ? dot : file.name.length);
279 + }}
280 + className="field mb-4"
229 281 autoFocus
230 282 />
231 283 <div className="flex gap-2 justify-end">
232 − <Button variant="ghost" onClick={() => setRenameModal(false)}>Annuler</Button>
233 − <Button onClick={handleRename} loading={loading}>Renommer</Button>
284 + <Button variant="ghost" onClick={() => { setRenameModal(false); onClose(); }}>Annuler</Button>
285 + <Button variant="accent" onClick={handleRename} loading={loading} disabled={!newName.trim() || newName.trim() === file.name}>Renommer</Button>
234 286 </div>
235 287 </Modal>
236 288
237 − {/* Delete Modal */}
238 − <Modal isOpen={deleteModal} onClose={() => setDeleteModal(false)} title="Supprimer">
239 − <p className="text-gray-500 dark:text-gray-400 text-sm mb-4">
240 − Supprimer <strong className="text-gray-900 dark:text-white">{file.name}</strong> ? Cette action est irréversible.
289 + <Modal isOpen={deleteModal} onClose={() => { setDeleteModal(false); onClose(); }} title="Supprimer le fichier">
290 + <p className="text-gray-600 dark:text-gray-300 text-sm mb-5">
291 + <strong className="text-gray-900 dark:text-white break-all">{file.name}</strong> sera déplacé dans la corbeille. Vous pourrez le restaurer pendant 30 jours.
241 292 </p>
242 293 <div className="flex gap-2 justify-end">
243 − <Button variant="ghost" onClick={() => setDeleteModal(false)}>Annuler</Button>
244 − <Button variant="danger" onClick={handleDelete} loading={loading}>Supprimer</Button>
294 + <Button variant="ghost" onClick={() => { setDeleteModal(false); onClose(); }}>Annuler</Button>
295 + <Button variant="danger" onClick={handleDelete} loading={loading}><Trash2 className="w-4 h-4" /> Supprimer</Button>
245 296 </div>
246 297 </Modal>
247 298
248 − {/* Share Modal */}
249 − <Modal isOpen={shareModal} onClose={() => { setShareModal(false); setShareLink(null); onClose(); }} title="Partager">
299 + <Modal
300 + isOpen={shareModal}
301 + onClose={() => { setShareModal(false); setShareLink(null); onClose(); }}
302 + title="Partager"
303 + description={file.name}
304 + >
250 305 {shareLink ? (
251 306 <div className="space-y-4">
252 − <p className="text-sm text-green-600 dark:text-green-400 font-medium">Lien de partage créé !</p>
307 + <div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 font-medium">
308 + <Check className="w-4 h-4" /> Lien de partage créé
309 + </div>
253 310 <div className="flex items-center gap-2">
254 − <input
255 − type="text"
256 − value={shareLink}
257 − readOnly
258 − className={`${inputClasses} text-xs font-mono`}
259 − onClick={(e) => (e.target as HTMLInputElement).select()}
260 − />
311 + <div className="relative flex-1">
312 + <Link2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
313 + <input
314 + type="text"
315 + value={shareLink}
316 + readOnly
317 + className="field pl-9 text-xs font-mono"
318 + onClick={(e) => (e.target as HTMLInputElement).select()}
319 + />
320 + </div>
261 321 <Button
262 322 size="sm"
263 − onClick={() => {
264 − copyToClipboard(shareLink);
265 − toast.success("Lien copié !");
323 + variant={copied ? "secondary" : "accent"}
324 + onClick={async () => {
325 + await copyToClipboard(shareLink);
326 + setCopied(true);
327 + toast.success("Lien copié");
328 + setTimeout(() => setCopied(false), 1500);
266 329 }}
267 330 >
268 − Copier
331 + {copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
332 + {copied ? "Copié" : "Copier"}
269 333 </Button>
270 334 </div>
271 335 <Button variant="ghost" onClick={() => { setShareModal(false); setShareLink(null); onClose(); }} className="w-full">
@@ -275,49 +339,53 @@ export default function FileActions({ file, onClose, onRefresh }: FileActionsPro
275 339 ) : (
276 340 <div className="space-y-4">
277 341 <div>
278 − <label className="text-xs text-gray-500 dark:text-gray-400 block mb-1">Mode</label>
279 − <select
280 − value={shareMode}
281 − onChange={(e) => setShareMode(e.target.value as "DOWNLOAD" | "PREVIEW")}
282 − className={inputClasses}
283 − >
284 − <option value="DOWNLOAD">Téléchargement</option>
285 − <option value="PREVIEW">Aperçu seulement</option>
286 − </select>
342 + <label className="text-xs font-medium text-gray-500 dark:text-gray-400 block mb-1.5">Mode</label>
343 + <div className="grid grid-cols-2 gap-2">
344 + {([["DOWNLOAD", "Téléchargement", Download], ["PREVIEW", "Aperçu seulement", Eye]] as const).map(([v, label, Icon]) => (
345 + <button
346 + key={v}
347 + type="button"
348 + onClick={() => setShareMode(v)}
349 + className={`flex items-center gap-2 px-3 h-11 rounded-xl border text-sm transition-colors ${
350 + shareMode === v
351 + ? "border-blue-500 bg-blue-500/10 text-blue-700 dark:text-blue-300 font-medium"
352 + : "border-line dark:border-white/10 text-gray-600 dark:text-gray-300 hover:border-gray-300 dark:hover:border-white/20"
353 + }`}
354 + >
355 + <Icon className="w-4 h-4" /> {label}
356 + </button>
357 + ))}
358 + </div>
287 359 </div>
288 360 <div>
289 − <label className="text-xs text-gray-500 dark:text-gray-400 block mb-1">Mot de passe (optionnel)</label>
361 + <label className="text-xs font-medium text-gray-500 dark:text-gray-400 block mb-1.5">Mot de passe (optionnel)</label>
290 362 <input
291 363 type="password"
292 364 value={sharePassword}
293 365 onChange={(e) => setSharePassword(e.target.value)}
294 − placeholder="Laisser vide pour pas de mot de passe"
295 − className={inputClasses}
366 + placeholder="Laisser vide pour aucun mot de passe"
367 + className="field"
368 + autoComplete="new-password"
296 369 />
297 370 </div>
298 371 <div>
299 − <label className="text-xs text-gray-500 dark:text-gray-400 block mb-1">Expiration (optionnel)</label>
300 − <input
301 − type="datetime-local"
302 − value={shareExpiry}
303 − onChange={(e) => setShareExpiry(e.target.value)}
304 − className={inputClasses}
305 − />
372 + <label className="text-xs font-medium text-gray-500 dark:text-gray-400 block mb-1.5">Expiration (optionnel)</label>
373 + <input type="datetime-local" value={shareExpiry} onChange={(e) => setShareExpiry(e.target.value)} className="field" />
306 374 </div>
307 − <div className="flex gap-2 justify-end">
375 + <div className="flex gap-2 justify-end pt-1">
308 376 <Button variant="ghost" onClick={() => { setShareModal(false); onClose(); }}>Annuler</Button>
309 − <Button onClick={handleShare} loading={loading}>Créer le lien</Button>
377 + <Button variant="accent" onClick={handleShare} loading={loading}><Share2 className="w-4 h-4" /> Créer le lien</Button>
310 378 </div>
311 379 </div>
312 380 )}
313 381 </Modal>
314 382
315 − {/* Move Modal (arborescence) */}
316 383 <MoveModal
317 384 isOpen={moveModal}
318 − onClose={() => setMoveModal(false)}
385 + onClose={() => { setMoveModal(false); onClose(); }}
319 386 onSelect={handleMove}
320 387 itemName={file.name}
388 + currentFolderId={file.folderId ?? "root"}
321 389 />
322 390 </>
323 391 );
modified components/files/FileCard.tsx +229 −138
@@ -1,71 +1,57 @@
1 1 "use client";
2 2
3 −import { motion } from "framer-motion";
4 3 import {
5 − FileText,
6 − Image,
7 − Video,
8 − Music,
9 − FileCode,
10 − FileSpreadsheet,
11 − File,
12 − Archive,
13 − MoreVertical,
14 − Star,
4 + FileText, Image, Video, Music, FileCode, FileSpreadsheet, File, Archive, MoreVertical, Star, Download, Check, type LucideIcon,
15 5 } from "lucide-react";
16 6 import { formatFileSize, getFileIcon } from "@/lib/utils";
17 −import { useState, useRef, useEffect } from "react";
7 +import { useState, useRef, useEffect, memo } from "react";
18 8 import FileActions from "./FileActions";
19 9 import type { FileData } from "@/types/files";
10 +import { DND_FILES, setDragGhost } from "./dnd";
20 11
21 12 interface FileCardProps {
22 13 file: FileData;
23 14 onPreview: (file: FileData) => void;
24 15 onRefresh: () => void;
25 16 viewMode: "grid" | "list";
17 + /** Sélection (explorateur) */
18 + selected?: boolean;
19 + selectMode?: boolean;
20 + onToggleSelect?: (file: FileData, e: React.MouseEvent) => void;
21 + /** Ids à emporter dans le glisser (sélection multiple) */
22 + dragIds?: string[];
23 + /** Menu contextuel : géré par le parent (position souris) */
24 + onContextMenu?: (file: FileData, e: React.MouseEvent) => void;
25 + /** Navigation clavier (roving tabindex) */
26 + focusable?: boolean;
27 + onFocusItem?: (file: FileData) => void;
28 + /** Colonnes de la vue liste rendues par le parent (sticky header aligné) */
29 + dense?: boolean;
26 30 }
27 31
28 −const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
29 − image: Image,
30 − video: Video,
31 − audio: Music,
32 − pdf: FileText,
33 − code: FileCode,
34 − spreadsheet: FileSpreadsheet,
35 − archive: Archive,
36 − document: FileText,
37 − file: File,
38 − presentation: FileText,
32 +const iconMap: Record<string, LucideIcon> = {
33 + image: Image, video: Video, audio: Music, pdf: FileText, code: FileCode, spreadsheet: FileSpreadsheet,
34 + archive: Archive, document: FileText, file: File, presentation: FileText,
39 35 };
40 36
41 37 const iconColorMap: Record<string, string> = {
42 − image: "text-emerald-500",
43 − video: "text-purple-500",
44 − audio: "text-pink-500",
45 − pdf: "text-red-500",
46 − code: "text-amber-500",
47 − spreadsheet: "text-green-600",
48 − archive: "text-orange-500",
49 − document: "text-blue-500",
50 − presentation: "text-orange-500",
51 − file: "text-gray-400",
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",
52 40 };
53 41
54 −// Soft tinted bg per file type (light + dark)
55 42 const bgTintMap: Record<string, string> = {
56 − image: "bg-emerald-50 dark:bg-emerald-950/30",
57 − video: "bg-purple-50 dark:bg-purple-950/30",
58 − audio: "bg-pink-50 dark:bg-pink-950/30",
59 − pdf: "bg-red-50 dark:bg-red-950/30",
60 − code: "bg-amber-50 dark:bg-amber-950/30",
61 − spreadsheet: "bg-green-50 dark:bg-green-950/30",
62 − archive: "bg-orange-50 dark:bg-orange-950/30",
63 − document: "bg-blue-50 dark:bg-blue-950/30",
64 − presentation: "bg-orange-50 dark:bg-orange-950/30",
65 − file: "bg-gray-50 dark:bg-white/[0.03]",
43 + image: "bg-emerald-50 dark:bg-emerald-950/30", video: "bg-purple-50 dark:bg-purple-950/30", audio: "bg-pink-50 dark:bg-pink-950/30",
44 + pdf: "bg-rose-50 dark:bg-rose-950/30", code: "bg-amber-50 dark:bg-amber-950/30", spreadsheet: "bg-green-50 dark:bg-green-950/30",
45 + archive: "bg-orange-50 dark:bg-orange-950/30", document: "bg-blue-50 dark:bg-blue-950/30", presentation: "bg-orange-50 dark:bg-orange-950/30",
46 + file: "bg-gray-50 dark:bg-white/[0.03]",
66 47 };
67 48
68 −function formatRelativeDate(dateStr: string): string {
49 +const typeLabel: Record<string, string> = {
50 + image: "Image", video: "Vidéo", audio: "Audio", pdf: "PDF", code: "Code", spreadsheet: "Tableur", archive: "Archive",
51 + document: "Document", presentation: "Présentation", file: "Fichier",
52 +};
53 +
54 +export function formatRelativeDate(dateStr: string): string {
69 55 const now = Date.now();
70 56 const then = new Date(dateStr).getTime();
71 57 const diff = now - then;
@@ -74,15 +60,24 @@ function formatRelativeDate(dateStr: string): string {
74 60 const days = Math.floor(diff / 86400000);
75 61 const months = Math.floor(days / 30);
76 62 if (minutes < 1) return "à l'instant";
77 − if (minutes < 60) return `il y a ${minutes}m`;
78 − if (hours < 24) return `il y a ${hours}h`;
79 − if (days < 30) return `il y a ${days}j`;
63 + if (minutes < 60) return `il y a ${minutes} min`;
64 + if (hours < 24) return `il y a ${hours} h`;
65 + if (days < 30) return `il y a ${days} j`;
80 66 if (months < 12) return `il y a ${months} mois`;
81 67 return new Date(dateStr).toLocaleDateString("fr-CA", { year: "numeric", month: "short" });
82 68 }
83 69
84 −export default function FileCard({ file, onPreview, onRefresh, viewMode }: FileCardProps) {
70 +function extOf(name: string): string {
71 + const i = name.lastIndexOf(".");
72 + return i > 0 ? name.slice(i + 1).toUpperCase().slice(0, 5) : "";
73 +}
74 +
75 +function FileCardInner({
76 + file, onPreview, onRefresh, viewMode, selected = false, selectMode = false, onToggleSelect, dragIds, onContextMenu, focusable, onFocusItem,
77 +}: FileCardProps) {
85 78 const [showActions, setShowActions] = useState(false);
79 + const [dragging, setDragging] = useState(false);
80 + const [favBusy, setFavBusy] = useState(false);
86 81 const menuRef = useRef<HTMLDivElement>(null);
87 82
88 83 const iconType = getFileIcon(file.mimeType);
@@ -90,144 +85,240 @@ export default function FileCard({ file, onPreview, onRefresh, viewMode }: FileC
90 85 const iconColor = iconColorMap[iconType] || "text-gray-400";
91 86 const bgTint = bgTintMap[iconType] || "bg-gray-50 dark:bg-white/[0.03]";
92 87 const isImage = file.mimeType.startsWith("image/");
93 − const isFavorite = file.metadata?.isFavorite;
88 + const isFavorite = !!file.metadata?.isFavorite;
94 89 const tags = file.tags ?? [];
95 90
96 91 useEffect(() => {
92 + if (!showActions) return;
97 93 function handleClick(e: MouseEvent) {
98 − if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
99 − setShowActions(false);
100 − }
94 + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setShowActions(false);
101 95 }
102 96 document.addEventListener("mousedown", handleClick);
103 97 return () => document.removeEventListener("mousedown", handleClick);
104 − }, []);
98 + }, [showActions]);
105 99
106 − // Tags as color dots — max 4 visible, then +N
107 − function TagDots() {
108 − if (tags.length === 0) return null;
109 − const visible = tags.slice(0, 4);
110 − const overflow = tags.length - 4;
111 − return (
112 − <div className="flex items-center gap-1 mt-1.5 flex-wrap">
113 − {visible.map(({ tag }) => (
114 − <span
115 − key={tag.id}
116 − className="w-2.5 h-2.5 rounded-full flex-shrink-0 border border-white/20"
117 − style={{ backgroundColor: tag.color }}
118 − title={tag.name}
119 − />
120 − ))}
121 − {overflow > 0 && (
122 − <span className="text-[10px] text-gray-400 leading-none">+{overflow}</span>
123 − )}
124 − </div>
125 − );
126 − }
100 + const onDragStart = (e: React.DragEvent) => {
101 + const ids = dragIds && dragIds.length && dragIds.includes(file.id) ? dragIds : [file.id];
102 + e.dataTransfer.setData(DND_FILES, JSON.stringify(ids));
103 + e.dataTransfer.setData("text/plain", file.name);
104 + e.dataTransfer.effectAllowed = "move";
105 + setDragGhost(e, ids.length > 1 ? `${ids.length} fichiers` : file.name);
106 + setDragging(true);
107 + };
108 + const onDragEnd = () => setDragging(false);
109 +
110 + const toggleFav = async (e: React.MouseEvent) => {
111 + e.stopPropagation();
112 + if (favBusy) return;
113 + setFavBusy(true);
114 + try {
115 + await fetch(`/api/files/${file.id}/favorite`, { method: "POST" });
116 + onRefresh();
117 + } catch {}
118 + setFavBusy(false);
119 + };
120 +
121 + const handleClick = (e: React.MouseEvent) => {
122 + if (selectMode || e.metaKey || e.ctrlKey || e.shiftKey) {
123 + onToggleSelect?.(file, e);
124 + return;
125 + }
126 + onPreview(file);
127 + };
128 +
129 + const handleKey = (e: React.KeyboardEvent) => {
130 + if (e.key === "Enter") { e.preventDefault(); onPreview(file); }
131 + else if (e.key === " ") { e.preventDefault(); onToggleSelect?.(file, e as unknown as React.MouseEvent); }
132 + };
133 +
134 + const ctx = (e: React.MouseEvent) => {
135 + if (!onContextMenu) return;
136 + e.preventDefault();
137 + e.stopPropagation();
138 + onContextMenu(file, e);
139 + };
140 +
141 + const commonProps = {
142 + draggable: true,
143 + onDragStart,
144 + onDragEnd,
145 + onContextMenu: ctx,
146 + tabIndex: focusable ? 0 : -1,
147 + onFocus: () => onFocusItem?.(file),
148 + onKeyDown: handleKey,
149 + "data-file-id": file.id,
150 + role: "option",
151 + "aria-selected": selected,
152 + } as const;
153 +
154 + const Checkbox = ({ className = "" }: { className?: string }) => (
155 + <button
156 + type="button"
157 + aria-label={selected ? "Désélectionner" : "Sélectionner"}
158 + onClick={(e) => { e.stopPropagation(); onToggleSelect?.(file, e); }}
159 + className={`w-5 h-5 rounded-md border flex items-center justify-center transition-all ${
160 + selected
161 + ? "bg-blue-500 border-blue-500 text-white shadow-[0_0_0_3px_rgb(var(--accent-500)/.2)]"
162 + : "border-gray-300 dark:border-white/25 bg-white/80 dark:bg-black/40 text-transparent hover:border-blue-400"
163 + } ${className}`}
164 + >
165 + <Check className="w-3.5 h-3.5" strokeWidth={3} />
166 + </button>
167 + );
127 168
169 + /* ================= LISTE ================= */
128 170 if (viewMode === "list") {
129 171 return (
130 − <motion.div
131 − initial={{ opacity: 0 }}
132 − animate={{ opacity: 1 }}
133 − className="flex items-center gap-3 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-white/5 cursor-pointer group transition-colors"
134 − onClick={() => onPreview(file)}
172 + <div
173 + {...commonProps}
174 + className={`group relative flex items-center gap-3 px-3 density-row cursor-pointer select-none transition-colors outline-none ${
175 + selected ? "bg-blue-500/10 dark:bg-blue-400/10" : "hover:bg-gray-50 dark:hover:bg-white/[0.04]"
176 + } ${dragging ? "opacity-40" : ""} focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500`}
177 + onClick={handleClick}
178 + onDoubleClick={() => onPreview(file)}
135 179 >
136 − {/* Icon */}
137 − <div className={`w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 ${bgTint}`}>
138 − <Icon className={`w-4 h-4 ${iconColor}`} />
180 + {/* Case + icône */}
181 + <div className="w-8 h-8 shrink-0 relative flex items-center justify-center">
182 + <div className={`w-8 h-8 rounded-lg flex items-center justify-center ${bgTint} transition-opacity ${selectMode || selected ? "opacity-0" : "group-hover:opacity-0"}`}>
183 + {isImage ? (
184 + <img src={`/api/files/${file.id}/preview?w=200`} alt="" className="w-8 h-8 rounded-lg object-cover" loading="lazy" decoding="async" draggable={false} />
185 + ) : (
186 + <Icon className={`w-4 h-4 ${iconColor}`} />
187 + )}
188 + </div>
189 + <div className={`absolute inset-0 flex items-center justify-center transition-opacity ${selectMode || selected ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>
190 + <Checkbox />
191 + </div>
139 192 </div>
140 193
141 − {/* Name */}
142 − <span className="text-sm text-gray-700 dark:text-gray-200 flex-1 truncate min-w-0">{file.name}</span>
143 −
144 − {/* Tags dots (list) */}
145 − {tags.length > 0 && (
146 − <div className="hidden sm:flex items-center gap-1 flex-shrink-0">
147 − {tags.slice(0, 3).map(({ tag }) => (
148 − <span
149 − key={tag.id}
150 − className="w-2 h-2 rounded-full"
151 − style={{ backgroundColor: tag.color }}
152 − title={tag.name}
153 − />
154 − ))}
155 − </div>
156 − )}
194 + {/* Nom */}
195 + <div className="flex-1 min-w-0 flex items-center gap-2">
196 + <span className="text-[13.5px] text-gray-800 dark:text-gray-100 truncate">{file.name}</span>
197 + {isFavorite && <Star className="w-3.5 h-3.5 text-amber-400 fill-amber-400 shrink-0" />}
198 + {tags.length > 0 && (
199 + <span className="hidden sm:flex items-center gap-1 shrink-0">
200 + {tags.slice(0, 3).map(({ tag }) => (
201 + <span key={tag.id} className="w-2 h-2 rounded-full ring-1 ring-black/5 dark:ring-white/10" style={{ backgroundColor: tag.color }} title={tag.name} />
202 + ))}
203 + {tags.length > 3 && <span className="text-[10px] text-gray-400">+{tags.length - 3}</span>}
204 + </span>
205 + )}
206 + </div>
157 207
158 − {isFavorite && <Star className="w-3.5 h-3.5 text-amber-400 fill-amber-400 flex-shrink-0 hidden sm:block" />}
159 − <span className="text-sm text-gray-500 hidden sm:block flex-shrink-0 w-24 text-right tabular-nums">{formatFileSize(file.size)}</span>
160 − <span className="text-xs text-gray-400 hidden md:block flex-shrink-0 w-28 text-right">{formatRelativeDate(file.createdAt)}</span>
208 + {/* Type */}
209 + <span className="hidden lg:block w-24 shrink-0 text-[12px] text-gray-400 truncate">{extOf(file.name) || typeLabel[iconType]}</span>
210 + {/* Taille */}
211 + <span className="hidden sm:block w-24 shrink-0 text-right text-[13px] text-gray-500 dark:text-gray-400 tabular-nums">{formatFileSize(file.size)}</span>
212 + {/* Date */}
213 + <span className="hidden md:block w-28 shrink-0 text-right text-[12px] text-gray-400 tabular-nums" title={new Date(file.updatedAt || file.createdAt).toLocaleString("fr-CA")}>
214 + {formatRelativeDate(file.updatedAt || file.createdAt)}
215 + </span>
161 216
162 − {/* Actions menu */}
163 − <div className="relative flex-shrink-0" ref={menuRef}>
217 + {/* Actions */}
218 + <div className="relative shrink-0 w-10 flex justify-end" ref={menuRef}>
164 219 <button
165 220 onClick={(e) => { e.stopPropagation(); setShowActions(!showActions); }}
166 − className="min-w-[44px] min-h-[44px] flex items-center justify-center text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-white sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
221 + className={`w-9 h-9 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-200/60 dark:hover:bg-white/10 transition-all ${showActions ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100"}`}
222 + aria-label="Actions"
167 223 >
168 224 <MoreVertical className="w-4 h-4" />
169 225 </button>
170 − {showActions && (
171 − <FileActions file={file} onClose={() => setShowActions(false)} onRefresh={onRefresh} />
172 − )}
226 + {showActions && <FileActions file={file} onClose={() => setShowActions(false)} onRefresh={onRefresh} onPreview={() => onPreview(file)} />}
173 227 </div>
174 − </motion.div>
228 + </div>
175 229 );
176 230 }
177 231
178 − // Grid view
232 + /* ================= GRILLE ================= */
179 233 return (
180 − <motion.div
181 − initial={{ opacity: 0, scale: 0.95 }}
182 − animate={{ opacity: 1, scale: 1 }}
183 − className={`group relative bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl hover:border-gray-300 dark:hover:border-white/20 transition-colors cursor-pointer shadow-sm dark:shadow-none ${showActions ? "z-20" : ""}`}
184 − onClick={() => onPreview(file)}
234 + <div
235 + {...commonProps}
236 + className={`group relative card card-hover rounded-2xl overflow-hidden cursor-pointer select-none outline-none animate-scale-in focus-visible:ring-2 focus-visible:ring-blue-500 ${
237 + selected ? "ring-2 ring-blue-500 border-transparent" : ""
238 + } ${dragging ? "opacity-40" : ""} ${showActions ? "z-30" : ""}`}
239 + onClick={handleClick}
185 240 >
186 − {/* Thumbnail */}
187 − <div className={`aspect-square flex items-center justify-center rounded-t-xl overflow-hidden ${isImage ? "" : bgTint}`}>
241 + {/* Vignette */}
242 + <div className={`aspect-square flex items-center justify-center overflow-hidden ${isImage ? "bg-gray-100 dark:bg-white/5" : bgTint}`}>
188 243 {isImage ? (
189 244 <img
190 245 src={`/api/files/${file.id}/preview?w=400`}
191 246 alt={file.name}
192 − className="w-full h-full object-cover"
247 + className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
193 248 loading="lazy"
194 249 decoding="async"
250 + draggable={false}
195 251 />
196 252 ) : (
197 − <Icon className={`w-10 h-10 ${iconColor}`} />
253 + <div className="flex flex-col items-center gap-1.5">
254 + <Icon className={`w-10 h-10 ${iconColor}`} strokeWidth={1.6} />
255 + {extOf(file.name) && <span className={`text-[10px] font-semibold tracking-wider ${iconColor} opacity-80`}>{extOf(file.name)}</span>}
256 + </div>
198 257 )}
258 + {/* Voile hover */}
259 + <div className="absolute inset-x-0 top-0 h-14 bg-gradient-to-b from-black/35 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
260 + </div>
261 +
262 + {/* Case (haut gauche) */}
263 + <div className={`absolute top-2 left-2 z-10 transition-opacity ${selectMode || selected ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>
264 + <Checkbox />
199 265 </div>
200 266
201 − {/* Favorite star — top left */}
202 − {isFavorite && (
203 − <div className="absolute top-2 left-2 z-10">
267 + {/* Favori (haut gauche, quand pas de case) */}
268 + {isFavorite && !selectMode && !selected && (
269 + <div className="absolute top-2 left-2 z-[5] group-hover:opacity-0 transition-opacity">
204 270 <Star className="w-4 h-4 text-amber-400 fill-amber-400 drop-shadow" />
205 271 </div>
206 272 )}
207 273
208 − {/* Actions button — top right */}
209 − <div className="absolute top-2 right-2 z-20" ref={menuRef}>
274 + {/* Actions rapides (haut droite) */}
275 + <div className={`absolute top-1.5 right-1.5 z-20 flex items-center gap-0.5 transition-opacity ${showActions ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100"}`} ref={menuRef}>
210 276 <button
211 − onClick={(e) => { e.stopPropagation(); setShowActions(!showActions); }}
212 − className="p-2 bg-white/80 dark:bg-black/50 rounded-lg text-gray-500 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white sm:opacity-0 sm:group-hover:opacity-100 transition-opacity backdrop-blur-sm min-w-[40px] min-h-[40px] flex items-center justify-center"
277 + onClick={toggleFav}
278 + className="hidden sm:flex w-8 h-8 items-center justify-center rounded-lg bg-white/85 dark:bg-black/55 backdrop-blur text-gray-600 dark:text-gray-200 hover:text-amber-500 transition-colors"
279 + aria-label={isFavorite ? "Retirer des favoris" : "Ajouter aux favoris"}
213 280 >
214 − <MoreVertical className="w-4 h-4" />
281 + <Star className={`w-4 h-4 ${isFavorite ? "text-amber-400 fill-amber-400" : ""}`} />
215 282 </button>
216 − {showActions && (
217 − <FileActions file={file} onClose={() => setShowActions(false)} onRefresh={onRefresh} />
218 − )}
283 + <button
284 + onClick={(e) => { e.stopPropagation(); window.open(`/api/files/${file.id}/download`, "_blank"); }}
285 + className="hidden sm:flex w-8 h-8 items-center justify-center rounded-lg bg-white/85 dark:bg-black/55 backdrop-blur text-gray-600 dark:text-gray-200 hover:text-blue-600 transition-colors"
286 + aria-label="Télécharger"
287 + >
288 + <Download className="w-4 h-4" />
289 + </button>
290 + <div className="relative">
291 + <button
292 + onClick={(e) => { e.stopPropagation(); setShowActions(!showActions); }}
293 + className="w-9 h-9 sm:w-8 sm:h-8 flex items-center justify-center rounded-lg bg-white/85 dark:bg-black/55 backdrop-blur text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
294 + aria-label="Actions"
295 + >
296 + <MoreVertical className="w-4 h-4" />
297 + </button>
298 + {showActions && <FileActions file={file} onClose={() => setShowActions(false)} onRefresh={onRefresh} onPreview={() => onPreview(file)} />}
299 + </div>
219 300 </div>
220 301
221 − {/* Info */}
222 − <div className="p-2">
223 − <p className="text-[13px] leading-tight text-gray-700 dark:text-gray-200 truncate font-medium">{file.name}</p>
224 − <p className="text-[11px] text-gray-500 mt-0.5 flex items-center gap-1">
302 + {/* Infos */}
303 + <div className="p-2.5">
304 + <p className="text-[13px] leading-tight text-gray-800 dark:text-gray-100 truncate font-medium" title={file.name}>{file.name}</p>
305 + <p className="text-[11px] text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1 tabular-nums">
225 306 <span>{formatFileSize(file.size)}</span>
226 − <span className="text-gray-300 dark:text-gray-700">·</span>
227 − <span className="truncate">{formatRelativeDate(file.createdAt)}</span>
307 + <span className="text-gray-300 dark:text-gray-600">·</span>
308 + <span className="truncate">{formatRelativeDate(file.updatedAt || file.createdAt)}</span>
228 309 </p>
229 − <TagDots />
310 + {tags.length > 0 && (
311 + <div className="flex items-center gap-1 mt-1.5 flex-wrap">
312 + {tags.slice(0, 4).map(({ tag }) => (
313 + <span key={tag.id} className="w-2.5 h-2.5 rounded-full shrink-0 ring-1 ring-black/5 dark:ring-white/10" style={{ backgroundColor: tag.color }} title={tag.name} />
314 + ))}
315 + {tags.length > 4 && <span className="text-[10px] text-gray-400 leading-none">+{tags.length - 4}</span>}
316 + </div>
317 + )}
230 318 </div>
231 − </motion.div>
319 + </div>
232 320 );
233 321 }
322 +
323 +const FileCard = memo(FileCardInner);
324 +export default FileCard;
modified components/files/FileExplorer.tsx +648 −513
@@ -1,34 +1,25 @@
1 1 "use client";
2 2
3 −import { useState, useEffect, useCallback, useRef } from "react";
3 +import { useState, useEffect, useCallback, useRef, useMemo } from "react";
4 +import { usePathname, useRouter, useSearchParams } from "next/navigation";
4 5 import {
5 − Grid,
6 − List,
7 − Plus,
8 − Upload,
9 − CheckSquare,
10 − Square,
11 − Users,
12 − FolderOpen,
13 − Search,
14 − FileText,
15 − Image,
16 − Film,
17 − X,
18 − ArrowUpNarrowWide,
19 − ArrowDownWideNarrow,
6 + LayoutGrid, List, FolderPlus, Upload, CheckSquare, Users, Search, X, ArrowUpNarrowWide, ArrowDownWideNarrow,
7 + ArrowUpDown, ChevronDown, Check, FolderOpen, Tag, Loader2, CloudUpload,
20 8 } from "lucide-react";
9 +import { motion, AnimatePresence } from "framer-motion";
10 +import toast from "react-hot-toast";
21 11 import FileCard from "./FileCard";
22 −import FileUpload from "./FileUpload";
12 +import FileUpload, { type FileUploadHandle } from "./FileUpload";
23 13 import PreviewEngine from "../preview/PreviewEngine";
24 14 import FolderCard from "../folders/FolderCard";
25 15 import CreateFolderModal from "../folders/CreateFolderModal";
26 16 import CreateSharedFolderModal from "../folders/CreateSharedFolderModal";
27 −import SearchBar from "../ui/SearchBar";
17 +import FileActions from "./FileActions";
28 18 import Button from "../ui/Button";
29 19 import BulkActions from "./BulkActions";
30 −import { motion, AnimatePresence } from "framer-motion";
20 +import { EmptyState } from "../ui/PageHeader";
31 21 import type { FileData } from "@/types/files";
22 +import { FOLDERS_CHANGED_EVENT, handleInternalDrop, hasInternalDrag, hasNativeFiles, readFileIds, readFolderId } from "./dnd";
32 23
33 24 interface FolderData {
34 25 id: string;
@@ -41,31 +32,59 @@ interface FolderData {
41 32
42 33 interface FileExplorerProps {
43 34 folderId?: string | null;
35 + /** Nom du dossier courant (affiché dans la zone d'upload / overlay) */
36 + folderName?: string;
44 37 }
45 38
39 +type SortKey = "name" | "date" | "size" | "type";
40 +type SortDir = "asc" | "desc";
41 +type ViewMode = "grid" | "list";
42 +
46 43 const PAGE_LIMIT = 50;
44 +const SORT_LABELS: Record<SortKey, string> = { name: "Nom", date: "Date", size: "Taille", type: "Type" };
45 +
46 +export default function FileExplorer({ folderId, folderName }: FileExplorerProps) {
47 + const router = useRouter();
48 + const pathname = usePathname();
49 + const searchParams = useSearchParams();
47 50
48 −export default function FileExplorer({ folderId }: FileExplorerProps) {
49 51 const [files, setFiles] = useState<FileData[]>([]);
50 52 const [folders, setFolders] = useState<FolderData[]>([]);
51 − const [viewMode, setViewModeState] = useState<"grid" | "list">("list");
52 − const [sortKey, setSortKey] = useState<"name" | "date" | "size" | "type">("name");
53 − const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
53 + const [total, setTotal] = useState<number | null>(null);
54 + const [viewMode, setViewModeState] = useState<ViewMode>("list");
55 + const [sortKey, setSortKey] = useState<SortKey>("name");
56 + const [sortDir, setSortDir] = useState<SortDir>("asc");
57 + const [sortOpen, setSortOpen] = useState(false);
54 58 const [showUpload, setShowUpload] = useState(false);
55 59 const [showCreateFolder, setShowCreateFolder] = useState(false);
56 60 const [showCreateSharedFolder, setShowCreateSharedFolder] = useState(false);
57 61 const [previewFile, setPreviewFile] = useState<FileData | null>(null);
62 + const [searchInput, setSearchInput] = useState("");
58 63 const [searchQuery, setSearchQuery] = useState("");
59 64 const [loading, setLoading] = useState(true);
60 − const [selectedIds, setSelectedIds] = useState<string[]>([]);
65 + const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
61 66 const [selectMode, setSelectMode] = useState(false);
67 + const [lastClickedId, setLastClickedId] = useState<string | null>(null);
62 68 const [offset, setOffset] = useState(0);
63 69 const [hasMore, setHasMore] = useState(false);
64 70 const [loadingMore, setLoadingMore] = useState(false);
71 + const [contextMenu, setContextMenu] = useState<{ file: FileData; x: number; y: number } | null>(null);
72 + const [bgMenu, setBgMenu] = useState<{ x: number; y: number } | null>(null);
73 + const [nativeDragOver, setNativeDragOver] = useState(false);
74 + const [tagFilter, setTagFilter] = useState<{ id: string; name?: string; color?: string } | null>(null);
75 +
65 76 const containerRef = useRef<HTMLDivElement>(null);
77 + const listRef = useRef<HTMLDivElement>(null);
78 + const uploadRef = useRef<FileUploadHandle>(null);
79 + const sortRef = useRef<HTMLDivElement>(null);
80 + const sentinelRef = useRef<HTMLDivElement>(null);
81 + const nativeDragCount = useRef(0);
82 + const deepLinkHandled = useRef<string | null>(null);
83 +
84 + const effectiveSelectMode = selectMode || selectedIds.size > 0;
66 85
67 − // Vue & tri mémorisés (localStorage)
68 − const setViewMode = useCallback((v: "grid" | "list") => {
86 + /* ---------- Préférences persistées ---------- */
87 + const setViewMode = useCallback((v: ViewMode) => {
69 88 setViewModeState(v);
70 89 try { localStorage.setItem("spb-view", v); } catch {}
71 90 }, []);
@@ -87,597 +106,713 @@ export default function FileExplorer({ folderId }: FileExplorerProps) {
87 106 try { localStorage.setItem("spb-sort", JSON.stringify({ key: sortKey, dir: sortDir })); } catch {}
88 107 }, [sortKey, sortDir]);
89 108
109 + /* ---------- Recherche (debounce) ---------- */
110 + useEffect(() => {
111 + const t = setTimeout(() => setSearchQuery(searchInput.trim()), 220);
112 + return () => clearTimeout(t);
113 + }, [searchInput]);
114 +
115 + /* ---------- Filtre par tag (?tagId=) ---------- */
116 + const tagIdParam = searchParams.get("tagId");
117 + useEffect(() => {
118 + if (!tagIdParam) { setTagFilter(null); return; }
119 + setTagFilter({ id: tagIdParam });
120 + fetch("/api/tags").then((r) => (r.ok ? r.json() : [])).then((tags) => {
121 + const t = Array.isArray(tags) ? tags.find((x: { id: string }) => x.id === tagIdParam) : null;
122 + if (t) setTagFilter({ id: t.id, name: t.name, color: t.color });
123 + }).catch(() => {});
124 + }, [tagIdParam]);
125 +
126 + /* ---------- Chargement ---------- */
90 127 const fetchData = useCallback(
91 128 async (loadMore = false) => {
92 − if (loadMore) {
93 − setLoadingMore(true);
94 − } else {
95 − setLoading(true);
96 − setOffset(0);
97 − }
98 −
129 + if (loadMore) setLoadingMore(true);
130 + else { setLoading(true); setOffset(0); }
99 131 try {
100 132 const currentOffset = loadMore ? offset : 0;
101 133 const params = new URLSearchParams();
102 − if (folderId) params.set("folderId", folderId);
103 − else params.set("folderId", "root");
134 + if (tagIdParam) params.set("tagId", tagIdParam);
135 + else params.set("folderId", folderId || "root");
104 136 if (searchQuery) params.set("search", searchQuery);
105 137 params.set("limit", String(PAGE_LIMIT));
106 138 params.set("offset", String(currentOffset));
107 139
108 140 const [filesRes, foldersRes] = await Promise.all([
109 141 fetch(`/api/files?${params}`),
110 − loadMore
111 − ? Promise.resolve(null)
112 − : fetch(`/api/folders?parentId=${folderId || "root"}`),
142 + loadMore || tagIdParam ? Promise.resolve(null) : fetch(`/api/folders?parentId=${folderId || "root"}`),
113 143 ]);
114 144
115 145 if (filesRes.ok) {
116 146 const data = await filesRes.json();
117 − // Support both array response and paginated {files, total} response
118 − const newFiles = Array.isArray(data) ? data : data.files || data;
119 − const total = Array.isArray(data) ? undefined : data.total;
120 −
121 − if (loadMore) {
122 − setFiles((prev) => [...prev, ...newFiles]);
123 − } else {
124 − setFiles(newFiles);
125 − }
126 −
127 − // Determine if there are more files
128 − if (total !== undefined) {
129 − setHasMore(currentOffset + PAGE_LIMIT < total);
130 − } else {
131 − setHasMore(newFiles.length >= PAGE_LIMIT);
132 − }
133 −
147 + const newFiles: FileData[] = Array.isArray(data) ? data : data.files || [];
148 + const tot: number | undefined = Array.isArray(data) ? undefined : data.total;
149 + setFiles((prev) => (loadMore ? [...prev, ...newFiles] : newFiles));
150 + setTotal(tot ?? null);
151 + setHasMore(tot !== undefined ? currentOffset + PAGE_LIMIT < tot : newFiles.length >= PAGE_LIMIT);
134 152 setOffset(currentOffset + newFiles.length);
135 153 }
136 −
137 − if (foldersRes && foldersRes.ok) {
138 − setFolders(await foldersRes.json());
139 − }
154 + if (tagIdParam && !loadMore) setFolders([]);
155 + if (foldersRes && foldersRes.ok) setFolders(await foldersRes.json());
140 156 } catch (err) {
141 157 console.error("Failed to fetch data:", err);
158 + toast.error("Impossible de charger le dossier");
142 159 } finally {
143 160 setLoading(false);
144 161 setLoadingMore(false);
145 162 }
146 163 },
147 − [folderId, searchQuery, offset]
164 + [folderId, searchQuery, offset, tagIdParam]
148 165 );
149 166
150 167 useEffect(() => {
151 168 fetchData();
152 169 // eslint-disable-next-line react-hooks/exhaustive-deps
153 − }, [folderId, searchQuery]);
170 + }, [folderId, searchQuery, tagIdParam]);
154 171
155 − // Keyboard shortcuts
172 + const handleRefresh = useCallback(() => {
173 + fetchData(false);
174 + // eslint-disable-next-line react-hooks/exhaustive-deps
175 + }, [folderId, searchQuery, tagIdParam]);
176 +
177 + // Rafraîchir quand un dépôt externe (arbre latéral) a déplacé des fichiers
178 + useEffect(() => {
179 + const h = () => handleRefresh();
180 + window.addEventListener("spb:files-changed", h);
181 + window.addEventListener(FOLDERS_CHANGED_EVENT, h);
182 + return () => {
183 + window.removeEventListener("spb:files-changed", h);
184 + window.removeEventListener(FOLDERS_CHANGED_EVENT, h);
185 + };
186 + }, [handleRefresh]);
187 +
188 + // Purger la sélection des fichiers disparus
189 + useEffect(() => {
190 + setSelectedIds((prev) => {
191 + if (prev.size === 0) return prev;
192 + const ids = new Set(files.map((f) => f.id));
193 + const next = new Set(Array.from(prev).filter((id) => ids.has(id)));
194 + return next.size === prev.size ? prev : next;
195 + });
196 + }, [files]);
197 +
198 + // Chargement infini
199 + useEffect(() => {
200 + const el = sentinelRef.current;
201 + if (!el || !hasMore || loading) return;
202 + const io = new IntersectionObserver((entries) => {
203 + if (entries[0]?.isIntersecting && !loadingMore) fetchData(true);
204 + }, { rootMargin: "400px" });
205 + io.observe(el);
206 + return () => io.disconnect();
207 + }, [hasMore, loading, loadingMore, fetchData]);
208 +
209 + /* ---------- Deep links : ?file= ?upload=1 ?new=1 ---------- */
210 + useEffect(() => {
211 + const fileParam = searchParams.get("file");
212 + const uploadParam = searchParams.get("upload");
213 + const newParam = searchParams.get("new");
214 + if (!fileParam && !uploadParam && !newParam) { deepLinkHandled.current = null; return; }
215 + const key = `${fileParam}|${uploadParam}|${newParam}`;
216 + if (deepLinkHandled.current === key) return;
217 + if (fileParam && loading) return; // attendre la liste pour retrouver le fichier
218 + deepLinkHandled.current = key;
219 +
220 + if (uploadParam) setShowUpload(true);
221 + if (newParam) setShowCreateFolder(true);
222 + if (fileParam) {
223 + const found = files.find((f) => f.id === fileParam);
224 + if (found) setPreviewFile(found);
225 + else {
226 + fetch(`/api/files/${fileParam}`)
227 + .then((r) => (r.ok ? r.json() : null))
228 + .then((f) => { if (f && f.id) setPreviewFile(f as FileData); else toast.error("Fichier introuvable"); })
229 + .catch(() => toast.error("Fichier introuvable"));
230 + }
231 + }
232 + // Nettoyer l'URL (conserver le filtre tag)
233 + const keep = new URLSearchParams();
234 + if (tagIdParam) keep.set("tagId", tagIdParam);
235 + const qs = keep.toString();
236 + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
237 + }, [searchParams, files, loading, router, pathname, tagIdParam]);
238 +
239 + /* ---------- Tri ---------- */
240 + const dirMul = sortDir === "asc" ? 1 : -1;
241 + const sortedFolders = useMemo(() => [...folders].sort((a, b) => dirMul * a.name.localeCompare(b.name, "fr", { numeric: true })), [folders, dirMul]);
242 + const sortedFiles = useMemo(() => [...files].sort((a, b) => {
243 + let r = 0;
244 + switch (sortKey) {
245 + case "date": r = new Date(a.updatedAt || a.createdAt).getTime() - new Date(b.updatedAt || b.createdAt).getTime(); break;
246 + case "size": r = (a.size || 0) - (b.size || 0); break;
247 + case "type": r = (a.mimeType || "").localeCompare(b.mimeType || "") || a.name.localeCompare(b.name, "fr", { numeric: true }); break;
248 + default: r = a.name.localeCompare(b.name, "fr", { numeric: true });
249 + }
250 + return dirMul * r;
251 + }), [files, sortKey, dirMul]);
252 +
253 + const toggleSortColumn = (key: SortKey) => {
254 + if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
255 + else { setSortKey(key); setSortDir(key === "date" || key === "size" ? "desc" : "asc"); }
256 + };
257 +
258 + useEffect(() => {
259 + if (!sortOpen) return;
260 + const h = (e: MouseEvent) => { if (sortRef.current && !sortRef.current.contains(e.target as Node)) setSortOpen(false); };
261 + document.addEventListener("mousedown", h);
262 + return () => document.removeEventListener("mousedown", h);
263 + }, [sortOpen]);
264 +
265 + /* ---------- Sélection ---------- */
266 + const toggleSelect = useCallback((file: FileData, e?: { shiftKey?: boolean }) => {
267 + setSelectedIds((prev) => {
268 + const next = new Set(prev);
269 + if (e?.shiftKey && lastClickedId) {
270 + const ids = sortedFiles.map((f) => f.id);
271 + const a = ids.indexOf(lastClickedId);
272 + const b = ids.indexOf(file.id);
273 + if (a >= 0 && b >= 0) {
274 + const [lo, hi] = a < b ? [a, b] : [b, a];
275 + for (let i = lo; i <= hi; i++) next.add(ids[i]);
276 + return next;
277 + }
278 + }
279 + next.has(file.id) ? next.delete(file.id) : next.add(file.id);
280 + return next;
281 + });
282 + setLastClickedId(file.id);
283 + }, [lastClickedId, sortedFiles]);
284 +
285 + const selectAll = useCallback(() => {
286 + setSelectedIds((prev) => (prev.size === files.length ? new Set() : new Set(files.map((f) => f.id))));
287 + }, [files]);
288 +
289 + const clearSelection = useCallback(() => { setSelectedIds(new Set()); setSelectMode(false); }, []);
290 +
291 + /* ---------- Raccourcis ---------- */
156 292 useEffect(() => {
157 293 const handleKeyDown = (e: KeyboardEvent) => {
158 294 const target = e.target as HTMLElement;
159 − // Skip if user is typing in an input
160 − if (
161 − target.tagName === "INPUT" ||
162 − target.tagName === "TEXTAREA" ||
163 − target.isContentEditable
164 − )
165 − return;
166 −
167 − // Delete selected files
295 + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return;
168 296 if (e.key === "Delete" || e.key === "Backspace") {
169 − if (selectMode && selectedIds.length > 0) {
297 + if (selectedIds.size > 0) {
170 298 e.preventDefault();
171 − // Trigger bulk delete via the BulkActions component (it handles delete)
172 − const deleteBtn = document.querySelector(
173 − '[data-bulk-delete]'
174 − ) as HTMLButtonElement;
175 − if (deleteBtn) deleteBtn.click();
299 + (document.querySelector("[data-bulk-delete]") as HTMLButtonElement | null)?.click();
176 300 }
177 301 }
178 −
179 − // Ctrl/Cmd + A: select all
180 302 if ((e.ctrlKey || e.metaKey) && e.key === "a") {
181 − if (files.length > 0) {
182 − e.preventDefault();
183 − if (!selectMode) setSelectMode(true);
184 − if (selectedIds.length === files.length) {
185 − setSelectedIds([]);
186 − } else {
187 − setSelectedIds(files.map((f) => f.id));
188 − }
189 − }
303 + if (files.length > 0) { e.preventDefault(); setSelectMode(true); selectAll(); }
190 304 }
191 −
192 − // Escape: clear selection or close preview
193 305 if (e.key === "Escape") {
194 − if (previewFile) {
195 − setPreviewFile(null);
196 − } else if (selectMode) {
197 − setSelectedIds([]);
198 − setSelectMode(false);
199 − }
200 − }
201 −
202 − // Ctrl/Cmd + Shift + N: new folder
203 − if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "N") {
204 − e.preventDefault();
205 − setShowCreateFolder(true);
306 + if (contextMenu || bgMenu) { setContextMenu(null); setBgMenu(null); }
307 + else if (previewFile) setPreviewFile(null);
308 + else if (selectedIds.size > 0 || selectMode) clearSelection();
309 + else if (showUpload) setShowUpload(false);
206 310 }
311 + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "n") { e.preventDefault(); setShowCreateFolder(true); }
312 + if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === "u" && !previewFile) { e.preventDefault(); setShowUpload(true); }
207 313 };
208 −
209 314 window.addEventListener("keydown", handleKeyDown);
210 315 return () => window.removeEventListener("keydown", handleKeyDown);
211 − }, [selectMode, selectedIds, files, previewFile]);
316 + }, [selectMode, selectedIds, files, previewFile, contextMenu, bgMenu, showUpload, selectAll, clearSelection]);
317 +
318 + /* ---------- Navigation clavier (roving) ---------- */
319 + const onListKeyDown = (e: React.KeyboardEvent) => {
320 + if (!["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
321 + const t = e.target as HTMLElement;
322 + if (t.tagName === "INPUT" || t.tagName === "TEXTAREA") return;
323 + const items = Array.from(listRef.current?.querySelectorAll<HTMLElement>('[role="option"]') ?? []);
324 + if (!items.length) return;
325 + const cur = items.findIndex((el) => el === document.activeElement || el.contains(document.activeElement));
326 + let next = cur;
327 + let cols = 1;
328 + if (viewMode === "grid" && cur >= 0) {
329 + const top = items[cur].offsetTop;
330 + cols = items.filter((el) => el.offsetTop === top && el.parentElement === items[cur].parentElement).length || 1;
331 + }
332 + switch (e.key) {
333 + case "ArrowDown": next = cur < 0 ? 0 : Math.min(items.length - 1, cur + cols); break;
334 + case "ArrowUp": next = cur < 0 ? 0 : Math.max(0, cur - cols); break;
335 + case "ArrowRight": next = cur < 0 ? 0 : Math.min(items.length - 1, cur + 1); break;
336 + case "ArrowLeft": next = cur < 0 ? 0 : Math.max(0, cur - 1); break;
337 + case "Home": next = 0; break;
338 + case "End": next = items.length - 1; break;
339 + }
340 + if (next !== cur && items[next]) {
341 + e.preventDefault();
342 + items[next].focus();
343 + items[next].scrollIntoView({ block: "nearest" });
344 + }
345 + };
212 346
213 − const handleSearch = useCallback((query: string) => {
214 − setSearchQuery(query);
347 + /* ---------- Dépôt natif (fichiers du bureau) : overlay plein écran ---------- */
348 + useEffect(() => {
349 + const onEnter = (e: DragEvent) => {
350 + if (!hasNativeFiles(e.dataTransfer)) return;
351 + e.preventDefault();
352 + nativeDragCount.current++;
353 + setNativeDragOver(true);
354 + };
355 + const onOver = (e: DragEvent) => {
356 + if (!hasNativeFiles(e.dataTransfer)) return;
357 + e.preventDefault();
358 + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
359 + };
360 + const onLeave = (e: DragEvent) => {
361 + if (!hasNativeFiles(e.dataTransfer)) return;
362 + nativeDragCount.current = Math.max(0, nativeDragCount.current - 1);
363 + if (nativeDragCount.current === 0) setNativeDragOver(false);
364 + };
365 + const onDrop = (e: DragEvent) => {
366 + if (!hasNativeFiles(e.dataTransfer)) return;
367 + e.preventDefault();
368 + nativeDragCount.current = 0;
369 + setNativeDragOver(false);
370 + const dropped = Array.from(e.dataTransfer?.files ?? []);
371 + if (!dropped.length) return;
372 + setShowUpload(true);
373 + // Laisser FileUpload se monter/afficher puis injecter
374 + requestAnimationFrame(() => uploadRef.current?.addFiles(dropped));
375 + toast.success(dropped.length > 1 ? `${dropped.length} fichiers ajoutés à l'envoi` : "Fichier ajouté à l'envoi");
376 + };
377 + const onEnd = () => { nativeDragCount.current = 0; setNativeDragOver(false); };
378 + window.addEventListener("dragenter", onEnter);
379 + window.addEventListener("dragover", onOver);
380 + window.addEventListener("dragleave", onLeave);
381 + window.addEventListener("drop", onDrop);
382 + window.addEventListener("dragend", onEnd);
383 + return () => {
384 + window.removeEventListener("dragenter", onEnter);
385 + window.removeEventListener("dragover", onOver);
386 + window.removeEventListener("dragleave", onLeave);
387 + window.removeEventListener("drop", onDrop);
388 + window.removeEventListener("dragend", onEnd);
389 + };
215 390 }, []);
216 391
217 − const toggleSelect = (id: string) => {
218 − setSelectedIds((prev) =>
219 − prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
220 − );
392 + /* ---------- Dépôt interne sur les miettes de la TopBar (délégation) ---------- */
393 + useEffect(() => {
394 + const crumbOf = (t: EventTarget | null) => (t instanceof Element ? t.closest<HTMLElement>("[data-crumb-root],[data-crumb-id]") : null);
395 + const onOver = (e: DragEvent) => {
396 + const el = crumbOf(e.target);
397 + if (!el || !hasInternalDrag(e.dataTransfer)) return;
398 + e.preventDefault();
399 + if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
400 + el.classList.add("drop-target");
401 + };
402 + const onLeave = (e: DragEvent) => {
403 + const el = crumbOf(e.target);
404 + if (el) el.classList.remove("drop-target");
405 + };
406 + const onDrop = async (e: DragEvent) => {
407 + const el = crumbOf(e.target);
408 + if (!el || !hasInternalDrag(e.dataTransfer)) return;
409 + e.preventDefault();
410 + e.stopPropagation();
411 + el.classList.remove("drop-target");
412 + const target = el.dataset.crumbId || "root";
413 + if (target === (folderId || "root")) return;
414 + const moved = await handleInternalDrop(e.dataTransfer, target);
415 + if (moved) handleRefresh();
416 + };
417 + document.addEventListener("dragover", onOver);
418 + document.addEventListener("dragleave", onLeave);
419 + document.addEventListener("drop", onDrop);
420 + return () => {
421 + document.removeEventListener("dragover", onOver);
422 + document.removeEventListener("dragleave", onLeave);
423 + document.removeEventListener("drop", onDrop);
424 + };
425 + }, [folderId, handleRefresh]);
426 +
427 + // Dépôt interne « vers la racine du dossier courant » sur la zone vide de l'explorateur
428 + const onContentDrop = async (e: React.DragEvent) => {
429 + if (!hasInternalDrag(e.dataTransfer)) return;
430 + e.preventDefault();
431 + const here = new Set(files.map((f) => f.id));
432 + const ids = readFileIds(e.dataTransfer);
433 + if (ids.length && ids.every((id) => here.has(id))) return; // déjà dans ce dossier
434 + if (readFolderId(e.dataTransfer) && folders.some((f) => f.id === readFolderId(e.dataTransfer))) return;
435 + const moved = await handleInternalDrop(e.dataTransfer, folderId || "root");
436 + if (moved) handleRefresh();
221 437 };
222 438
223 − const selectAll = () => {
224 − if (selectedIds.length === files.length) setSelectedIds([]);
225 − else setSelectedIds(files.map((f) => f.id));
439 + /* ---------- Menus contextuels ---------- */
440 + const openContextMenu = (file: FileData, e: React.MouseEvent) => {
441 + setBgMenu(null);
442 + setContextMenu({ file, x: e.clientX, y: e.clientY });
226 443 };
227 −
228 − const handleFileClick = (file: FileData) => {
229 − if (selectMode) {
230 − toggleSelect(file.id);
231 − } else {
232 − setPreviewFile(file);
233 − }
444 + const onBackgroundContext = (e: React.MouseEvent) => {
445 + if ((e.target as HTMLElement).closest('[role="option"]')) return;
446 + e.preventDefault();
447 + setContextMenu(null);
448 + setBgMenu({ x: Math.min(e.clientX, window.innerWidth - 220), y: Math.min(e.clientY, window.innerHeight - 120) });
234 449 };
235 −
236 − const handleRefresh = useCallback(() => {
237 − fetchData(false);
238 − // eslint-disable-next-line react-hooks/exhaustive-deps
239 − }, [folderId, searchQuery]);
240 −
241 − // Skeleton components
242 − const SkeletonGrid = () => (
243 − <div className="grid grid-cols-3 gap-2 sm:grid-cols-4 sm:gap-3 lg:grid-cols-6 xl:grid-cols-8">
244 − {Array.from({ length: 8 }).map((_, i) => (
245 − <div
246 − key={i}
247 − className="aspect-square bg-gray-100 dark:bg-white/5 rounded-xl animate-pulse"
248 − >
249 − <div className="p-4 h-full flex flex-col justify-between">
250 − <div className="w-10 h-10 bg-gray-200 dark:bg-white/10 rounded-lg" />
251 − <div className="space-y-2">
252 − <div className="h-3 bg-gray-200 dark:bg-white/10 rounded w-3/4" />
253 − <div className="h-2 bg-gray-200 dark:bg-white/10 rounded w-1/2" />
450 + useEffect(() => {
451 + if (!bgMenu) return;
452 + const h = () => setBgMenu(null);
453 + document.addEventListener("mousedown", h);
454 + return () => document.removeEventListener("mousedown", h);
455 + }, [bgMenu]);
456 +
457 + const selectedArr = useMemo(() => Array.from(selectedIds), [selectedIds]);
458 + const isSearch = searchQuery.length > 0;
459 + const currentName = folderName || (folderId ? "ce dossier" : "Fichiers");
460 +
461 + /* ---------- Squelettes ---------- */
462 + const Skeleton = () => (
463 + <div className="space-y-2" aria-busy>
464 + {viewMode === "grid" ? (
465 + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-3">
466 + {Array.from({ length: 10 }).map((_, i) => (
467 + <div key={i} className="card overflow-hidden animate-pulse">
468 + <div className="aspect-square bg-gray-100 dark:bg-white/5" />
469 + <div className="p-2.5 space-y-1.5"><div className="h-3 bg-gray-200 dark:bg-white/10 rounded w-3/4" /><div className="h-2 bg-gray-200 dark:bg-white/10 rounded w-1/2" /></div>
254 470 </div>
255 − </div>
471 + ))}
256 472 </div>
257 − ))}
258 − </div>
259 − );
260 −
261 − const SkeletonList = () => (
262 − <div className="space-y-1">
263 − {Array.from({ length: 6 }).map((_, i) => (
264 − <div
265 − key={i}
266 − className="flex items-center gap-3 px-3 py-3 bg-gray-50 dark:bg-white/[0.02] rounded-lg animate-pulse"
267 − >
268 − <div className="w-8 h-8 bg-gray-200 dark:bg-white/10 rounded-lg flex-shrink-0" />
269 − <div className="flex-1 space-y-1.5">
270 − <div className="h-3 bg-gray-200 dark:bg-white/10 rounded w-2/5" />
271 − <div className="h-2 bg-gray-200 dark:bg-white/10 rounded w-1/4" />
272 − </div>
273 − <div className="w-16 h-3 bg-gray-200 dark:bg-white/10 rounded flex-shrink-0" />
473 + ) : (
474 + <div className="card overflow-hidden divide-y divide-line dark:divide-white/5">
475 + {Array.from({ length: 8 }).map((_, i) => (
476 + <div key={i} className="flex items-center gap-3 px-3 py-3 animate-pulse">
477 + <div className="w-8 h-8 bg-gray-200 dark:bg-white/10 rounded-lg shrink-0" />
478 + <div className="flex-1 h-3 bg-gray-200 dark:bg-white/10 rounded" style={{ maxWidth: `${35 + ((i * 17) % 40)}%` }} />
479 + <div className="w-16 h-3 bg-gray-200 dark:bg-white/10 rounded hidden sm:block" />
480 + <div className="w-20 h-3 bg-gray-200 dark:bg-white/10 rounded hidden md:block" />
481 + </div>
482 + ))}
274 483 </div>
275 − ))}
484 + )}
276 485 </div>
277 486 );
278 487
279 − // Empty state
280 − const EmptyState = () => {
281 − const isSearch = searchQuery.length > 0;
282 −
488 + const SortIndicator = ({ k }: { k: SortKey }) =>
489 + sortKey === k ? (
490 + sortDir === "asc" ? <ArrowUpNarrowWide className="w-3.5 h-3.5 text-blue-500" /> : <ArrowDownWideNarrow className="w-3.5 h-3.5 text-blue-500" />
491 + ) : <ArrowUpDown className="w-3 h-3 opacity-0 group-hover/th:opacity-60" />;
492 +
493 + const ListHeader = () => {
494 + const allSelected = files.length > 0 && selectedIds.size === files.length;
495 + const Th = ({ k, className, children }: { k: SortKey; className: string; children: React.ReactNode }) => (
496 + <button onClick={() => toggleSortColumn(k)} className={`group/th flex items-center gap-1 hover:text-gray-800 dark:hover:text-gray-200 transition-colors ${className}`}>
497 + {children}<SortIndicator k={k} />
498 + </button>
499 + );
283 500 return (
284 − <div className="flex flex-col items-center justify-center h-64 text-gray-500">
285 − {isSearch ? (
286 − <>
287 − <div className="relative mb-6">
288 − <Search className="w-16 h-16 text-gray-200 dark:text-gray-700" />
289 − <div className="absolute -bottom-1 -right-1 w-6 h-6 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
290 − <X className="w-3.5 h-3.5 text-gray-400" />
291 − </div>
292 − </div>
293 − <p className="text-lg font-medium text-gray-600 dark:text-gray-400">
294 − Aucun resultat
295 − </p>
296 − <p className="text-sm mt-1 text-gray-400 dark:text-gray-500">
297 − Aucun fichier ne correspond a "{searchQuery}"
298 − </p>
299 − </>
300 − ) : (
301 − <>
302 − <div className="relative mb-6">
303 − <FolderOpen className="w-16 h-16 text-gray-200 dark:text-gray-700" />
304 − <div className="absolute -top-2 -right-4 flex gap-1.5 opacity-40">
305 − <FileText className="w-5 h-5 text-blue-400" />
306 − <Image className="w-5 h-5 text-green-400" />
307 − <Film className="w-5 h-5 text-purple-400" />
308 − </div>
309 − </div>
310 − <p className="text-lg font-medium text-gray-600 dark:text-gray-400">
311 − Dossier vide
312 − </p>
313 − <p className="text-sm mt-1 text-gray-400 dark:text-gray-500">
314 − Uploadez vos premiers fichiers pour commencer
315 − </p>
316 − <Button
317 − className="mt-4"
318 − size="sm"
319 − onClick={() => setShowUpload(true)}
501 + <div className="sticky top-0 z-10 hidden sm:flex items-center gap-3 px-3 h-9 bg-surface/95 backdrop-blur border-b border-line dark:border-white/[0.07] text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 select-none">
502 + <span className="w-8 shrink-0 flex items-center justify-center">
503 + {files.length > 0 && (
504 + <button
505 + onClick={selectAll}
506 + aria-label={allSelected ? "Tout désélectionner" : "Tout sélectionner"}
507 + className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${allSelected ? "bg-blue-500 border-blue-500 text-white" : selectedIds.size > 0 ? "bg-blue-500/40 border-blue-500 text-white" : "border-gray-300 dark:border-white/25 text-transparent hover:border-blue-400"}`}
320 508 >
321 − <Upload className="w-4 h-4" /> Upload
322 − </Button>
323 − </>
324 − )}
509 + <Check className="w-3 h-3" strokeWidth={3} />
510 + </button>
511 + )}
512 + </span>
513 + <Th k="name" className="flex-1 min-w-0 text-left">Nom</Th>
514 + <Th k="type" className="hidden lg:flex w-24 shrink-0">Type</Th>
515 + <Th k="size" className="w-24 shrink-0 justify-end">Taille</Th>
516 + <Th k="date" className="hidden md:flex w-28 shrink-0 justify-end">Modifié</Th>
517 + <span className="w-10 shrink-0" />
325 518 </div>
326 519 );
327 520 };
328 521
329 − // Tri (dossiers toujours triés par nom ; fichiers selon le critère choisi)
330 − const dirMul = sortDir === "asc" ? 1 : -1;
331 − const sortedFolders = [...folders].sort(
332 − (a, b) => dirMul * a.name.localeCompare(b.name, "fr")
333 − );
334 − const sortedFiles = [...files].sort((a, b) => {
335 − let r = 0;
336 − switch (sortKey) {
337 − case "date":
338 − r = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
339 − break;
340 − case "size":
341 − r = (a.size || 0) - (b.size || 0);
342 − break;
343 − case "type":
344 − r = (a.mimeType || "").localeCompare(b.mimeType || "") ||
345 − a.name.localeCompare(b.name, "fr");
346 − break;
347 − default:
348 − r = a.name.localeCompare(b.name, "fr");
349 − }
350 − return dirMul * r;
351 − });
352 −
353 − // En-tête de colonnes (vue liste)
354 − const ListHeader = () => (
355 − <div className="hidden sm:flex items-center gap-3 px-3 py-2 border-b border-gray-200 dark:border-white/10 text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
356 − <span className="w-8 flex-shrink-0" />
357 − <span className="flex-1 min-w-0">Nom</span>
358 − <span className="w-24 text-right flex-shrink-0">Taille</span>
359 − <span className="w-28 text-right flex-shrink-0 hidden md:block">Modifié</span>
360 − <span className="w-11 flex-shrink-0" />
361 − </div>
362 − );
363 −
364 522 return (
365 − <div className="flex flex-col h-full lg:flex-row overflow-x-hidden">
366 − <div
367 − ref={containerRef}
368 − className="flex-1 flex flex-col min-w-0 overflow-x-hidden"
369 − >
370 − {/* Toolbar */}
371 − <div className="px-4 lg:px-6 py-4 border-b border-gray-200 dark:border-white/10 flex flex-col sm:flex-row items-start sm:items-center gap-3">
372 − <div className="flex-1 w-full sm:w-auto">
373 − <SearchBar
374 − onSearch={handleSearch}
375 − placeholder="Rechercher des fichiers..."
523 + <div className="flex flex-col h-full lg:flex-row overflow-x-hidden" ref={containerRef}>
524 + <div className="flex-1 flex flex-col min-w-0 overflow-x-hidden">
525 + {/* ===== Barre d'outils ===== */}
526 + <div className="px-3 sm:px-4 lg:px-6 pt-3 pb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 shrink-0">
527 + {/* Recherche */}
528 + <div className="relative flex-1 min-w-0">
529 + <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
530 + <input
531 + type="search"
532 + value={searchInput}
533 + onChange={(e) => setSearchInput(e.target.value)}
534 + placeholder={`Rechercher dans ${currentName}…`}
535 + className="field pl-10 pr-9 h-10"
536 + aria-label="Rechercher des fichiers"
376 537 />
538 + {searchInput && (
539 + <button onClick={() => setSearchInput("")} className="absolute right-1.5 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200/60 dark:hover:bg-white/10" aria-label="Effacer">
540 + <X className="w-4 h-4" />
541 + </button>
542 + )}
377 543 </div>
378 − <div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
544 +
545 + <div className="flex items-center gap-1.5 sm:gap-2 overflow-x-auto scrollbar-none -mx-3 px-3 sm:mx-0 sm:px-0 sm:overflow-visible">
379 546 {/* Tri */}
380 − <div className="flex items-center gap-1">
381 − <select
382 − value={sortKey}
383 − onChange={(e) => setSortKey(e.target.value as "name" | "date" | "size" | "type")}
384 − className="bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-xs text-gray-600 dark:text-gray-300 h-10 pl-3 focus:outline-none"
385 − title="Trier par"
386 − >
387 − <option value="name">Nom</option>
388 − <option value="date">Date</option>
389 − <option value="size">Taille</option>
390 − <option value="type">Type</option>
391 − </select>
547 + <div className="relative shrink-0" ref={sortRef}>
392 548 <button
393 − onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
394 − className="min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg bg-gray-100 dark:bg-white/5 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
395 − title={sortDir === "asc" ? "Ordre croissant" : "Ordre décroissant"}
549 + onClick={() => setSortOpen((o) => !o)}
550 + className="h-10 px-3 flex items-center gap-1.5 rounded-xl bg-gray-100 dark:bg-white/[0.06] hover:bg-gray-200/70 dark:hover:bg-white/10 text-[13px] text-gray-700 dark:text-gray-200 transition-colors"
551 + aria-haspopup="menu"
552 + aria-expanded={sortOpen}
396 553 >
397 − {sortDir === "asc" ? <ArrowUpNarrowWide className="w-4 h-4" /> : <ArrowDownWideNarrow className="w-4 h-4" />}
554 + {sortDir === "asc" ? <ArrowUpNarrowWide className="w-4 h-4 text-gray-500" /> : <ArrowDownWideNarrow className="w-4 h-4 text-gray-500" />}
555 + <span className="hidden md:inline">{SORT_LABELS[sortKey]}</span>
556 + <ChevronDown className={`w-3.5 h-3.5 text-gray-400 transition-transform ${sortOpen ? "rotate-180" : ""}`} />
398 557 </button>
558 + <AnimatePresence>
559 + {sortOpen && (
560 + <motion.div
561 + initial={{ opacity: 0, y: -4, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -4, scale: 0.98 }} transition={{ duration: 0.14 }}
562 + className="absolute right-0 sm:left-0 sm:right-auto top-full mt-1.5 w-52 glass rounded-2xl shadow-pop p-1.5 z-40"
563 + role="menu"
564 + >
565 + <p className="px-3 pt-1.5 pb-1 text-[10.5px] font-semibold uppercase tracking-wider text-gray-400">Trier par</p>
566 + {(Object.keys(SORT_LABELS) as SortKey[]).map((k) => (
567 + <button key={k} role="menuitemradio" aria-checked={sortKey === k} onClick={() => { setSortKey(k); }} className={`w-full flex items-center gap-2 px-3 h-9 rounded-xl text-[13px] transition-colors ${sortKey === k ? "bg-blue-500/10 text-blue-700 dark:text-blue-300 font-medium" : "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.08]"}`}>
568 + <span className="flex-1 text-left">{SORT_LABELS[k]}</span>
569 + {sortKey === k && <Check className="w-4 h-4" />}
570 + </button>
571 + ))}
572 + <div className="my-1 border-t border-line dark:border-white/10" />
573 + <div className="grid grid-cols-2 gap-1 p-0.5">
574 + {(["asc", "desc"] as SortDir[]).map((d) => (
575 + <button key={d} onClick={() => setSortDir(d)} className={`h-8 rounded-lg text-[12px] flex items-center justify-center gap-1 transition-colors ${sortDir === d ? "bg-gray-900 text-white dark:bg-white dark:text-gray-900" : "text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10"}`}>
576 + {d === "asc" ? <ArrowUpNarrowWide className="w-3.5 h-3.5" /> : <ArrowDownWideNarrow className="w-3.5 h-3.5" />}
577 + {d === "asc" ? "Croissant" : "Décroissant"}
578 + </button>
579 + ))}
580 + </div>
581 + </motion.div>
582 + )}
583 + </AnimatePresence>
399 584 </div>
400 − <div className="flex items-center gap-1 bg-gray-100 dark:bg-white/5 rounded-lg p-1">
401 − <button
402 − onClick={() => setViewMode("grid")}
403 − className={`min-w-[40px] min-h-[40px] flex items-center justify-center rounded transition-colors ${viewMode === "grid" ? "bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm dark:shadow-none" : "text-gray-400"}`}
404 − >
405 − <Grid className="w-4 h-4" />
406 − </button>
407 − <button
408 − onClick={() => setViewMode("list")}
409 − className={`min-w-[40px] min-h-[40px] flex items-center justify-center rounded transition-colors ${viewMode === "list" ? "bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm dark:shadow-none" : "text-gray-400"}`}
410 − >
411 − <List className="w-4 h-4" />
412 − </button>
585 +
586 + {/* Vue */}
587 + <div className="relative flex items-center h-10 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06] shrink-0" role="radiogroup" aria-label="Affichage">
588 + {(["list", "grid"] as ViewMode[]).map((v) => (
589 + <button
590 + key={v}
591 + role="radio"
592 + aria-checked={viewMode === v}
593 + onClick={() => setViewMode(v)}
594 + className={`relative w-9 h-8 flex items-center justify-center rounded-lg transition-colors ${viewMode === v ? "text-gray-900 dark:text-white" : "text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"}`}
595 + title={v === "list" ? "Liste" : "Grille"}
596 + >
597 + {viewMode === v && <motion.span layoutId="view-seg" className="absolute inset-0 rounded-lg bg-surface dark:bg-white/10 shadow-card dark:shadow-none" transition={{ type: "spring", stiffness: 500, damping: 40 }} />}
598 + <span className="relative">{v === "list" ? <List className="w-4 h-4" /> : <LayoutGrid className="w-4 h-4" />}</span>
599 + </button>
600 + ))}
413 601 </div>
414 − <Button
415 − variant={selectMode ? "secondary" : "ghost"}
416 − size="sm"
417 − onClick={() => {
418 − setSelectMode(!selectMode);
419 − setSelectedIds([]);
420 − }}
421 − className="min-w-[40px] min-h-[40px]"
422 − >
423 − <CheckSquare className="w-4 h-4" />
424 − <span className="hidden sm:inline">Selection</span>
602 +
603 + <div className="hidden sm:block w-px h-6 bg-line dark:bg-white/10 mx-0.5 shrink-0" />
604 +
605 + <Button variant={effectiveSelectMode ? "secondary" : "ghost"} size="sm" onClick={() => (effectiveSelectMode ? clearSelection() : setSelectMode(true))} className="shrink-0 h-10" title="Mode sélection">
606 + <CheckSquare className="w-4 h-4" /><span className="hidden lg:inline">Sélection</span>
425 607 </Button>
426 − <Button
427 − variant="ghost"
428 − size="sm"
429 − onClick={() => setShowCreateFolder(true)}
430 − className="min-w-[40px] min-h-[40px]"
431 − >
432 − <Plus className="w-4 h-4" />
433 − <span className="hidden sm:inline">Dossier</span>
608 + <Button variant="ghost" size="sm" onClick={() => setShowCreateFolder(true)} className="shrink-0 h-10" title="Nouveau dossier (⌘⇧N)">
609 + <FolderPlus className="w-4 h-4" /><span className="hidden lg:inline">Dossier</span>
434 610 </Button>
435 − <Button
436 − variant="ghost"
437 − size="sm"
438 − onClick={() => setShowCreateSharedFolder(true)}
439 − className="min-w-[40px] min-h-[40px] text-blue-700 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
440 − >
441 − <Users className="w-4 h-4" />
442 − <span className="hidden sm:inline">Partage</span>
611 + <Button variant="ghost" size="sm" onClick={() => setShowCreateSharedFolder(true)} className="shrink-0 h-10 text-blue-600 dark:text-blue-300 hover:text-blue-700" title="Espace partagé">
612 + <Users className="w-4 h-4" /><span className="hidden lg:inline">Partage</span>
443 613 </Button>
444 − <Button
445 − size="sm"
446 − onClick={() => setShowUpload(!showUpload)}
447 − className="min-w-[40px] min-h-[40px]"
448 − >
449 − <Upload className="w-4 h-4" />
450 − <span className="hidden sm:inline">Upload</span>
614 + <Button variant="accent" size="sm" onClick={() => setShowUpload((s) => !s)} className="shrink-0 h-10" title="Envoyer des fichiers (U)">
615 + <Upload className="w-4 h-4" /><span className="hidden sm:inline">Envoyer</span>
451 616 </Button>
452 617 </div>
453 618 </div>
454 619
455 − {/* Bulk Actions Bar */}
456 − <BulkActions
457 − selectedIds={selectedIds}
458 − onClear={() => {
459 − setSelectedIds([]);
460 − setSelectMode(false);
461 − }}
462 − onRefresh={handleRefresh}
463 − />
464 −
465 − {/* Select All */}
466 − {selectMode && files.length > 0 && (
467 − <div className="px-4 lg:px-6 py-2 border-b border-gray-100 dark:border-white/5">
620 + {/* Sous-titre : compteurs + filtre tag */}
621 + <div className="px-3 sm:px-4 lg:px-6 pb-2 flex items-center gap-2 text-[12px] text-gray-400 min-h-[24px] shrink-0">
622 + {!loading && (
623 + <span className="tabular-nums">
624 + {tagIdParam ? null : <>{sortedFolders.length} dossier{sortedFolders.length !== 1 ? "s" : ""} · </>}
625 + {total ?? files.length} fichier{(total ?? files.length) !== 1 ? "s" : ""}
626 + {isSearch && <> pour « {searchQuery} »</>}
627 + </span>
628 + )}
629 + {tagFilter && (
468 630 <button
469 − onClick={selectAll}
470 − className="flex items-center gap-2 text-sm sm:text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 min-h-[40px]"
631 + onClick={() => router.replace(pathname)}
632 + className="flex items-center gap-1.5 pl-2 pr-1.5 h-6 rounded-full bg-gray-100 dark:bg-white/[0.07] text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-white/10 transition-colors"
633 + title="Retirer le filtre"
471 634 >
472 − {selectedIds.length === files.length ? (
473 − <CheckSquare className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
474 − ) : (
475 − <Square className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
476 − )}
477 − {selectedIds.length === files.length
478 − ? "Tout deselectionner"
479 − : "Tout selectionner"}
635 + <Tag className="w-3 h-3 text-gray-400" />
636 + {tagFilter.color && <span className="w-2 h-2 rounded-full" style={{ backgroundColor: tagFilter.color }} />}
637 + <span className="font-medium">{tagFilter.name || "Tag"}</span>
638 + <X className="w-3 h-3" />
480 639 </button>
481 − </div>
482 − )}
483 −
484 − {/* Upload Zone */}
485 − <AnimatePresence>
486 − {showUpload && (
487 − <motion.div
488 − initial={{ opacity: 0, height: 0 }}
489 − animate={{ opacity: 1, height: "auto" }}
490 − exit={{ opacity: 0, height: 0 }}
491 − className="px-4 lg:px-6 pt-4"
492 − >
493 − <FileUpload folderId={folderId} onUploadComplete={handleRefresh} />
494 − </motion.div>
495 640 )}
496 − </AnimatePresence>
641 + {selectedIds.size > 0 && <span className="ml-auto text-blue-600 dark:text-blue-300 font-medium tabular-nums">{selectedIds.size} sélectionné{selectedIds.size > 1 ? "s" : ""}</span>}
642 + </div>
643 +
644 + {/* Zone d'upload (montée en permanence pour la file d'attente) */}
645 + <div className={`px-3 sm:px-4 lg:px-6 shrink-0 ${showUpload ? "pb-3" : ""}`}>
646 + <FileUpload ref={uploadRef} folderId={folderId} onUploadComplete={handleRefresh} showZone={showUpload} folderName={folderName} />
647 + </div>
497 648
498 − {/* Content */}
499 − <div className="flex-1 overflow-auto overflow-x-hidden px-4 lg:px-6 py-4">
649 + {/* ===== Contenu ===== */}
650 + <div
651 + ref={listRef}
652 + className="flex-1 overflow-auto overflow-x-hidden px-3 sm:px-4 lg:px-6 pb-6 outline-none"
653 + onKeyDown={onListKeyDown}
654 + onContextMenu={onBackgroundContext}
655 + onDragOver={(e) => { if (hasInternalDrag(e.dataTransfer) && !(e.target as HTMLElement).closest("[data-folder-id]")) e.preventDefault(); }}
656 + onDrop={onContentDrop}
657 + role="listbox"
658 + aria-multiselectable
659 + aria-label="Contenu du dossier"
660 + >
500 661 {loading ? (
501 − <div>
502 − {/* Skeleton for folders */}
503 − <div className="mb-6">
504 − <div className="h-3 w-16 bg-gray-200 dark:bg-white/10 rounded animate-pulse mb-3" />
505 − {viewMode === "grid" ? (
506 − <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 xl:grid-cols-6">
507 − {Array.from({ length: 3 }).map((_, i) => (
508 − <div
509 − key={`sf-${i}`}
510 − className="h-14 bg-gray-100 dark:bg-white/5 rounded-xl animate-pulse"
511 − />
512 − ))}
513 − </div>
514 − ) : (
515 − <div className="space-y-1">
516 − {Array.from({ length: 2 }).map((_, i) => (
517 − <div
518 − key={`sf-${i}`}
519 − className="h-12 bg-gray-50 dark:bg-white/[0.02] rounded-lg animate-pulse"
520 − />
521 − ))}
662 + <Skeleton />
663 + ) : folders.length === 0 && files.length === 0 ? (
664 + isSearch ? (
665 + <EmptyState icon={Search} title="Aucun résultat" description={`Aucun fichier ne correspond à « ${searchQuery} » dans ${currentName}.`} action={<Button variant="secondary" size="sm" onClick={() => setSearchInput("")}>Effacer la recherche</Button>} />
666 + ) : tagIdParam ? (
667 + <EmptyState icon={Tag} title="Aucun fichier avec ce tag" action={<Button variant="secondary" size="sm" onClick={() => router.replace(pathname)}>Retirer le filtre</Button>} />
668 + ) : (
669 + <EmptyState
670 + icon={FolderOpen}
671 + title="Ce dossier est vide"
672 + description="Glissez des fichiers ici ou utilisez le bouton Envoyer. Vous pouvez aussi créer un sous-dossier."
673 + action={
674 + <div className="flex items-center gap-2">
675 + <Button variant="secondary" size="sm" onClick={() => setShowCreateFolder(true)}><FolderPlus className="w-4 h-4" /> Nouveau dossier</Button>
676 + <Button variant="accent" size="sm" onClick={() => { setShowUpload(true); uploadRef.current?.openPicker(); }}><Upload className="w-4 h-4" /> Envoyer</Button>
522 677 </div>
523 − )}
678 + }
679 + />
680 + )
681 + ) : viewMode === "list" ? (
682 + <div className="card overflow-clip">
683 + <ListHeader />
684 + <div className="divide-y divide-line/70 dark:divide-white/5">
685 + {sortedFolders.map((folder) => (
686 + <FolderCard key={folder.id} folder={folder} onRefresh={handleRefresh} viewMode="list" focusable />
687 + ))}
688 + {sortedFiles.map((file) => (
689 + <FileCard
690 + key={file.id}
691 + file={file}
692 + onPreview={setPreviewFile}
693 + onRefresh={handleRefresh}
694 + viewMode="list"
695 + selected={selectedIds.has(file.id)}
696 + selectMode={effectiveSelectMode}
697 + onToggleSelect={toggleSelect}
698 + dragIds={selectedArr}
699 + onContextMenu={openContextMenu}
700 + focusable
701 + />
702 + ))}
524 703 </div>
525 − {/* Skeleton for files */}
526 − <div className="h-3 w-20 bg-gray-200 dark:bg-white/10 rounded animate-pulse mb-3" />
527 − {viewMode === "grid" ? <SkeletonGrid /> : <SkeletonList />}
528 704 </div>
529 705 ) : (
530 − <>
531 − {folders.length === 0 && files.length === 0 ? (
532 − <EmptyState />
533 − ) : viewMode === "list" ? (
534 − /* ===== Vue Liste — explorateur à colonnes (dossiers d'abord) ===== */
535 − <div className="rounded-xl border border-gray-200 dark:border-white/10 overflow-hidden bg-white dark:bg-white/[0.02] shadow-sm dark:shadow-none">
536 − <ListHeader />
537 − <div className="divide-y divide-gray-100 dark:divide-white/5">
706 + <div className="space-y-6">
707 + {sortedFolders.length > 0 && (
708 + <section>
709 + <h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-2.5">Dossiers</h3>
710 + <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 xl:grid-cols-5">
538 711 {sortedFolders.map((folder) => (
539 − <FolderCard
540 − key={folder.id}
541 − folder={folder}
542 − onRefresh={handleRefresh}
543 − viewMode="list"
544 − />
712 + <FolderCard key={folder.id} folder={folder} onRefresh={handleRefresh} viewMode="grid" focusable />
545 713 ))}
714 + </div>
715 + </section>
716 + )}
717 + {sortedFiles.length > 0 && (
718 + <section>
719 + <h3 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-2.5">Fichiers</h3>
720 + <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8">
546 721 {sortedFiles.map((file) => (
547 − <div key={file.id} className="relative">
548 − {selectMode && (
549 − <button
550 − onClick={(e) => {
551 − e.stopPropagation();
552 − toggleSelect(file.id);
553 − }}
554 − className="absolute top-1/2 -translate-y-1/2 left-1.5 z-10 p-1 bg-white/90 dark:bg-black/60 rounded-md backdrop-blur-sm flex items-center justify-center"
555 − >
556 − {selectedIds.includes(file.id) ? (
557 − <CheckSquare className="w-4 h-4 text-blue-500" />
558 − ) : (
559 − <Square className="w-4 h-4 text-gray-400" />
560 − )}
561 − </button>
562 − )}
563 − <div className={selectedIds.includes(file.id) ? "ring-2 ring-inset ring-blue-500" : ""}>
564 − <FileCard
565 − file={file}
566 − onPreview={handleFileClick}
567 − onRefresh={handleRefresh}
568 − viewMode="list"
569 − />
570 − </div>
571 − </div>
722 + <FileCard
723 + key={file.id}
724 + file={file}
725 + onPreview={setPreviewFile}
726 + onRefresh={handleRefresh}
727 + viewMode="grid"
728 + selected={selectedIds.has(file.id)}
729 + selectMode={effectiveSelectMode}
730 + onToggleSelect={toggleSelect}
731 + dragIds={selectedArr}
732 + onContextMenu={openContextMenu}
733 + focusable
734 + />
572 735 ))}
573 736 </div>
574 − </div>
575 − ) : (
576 − /* ===== Vue Grille ===== */
577 − <>
578 − {folders.length > 0 && (
579 − <div className="mb-6">
580 − <h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
581 − Dossiers
582 − </h3>
583 − <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 xl:grid-cols-6">
584 − {sortedFolders.map((folder) => (
585 − <FolderCard
586 − key={folder.id}
587 − folder={folder}
588 − onRefresh={handleRefresh}
589 − viewMode="grid"
590 − />
591 − ))}
592 − </div>
593 − </div>
594 − )}
595 − {files.length > 0 && (
596 − <div>
597 − <h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
598 − Fichiers ({files.length}
599 − {hasMore ? "+" : ""})
600 − </h3>
601 − <div className="grid grid-cols-3 gap-2 sm:grid-cols-4 sm:gap-3 lg:grid-cols-6 xl:grid-cols-8">
602 − {sortedFiles.map((file) => (
603 − <div key={file.id} className="relative">
604 − {selectMode && (
605 − <button
606 − onClick={(e) => {
607 − e.stopPropagation();
608 − toggleSelect(file.id);
609 − }}
610 − className="absolute top-2 left-2 z-10 p-1.5 bg-white/80 dark:bg-black/50 rounded-md backdrop-blur-sm min-w-[36px] min-h-[36px] flex items-center justify-center"
611 − >
612 − {selectedIds.includes(file.id) ? (
613 − <CheckSquare className="w-4 h-4 text-blue-500" />
614 − ) : (
615 − <Square className="w-4 h-4 text-gray-400" />
616 − )}
617 − </button>
618 − )}
619 − <div className={selectedIds.includes(file.id) ? "ring-2 ring-blue-500 rounded-xl" : ""}>
620 − <FileCard
621 − file={file}
622 − onPreview={handleFileClick}
623 − onRefresh={handleRefresh}
624 − viewMode="grid"
625 − />
626 − </div>
627 − </div>
628 − ))}
629 − </div>
630 − </div>
631 − )}
632 − </>
737 + </section>
633 738 )}
739 + </div>
740 + )}
634 741
635 − {/* Charger plus (commun aux deux vues) */}
636 − {hasMore && (
637 − <div className="mt-6 flex justify-center">
638 − <button
639 − onClick={() => fetchData(true)}
640 − disabled={loadingMore}
641 − className="px-6 py-3 text-sm font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10 rounded-xl transition-colors disabled:opacity-50 min-h-[44px]"
642 − >
643 − {loadingMore ? (
644 − <span className="flex items-center gap-2">
645 − <div className="w-4 h-4 border-2 border-gray-400 dark:border-white/30 border-t-transparent rounded-full animate-spin" />
646 − Chargement...
647 − </span>
648 − ) : (
649 − "Charger plus"
650 − )}
651 − </button>
652 − </div>
653 − )}
654 − </>
742 + {/* Chargement infini */}
743 + {hasMore && !loading && (
744 + <div ref={sentinelRef} className="mt-6 flex justify-center">
745 + <button onClick={() => fetchData(true)} disabled={loadingMore} className="px-5 h-10 text-[13px] font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-white/[0.06] hover:bg-gray-200 dark:hover:bg-white/10 rounded-xl transition-colors disabled:opacity-60 flex items-center gap-2">
746 + {loadingMore ? <><Loader2 className="w-4 h-4 animate-spin" /> Chargement…</> : `Charger plus (${Math.max(0, (total ?? 0) - files.length)} restants)`}
747 + </button>
748 + </div>
655 749 )}
656 750 </div>
657 751 </div>
658 752
753 + {/* ===== Aperçu (panneau latéral desktop / plein écran mobile) ===== */}
659 754 {previewFile && (
660 − <div className="fixed inset-0 z-50 lg:relative lg:inset-auto lg:w-[400px] lg:border-l lg:border-gray-200 dark:lg:border-white/10">
661 − <PreviewEngine
662 − file={previewFile}
663 − files={files}
664 − onClose={() => setPreviewFile(null)}
665 − onNavigate={setPreviewFile}
666 − />
755 + <div className="fixed inset-0 z-50 lg:relative lg:inset-auto lg:z-auto lg:w-[420px] xl:w-[460px] lg:shrink-0 lg:border-l lg:border-line dark:lg:border-white/[0.07]">
756 + <PreviewEngine file={previewFile} files={sortedFiles} onClose={() => setPreviewFile(null)} onNavigate={setPreviewFile} onChanged={handleRefresh} />
667 757 </div>
668 758 )}
669 759
670 − <CreateFolderModal
671 − isOpen={showCreateFolder}
672 − onClose={() => setShowCreateFolder(false)}
673 − parentId={folderId || null}
674 − onCreated={handleRefresh}
675 − />
676 − <CreateSharedFolderModal
677 − isOpen={showCreateSharedFolder}
678 − onClose={() => setShowCreateSharedFolder(false)}
679 − onCreated={handleRefresh}
680 − />
760 + {/* ===== Barre d'actions groupées ===== */}
761 + <BulkActions selectedIds={selectedArr} totalCount={files.length} onClear={clearSelection} onRefresh={handleRefresh} onSelectAll={selectAll} currentFolderId={folderId || "root"} />
762 +
763 + {/* ===== Menu contextuel fichier ===== */}
764 + {contextMenu && (
765 + <FileActions
766 + key={contextMenu.file.id}
767 + file={contextMenu.file}
768 + anchor={{ x: contextMenu.x, y: contextMenu.y }}
769 + onClose={() => setContextMenu(null)}
770 + onRefresh={handleRefresh}
771 + onPreview={() => setPreviewFile(contextMenu.file)}
772 + />
773 + )}
774 +
775 + {/* ===== Menu contextuel fond ===== */}
776 + <AnimatePresence>
777 + {bgMenu && (
778 + <motion.div
779 + initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.96 }} transition={{ duration: 0.12 }}
780 + className="fixed z-[75] w-52 glass rounded-2xl shadow-pop p-1.5" style={{ left: bgMenu.x, top: bgMenu.y }}
781 + onMouseDown={(e) => e.stopPropagation()} role="menu"
782 + >
783 + {[
784 + { icon: FolderPlus, label: "Nouveau dossier", hint: "⌘⇧N", run: () => setShowCreateFolder(true) },
785 + { icon: Upload, label: "Envoyer des fichiers", hint: "U", run: () => { setShowUpload(true); uploadRef.current?.openPicker(); } },
786 + { icon: CheckSquare, label: "Tout sélectionner", hint: "⌘A", run: () => { setSelectMode(true); selectAll(); } },
787 + ].map((it) => (
788 + <button key={it.label} role="menuitem" onClick={() => { it.run(); setBgMenu(null); }} className="w-full flex items-center gap-2.5 px-3 h-9 rounded-xl text-[13px] text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.08] transition-colors">
789 + <it.icon className="w-4 h-4 text-gray-400" /><span className="flex-1 text-left">{it.label}</span><kbd>{it.hint}</kbd>
790 + </button>
791 + ))}
792 + </motion.div>
793 + )}
794 + </AnimatePresence>
795 +
796 + {/* ===== Overlay de dépôt natif ===== */}
797 + <AnimatePresence>
798 + {nativeDragOver && (
799 + <motion.div
800 + initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}
801 + className="fixed inset-0 z-[65] flex items-center justify-center bg-canvas/70 dark:bg-canvas/80 backdrop-blur-sm pointer-events-none"
802 + >
803 + <motion.div initial={{ scale: 0.94, y: 8 }} animate={{ scale: 1, y: 0 }} className="mx-6 w-full max-w-md rounded-3xl border-2 border-dashed border-blue-500 bg-surface/90 dark:bg-elevated/90 shadow-glow p-10 text-center">
804 + <div className="mx-auto mb-4 w-16 h-16 rounded-3xl bg-accent-gradient text-white flex items-center justify-center shadow-glow animate-float">
805 + <CloudUpload className="w-8 h-8" />
806 + </div>
807 + <p className="text-lg font-semibold text-gray-900 dark:text-white">Déposer pour envoyer</p>
808 + <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">dans <span className="font-medium text-gray-800 dark:text-gray-100">{currentName}</span></p>
809 + </motion.div>
810 + </motion.div>
811 + )}
812 + </AnimatePresence>
813 +
814 + <CreateFolderModal isOpen={showCreateFolder} onClose={() => setShowCreateFolder(false)} parentId={folderId || null} onCreated={handleRefresh} />
815 + <CreateSharedFolderModal isOpen={showCreateSharedFolder} onClose={() => setShowCreateSharedFolder(false)} onCreated={handleRefresh} />
681 816 </div>
682 817 );
683 818 }
modified components/files/FilePreview.tsx +1 −1
@@ -215,7 +215,7 @@ export default function FilePreview({ file, files = [], onClose, onNavigate }: F
215 215 initial={{ opacity: 0 }}
216 216 animate={{ opacity: 1 }}
217 217 exit={{ opacity: 0 }}
218 − className={`fixed inset-0 z-50 flex flex-col bg-[#0a0a0a] ${
218 + className={`fixed inset-0 z-50 flex flex-col bg-gray-950 ${
219 219 fullscreen ? "" : "lg:relative lg:inset-auto lg:h-full"
220 220 }`}
221 221 >
modified components/files/FileUpload.tsx +151 −270
@@ -1,18 +1,9 @@
1 1 "use client";
2 2
3 −import { useCallback, useState, useRef, useEffect } from "react";
3 +import { useCallback, useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";
4 4 import { useDropzone } from "react-dropzone";
5 5 import { motion, AnimatePresence } from "framer-motion";
6 −import {
7 − Upload,
8 − CheckCircle,
9 − AlertCircle,
10 − X,
11 − ChevronDown,
12 − ChevronUp,
13 − Clock,
14 − Ban,
15 −} from "lucide-react";
6 +import { Upload, CheckCircle, AlertCircle, X, ChevronDown, ChevronUp, Clock, Ban, FolderUp } from "lucide-react";
16 7 import { formatFileSize } from "@/lib/utils";
17 8
18 9 type UploadStatus = "waiting" | "uploading" | "done" | "error" | "cancelled";
@@ -29,47 +20,62 @@ interface UploadItem {
29 20 interface FileUploadProps {
30 21 folderId?: string | null;
31 22 onUploadComplete: () => void;
23 + /** Afficher la zone de dépôt (la file d'attente reste visible même si false) */
24 + showZone?: boolean;
25 + /** Nom du dossier courant (affiché dans la zone) */
26 + folderName?: string;
27 +}
28 +
29 +/** API impérative : permet d'injecter des fichiers (dépôt global, palette…) */
30 +export interface FileUploadHandle {
31 + addFiles: (files: File[]) => void;
32 + openPicker: () => void;
32 33 }
33 34
34 35 const CONCURRENT_LIMIT = 2;
35 36
36 −export default function FileUpload({
37 − folderId,
38 − onUploadComplete,
39 −}: FileUploadProps) {
37 +const FileUpload = forwardRef<FileUploadHandle, FileUploadProps>(function FileUpload(
38 + { folderId, onUploadComplete, showZone = true, folderName },
39 + ref
40 +) {
40 41 const [uploads, setUploads] = useState<UploadItem[]>([]);
41 42 const [collapsed, setCollapsed] = useState(false);
42 43 const [showSummary, setShowSummary] = useState(false);
43 44 const activeCount = useRef(0);
44 45 const queueRef = useRef<UploadItem[]>([]);
45 46 const uploadsRef = useRef<UploadItem[]>([]);
47 + const completeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
46 48
47 − // Keep ref in sync with state
48 49 useEffect(() => {
49 50 uploadsRef.current = uploads;
50 51 }, [uploads]);
51 52
52 − const updateUpload = useCallback(
53 − (id: string, patch: Partial<UploadItem>) => {
54 − setUploads((prev) =>
55 − prev.map((u) => (u.id === id ? { ...u, ...patch } : u))
56 − );
57 − },
58 − []
59 − );
53 + const updateUpload = useCallback((id: string, patch: Partial<UploadItem>) => {
54 + setUploads((prev) => prev.map((u) => (u.id === id ? { ...u, ...patch } : u)));
55 + }, []);
56 +
57 + // Rafraîchit la liste au plus une fois par demi-seconde (évite N refetch pour N fichiers)
58 + const scheduleComplete = useCallback(() => {
59 + if (completeTimer.current) clearTimeout(completeTimer.current);
60 + completeTimer.current = setTimeout(() => onUploadComplete(), 400);
61 + }, [onUploadComplete]);
62 +
63 + const checkAllDone = () => {
64 + setTimeout(() => {
65 + setUploads((prev) => {
66 + const allFinished = prev.every((u) => u.status === "done" || u.status === "error" || u.status === "cancelled");
67 + if (allFinished && prev.length > 0) setShowSummary(true);
68 + return prev;
69 + });
70 + }, 200);
71 + };
60 72
61 73 const processQueue = useCallback(() => {
62 − while (
63 − activeCount.current < CONCURRENT_LIMIT &&
64 − queueRef.current.length > 0
65 − ) {
74 + while (activeCount.current < CONCURRENT_LIMIT && queueRef.current.length > 0) {
66 75 const item = queueRef.current.shift();
67 76 if (!item) break;
68 −
69 − // Check if cancelled while waiting
70 77 const current = uploadsRef.current.find((u) => u.id === item.id);
71 78 if (current && current.status === "cancelled") continue;
72 −
73 79 activeCount.current++;
74 80 startUpload(item);
75 81 }
@@ -83,38 +89,24 @@ export default function FileUpload({
83 89 formData.append("file", item.file);
84 90 if (folderId) formData.append("folderId", folderId);
85 91
86 − // Store xhr reference for cancel
87 92 updateUpload(item.id, { status: "uploading", xhr });
88 93
89 94 xhr.upload.addEventListener("progress", (e) => {
90 − if (e.lengthComputable) {
91 − const pct = Math.round((e.loaded / e.total) * 100);
92 − updateUpload(item.id, { progress: pct });
93 − }
95 + if (e.lengthComputable) updateUpload(item.id, { progress: Math.round((e.loaded / e.total) * 100) });
94 96 });
95 97
96 98 xhr.addEventListener("load", () => {
97 99 activeCount.current--;
98 100 if (xhr.status >= 200 && xhr.status < 300) {
99 − updateUpload(item.id, {
100 − progress: 100,
101 − status: "done",
102 − xhr: undefined,
103 − });
104 − onUploadComplete();
101 + updateUpload(item.id, { progress: 100, status: "done", xhr: undefined });
102 + scheduleComplete();
105 103 } else {
106 − let errorMsg = "Erreur upload";
104 + let errorMsg = "Erreur d'envoi";
107 105 try {
108 106 const data = JSON.parse(xhr.responseText);
109 107 errorMsg = data.error || errorMsg;
110 − } catch {
111 − // ignore parse error
112 − }
113 − updateUpload(item.id, {
114 − status: "error",
115 − error: errorMsg,
116 − xhr: undefined,
117 − });
108 + } catch {}
109 + updateUpload(item.id, { status: "error", error: errorMsg, xhr: undefined });
118 110 }
119 111 processQueue();
120 112 checkAllDone();
@@ -122,21 +114,14 @@ export default function FileUpload({
122 114
123 115 xhr.addEventListener("error", () => {
124 116 activeCount.current--;
125 − updateUpload(item.id, {
126 − status: "error",
127 − error: "Erreur reseau",
128 − xhr: undefined,
129 − });
117 + updateUpload(item.id, { status: "error", error: "Erreur réseau", xhr: undefined });
130 118 processQueue();
131 119 checkAllDone();
132 120 });
133 121
134 122 xhr.addEventListener("abort", () => {
135 123 activeCount.current--;
136 − updateUpload(item.id, {
137 − status: "cancelled",
138 − xhr: undefined,
139 − });
124 + updateUpload(item.id, { status: "cancelled", xhr: undefined });
140 125 processQueue();
141 126 checkAllDone();
142 127 });
@@ -145,33 +130,14 @@ export default function FileUpload({
145 130 xhr.send(formData);
146 131 },
147 132 // eslint-disable-next-line react-hooks/exhaustive-deps
148 − [folderId, onUploadComplete, updateUpload, processQueue]
133 + [folderId, scheduleComplete, updateUpload, processQueue]
149 134 );
150 135
151 − const checkAllDone = () => {
152 − // Use a small timeout so state has flushed
153 − setTimeout(() => {
154 − setUploads((prev) => {
155 − const allFinished = prev.every(
156 − (u) =>
157 − u.status === "done" ||
158 − u.status === "error" ||
159 − u.status === "cancelled"
160 − );
161 − if (allFinished && prev.length > 0) {
162 − setShowSummary(true);
163 − }
164 − return prev;
165 − });
166 − }, 200);
167 − };
168 −
169 136 const cancelUpload = useCallback((id: string) => {
170 137 setUploads((prev) =>
171 138 prev.map((u) => {
172 139 if (u.id === id) {
173 140 if (u.xhr) u.xhr.abort();
174 − // If waiting, just mark cancelled
175 141 if (u.status === "waiting") {
176 142 queueRef.current = queueRef.current.filter((q) => q.id !== id);
177 143 return { ...u, status: "cancelled" as UploadStatus };
@@ -186,12 +152,8 @@ export default function FileUpload({
186 152 queueRef.current = [];
187 153 setUploads((prev) =>
188 154 prev.map((u) => {
189 − if (u.status === "uploading" && u.xhr) {
190 − u.xhr.abort();
191 − }
192 − if (u.status === "waiting" || u.status === "uploading") {
193 − return { ...u, status: "cancelled" as UploadStatus, xhr: undefined };
194 − }
155 + if (u.status === "uploading" && u.xhr) u.xhr.abort();
156 + if (u.status === "waiting" || u.status === "uploading") return { ...u, status: "cancelled" as UploadStatus, xhr: undefined };
195 157 return u;
196 158 })
197 159 );
@@ -205,170 +167,140 @@ export default function FileUpload({
205 167 activeCount.current = 0;
206 168 }, []);
207 169
208 − const onDrop = useCallback(
170 + const addFiles = useCallback(
209 171 (acceptedFiles: File[]) => {
172 + if (!acceptedFiles.length) return;
210 173 setShowSummary(false);
174 + setCollapsed(false);
211 175 const newItems: UploadItem[] = acceptedFiles.map((file) => ({
212 176 id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
213 177 file,
214 178 progress: 0,
215 179 status: "waiting" as UploadStatus,
216 180 }));
217 −
218 181 setUploads((prev) => [...prev, ...newItems]);
219 −
220 − // Add to queue and process
221 182 queueRef.current.push(...newItems);
222 183 processQueue();
223 184 },
224 185 [processQueue]
225 186 );
226 187
227 − const { getRootProps, getInputProps, isDragActive } = useDropzone({
228 − onDrop,
188 + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
189 + onDrop: addFiles,
229 190 noClick: false,
191 + noKeyboard: false,
192 + // La zone gère ses propres dépôts ; l'overlay global (FileExplorer) gère le reste.
193 + noDragEventsBubbling: true,
230 194 });
231 195
196 + useImperativeHandle(ref, () => ({ addFiles, openPicker: open }), [addFiles, open]);
197 +
232 198 const doneCount = uploads.filter((u) => u.status === "done").length;
233 199 const errorCount = uploads.filter((u) => u.status === "error").length;
234 − const cancelledCount = uploads.filter(
235 − (u) => u.status === "cancelled"
236 − ).length;
237 − const hasActive = uploads.some(
238 − (u) => u.status === "uploading" || u.status === "waiting"
239 − );
200 + const cancelledCount = uploads.filter((u) => u.status === "cancelled").length;
201 + const hasActive = uploads.some((u) => u.status === "uploading" || u.status === "waiting");
202 + const totalProgress = uploads.length
203 + ? Math.round(uploads.reduce((s, u) => s + (u.status === "done" ? 100 : u.status === "uploading" ? u.progress : 0), 0) / uploads.length)
204 + : 0;
205 + const maxSize = parseInt(process.env.NEXT_PUBLIC_MAX_FILE_SIZE || "104857600");
240 206
241 207 return (
242 208 <div>
243 − {/* Drop zone */}
244 − <div
245 − {...getRootProps()}
246 − className={`border-2 border-dashed rounded-xl p-6 sm:p-8 text-center cursor-pointer transition-all ${
247 − isDragActive
248 − ? "border-blue-500 bg-blue-50 dark:bg-blue-500/5"
249 − : "border-gray-300 dark:border-white/10 hover:border-gray-400 dark:hover:border-white/20"
250 − }`}
251 − >
252 − <input {...getInputProps()} />
253 − <Upload className="w-8 h-8 mx-auto mb-3 text-gray-400 dark:text-gray-500" />
254 − <p className="text-sm text-gray-500 dark:text-gray-400">
255 − {isDragActive ? (
256 − "Deposez les fichiers ici..."
257 − ) : (
258 − <>
259 − <span className="hidden sm:inline">
260 − Glissez-deposez vos fichiers ou cliquez pour selectionner
261 − </span>
262 − <span className="sm:hidden">
263 − Tapez pour selectionner des fichiers
264 − </span>
265 − </>
266 − )}
267 − </p>
268 − <p className="text-xs text-gray-400 dark:text-gray-600 mt-1">
269 − Max{" "}
270 − {formatFileSize(
271 − parseInt(process.env.NEXT_PUBLIC_MAX_FILE_SIZE || "104857600")
272 − )}
273 − </p>
274 − </div>
209 + {/* Zone de dépôt */}
210 + <AnimatePresence initial={false}>
211 + {showZone && (
212 + <motion.div
213 + key="zone"
214 + initial={{ opacity: 0, height: 0 }}
215 + animate={{ opacity: 1, height: "auto" }}
216 + exit={{ opacity: 0, height: 0 }}
217 + transition={{ duration: 0.2 }}
218 + className="overflow-hidden"
219 + >
220 + <div
221 + {...getRootProps()}
222 + className={`relative group border-2 border-dashed rounded-2xl p-6 sm:p-8 text-center cursor-pointer transition-all ${
223 + isDragActive
224 + ? "border-blue-500 bg-blue-500/[0.06] shadow-glow"
225 + : "border-gray-300 dark:border-white/15 hover:border-blue-400 dark:hover:border-blue-400/60 hover:bg-blue-500/[0.03]"
226 + }`}
227 + >
228 + <input {...getInputProps()} />
229 + <div className={`mx-auto mb-3 w-12 h-12 rounded-2xl flex items-center justify-center transition-transform ${isDragActive ? "bg-blue-500 text-white scale-110" : "bg-blue-500/10 text-blue-600 dark:text-blue-300 group-hover:scale-105"}`}>
230 + <Upload className="w-5 h-5" />
231 + </div>
232 + <p className="text-sm font-medium text-gray-800 dark:text-gray-100">
233 + {isDragActive ? "Déposez les fichiers ici" : (
234 + <>
235 + <span className="hidden sm:inline">Glissez-déposez vos fichiers ou <span className="text-blue-600 dark:text-blue-300">parcourez</span></span>
236 + <span className="sm:hidden">Touchez pour choisir des fichiers</span>
237 + </>
238 + )}
239 + </p>
240 + <p className="text-[12px] text-gray-400 mt-1">
241 + {folderName ? <>Dans <span className="font-medium text-gray-500 dark:text-gray-300">{folderName}</span> · </> : null}
242 + Max {formatFileSize(maxSize)} par fichier
243 + </p>
244 + </div>
245 + </motion.div>
246 + )}
247 + </AnimatePresence>
275 248
276 − {/* Upload panel */}
249 + {/* File d'attente */}
277 250 <AnimatePresence>
278 251 {uploads.length > 0 && (
279 252 <motion.div
280 253 initial={{ opacity: 0, height: 0 }}
281 254 animate={{ opacity: 1, height: "auto" }}
282 255 exit={{ opacity: 0, height: 0 }}
283 − className="mt-3 bg-gray-50 dark:bg-white/[0.03] border border-gray-200 dark:border-white/10 rounded-xl overflow-hidden"
256 + className={`${showZone ? "mt-3" : ""} card overflow-hidden`}
284 257 >
285 − {/* Panel header */}
286 − <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-white/10">
287 − <button
288 − onClick={() => setCollapsed(!collapsed)}
289 − className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 min-h-[36px]"
290 − >
291 − {collapsed ? (
292 − <ChevronDown className="w-4 h-4" />
293 − ) : (
294 − <ChevronUp className="w-4 h-4" />
295 − )}
258 + <div className="relative flex items-center justify-between px-4 py-3 border-b border-line dark:border-white/[0.07]">
259 + {/* Barre de progression globale (fine, en haut) */}
260 + {hasActive && (
261 + <div className="absolute left-0 right-0 top-0 h-0.5 bg-gray-100 dark:bg-white/5">
262 + <motion.div className="h-full bg-accent-gradient" animate={{ width: `${totalProgress}%` }} transition={{ ease: "easeOut", duration: 0.3 }} />
263 + </div>
264 + )}
265 + <button onClick={() => setCollapsed(!collapsed)} className="flex items-center gap-2 text-[13px] font-medium text-gray-800 dark:text-gray-100 min-h-[36px]">
266 + {collapsed ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronUp className="w-4 h-4 text-gray-400" />}
267 + <FolderUp className="w-4 h-4 text-blue-500" />
296 268 <span>
297 − {doneCount}/{uploads.length} fichiers uploades
269 + {hasActive ? `Envoi ${doneCount}/${uploads.length}` : `${doneCount}/${uploads.length} fichier${uploads.length > 1 ? "s" : ""} envoyé${doneCount > 1 ? "s" : ""}`}
298 270 </span>
299 − {hasActive && (
300 − <span className="ml-2 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
301 − )}
271 + {hasActive && <span className="text-[12px] text-blue-600 dark:text-blue-300 tabular-nums">{totalProgress}%</span>}
302 272 </button>
303 − <div className="flex items-center gap-2">
304 − {hasActive && (
305 − <button
306 − onClick={cancelAll}
307 − className="text-xs text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 px-2 min-h-[36px] font-medium"
308 − >
309 − Annuler tout
273 + <div className="flex items-center gap-1">
274 + {hasActive ? (
275 + <button onClick={cancelAll} className="text-[12px] text-rose-600 hover:text-rose-700 dark:text-rose-400 px-2.5 h-8 rounded-lg hover:bg-rose-50 dark:hover:bg-rose-500/10 font-medium transition-colors">
276 + Tout annuler
310 277 </button>
311 − )}
312 − {!hasActive && (
313 − <button
314 − onClick={clearUploads}
315 − className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 px-2 min-h-[36px]"
316 − >
278 + ) : (
279 + <button onClick={clearUploads} className="text-[12px] text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 px-2.5 h-8 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 transition-colors">
317 280 Effacer
318 281 </button>
319 282 )}
320 283 </div>
321 284 </div>
322 285
323 − {/* File list */}
324 − <AnimatePresence>
286 + <AnimatePresence initial={false}>
325 287 {!collapsed && (
326 − <motion.div
327 − initial={{ height: 0, opacity: 0 }}
328 − animate={{ height: "auto", opacity: 1 }}
329 − exit={{ height: 0, opacity: 0 }}
330 − className="max-h-64 overflow-y-auto"
331 − >
332 − <div className="divide-y divide-gray-100 dark:divide-white/5">
288 + <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="max-h-64 overflow-y-auto">
289 + <div className="divide-y divide-line dark:divide-white/5">
333 290 {uploads.map((upload) => (
334 − <UploadRow
335 − key={upload.id}
336 − upload={upload}
337 − onCancel={() => cancelUpload(upload.id)}
338 − />
291 + <UploadRow key={upload.id} upload={upload} onCancel={() => cancelUpload(upload.id)} />
339 292 ))}
340 293 </div>
341 294 </motion.div>
342 295 )}
343 296 </AnimatePresence>
344 297
345 − {/* Summary */}
346 298 <AnimatePresence>
347 299 {showSummary && !hasActive && (
348 − <motion.div
349 − initial={{ opacity: 0 }}
350 − animate={{ opacity: 1 }}
351 − exit={{ opacity: 0 }}
352 − className="px-4 py-3 border-t border-gray-200 dark:border-white/10 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-3"
353 − >
354 − {doneCount > 0 && (
355 − <span className="flex items-center gap-1 text-green-600 dark:text-green-400">
356 − <CheckCircle className="w-3.5 h-3.5" />
357 − {doneCount} termine{doneCount > 1 ? "s" : ""}
358 − </span>
359 − )}
360 − {errorCount > 0 && (
361 − <span className="flex items-center gap-1 text-red-500 dark:text-red-400">
362 − <AlertCircle className="w-3.5 h-3.5" />
363 − {errorCount} erreur{errorCount > 1 ? "s" : ""}
364 − </span>
365 − )}
366 − {cancelledCount > 0 && (
367 − <span className="flex items-center gap-1 text-gray-400">
368 − <Ban className="w-3.5 h-3.5" />
369 − {cancelledCount} annule{cancelledCount > 1 ? "s" : ""}
370 − </span>
371 − )}
300 + <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="px-4 py-2.5 border-t border-line dark:border-white/[0.07] text-[12px] text-gray-500 dark:text-gray-400 flex items-center gap-3 bg-gray-50/60 dark:bg-white/[0.02]">
301 + {doneCount > 0 && <span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400"><CheckCircle className="w-3.5 h-3.5" />{doneCount} terminé{doneCount > 1 ? "s" : ""}</span>}
302 + {errorCount > 0 && <span className="flex items-center gap-1 text-rose-500 dark:text-rose-400"><AlertCircle className="w-3.5 h-3.5" />{errorCount} erreur{errorCount > 1 ? "s" : ""}</span>}
303 + {cancelledCount > 0 && <span className="flex items-center gap-1 text-gray-400"><Ban className="w-3.5 h-3.5" />{cancelledCount} annulé{cancelledCount > 1 ? "s" : ""}</span>}
372 304 </motion.div>
373 305 )}
374 306 </AnimatePresence>
@@ -377,91 +309,40 @@ export default function FileUpload({
377 309 </AnimatePresence>
378 310 </div>
379 311 );
380 −}
312 +});
381 313
382 −function UploadRow({
383 − upload,
384 − onCancel,
385 −}: {
386 − upload: UploadItem;
387 − onCancel: () => void;
388 −}) {
389 − const { file, progress, status, error } = upload;
314 +export default FileUpload;
390 315
316 +function UploadRow({ upload, onCancel }: { upload: UploadItem; onCancel: () => void }) {
317 + const { file, progress, status, error } = upload;
391 318 return (
392 − <div className="px-4 py-3">
319 + <div className="px-4 py-2.5">
393 320 <div className="flex items-center gap-3">
394 − {/* Status icon */}
395 − <div className="flex-shrink-0">
396 − {status === "waiting" && (
397 − <Clock className="w-4 h-4 text-gray-400" />
398 − )}
399 − {status === "uploading" && (
400 − <div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
401 − )}
402 − {status === "done" && (
403 − <CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />
404 − )}
405 − {status === "error" && (
406 − <AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" />
407 − )}
408 − {status === "cancelled" && (
409 − <Ban className="w-4 h-4 text-gray-400" />
410 − )}
321 + <div className="shrink-0 w-4 flex justify-center">
322 + {status === "waiting" && <Clock className="w-4 h-4 text-gray-400" />}
323 + {status === "uploading" && <div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />}
324 + {status === "done" && <CheckCircle className="w-4 h-4 text-emerald-500" />}
325 + {status === "error" && <AlertCircle className="w-4 h-4 text-rose-500" />}
326 + {status === "cancelled" && <Ban className="w-4 h-4 text-gray-400" />}
411 327 </div>
412 −
413 − {/* File info */}
414 328 <div className="flex-1 min-w-0">
415 329 <div className="flex items-center justify-between gap-2">
416 − <span
417 − className={`text-sm truncate ${
418 − status === "cancelled"
419 − ? "text-gray-400 line-through"
420 − : status === "error"
421 − ? "text-red-500 dark:text-red-400"
422 − : "text-gray-700 dark:text-gray-300"
423 − }`}
424 − >
425 − {file.name}
426 − </span>
427 − <div className="flex items-center gap-2 flex-shrink-0">
428 − <span className="text-xs text-gray-400">
429 − {formatFileSize(file.size)}
430 − </span>
431 − {status === "uploading" && (
432 − <span className="text-xs font-medium text-blue-500 tabular-nums w-9 text-right">
433 − {progress}%
434 − </span>
435 − )}
330 + <span className={`text-[13px] truncate ${status === "cancelled" ? "text-gray-400 line-through" : status === "error" ? "text-rose-500 dark:text-rose-400" : "text-gray-700 dark:text-gray-200"}`}>{file.name}</span>
331 + <div className="flex items-center gap-2 shrink-0">
332 + <span className="text-[11.5px] text-gray-400 tabular-nums">{formatFileSize(file.size)}</span>
333 + {status === "uploading" && <span className="text-[11.5px] font-medium text-blue-600 dark:text-blue-300 tabular-nums w-9 text-right">{progress}%</span>}
436 334 </div>
437 335 </div>
438 −
439 − {/* Progress bar */}
440 336 {(status === "uploading" || status === "waiting") && (
441 − <div className="mt-1.5 h-1.5 bg-gray-200 dark:bg-white/10 rounded-full overflow-hidden">
442 − <motion.div
443 − className="h-full bg-blue-500 rounded-full"
444 − initial={{ width: 0 }}
445 − animate={{ width: `${progress}%` }}
446 − transition={{ duration: 0.3, ease: "easeOut" }}
447 − />
337 + <div className="mt-1.5 h-1 bg-gray-200 dark:bg-white/10 rounded-full overflow-hidden">
338 + <motion.div className="h-full bg-accent-gradient rounded-full" initial={{ width: 0 }} animate={{ width: `${progress}%` }} transition={{ duration: 0.3, ease: "easeOut" }} />
448 339 </div>
449 340 )}
450 −
451 − {/* Error message */}
452 − {status === "error" && error && (
453 − <p className="text-xs text-red-400 mt-1">{error}</p>
454 − )}
341 + {status === "error" && error && <p className="text-[11.5px] text-rose-400 mt-0.5">{error}</p>}
455 342 </div>
456 −
457 − {/* Cancel button */}
458 343 {(status === "uploading" || status === "waiting") && (
459 − <button
460 − onClick={onCancel}
461 − className="flex-shrink-0 p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-white/10 transition-colors min-w-[32px] min-h-[32px] flex items-center justify-center"
462 − title="Annuler"
463 − >
464 − <X className="w-3.5 h-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" />
344 + <button onClick={onCancel} className="shrink-0 w-8 h-8 rounded-lg hover:bg-gray-200/60 dark:hover:bg-white/10 transition-colors flex items-center justify-center" title="Annuler" aria-label="Annuler">
345 + <X className="w-3.5 h-3.5 text-gray-400" />
465 346 </button>
466 347 )}
467 348 </div>
modified components/files/MoveModal.tsx +85 −32
@@ -1,13 +1,15 @@
1 1 "use client";
2 2
3 3 import { useEffect, useState, useCallback } from "react";
4 −import { Folder, FolderOpen, Home, ChevronRight, ChevronDown } from "lucide-react";
4 +import { Folder, FolderOpen, Home, ChevronRight, ChevronDown, Search } from "lucide-react";
5 5 import Modal from "../ui/Modal";
6 +import Button from "../ui/Button";
6 7
7 8 interface TreeFolder {
8 9 id: string;
9 10 name: string;
10 11 children?: TreeFolder[];
12 + _count?: { files: number };
11 13 }
12 14
13 15 interface MoveModalProps {
@@ -19,6 +21,14 @@ interface MoveModalProps {
19 21 excludeId?: string;
20 22 title?: string;
21 23 itemName?: string;
24 + /** Emplacement actuel (désactivé dans la liste) */
25 + currentFolderId?: string | null;
26 +}
27 +
28 +function matches(node: TreeFolder, q: string): boolean {
29 + if (!q) return true;
30 + if (node.name.toLowerCase().includes(q)) return true;
31 + return (node.children ?? []).some((c) => matches(c, q));
22 32 }
23 33
24 34 export default function MoveModal({
@@ -28,17 +38,26 @@ export default function MoveModal({
28 38 excludeId,
29 39 title = "Déplacer vers",
30 40 itemName,
41 + currentFolderId,
31 42 }: MoveModalProps) {
32 43 const [tree, setTree] = useState<TreeFolder[]>([]);
33 44 const [expanded, setExpanded] = useState<Set<string>>(new Set());
34 45 const [loading, setLoading] = useState(false);
46 + const [query, setQuery] = useState("");
47 + const [chosen, setChosen] = useState<string | null>(null);
35 48
36 49 useEffect(() => {
37 50 if (!isOpen) return;
38 51 setLoading(true);
52 + setQuery("");
53 + setChosen(null);
39 54 fetch("/api/folders?tree=true")
40 55 .then((r) => (r.ok ? r.json() : []))
41 − .then((data) => setTree(Array.isArray(data) ? data : []))
56 + .then((data) => {
57 + const t: TreeFolder[] = Array.isArray(data) ? data : [];
58 + setTree(t);
59 + setExpanded(new Set(t.filter((n) => n.children?.length).map((n) => n.id)));
60 + })
42 61 .catch(() => setTree([]))
43 62 .finally(() => setLoading(false));
44 63 }, [isOpen]);
@@ -51,65 +70,99 @@ export default function MoveModal({
51 70 });
52 71 }, []);
53 72
73 + const q = query.trim().toLowerCase();
74 + const current = currentFolderId ?? undefined;
75 +
54 76 function renderNode(node: TreeFolder, depth: number): React.ReactNode {
55 − if (node.id === excludeId) return null; // masque le dossier déplacé + son sous-arbre
77 + if (node.id === excludeId) return null;
78 + if (!matches(node, q)) return null;
56 79 const kids = node.children ?? [];
57 80 const hasKids = kids.length > 0;
58 − const isOpen = expanded.has(node.id);
81 + const isOpen = q ? true : expanded.has(node.id);
82 + const isCurrent = node.id === current;
83 + const isChosen = node.id === chosen;
59 84 return (
60 85 <div key={node.id}>
61 86 <div
62 − className="flex items-center gap-1 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 transition-colors"
87 + className={`flex items-center gap-1 rounded-xl transition-colors ${isChosen ? "bg-blue-500/10 ring-1 ring-blue-500/30" : "hover:bg-gray-100 dark:hover:bg-white/5"}`}
63 88 style={{ paddingLeft: depth * 16 }}
64 89 >
65 90 <button
91 + type="button"
66 92 onClick={() => hasKids && toggle(node.id)}
67 − className="w-6 h-9 flex items-center justify-center flex-shrink-0 text-gray-400"
93 + className="w-7 h-10 flex items-center justify-center shrink-0 text-gray-400"
68 94 tabIndex={hasKids ? 0 : -1}
95 + aria-label={hasKids ? (isOpen ? "Replier" : "Déplier") : undefined}
69 96 >
70 − {hasKids ? (
71 − isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />
72 − ) : null}
97 + {hasKids ? (isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />) : null}
73 98 </button>
74 99 <button
75 − onClick={() => onSelect(node.id)}
76 − className="flex items-center gap-2 flex-1 min-w-0 py-2.5 text-sm text-gray-700 dark:text-gray-200 text-left"
100 + type="button"
101 + disabled={isCurrent}
102 + onClick={() => setChosen(node.id)}
103 + onDoubleClick={() => !isCurrent && onSelect(node.id)}
104 + className={`flex items-center gap-2 flex-1 min-w-0 h-10 pr-2 text-sm text-left rounded-r-xl ${isCurrent ? "text-gray-400 cursor-not-allowed" : "text-gray-700 dark:text-gray-200"}`}
77 105 >
78 − {isOpen && hasKids ? (
79 − <FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
80 − ) : (
81 − <Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
82 − )}
83 − <span className="truncate">{node.name}</span>
106 + {isOpen && hasKids ? <FolderOpen className="w-4 h-4 text-blue-500 shrink-0" /> : <Folder className="w-4 h-4 text-blue-400 shrink-0" />}
107 + <span className="truncate flex-1">{node.name}</span>
108 + {isCurrent && <span className="text-[10px] uppercase tracking-wider text-gray-400">actuel</span>}
109 + {!isCurrent && node._count?.files ? <span className="text-[11px] text-gray-400 tabular-nums">{node._count.files}</span> : null}
84 110 </button>
85 111 </div>
86 − {hasKids && isOpen && (
87 − <div>{kids.map((c) => renderNode(c, depth + 1))}</div>
88 − )}
112 + {hasKids && isOpen && <div className="border-l border-line dark:border-white/[0.07] ml-[13px]">{kids.map((c) => renderNode(c, depth + 1))}</div>}
89 113 </div>
90 114 );
91 115 }
92 116
117 + const rootIsCurrent = current === "root" || current === null;
118 +
93 119 return (
94 − <Modal isOpen={isOpen} onClose={onClose} title={title}>
95 − {itemName && (
96 − <p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
97 − Déplacer <strong className="text-gray-700 dark:text-gray-200">{itemName}</strong> vers&nbsp;:
98 − </p>
99 − )}
100 − <div className="space-y-0.5 max-h-[55vh] overflow-y-auto">
120 + <Modal
121 + isOpen={isOpen}
122 + onClose={onClose}
123 + title={title}
124 + description={itemName ? `Choisissez la destination de « ${itemName} »` : undefined}
125 + footer={
126 + <>
127 + <Button variant="ghost" onClick={onClose}>Annuler</Button>
128 + <Button variant="accent" disabled={!chosen} onClick={() => chosen && onSelect(chosen)}>Déplacer ici</Button>
129 + </>
130 + }
131 + >
132 + <div className="relative mb-3">
133 + <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
134 + <input
135 + type="text"
136 + value={query}
137 + onChange={(e) => setQuery(e.target.value)}
138 + placeholder="Filtrer les dossiers…"
139 + className="field pl-9"
140 + />
141 + </div>
142 + <div className="space-y-0.5 max-h-[48vh] overflow-y-auto -mx-1 px-1">
101 143 <button
102 − onClick={() => onSelect("root")}
103 − className="flex items-center gap-2 w-full px-2 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/5 rounded-lg"
144 + type="button"
145 + disabled={rootIsCurrent}
146 + onClick={() => setChosen("root")}
147 + onDoubleClick={() => !rootIsCurrent && onSelect("root")}
148 + className={`flex items-center gap-2 w-full px-2 h-10 text-sm font-medium rounded-xl transition-colors ${
149 + chosen === "root" ? "bg-blue-500/10 ring-1 ring-blue-500/30 text-gray-900 dark:text-white" : rootIsCurrent ? "text-gray-400 cursor-not-allowed" : "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/5"
150 + }`}
104 151 >
105 − <Home className="w-4 h-4 text-gray-500 flex-shrink-0" />
106 − Racine (Fichiers)
152 + <Home className="w-4 h-4 text-gray-500 shrink-0" />
153 + <span className="flex-1 text-left">Racine (Fichiers)</span>
154 + {rootIsCurrent && <span className="text-[10px] uppercase tracking-wider text-gray-400">actuel</span>}
107 155 </button>
108 156 {loading ? (
109 − <p className="px-2 py-3 text-sm text-gray-400">Chargement…</p>
157 + <div className="space-y-2 p-2">
158 + {[0, 1, 2].map((i) => <div key={i} className="h-9 rounded-xl bg-gray-100 dark:bg-white/5 animate-pulse" />)}
159 + </div>
110 160 ) : (
111 161 tree.map((n) => renderNode(n, 0))
112 162 )}
163 + {!loading && q && tree.every((n) => !matches(n, q)) && (
164 + <p className="px-2 py-4 text-sm text-gray-400 text-center">Aucun dossier ne correspond</p>
165 + )}
113 166 </div>
114 167 </Modal>
115 168 );
added components/files/dnd.ts +112 −0
@@ -0,0 +1,112 @@
1 +/**
2 + * Glisser-déposer interne SPB Cloud (fichiers ↔ dossiers) + événements de synchro.
3 + */
4 +import toast from "react-hot-toast";
5 +
6 +export const DND_FILES = "application/x-spb-files";
7 +export const DND_FOLDER = "application/x-spb-folder";
8 +export const FOLDERS_CHANGED_EVENT = "spb:folders-changed";
9 +
10 +/** Cible de dépôt : "root" ou id de dossier. */
11 +export type DropTarget = string;
12 +
13 +export function notifyFoldersChanged() {
14 + if (typeof window !== "undefined") window.dispatchEvent(new Event(FOLDERS_CHANGED_EVENT));
15 +}
16 +
17 +export function hasInternalDrag(dt: DataTransfer | null): boolean {
18 + if (!dt) return false;
19 + const types = Array.from(dt.types || []);
20 + return types.includes(DND_FILES) || types.includes(DND_FOLDER);
21 +}
22 +
23 +export function hasNativeFiles(dt: DataTransfer | null): boolean {
24 + if (!dt) return false;
25 + const types = Array.from(dt.types || []);
26 + return types.includes("Files") && !hasInternalDrag(dt);
27 +}
28 +
29 +export function readFileIds(dt: DataTransfer | null): string[] {
30 + try {
31 + const raw = dt?.getData(DND_FILES);
32 + if (!raw) return [];
33 + const arr = JSON.parse(raw);
34 + return Array.isArray(arr) ? arr.filter((x) => typeof x === "string") : [];
35 + } catch {
36 + return [];
37 + }
38 +}
39 +
40 +export function readFolderId(dt: DataTransfer | null): string | null {
41 + try {
42 + const raw = dt?.getData(DND_FOLDER);
43 + return raw || null;
44 + } catch {
45 + return null;
46 + }
47 +}
48 +
49 +/** Image fantôme personnalisée « N fichiers ». */
50 +export function setDragGhost(e: React.DragEvent, label: string) {
51 + try {
52 + const ghost = document.createElement("div");
53 + ghost.textContent = label;
54 + ghost.style.cssText =
55 + "position:fixed;top:-1000px;left:-1000px;padding:8px 14px;border-radius:12px;font:600 13px system-ui,sans-serif;color:#fff;" +
56 + "background:linear-gradient(135deg,rgb(var(--accent-500)),rgb(var(--accent-700)));box-shadow:0 8px 24px rgb(0 0 0/.25);white-space:nowrap;";
57 + document.body.appendChild(ghost);
58 + e.dataTransfer.setDragImage(ghost, 16, 16);
59 + setTimeout(() => ghost.remove(), 0);
60 + } catch {}
61 +}
62 +
63 +/** Déplace des fichiers vers un dossier ("root" accepté). Retourne le nombre déplacé. */
64 +export async function moveFiles(ids: string[], target: DropTarget): Promise<number> {
65 + const results = await Promise.all(
66 + ids.map((id) =>
67 + fetch(`/api/files/${id}`, {
68 + method: "PATCH",
69 + headers: { "Content-Type": "application/json" },
70 + body: JSON.stringify({ folderId: target }),
71 + }).then((r) => r.ok).catch(() => false)
72 + )
73 + );
74 + const ok = results.filter(Boolean).length;
75 + if (ok === ids.length) toast.success(ok > 1 ? `${ok} fichiers déplacés` : "Fichier déplacé");
76 + else if (ok > 0) toast.error(`${ids.length - ok} fichier(s) non déplacé(s)`);
77 + else toast.error("Déplacement impossible");
78 + return ok;
79 +}
80 +
81 +/** Déplace un dossier (anti-cycle géré côté serveur). */
82 +export async function moveFolder(id: string, target: DropTarget): Promise<boolean> {
83 + if (id === target) return false;
84 + try {
85 + const res = await fetch(`/api/folders/${id}`, {
86 + method: "PATCH",
87 + headers: { "Content-Type": "application/json" },
88 + body: JSON.stringify({ parentId: target }),
89 + });
90 + if (res.ok) {
91 + toast.success("Dossier déplacé");
92 + notifyFoldersChanged();
93 + return true;
94 + }
95 + const d = await res.json().catch(() => ({}));
96 + toast.error(d.error || "Déplacement impossible");
97 + } catch {
98 + toast.error("Erreur réseau");
99 + }
100 + return false;
101 +}
102 +
103 +/**
104 + * Gère un dépôt interne sur une cible. Retourne true si quelque chose a été déplacé.
105 + */
106 +export async function handleInternalDrop(dt: DataTransfer | null, target: DropTarget): Promise<boolean> {
107 + const folderId = readFolderId(dt);
108 + if (folderId) return moveFolder(folderId, target);
109 + const ids = readFileIds(dt);
110 + if (ids.length) return (await moveFiles(ids, target)) > 0;
111 + return false;
112 +}
modified components/folders/CreateFolderModal.tsx +23 −29
@@ -1,9 +1,11 @@
1 1 "use client";
2 2
3 3 import { useState } from "react";
4 +import { FolderPlus } from "lucide-react";
4 5 import Modal from "../ui/Modal";
5 6 import Button from "../ui/Button";
6 7 import toast from "react-hot-toast";
8 +import { notifyFoldersChanged } from "../files/dnd";
7 9
8 10 interface CreateFolderModalProps {
9 11 isOpen: boolean;
@@ -12,36 +14,27 @@ interface CreateFolderModalProps {
12 14 onCreated: () => void;
13 15 }
14 16
15 −export default function CreateFolderModal({
16 − isOpen,
17 − onClose,
18 − parentId,
19 − onCreated,
20 −}: CreateFolderModalProps) {
17 +export default function CreateFolderModal({ isOpen, onClose, parentId, onCreated }: CreateFolderModalProps) {
21 18 const [name, setName] = useState("");
22 19 const [loading, setLoading] = useState(false);
23 20
24 21 const handleCreate = async () => {
25 − if (!name.trim()) return;
22 + if (!name.trim() || loading) return;
26 23 setLoading(true);
27 −
28 24 try {
29 25 const res = await fetch("/api/folders", {
30 26 method: "POST",
31 27 headers: { "Content-Type": "application/json" },
32 − body: JSON.stringify({
33 − name: name.trim(),
34 − parentId: parentId || "root",
35 − }),
28 + body: JSON.stringify({ name: name.trim(), parentId: parentId || "root" }),
36 29 });
37 −
38 30 if (res.ok) {
39 31 toast.success("Dossier créé");
40 32 setName("");
33 + notifyFoldersChanged();
41 34 onCreated();
42 35 onClose();
43 36 } else {
44 − const data = await res.json();
37 + const data = await res.json().catch(() => ({}));
45 38 toast.error(data.error || "Erreur");
46 39 }
47 40 } catch {
@@ -52,22 +45,23 @@ export default function CreateFolderModal({
52 45 };
53 46
54 47 return (
55 − <Modal isOpen={isOpen} onClose={onClose} title="Nouveau dossier">
56 − <input
57 − type="text"
58 − value={name}
59 − onChange={(e) => setName(e.target.value)}
60 − placeholder="Nom du dossier"
61 − className="w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-gray-400 dark:focus:border-white/20 mb-4"
62 − autoFocus
63 − onKeyDown={(e) => e.key === "Enter" && handleCreate()}
64 − />
48 + <Modal isOpen={isOpen} onClose={onClose} title="Nouveau dossier" description={parentId ? "Créé dans le dossier courant" : "Créé à la racine"}>
49 + <div className="relative mb-4">
50 + <FolderPlus className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500 pointer-events-none" />
51 + <input
52 + type="text"
53 + value={name}
54 + onChange={(e) => setName(e.target.value)}
55 + placeholder="Nom du dossier"
56 + className="field pl-10 h-11"
57 + autoFocus
58 + onKeyDown={(e) => e.key === "Enter" && handleCreate()}
59 + />
60 + </div>
65 61 <div className="flex gap-2 justify-end">
66 − <Button variant="ghost" onClick={onClose}>
67 − Annuler
68 − </Button>
69 − <Button onClick={handleCreate} loading={loading} disabled={!name.trim()}>
70 − Créer
62 + <Button variant="ghost" onClick={onClose}>Annuler</Button>
63 + <Button variant="accent" onClick={handleCreate} loading={loading} disabled={!name.trim()}>
64 + <FolderPlus className="w-4 h-4" /> Créer
71 65 </Button>
72 66 </div>
73 67 </Modal>
modified components/folders/CreateSharedFolderModal.tsx +1 −2
@@ -19,8 +19,7 @@ interface CreateSharedFolderModalProps {
19 19 onCreated: () => void;
20 20 }
21 21
22 −const inputClasses =
23 − "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-blue-600 dark:focus:border-blue-700";
22 +const inputClasses = "field";
24 23
25 24 export default function CreateSharedFolderModal({
26 25 isOpen,
modified components/folders/FolderCard.tsx +187 −166
@@ -2,17 +2,19 @@
2 2
3 3 import { useRouter } from "next/navigation";
4 4 import { motion } from "framer-motion";
5 −import { Folder, MoreVertical, Pencil, Trash2, FolderInput } from "lucide-react";
6 −import { useState, useRef, useEffect } from "react";
5 +import { Folder, FolderOpen, MoreVertical, Pencil, Trash2, FolderInput, ChevronRight } from "lucide-react";
6 +import { useState, useRef, useEffect, memo } from "react";
7 7 import toast from "react-hot-toast";
8 8 import Modal from "../ui/Modal";
9 9 import Button from "../ui/Button";
10 10 import SharedBadge from "../ui/SharedBadge";
11 11 import MoveModal from "../files/MoveModal";
12 +import { DND_FOLDER, handleInternalDrop, hasInternalDrag, notifyFoldersChanged, readFolderId, setDragGhost } from "../files/dnd";
12 13
13 14 interface FolderData {
14 15 id: string;
15 16 name: string;
17 + parentId?: string | null;
16 18 sharedSpaceId?: string | null;
17 19 _count?: { files: number; children: number };
18 20 }
@@ -21,9 +23,12 @@ interface FolderCardProps {
21 23 folder: FolderData;
22 24 onRefresh: () => void;
23 25 viewMode: "grid" | "list";
26 + /** Navigation personnalisée (espaces partagés) */
27 + onOpen?: (folder: FolderData) => void;
28 + focusable?: boolean;
24 29 }
25 30
26 −export default function FolderCard({ folder, onRefresh, viewMode }: FolderCardProps) {
31 +function FolderCardInner({ folder, onRefresh, viewMode, onOpen, focusable }: FolderCardProps) {
27 32 const router = useRouter();
28 33 const [showMenu, setShowMenu] = useState(false);
29 34 const [renameModal, setRenameModal] = useState(false);
@@ -31,7 +36,21 @@ export default function FolderCard({ folder, onRefresh, viewMode }: FolderCardPr
31 36 const [moveModal, setMoveModal] = useState(false);
32 37 const [newName, setNewName] = useState(folder.name);
33 38 const [loading, setLoading] = useState(false);
39 + const [dragOver, setDragOver] = useState(false);
40 + const [dragging, setDragging] = useState(false);
34 41 const menuRef = useRef<HTMLDivElement>(null);
42 + const overCount = useRef(0);
43 +
44 + useEffect(() => {
45 + if (!showMenu) return;
46 + function handleClick(e: MouseEvent) {
47 + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setShowMenu(false);
48 + }
49 + document.addEventListener("mousedown", handleClick);
50 + return () => document.removeEventListener("mousedown", handleClick);
51 + }, [showMenu]);
52 +
53 + const open = () => (onOpen ? onOpen(folder) : router.push(`/dashboard/folder/${folder.id}`));
35 54
36 55 const handleMove = async (targetFolderId: string) => {
37 56 try {
@@ -42,6 +61,7 @@ export default function FolderCard({ folder, onRefresh, viewMode }: FolderCardPr
42 61 });
43 62 if (res.ok) {
44 63 toast.success("Dossier déplacé");
64 + notifyFoldersChanged();
45 65 onRefresh();
46 66 } else {
47 67 const d = await res.json().catch(() => ({}));
@@ -53,28 +73,20 @@ export default function FolderCard({ folder, onRefresh, viewMode }: FolderCardPr
53 73 setMoveModal(false);
54 74 };
55 75
56 − useEffect(() => {
57 − function handleClick(e: MouseEvent) {
58 − if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
59 − setShowMenu(false);
60 − }
61 − }
62 − document.addEventListener("mousedown", handleClick);
63 − return () => document.removeEventListener("mousedown", handleClick);
64 − }, []);
65 −
66 76 const handleRename = async () => {
77 + if (!newName.trim()) return;
67 78 setLoading(true);
68 79 try {
69 80 const res = await fetch(`/api/folders/${folder.id}`, {
70 81 method: "PATCH",
71 82 headers: { "Content-Type": "application/json" },
72 − body: JSON.stringify({ name: newName }),
83 + body: JSON.stringify({ name: newName.trim() }),
73 84 });
74 85 if (res.ok) {
75 86 toast.success("Dossier renommé");
87 + notifyFoldersChanged();
76 88 onRefresh();
77 − }
89 + } else toast.error("Renommage impossible");
78 90 } catch {
79 91 toast.error("Erreur");
80 92 } finally {
@@ -86,13 +98,12 @@ export default function FolderCard({ folder, onRefresh, viewMode }: FolderCardPr
86 98 const handleDelete = async () => {
87 99 setLoading(true);
88 100 try {
89 − const res = await fetch(`/api/folders/${folder.id}`, {
90 − method: "DELETE",
91 − });
101 + const res = await fetch(`/api/folders/${folder.id}`, { method: "DELETE" });
92 102 if (res.ok) {
93 103 toast.success("Dossier supprimé");
104 + notifyFoldersChanged();
94 105 onRefresh();
95 − }
106 + } else toast.error("Suppression impossible");
96 107 } catch {
97 108 toast.error("Erreur");
98 109 } finally {
@@ -101,181 +112,191 @@ export default function FolderCard({ folder, onRefresh, viewMode }: FolderCardPr
101 112 }
102 113 };
103 114
104 − const inputClasses = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none mb-4";
115 + /* ---- DnD : source ---- */
116 + const onDragStart = (e: React.DragEvent) => {
117 + e.dataTransfer.setData(DND_FOLDER, folder.id);
118 + e.dataTransfer.setData("text/plain", folder.name);
119 + e.dataTransfer.effectAllowed = "move";
120 + setDragGhost(e, `📁 ${folder.name}`);
121 + setDragging(true);
122 + };
123 +
124 + /* ---- DnD : cible ---- */
125 + const acceptable = (e: React.DragEvent) => hasInternalDrag(e.dataTransfer) && !dragging;
126 + const onDragEnter = (e: React.DragEvent) => {
127 + if (!acceptable(e)) return;
128 + e.preventDefault();
129 + overCount.current++;
130 + setDragOver(true);
131 + };
132 + const onDragOver = (e: React.DragEvent) => {
133 + if (!acceptable(e)) return;
134 + e.preventDefault();
135 + e.dataTransfer.dropEffect = "move";
136 + };
137 + const onDragLeave = (e: React.DragEvent) => {
138 + if (!acceptable(e)) return;
139 + overCount.current = Math.max(0, overCount.current - 1);
140 + if (overCount.current === 0) setDragOver(false);
141 + };
142 + const onDrop = async (e: React.DragEvent) => {
143 + if (!acceptable(e)) return;
144 + e.preventDefault();
145 + e.stopPropagation();
146 + overCount.current = 0;
147 + setDragOver(false);
148 + if (readFolderId(e.dataTransfer) === folder.id) return;
149 + const moved = await handleInternalDrop(e.dataTransfer, folder.id);
150 + if (moved) onRefresh();
151 + };
152 +
153 + const dndProps = {
154 + draggable: true,
155 + onDragStart,
156 + onDragEnd: () => setDragging(false),
157 + onDragEnter,
158 + onDragOver,
159 + onDragLeave,
160 + onDrop,
161 + "data-folder-id": folder.id,
162 + tabIndex: focusable ? 0 : -1,
163 + onKeyDown: (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); open(); } },
164 + } as const;
165 +
166 + const menuItems = [
167 + { icon: Pencil, label: "Renommer…", onClick: () => setRenameModal(true) },
168 + { icon: FolderInput, label: "Déplacer vers…", onClick: () => setMoveModal(true) },
169 + { icon: Trash2, label: "Supprimer", onClick: () => setDeleteModal(true), danger: true },
170 + ];
171 +
172 + const Menu = () =>
173 + showMenu ? (
174 + <motion.div
175 + initial={{ opacity: 0, scale: 0.96, y: -4 }}
176 + animate={{ opacity: 1, scale: 1, y: 0 }}
177 + transition={{ duration: 0.14 }}
178 + className="absolute right-0 top-full mt-1.5 w-52 glass rounded-2xl shadow-pop z-50 p-1.5"
179 + role="menu"
180 + >
181 + {menuItems.map((it, i) => (
182 + <div key={it.label}>
183 + {i === menuItems.length - 1 && <div className="my-1 border-t border-line dark:border-white/10" />}
184 + <button
185 + role="menuitem"
186 + onClick={(e) => { e.stopPropagation(); it.onClick(); setShowMenu(false); }}
187 + className={`flex items-center gap-2.5 w-full px-3 h-10 sm:h-9 rounded-xl text-[13px] transition-colors ${
188 + it.danger ? "text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10" : "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.08]"
189 + }`}
190 + >
191 + <it.icon className={`w-4 h-4 ${it.danger ? "" : "text-gray-400"}`} /> {it.label}
192 + </button>
193 + </div>
194 + ))}
195 + </motion.div>
196 + ) : null;
197 +
198 + const modals = (
199 + <>
200 + <Modal isOpen={renameModal} onClose={() => setRenameModal(false)} title="Renommer le dossier">
201 + <input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleRename()} className="field mb-4" autoFocus onFocus={(e) => e.currentTarget.select()} />
202 + <div className="flex gap-2 justify-end">
203 + <Button variant="ghost" onClick={() => setRenameModal(false)}>Annuler</Button>
204 + <Button variant="accent" onClick={handleRename} loading={loading} disabled={!newName.trim() || newName.trim() === folder.name}>Renommer</Button>
205 + </div>
206 + </Modal>
207 + <Modal isOpen={deleteModal} onClose={() => setDeleteModal(false)} title="Supprimer le dossier">
208 + <p className="text-gray-600 dark:text-gray-300 text-sm mb-5">
209 + Supprimer <strong className="text-gray-900 dark:text-white">{folder.name}</strong> et tout son contenu
210 + {folder._count ? <> ({folder._count.files} fichier{folder._count.files !== 1 ? "s" : ""}, {folder._count.children} sous-dossier{folder._count.children !== 1 ? "s" : ""})</> : null} ?
211 + </p>
212 + <div className="flex gap-2 justify-end">
213 + <Button variant="ghost" onClick={() => setDeleteModal(false)}>Annuler</Button>
214 + <Button variant="danger" onClick={handleDelete} loading={loading}><Trash2 className="w-4 h-4" /> Supprimer</Button>
215 + </div>
216 + </Modal>
217 + <MoveModal isOpen={moveModal} onClose={() => setMoveModal(false)} onSelect={handleMove} excludeId={folder.id} itemName={folder.name} currentFolderId={folder.parentId ?? "root"} />
218 + </>
219 + );
220 +
221 + const count = folder._count;
222 + const countLabel = count ? [count.files ? `${count.files} fich.` : null, count.children ? `${count.children} doss.` : null].filter(Boolean).join(" · ") || "Vide" : "";
105 223
224 + /* ================= LISTE ================= */
106 225 if (viewMode === "list") {
107 226 return (
108 227 <>
109 − <motion.div
110 − initial={{ opacity: 0 }}
111 − animate={{ opacity: 1 }}
112 − className="flex items-center gap-3 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-white/5 cursor-pointer group transition-colors"
113 − onClick={() => router.push(`/dashboard/folder/${folder.id}`)}
228 + <div
229 + {...dndProps}
230 + className={`group relative flex items-center gap-3 px-3 density-row cursor-pointer select-none transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 ${
231 + dragOver ? "drop-target" : "hover:bg-gray-50 dark:hover:bg-white/[0.04]"
232 + } ${dragging ? "opacity-40" : ""}`}
233 + onClick={open}
234 + role="option"
235 + aria-selected={false}
114 236 >
115 − <div className="w-8 h-8 flex items-center justify-center flex-shrink-0">
116 − <Folder className="w-5 h-5 text-blue-400" />
237 + <div className="w-8 h-8 shrink-0 flex items-center justify-center rounded-lg bg-blue-500/10">
238 + {dragOver ? <FolderOpen className="w-[18px] h-[18px] text-blue-500" /> : <Folder className="w-[18px] h-[18px] text-blue-500 fill-blue-500/20" />}
117 239 </div>
118 − <span className="text-sm font-medium text-gray-700 dark:text-gray-200 flex-1 truncate min-w-0">{folder.name}</span>
119 − {folder.sharedSpaceId && <SharedBadge />}
120 − <span className="text-sm text-gray-500 hidden sm:block flex-shrink-0 w-24 text-right tabular-nums">
121 − {folder._count ? `${folder._count.files} fich.` : ""}
122 − </span>
123 − <span className="hidden md:block flex-shrink-0 w-28 text-right text-xs text-gray-400">Dossier</span>
124 − <div className="relative flex-shrink-0" ref={menuRef}>
240 + <div className="flex-1 min-w-0 flex items-center gap-2">
241 + <span className="text-[13.5px] font-medium text-gray-800 dark:text-gray-100 truncate">{folder.name}</span>
242 + {folder.sharedSpaceId && <SharedBadge />}
243 + </div>
244 + <span className="hidden lg:block w-24 shrink-0 text-[12px] text-gray-400">Dossier</span>
245 + <span className="hidden sm:block w-24 shrink-0 text-right text-[12.5px] text-gray-500 dark:text-gray-400 tabular-nums truncate">{countLabel}</span>
246 + <span className="hidden md:flex w-28 shrink-0 justify-end text-gray-300 dark:text-gray-600"><ChevronRight className="w-4 h-4" /></span>
247 + <div className="relative shrink-0 w-10 flex justify-end" ref={menuRef}>
125 248 <button
126 − onClick={(e) => {
127 − e.stopPropagation();
128 − setShowMenu(!showMenu);
129 − }}
130 − className="min-w-[44px] min-h-[44px] flex items-center justify-center text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-white sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
249 + onClick={(e) => { e.stopPropagation(); setShowMenu(!showMenu); }}
250 + className={`w-9 h-9 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-200/60 dark:hover:bg-white/10 transition-all ${showMenu ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100"}`}
251 + aria-label="Actions du dossier"
131 252 >
132 253 <MoreVertical className="w-4 h-4" />
133 254 </button>
134 − {showMenu && (
135 − <div className="absolute right-0 top-full mt-1 w-40 bg-white dark:bg-gray-900 border border-gray-200 dark:border-white/10 rounded-xl shadow-2xl z-50 py-1 max-h-[70vh] overflow-auto">
136 − <button
137 − onClick={(e) => {
138 − e.stopPropagation();
139 − setRenameModal(true);
140 − setShowMenu(false);
141 − }}
142 − className="flex items-center gap-2 w-full px-4 py-3 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5"
143 − >
144 − <Pencil className="w-4 h-4" /> Renommer
145 − </button>
146 − <button
147 − onClick={(e) => {
148 − e.stopPropagation();
149 − setMoveModal(true);
150 − setShowMenu(false);
151 − }}
152 − className="flex items-center gap-2 w-full px-4 py-3 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5"
153 − >
154 − <FolderInput className="w-4 h-4" /> Déplacer
155 − </button>
156 − <button
157 − onClick={(e) => {
158 − e.stopPropagation();
159 − setDeleteModal(true);
160 − setShowMenu(false);
161 − }}
162 − className="flex items-center gap-2 w-full px-4 py-3 text-sm text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10"
163 − >
164 − <Trash2 className="w-4 h-4" /> Supprimer
165 − </button>
166 − </div>
167 − )}
168 − </div>
169 − </motion.div>
170 − <Modal isOpen={renameModal} onClose={() => setRenameModal(false)} title="Renommer le dossier">
171 − <input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} className={inputClasses} autoFocus />
172 − <div className="flex gap-2 justify-end">
173 − <Button variant="ghost" onClick={() => setRenameModal(false)}>Annuler</Button>
174 − <Button onClick={handleRename} loading={loading}>Renommer</Button>
255 + <Menu />
175 256 </div>
176 − </Modal>
177 − <Modal isOpen={deleteModal} onClose={() => setDeleteModal(false)} title="Supprimer le dossier">
178 − <p className="text-gray-500 dark:text-gray-400 text-sm mb-4">Supprimer <strong className="text-gray-900 dark:text-white">{folder.name}</strong> et tout son contenu ?</p>
179 − <div className="flex gap-2 justify-end">
180 − <Button variant="ghost" onClick={() => setDeleteModal(false)}>Annuler</Button>
181 − <Button variant="danger" onClick={handleDelete} loading={loading}>Supprimer</Button>
182 − </div>
183 − </Modal>
184 − <MoveModal
185 − isOpen={moveModal}
186 − onClose={() => setMoveModal(false)}
187 − onSelect={handleMove}
188 − excludeId={folder.id}
189 − itemName={folder.name}
190 − />
257 + </div>
258 + {modals}
191 259 </>
192 260 );
193 261 }
194 262
263 + /* ================= GRILLE ================= */
195 264 return (
196 265 <>
197 − <motion.div
198 − initial={{ opacity: 0, scale: 0.95 }}
199 − animate={{ opacity: 1, scale: 1 }}
200 − className="group bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl p-4 hover:border-blue-300 dark:hover:border-blue-500/30 transition-colors cursor-pointer relative shadow-sm dark:shadow-none min-h-[72px]"
201 − onClick={() => router.push(`/dashboard/folder/${folder.id}`)}
266 + <div
267 + {...dndProps}
268 + className={`group relative card card-hover rounded-2xl p-3 cursor-pointer select-none min-h-[68px] outline-none animate-scale-in focus-visible:ring-2 focus-visible:ring-blue-500 ${
269 + dragOver ? "drop-target" : ""
270 + } ${dragging ? "opacity-40" : ""} ${showMenu ? "z-30" : ""}`}
271 + onClick={open}
202 272 >
203 273 <div className="flex items-center gap-3">
204 − <Folder className="w-8 h-8 text-blue-400 flex-shrink-0" />
205 − <div className="flex-1 min-w-0">
206 − <div className="flex items-center gap-1.5 flex-wrap">
207 − <p className="text-sm text-gray-700 dark:text-gray-200 truncate">{folder.name}</p>
274 + <div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center shrink-0 transition-transform group-hover:scale-105">
275 + {dragOver ? <FolderOpen className="w-5 h-5 text-blue-500" /> : <Folder className="w-5 h-5 text-blue-500 fill-blue-500/20" />}
276 + </div>
277 + <div className="flex-1 min-w-0 pr-7">
278 + <div className="flex items-center gap-1.5 min-w-0">
279 + <p className="text-[13.5px] font-medium text-gray-800 dark:text-gray-100 truncate">{folder.name}</p>
208 280 {folder.sharedSpaceId && <SharedBadge />}
209 281 </div>
210 − {folder._count && (
211 − <p className="text-xs text-gray-500">
212 − {folder._count.files} fichiers, {folder._count.children} dossiers
213 − </p>
214 − )}
282 + {count && <p className="text-[11.5px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">{countLabel}</p>}
215 283 </div>
216 284 </div>
217 −
218 − <div className="absolute top-2 right-2" ref={menuRef}>
285 + <div className="absolute top-1.5 right-1.5" ref={menuRef}>
219 286 <button
220 − onClick={(e) => {
221 − e.stopPropagation();
222 − setShowMenu(!showMenu);
223 − }}
224 − className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-white sm:opacity-0 sm:group-hover:opacity-100 transition-opacity min-w-[40px] min-h-[40px] flex items-center justify-center"
287 + onClick={(e) => { e.stopPropagation(); setShowMenu(!showMenu); }}
288 + className={`w-9 h-9 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 transition-all ${showMenu ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100"}`}
289 + aria-label="Actions du dossier"
225 290 >
226 291 <MoreVertical className="w-4 h-4" />
227 292 </button>
228 − {showMenu && (
229 − <div className="absolute right-0 top-full mt-1 w-40 bg-white dark:bg-gray-900 border border-gray-200 dark:border-white/10 rounded-xl shadow-2xl z-50 py-1 max-h-[70vh] overflow-auto">
230 − <button
231 − onClick={(e) => {
232 − e.stopPropagation();
233 − setRenameModal(true);
234 − setShowMenu(false);
235 − }}
236 − className="flex items-center gap-2 w-full px-4 py-3 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5"
237 − >
238 − <Pencil className="w-4 h-4" /> Renommer
239 − </button>
240 − <button
241 − onClick={(e) => {
242 − e.stopPropagation();
243 − setMoveModal(true);
244 − setShowMenu(false);
245 − }}
246 − className="flex items-center gap-2 w-full px-4 py-3 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5"
247 − >
248 − <FolderInput className="w-4 h-4" /> Déplacer
249 − </button>
250 − <button
251 − onClick={(e) => {
252 − e.stopPropagation();
253 − setDeleteModal(true);
254 − setShowMenu(false);
255 − }}
256 − className="flex items-center gap-2 w-full px-4 py-3 text-sm text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10"
257 − >
258 − <Trash2 className="w-4 h-4" /> Supprimer
259 − </button>
260 − </div>
261 − )}
293 + <Menu />
262 294 </div>
263 − </motion.div>
264 −
265 − <Modal isOpen={renameModal} onClose={() => setRenameModal(false)} title="Renommer le dossier">
266 − <input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} className={inputClasses} autoFocus />
267 − <div className="flex gap-2 justify-end">
268 − <Button variant="ghost" onClick={() => setRenameModal(false)}>Annuler</Button>
269 − <Button onClick={handleRename} loading={loading}>Renommer</Button>
270 − </div>
271 − </Modal>
272 − <Modal isOpen={deleteModal} onClose={() => setDeleteModal(false)} title="Supprimer le dossier">
273 − <p className="text-gray-500 dark:text-gray-400 text-sm mb-4">Supprimer <strong className="text-gray-900 dark:text-white">{folder.name}</strong> et tout son contenu ?</p>
274 − <div className="flex gap-2 justify-end">
275 − <Button variant="ghost" onClick={() => setDeleteModal(false)}>Annuler</Button>
276 − <Button variant="danger" onClick={handleDelete} loading={loading}>Supprimer</Button>
277 − </div>
278 − </Modal>
295 + </div>
296 + {modals}
279 297 </>
280 298 );
281 299 }
300 +
301 +const FolderCard = memo(FolderCardInner);
302 +export default FolderCard;
modified components/layout/AppShell.tsx +51 −33
@@ -1,59 +1,77 @@
1 1 "use client";
2 2
3 −import { useState } from "react";
3 +import { useEffect } from "react";
4 +import { motion } from "framer-motion";
4 5 import Sidebar from "./Sidebar";
5 6 import MobileNav from "./MobileNav";
6 −import ThemeToggle from "../theme/ThemeToggle";
7 −import NotificationBell from "./NotificationBell";
7 +import MobileTabBar from "./MobileTabBar";
8 +import TopBar from "./TopBar";
9 +import CommandPalette from "./CommandPalette";
10 +import PreferencesModal from "../theme/PreferencesModal";
8 11 import AiPanel from "../ai/AiPanel";
9 12 import TopLoader from "../ui/TopLoader";
13 +import { ShellProvider, useShell } from "./ShellContext";
10 14
11 15 interface AppShellProps {
12 16 children: React.ReactNode;
17 + /** Masquer la barre supérieure (pages qui gèrent leur propre en-tête plein écran) */
18 + hideTopBar?: boolean;
13 19 }
14 20
15 −export default function AppShell({ children }: AppShellProps) {
16 − const [sidebarOpen, setSidebarOpen] = useState(false);
21 +function ShellFrame({ children, hideTopBar }: AppShellProps) {
22 + const { sidebarCollapsed, toggleSidebar, mobileNavOpen, setMobileNavOpen } = useShell();
23 +
24 + // ⌘B / Ctrl+B : replier la barre latérale
25 + useEffect(() => {
26 + const onKey = (e: KeyboardEvent) => {
27 + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
28 + const t = e.target as HTMLElement | null;
29 + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
30 + e.preventDefault();
31 + toggleSidebar();
32 + }
33 + };
34 + window.addEventListener("keydown", onKey);
35 + return () => window.removeEventListener("keydown", onKey);
36 + }, [toggleSidebar]);
17 37
18 38 return (
19 − <div className="h-screen overflow-hidden bg-gray-50 dark:bg-[#0a0a0a] flex transition-colors">
39 + <div className="h-[100dvh] overflow-hidden bg-canvas flex transition-colors">
20 40 <TopLoader />
21 41
22 − {/* Desktop Sidebar — fixed height, scrolls independently */}
23 − <div className="hidden lg:flex flex-col w-64 flex-shrink-0 h-full overflow-y-auto">
24 − <Sidebar />
25 − </div>
42 + {/* Barre latérale desktop (repliable) */}
43 + <motion.div
44 + initial={false}
45 + animate={{ width: sidebarCollapsed ? 72 : 264 }}
46 + transition={{ type: "spring", stiffness: 380, damping: 36 }}
47 + className="hidden lg:flex flex-col shrink-0 h-full overflow-hidden"
48 + >
49 + <Sidebar collapsed={sidebarCollapsed} showCollapse />
50 + </motion.div>
26 51
27 − {/* Mobile Sidebar Overlay */}
28 − <MobileNav isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
52 + {/* Tiroir mobile */}
53 + <MobileNav isOpen={mobileNavOpen} onClose={() => setMobileNavOpen(false)} />
29 54
30 − {/* Main Content — fills remaining space, scrolls independently */}
55 + {/* Colonne principale */}
31 56 <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden">
32 − {/* Mobile Header */}
33 − <div className="lg:hidden flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-white/10 bg-white dark:bg-[#0a0a0a] transition-colors safe-top shrink-0">
34 − <button
35 − onClick={() => setSidebarOpen(true)}
36 − className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white p-3 -ml-1 min-w-[44px] min-h-[44px] flex items-center justify-center active:bg-gray-100 dark:active:bg-white/10 rounded-xl transition-colors"
37 − >
38 − <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
39 − <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
40 − </svg>
41 − </button>
42 − <span className="text-gray-900 dark:text-white font-semibold text-base">SPB Cloud</span>
43 − <div className="flex items-center gap-0">
44 − <NotificationBell />
45 − <ThemeToggle />
46 − </div>
47 − </div>
48 −
49 − {/* Content */}
50 − <main className="flex-1 overflow-auto pb-safe min-h-0">
57 + {!hideTopBar && <TopBar />}
58 + <main className="flex-1 overflow-auto min-h-0 pb-tabbar" id="main">
51 59 {children}
52 60 </main>
53 61 </div>
54 62
55 − {/* AI Assistant */}
63 + <MobileTabBar />
64 + <CommandPalette />
65 + <PreferencesModal />
56 66 <AiPanel />
57 67 </div>
58 68 );
59 69 }
70 +
71 +export default function AppShell(props: AppShellProps) {
72 + return (
73 + <ShellProvider>
74 + <ShellFrame {...props} />
75 + </ShellProvider>
76 + );
77 +}
deleted components/layout/Breadcrumb.tsx +0 −38
@@ -1,38 +0,0 @@
1 −"use client";
2 −
3 −import Link from "next/link";
4 −import { ChevronRight, Home } from "lucide-react";
5 −
6 −interface BreadcrumbItem {
7 − id: string;
8 − name: string;
9 −}
10 −
11 −interface BreadcrumbProps {
12 − items: BreadcrumbItem[];
13 −}
14 −
15 −export default function Breadcrumb({ items }: BreadcrumbProps) {
16 − return (
17 − <nav className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 overflow-x-auto pb-1 flex-nowrap scrollbar-none">
18 − <Link
19 − href="/dashboard/files"
20 − className="flex items-center gap-1 hover:text-gray-900 dark:hover:text-white transition-colors whitespace-nowrap py-1 flex-shrink-0"
21 − >
22 − <Home className="w-3.5 h-3.5" />
23 − <span>Fichiers</span>
24 − </Link>
25 − {items.map((item) => (
26 − <span key={item.id} className="flex items-center gap-1 flex-shrink-0">
27 − <ChevronRight className="w-3.5 h-3.5 text-gray-400 dark:text-gray-600 flex-shrink-0" />
28 − <Link
29 − href={`/dashboard/folder/${item.id}`}
30 − className="hover:text-gray-900 dark:hover:text-white transition-colors whitespace-nowrap py-1"
31 − >
32 − {item.name}
33 − </Link>
34 − </span>
35 − ))}
36 − </nav>
37 − );
38 −}
added components/layout/CommandPalette.tsx +289 −0
@@ -0,0 +1,289 @@
1 +"use client";
2 +
3 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4 +import { useRouter, usePathname } from "next/navigation";
5 +import { motion, AnimatePresence } from "framer-motion";
6 +import {
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";
12 +import { useShell, toggleAiPanel } from "./ShellContext";
13 +import { useTheme } from "../theme/ThemeProvider";
14 +import { formatFileSize, getFileIcon } from "@/lib/utils";
15 +import type { FileData } from "@/types/files";
16 +
17 +type ItemKind = "nav" | "action" | "file" | "folder" | "recent";
18 +
19 +interface 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 +}
30 +
31 +interface TreeFolder { id: string; name: string; children?: TreeFolder[]; _count?: { files: number } }
32 +
33 +const 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 +};
37 +const 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 +};
41 +
42 +const RECENT_KEY = "spb-cmd-recent";
43 +
44 +/** Score fuzzy simple : sous-séquence + bonus préfixe/mot. 0 = pas de correspondance. */
45 +function 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 +}
61 +
62 +function 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 +}
70 +
71 +export 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);
85 +
86 + const isFilesPage = pathname.startsWith("/dashboard/files") || pathname.startsWith("/dashboard/folder");
87 + const filesTarget = isFilesPage ? pathname : "/dashboard/files";
88 +
89 + // Ouverture : reset + focus + chargement de l'arbre
90 + 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]);
99 +
100 + // 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]);
118 +
119 + 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]);
125 +
126 + const go = useCallback((href: string) => { router.push(href); closeCommand(); }, [router, closeCommand]);
127 +
128 + 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") });
142 +
143 + 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]);
153 +
154 + 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]);
158 +
159 + 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]);
166 +
167 + // Résultats groupés
168 + 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);
173 +
174 + 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]);
190 +
191 + const flat = useMemo(() => groups.flatMap((g) => g.items), [groups]);
192 + useEffect(() => setActive(0), [query, flat.length]);
193 +
194 + const select = useCallback((it: Item) => {
195 + if (it.kind === "nav" || it.kind === "action" || it.kind === "recent") remember(it.id);
196 + it.run();
197 + }, [remember]);
198 +
199 + 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 + };
205 +
206 + // Garder l'élément actif visible
207 + useEffect(() => {
208 + const node = listRef.current?.querySelector<HTMLElement>(`[data-idx="${active}"]`);
209 + node?.scrollIntoView({ block: "nearest" });
210 + }, [active]);
211 +
212 + 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.div
218 + 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 + <input
227 + 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>
238 +
239 + <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 + <button
255 + 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>
277 +
278 + <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 +}
modified components/layout/FolderTree.tsx +90 −28
@@ -2,8 +2,9 @@
2 2
3 3 import Link from "next/link";
4 4 import { usePathname } from "next/navigation";
5 −import { useEffect, useState, useCallback } from "react";
6 −import { Folder, FolderOpen, ChevronRight, ChevronDown } from "lucide-react";
5 +import { useEffect, useState, useCallback, useRef } from "react";
6 +import { Folder, FolderOpen, ChevronRight } from "lucide-react";
7 +import { FOLDERS_CHANGED_EVENT, handleInternalDrop, hasInternalDrag, readFolderId } from "../files/dnd";
7 8
8 9 interface TreeFolder {
9 10 id: string;
@@ -16,71 +17,132 @@ interface FolderTreeProps {
16 17 onNavigate?: () => void;
17 18 }
18 19
20 +const EXPANDED_KEY = "spb-tree-expanded";
21 +
19 22 export default function FolderTree({ onNavigate }: FolderTreeProps) {
20 23 const pathname = usePathname();
21 24 const [tree, setTree] = useState<TreeFolder[]>([]);
22 25 const [expanded, setExpanded] = useState<Set<string>>(new Set());
26 + const [dragOverId, setDragOverId] = useState<string | null>(null);
27 + const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
23 28
24 − useEffect(() => {
29 + const load = useCallback(() => {
25 30 fetch("/api/folders?tree=true")
26 31 .then((r) => (r.ok ? r.json() : []))
27 32 .then((d) => setTree(Array.isArray(d) ? d : []))
28 33 .catch(() => {});
29 34 }, []);
30 35
36 + useEffect(() => {
37 + load();
38 + try {
39 + const saved = JSON.parse(localStorage.getItem(EXPANDED_KEY) || "[]");
40 + if (Array.isArray(saved)) setExpanded(new Set(saved));
41 + } catch {}
42 + window.addEventListener(FOLDERS_CHANGED_EVENT, load);
43 + return () => window.removeEventListener(FOLDERS_CHANGED_EVENT, load);
44 + }, [load]);
45 +
46 + // Déplier automatiquement le chemin du dossier courant
47 + useEffect(() => {
48 + const m = pathname.match(/^\/dashboard\/folder\/([^/]+)/);
49 + if (!m || tree.length === 0) return;
50 + const target = m[1];
51 + const path: string[] = [];
52 + const find = (nodes: TreeFolder[], acc: string[]): boolean => {
53 + for (const n of nodes) {
54 + if (n.id === target) { path.push(...acc); return true; }
55 + if (n.children?.length && find(n.children, [...acc, n.id])) return true;
56 + }
57 + return false;
58 + };
59 + find(tree, []);
60 + if (path.length) setExpanded((prev) => new Set([...Array.from(prev), ...path]));
61 + }, [pathname, tree]);
62 +
31 63 const toggle = useCallback((id: string) => {
32 64 setExpanded((prev) => {
33 65 const next = new Set(prev);
34 66 next.has(id) ? next.delete(id) : next.add(id);
67 + try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(Array.from(next))); } catch {}
35 68 return next;
36 69 });
37 70 }, []);
38 71
72 + const clearHover = () => {
73 + if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; }
74 + };
75 +
39 76 function renderNode(node: TreeFolder, depth: number): React.ReactNode {
40 77 const kids = node.children ?? [];
41 78 const hasKids = kids.length > 0;
42 79 const isOpen = expanded.has(node.id);
43 80 const active = pathname === `/dashboard/folder/${node.id}`;
81 + const over = dragOverId === node.id;
82 +
44 83 return (
45 − <div key={node.id}>
84 + <div key={node.id} className="relative">
85 + {depth > 0 && <span className="absolute top-0 bottom-0 border-l border-line dark:border-white/[0.08]" style={{ left: 10 + (depth - 1) * 14 }} aria-hidden />}
46 86 <div
47 − className={`flex items-center rounded-lg transition-colors ${
48 − active
49 − ? "bg-gray-100 dark:bg-white/10"
50 − : "hover:bg-gray-50 dark:hover:bg-white/5"
87 + className={`relative flex items-center rounded-lg transition-colors ${
88 + over ? "drop-target" : active ? "bg-blue-500/10 dark:bg-blue-400/10" : "hover:bg-gray-100/80 dark:hover:bg-white/[0.05]"
51 89 }`}
52 − style={{ paddingLeft: 4 + depth * 12 }}
90 + style={{ paddingLeft: 2 + depth * 14 }}
91 + onDragEnter={(e) => {
92 + if (!hasInternalDrag(e.dataTransfer)) return;
93 + e.preventDefault();
94 + setDragOverId(node.id);
95 + clearHover();
96 + if (hasKids && !isOpen) hoverTimer.current = setTimeout(() => setExpanded((p) => new Set([...Array.from(p), node.id])), 600);
97 + }}
98 + onDragOver={(e) => {
99 + if (!hasInternalDrag(e.dataTransfer)) return;
100 + e.preventDefault();
101 + e.dataTransfer.dropEffect = "move";
102 + if (dragOverId !== node.id) setDragOverId(node.id);
103 + }}
104 + onDragLeave={(e) => {
105 + const next = e.relatedTarget as Node | null;
106 + if (next && e.currentTarget.contains(next)) return;
107 + clearHover();
108 + setDragOverId((cur) => (cur === node.id ? null : cur));
109 + }}
110 + onDrop={async (e) => {
111 + if (!hasInternalDrag(e.dataTransfer)) return;
112 + e.preventDefault();
113 + e.stopPropagation();
114 + clearHover();
115 + setDragOverId(null);
116 + if (readFolderId(e.dataTransfer) === node.id) return;
117 + const moved = await handleInternalDrop(e.dataTransfer, node.id);
118 + if (moved) {
119 + window.dispatchEvent(new Event(FOLDERS_CHANGED_EVENT));
120 + window.dispatchEvent(new Event("spb:files-changed"));
121 + }
122 + }}
53 123 >
54 124 <button
55 125 onClick={() => hasKids && toggle(node.id)}
56 − className="w-5 h-8 flex items-center justify-center flex-shrink-0 text-gray-400"
126 + className={`w-5 h-8 flex items-center justify-center shrink-0 text-gray-400 rounded ${hasKids ? "hover:text-gray-700 dark:hover:text-gray-200" : "pointer-events-none"}`}
57 127 tabIndex={hasKids ? 0 : -1}
58 − aria-label={hasKids ? "Déplier" : undefined}
128 + aria-label={hasKids ? (isOpen ? "Replier" : "Déplier") : undefined}
59 129 >
60 − {hasKids ? (
61 − isOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />
62 − ) : null}
130 + {hasKids && <ChevronRight className={`w-3.5 h-3.5 transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`} />}
63 131 </button>
64 132 <Link
65 133 href={`/dashboard/folder/${node.id}`}
66 134 onClick={onNavigate}
67 − className={`flex items-center gap-2 flex-1 min-w-0 py-2 pr-2 text-sm transition-colors ${
68 − active
69 − ? "text-gray-900 dark:text-white font-medium"
70 − : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
135 + className={`flex items-center gap-2 flex-1 min-w-0 h-8 pr-2 text-[13px] transition-colors ${
136 + active ? "text-blue-700 dark:text-blue-300 font-medium" : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
71 137 }`}
72 138 >
73 − {isOpen && hasKids ? (
74 − <FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
139 + {(isOpen && hasKids) || over ? (
140 + <FolderOpen className="w-4 h-4 text-blue-500 shrink-0" />
75 141 ) : (
76 − <Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
142 + <Folder className={`w-4 h-4 shrink-0 ${active ? "text-blue-500 fill-blue-500/20" : "text-blue-400"}`} />
77 143 )}
78 144 <span className="truncate flex-1">{node.name}</span>
79 − {node._count?.files ? (
80 − <span className="text-[10px] text-gray-400 dark:text-gray-600 tabular-nums">
81 − {node._count.files}
82 − </span>
83 − ) : null}
145 + {node._count?.files ? <span className="text-[10.5px] text-gray-400 dark:text-gray-600 tabular-nums">{node._count.files}</span> : null}
84 146 </Link>
85 147 </div>
86 148 {hasKids && isOpen && <div>{kids.map((c) => renderNode(c, depth + 1))}</div>}
@@ -88,7 +150,7 @@ export default function FolderTree({ onNavigate }: FolderTreeProps) {
88 150 );
89 151 }
90 152
91 − if (tree.length === 0) return null;
153 + if (tree.length === 0) return <p className="px-3 py-2 text-[12px] text-gray-400">Aucun dossier</p>;
92 154
93 − return <div className="space-y-0.5 ml-1">{tree.map((n) => renderNode(n, 0))}</div>;
155 + return <div className="space-y-px">{tree.map((n) => renderNode(n, 0))}</div>;
94 156 }
modified components/layout/MobileNav.tsx +17 −5
@@ -1,6 +1,7 @@
1 1 "use client";
2 2
3 3 import { motion, AnimatePresence } from "framer-motion";
4 +import { X } from "lucide-react";
4 5 import Sidebar from "./Sidebar";
5 6
6 7 interface MobileNavProps {
@@ -17,17 +18,28 @@ export default function MobileNav({ isOpen, onClose }: MobileNavProps) {
17 18 initial={{ opacity: 0 }}
18 19 animate={{ opacity: 1 }}
19 20 exit={{ opacity: 0 }}
20 − className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40 lg:hidden"
21 + className="fixed inset-0 bg-gray-950/50 backdrop-blur-sm z-[60] lg:hidden"
21 22 onClick={onClose}
22 23 />
23 24 <motion.div
24 − initial={{ x: -300 }}
25 + initial={{ x: -320 }}
25 26 animate={{ x: 0 }}
26 − exit={{ x: -300 }}
27 − transition={{ type: "spring", damping: 28, stiffness: 300 }}
28 − className="fixed left-0 top-0 bottom-0 w-[280px] max-w-[85vw] z-50 lg:hidden shadow-2xl"
27 + exit={{ x: -320 }}
28 + transition={{ type: "spring", damping: 30, stiffness: 320 }}
29 + drag="x"
30 + dragConstraints={{ left: -320, right: 0 }}
31 + dragElastic={0.05}
32 + onDragEnd={(_, info) => { if (info.offset.x < -80 || info.velocity.x < -400) onClose(); }}
33 + className="fixed left-0 top-0 bottom-0 w-[300px] max-w-[86vw] z-[61] lg:hidden shadow-pop safe-top"
29 34 >
30 35 <Sidebar onNavigate={onClose} />
36 + <button
37 + onClick={onClose}
38 + className="absolute top-3 -right-12 w-10 h-10 rounded-full glass flex items-center justify-center text-gray-700 dark:text-gray-200"
39 + aria-label="Fermer le menu"
40 + >
41 + <X className="w-5 h-5" />
42 + </button>
31 43 </motion.div>
32 44 </>
33 45 )}
added components/layout/MobileTabBar.tsx +43 −0
@@ -0,0 +1,43 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { usePathname } from "next/navigation";
5 +import { motion } from "framer-motion";
6 +import { Home, FolderOpen, Search, Star, Menu } from "lucide-react";
7 +import { useShell } from "./ShellContext";
8 +
9 +/** Barre d'onglets mobile (iOS-like) — masquée ≥ lg. */
10 +export default function MobileTabBar() {
11 + const pathname = usePathname();
12 + const { openCommand, setMobileNavOpen } = useShell();
13 +
14 + const tabs = [
15 + { key: "home", label: "Accueil", icon: Home, href: "/dashboard", active: pathname === "/dashboard" },
16 + { key: "files", label: "Fichiers", icon: FolderOpen, href: "/dashboard/files", active: pathname.startsWith("/dashboard/files") || pathname.startsWith("/dashboard/folder") },
17 + { key: "search", label: "Rechercher", icon: Search, onClick: () => openCommand(""), active: false },
18 + { key: "fav", label: "Favoris", icon: Star, href: "/dashboard/favorites", active: pathname === "/dashboard/favorites" },
19 + { key: "menu", label: "Menu", icon: Menu, onClick: () => setMobileNavOpen(true), active: false },
20 + ];
21 +
22 + return (
23 + <nav className="lg:hidden fixed bottom-0 inset-x-0 z-40 glass border-t border-line dark:border-white/10 pb-safe" aria-label="Navigation principale">
24 + <div className="grid grid-cols-5 h-16">
25 + {tabs.map((t) => {
26 + const cls = `relative flex flex-col items-center justify-center gap-1 text-[10.5px] font-medium transition-colors ${t.active ? "text-blue-600 dark:text-blue-300" : "text-gray-500 dark:text-gray-400 active:text-gray-900 dark:active:text-white"}`;
27 + const inner = (
28 + <>
29 + {t.active && <motion.span layoutId="tab-dot" className="absolute top-1.5 w-8 h-1 rounded-full bg-blue-500" />}
30 + <t.icon className={`w-[22px] h-[22px] ${t.key === "search" ? "" : ""}`} strokeWidth={t.active ? 2.4 : 2} />
31 + <span>{t.label}</span>
32 + </>
33 + );
34 + return t.href ? (
35 + <Link key={t.key} href={t.href} className={cls} aria-current={t.active ? "page" : undefined}>{inner}</Link>
36 + ) : (
37 + <button key={t.key} onClick={t.onClick} className={cls}>{inner}</button>
38 + );
39 + })}
40 + </div>
41 + </nav>
42 + );
43 +}
modified components/layout/NotificationBell.tsx +162 −158
@@ -1,17 +1,7 @@
1 1 "use client";
2 2
3 3 import { useState, useEffect, useRef, useCallback } from "react";
4 −import {
5 − Bell,
6 − Check,
7 − CheckCheck,
8 − Upload,
9 − Share2,
10 − MessageCircle,
11 − Sparkles,
12 − Database,
13 − X,
14 −} from "lucide-react";
4 +import { Bell, Check, CheckCheck, Upload, Share2, MessageCircle, Sparkles, Database, X, BellOff, type LucideIcon } from "lucide-react";
15 5 import { motion, AnimatePresence } from "framer-motion";
16 6
17 7 interface NotificationData {
@@ -24,7 +14,7 @@ interface NotificationData {
24 14 createdAt: string;
25 15 }
26 16
27 −const typeIcons: Record<string, typeof Upload> = {
17 +const typeIcons: Record<string, LucideIcon> = {
28 18 upload_complete: Upload,
29 19 share: Share2,
30 20 comment: MessageCircle,
@@ -32,26 +22,23 @@ const typeIcons: Record<string, typeof Upload> = {
32 22 backup_complete: Database,
33 23 };
34 24
35 −const typeColors: Record<string, string> = {
36 − upload_complete: "text-green-400 bg-green-500/10",
37 − share: "text-orange-400 bg-orange-500/10",
38 − comment: "text-blue-400 bg-blue-500/10",
39 − ai_complete: "text-blue-400 bg-blue-500/10",
40 − backup_complete: "text-cyan-400 bg-cyan-500/10",
25 +const typeTones: Record<string, string> = {
26 + upload_complete: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-300",
27 + share: "text-purple-600 bg-purple-500/10 dark:text-purple-300",
28 + comment: "text-blue-600 bg-blue-500/10 dark:text-blue-300",
29 + ai_complete: "text-blue-600 bg-blue-500/10 dark:text-blue-300",
30 + backup_complete: "text-cyan-600 bg-cyan-500/10 dark:text-cyan-300",
41 31 };
42 32
43 33 function timeAgo(date: string): string {
44 − const now = new Date();
45 − const d = new Date(date);
46 − const seconds = Math.floor((now.getTime() - d.getTime()) / 1000);
47 −
48 − if (seconds < 60) return "a l'instant";
34 + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
35 + if (seconds < 60) return "à l'instant";
49 36 const minutes = Math.floor(seconds / 60);
50 − if (minutes < 60) return `il y a ${minutes}min`;
37 + if (minutes < 60) return `il y a ${minutes} min`;
51 38 const hours = Math.floor(minutes / 60);
52 − if (hours < 24) return `il y a ${hours}h`;
39 + if (hours < 24) return `il y a ${hours} h`;
53 40 const days = Math.floor(hours / 24);
54 − if (days < 30) return `il y a ${days}j`;
41 + if (days < 30) return `il y a ${days} j`;
55 42 const months = Math.floor(days / 30);
56 43 return `il y a ${months} mois`;
57 44 }
@@ -60,6 +47,7 @@ export default function NotificationBell() {
60 47 const [notifications, setNotifications] = useState<NotificationData[]>([]);
61 48 const [unreadCount, setUnreadCount] = useState(0);
62 49 const [open, setOpen] = useState(false);
50 + const [loaded, setLoaded] = useState(false);
63 51 const ref = useRef<HTMLDivElement>(null);
64 52
65 53 const fetchCount = useCallback(async () => {
@@ -70,7 +58,7 @@ export default function NotificationBell() {
70 58 setUnreadCount(data.count);
71 59 }
72 60 } catch {
73 − // silent
61 + // silencieux
74 62 }
75 63 }, []);
76 64
@@ -79,39 +67,46 @@ export default function NotificationBell() {
79 67 const res = await fetch("/api/notifications");
80 68 if (res.ok) {
81 69 const data = await res.json();
82 − setNotifications(data);
83 − // Update unread count from full list
84 − setUnreadCount(data.filter((n: NotificationData) => !n.read).length);
70 + if (Array.isArray(data)) {
71 + setNotifications(data);
72 + setUnreadCount(data.filter((n: NotificationData) => !n.read).length);
73 + }
85 74 }
86 75 } catch {
87 − // silent
76 + // silencieux
77 + } finally {
78 + setLoaded(true);
88 79 }
89 80 }, []);
90 81
91 − // Poll every 15 seconds
82 + // Sondage toutes les 15 s
92 83 useEffect(() => {
93 84 fetchCount();
94 85 const interval = setInterval(fetchCount, 15000);
95 86 return () => clearInterval(interval);
96 87 }, [fetchCount]);
97 88
98 − // Fetch full list when opened, and poll while open
99 89 useEffect(() => {
100 − if (open) {
101 − fetchNotifications();
102 − const interval = setInterval(fetchNotifications, 15000);
103 − return () => clearInterval(interval);
104 − }
90 + if (!open) return;
91 + fetchNotifications();
92 + const interval = setInterval(fetchNotifications, 15000);
93 + return () => clearInterval(interval);
105 94 }, [open, fetchNotifications]);
106 95
107 − // Close on outside click
96 + // Fermer au clic extérieur / Échap
108 97 useEffect(() => {
109 − function handleClick(e: MouseEvent) {
98 + if (!open) return;
99 + const onClick = (e: MouseEvent) => {
110 100 if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
111 − }
112 − document.addEventListener("mousedown", handleClick);
113 − return () => document.removeEventListener("mousedown", handleClick);
114 − }, []);
101 + };
102 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
103 + document.addEventListener("mousedown", onClick);
104 + window.addEventListener("keydown", onKey);
105 + return () => {
106 + document.removeEventListener("mousedown", onClick);
107 + window.removeEventListener("keydown", onKey);
108 + };
109 + }, [open]);
115 110
116 111 const markAllRead = async () => {
117 112 await fetch("/api/notifications", {
@@ -125,9 +120,7 @@ export default function NotificationBell() {
125 120
126 121 const markRead = async (id: string) => {
127 122 await fetch(`/api/notifications/${id}`, { method: "PATCH" });
128 − setNotifications((prev) =>
129 − prev.map((n) => (n.id === id ? { ...n, read: true } : n))
130 − );
123 + setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));
131 124 setUnreadCount((prev) => Math.max(0, prev - 1));
132 125 };
133 126
@@ -135,16 +128,20 @@ export default function NotificationBell() {
135 128 <div className="relative" ref={ref}>
136 129 <button
137 130 onClick={() => setOpen(!open)}
138 − className="relative min-w-[44px] min-h-[44px] p-2.5 flex items-center justify-center text-gray-400 hover:text-gray-700 dark:hover:text-white transition-colors"
131 + className={`relative w-10 h-10 flex items-center justify-center rounded-xl transition-colors ${open ? "text-gray-900 bg-gray-100 dark:text-white dark:bg-white/10" : "text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10"}`}
132 + aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} non lue${unreadCount > 1 ? "s" : ""}` : "Notifications"}
133 + aria-expanded={open}
134 + aria-haspopup="dialog"
139 135 >
140 − <Bell className="w-5 h-5" />
136 + <Bell className="w-[18px] h-[18px]" />
141 137 <AnimatePresence>
142 138 {unreadCount > 0 && (
143 139 <motion.span
144 140 initial={{ scale: 0 }}
145 141 animate={{ scale: 1 }}
146 142 exit={{ scale: 0 }}
147 − className="absolute top-0.5 right-0.5 min-w-[18px] h-[18px] px-1 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center"
143 + transition={{ type: "spring", stiffness: 500, damping: 28 }}
144 + className="absolute top-1 right-1 min-w-[17px] h-[17px] px-1 rounded-full bg-accent-gradient text-white text-[10px] font-semibold flex items-center justify-center ring-2 ring-surface dark:ring-canvas tabular-nums"
148 145 >
149 146 {unreadCount > 99 ? "99+" : unreadCount}
150 147 </motion.span>
@@ -154,124 +151,131 @@ export default function NotificationBell() {
154 151
155 152 <AnimatePresence>
156 153 {open && (
157 − <motion.div
158 − initial={{ opacity: 0, y: 8, scale: 0.95 }}
159 − animate={{ opacity: 1, y: 0, scale: 1 }}
160 − exit={{ opacity: 0, y: 8, scale: 0.95 }}
161 − transition={{ duration: 0.15 }}
162 − className="fixed inset-x-3 top-14 sm:absolute sm:inset-x-auto sm:top-full sm:right-0 sm:mt-2 sm:w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden"
163 − >
164 − {/* Header */}
165 − <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-white/10">
166 − <div className="flex items-center gap-2">
167 − <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
168 − Notifications
169 − </h3>
170 − {unreadCount > 0 && (
171 − <span className="px-1.5 py-0.5 bg-red-500/10 text-red-500 text-[10px] font-bold rounded-full">
172 − {unreadCount}
173 − </span>
174 − )}
154 + <>
155 + {/* Voile mobile */}
156 + <motion.div
157 + initial={{ opacity: 0 }}
158 + animate={{ opacity: 1 }}
159 + exit={{ opacity: 0 }}
160 + className="sm:hidden fixed inset-0 z-[55] bg-gray-950/40 backdrop-blur-sm"
161 + onClick={() => setOpen(false)}
162 + />
163 + <motion.div
164 + initial={{ opacity: 0, y: 8, scale: 0.98 }}
165 + animate={{ opacity: 1, y: 0, scale: 1 }}
166 + exit={{ opacity: 0, y: 8, scale: 0.98 }}
167 + transition={{ type: "spring", stiffness: 420, damping: 34 }}
168 + role="dialog"
169 + aria-label="Notifications"
170 + className="fixed inset-x-0 bottom-0 sm:bottom-auto sm:absolute sm:inset-x-auto sm:top-full sm:right-0 sm:mt-2 sm:w-[360px] glass rounded-t-3xl sm:rounded-2xl shadow-pop z-[60] overflow-hidden pb-safe sm:pb-0"
171 + >
172 + {/* Poignée mobile */}
173 + <div className="sm:hidden flex justify-center pt-2.5 pb-0.5">
174 + <span className="w-10 h-1 rounded-full bg-gray-300 dark:bg-white/20" />
175 175 </div>
176 − <div className="flex items-center gap-1">
177 − {unreadCount > 0 && (
176 +
177 + {/* En-tête */}
178 + <div className="flex items-center justify-between pl-4 pr-2 h-12 border-b border-line dark:border-white/10">
179 + <div className="flex items-center gap-2">
180 + <h3 className="text-[14px] font-semibold text-gray-900 dark:text-white">Notifications</h3>
181 + {unreadCount > 0 && (
182 + <span className="px-1.5 h-5 min-w-[20px] rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-300 text-[11px] font-semibold flex items-center justify-center tabular-nums">{unreadCount}</span>
183 + )}
184 + </div>
185 + <div className="flex items-center gap-0.5">
186 + {unreadCount > 0 && (
187 + <button
188 + onClick={markAllRead}
189 + className="h-9 px-2.5 rounded-lg text-[12px] font-medium text-blue-600 dark:text-blue-300 hover:bg-blue-500/10 flex items-center gap-1.5 transition-colors"
190 + >
191 + <CheckCheck className="w-3.5 h-3.5" />
192 + Tout lire
193 + </button>
194 + )}
178 195 <button
179 − onClick={markAllRead}
180 − className="text-xs text-blue-500 hover:text-blue-400 flex items-center gap-1 min-h-[36px] px-2 rounded-lg hover:bg-blue-500/5 transition-colors"
196 + onClick={() => setOpen(false)}
197 + className="sm:hidden 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/10"
198 + aria-label="Fermer"
181 199 >
182 − <CheckCheck className="w-3.5 h-3.5" />
183 − Tout marquer comme lu
200 + <X className="w-4 h-4" />
184 201 </button>
185 − )}
186 − <button
187 − onClick={() => setOpen(false)}
188 − className="sm:hidden p-1.5 text-gray-400 hover:text-white min-w-[36px] min-h-[36px] flex items-center justify-center"
189 − >
190 − <X className="w-4 h-4" />
191 − </button>
192 − </div>
193 − </div>
194 −
195 − {/* Notification List */}
196 − <div className="max-h-[60vh] sm:max-h-96 overflow-y-auto">
197 − {notifications.length === 0 ? (
198 − <div className="flex flex-col items-center justify-center py-12 text-gray-500">
199 − <Bell className="w-8 h-8 mb-2 opacity-30" />
200 − <p className="text-sm">Aucune notification</p>
201 202 </div>
202 − ) : (
203 − notifications.map((n, i) => {
204 − const IconComponent = typeIcons[n.type] || Bell;
205 − const colorClass =
206 − typeColors[n.type] || "text-gray-400 bg-gray-500/10";
203 + </div>
207 204
208 − return (
209 − <motion.div
210 − key={n.id}
211 − initial={{ opacity: 0, x: -10 }}
212 − animate={{ opacity: 1, x: 0 }}
213 − transition={{ delay: i * 0.03 }}
214 − className={`px-4 py-3 border-b border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/5 transition-colors cursor-pointer ${
215 − !n.read
216 − ? "bg-blue-50/50 dark:bg-blue-500/5"
217 − : ""
218 − }`}
219 − onClick={() => {
220 − if (!n.read) markRead(n.id);
221 − }}
222 − >
223 − <div className="flex items-start gap-3">
224 − {/* Icon */}
225 − <div
226 − className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${colorClass}`}
205 + {/* Liste */}
206 + <div className="max-h-[65dvh] sm:max-h-[420px] overflow-y-auto scrollbar-thin">
207 + {!loaded ? (
208 + <div className="p-3 space-y-2" aria-busy="true">
209 + {[...Array(3)].map((_, i) => (
210 + <div key={i} className="flex items-start gap-3 p-2">
211 + <span className="w-9 h-9 rounded-xl bg-gray-200/80 dark:bg-white/[0.07] animate-pulse shrink-0" />
212 + <span className="flex-1 space-y-2 pt-1">
213 + <span className="block h-3 w-2/3 rounded bg-gray-200/80 dark:bg-white/[0.07] animate-pulse" />
214 + <span className="block h-2.5 w-1/2 rounded bg-gray-200/80 dark:bg-white/[0.07] animate-pulse" />
215 + </span>
216 + </div>
217 + ))}
218 + </div>
219 + ) : notifications.length === 0 ? (
220 + <div className="flex flex-col items-center justify-center py-12 px-6 text-center">
221 + <div className="relative mb-4">
222 + <div className="absolute inset-0 rounded-full bg-blue-500/10 blur-2xl scale-150" />
223 + <div className="relative w-14 h-14 rounded-3xl bg-gray-100 dark:bg-white/5 border border-line dark:border-white/10 flex items-center justify-center">
224 + <BellOff className="w-6 h-6 text-gray-400" />
225 + </div>
226 + </div>
227 + <p className="text-[14px] font-medium text-gray-800 dark:text-gray-100">Tout est calme</p>
228 + <p className="text-[12.5px] text-gray-500 dark:text-gray-400 mt-1">Les téléversements, partages et analyses IA terminés apparaîtront ici.</p>
229 + </div>
230 + ) : (
231 + <ul className="p-1.5" role="list">
232 + {notifications.map((n, i) => {
233 + const Icon = typeIcons[n.type] || Bell;
234 + const tone = typeTones[n.type] || "text-gray-500 bg-gray-500/10 dark:text-gray-300";
235 + return (
236 + <motion.li
237 + key={n.id}
238 + initial={{ opacity: 0, x: -8 }}
239 + animate={{ opacity: 1, x: 0 }}
240 + transition={{ delay: Math.min(i, 10) * 0.025 }}
227 241 >
228 − <IconComponent className="w-4 h-4" />
229 − </div>
230 −
231 − {/* Content */}
232 − <div className="flex-1 min-w-0">
233 − <div className="flex items-center gap-2">
234 − <p
235 − className={`text-sm font-medium truncate ${
236 − n.read
237 − ? "text-gray-600 dark:text-gray-400"
238 − : "text-gray-900 dark:text-white"
239 − }`}
240 − >
241 − {n.title}
242 − </p>
242 + <div
243 + role="button"
244 + tabIndex={0}
245 + onClick={() => { if (!n.read) markRead(n.id); }}
246 + onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !n.read) { e.preventDefault(); markRead(n.id); } }}
247 + className={`relative flex items-start gap-3 p-2.5 rounded-xl transition-colors cursor-pointer ${n.read ? "hover:bg-gray-100/70 dark:hover:bg-white/[0.04]" : "bg-blue-500/[0.06] hover:bg-blue-500/10"}`}
248 + >
249 + <span className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${tone}`}>
250 + <Icon className="w-4 h-4" />
251 + </span>
252 + <div className="flex-1 min-w-0">
253 + <div className="flex items-center gap-2">
254 + <p className={`text-[13px] font-medium truncate ${n.read ? "text-gray-600 dark:text-gray-300" : "text-gray-900 dark:text-white"}`}>{n.title}</p>
255 + {!n.read && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" aria-label="Non lue" />}
256 + </div>
257 + <p className="text-[12px] text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2 leading-snug">{n.message}</p>
258 + <p className="text-[11px] text-gray-400 dark:text-gray-500 mt-1 tabular-nums">{timeAgo(n.createdAt)}</p>
259 + </div>
243 260 {!n.read && (
244 − <span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0" />
261 + <button
262 + onClick={(e) => { e.stopPropagation(); markRead(n.id); }}
263 + className="w-8 h-8 -mr-1 flex items-center justify-center rounded-lg text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-300 hover:bg-emerald-500/10 transition-colors shrink-0"
264 + title="Marquer comme lue"
265 + aria-label="Marquer comme lue"
266 + >
267 + <Check className="w-3.5 h-3.5" />
268 + </button>
245 269 )}
246 270 </div>
247 − <p className="text-xs text-gray-500 mt-0.5 line-clamp-2">
248 − {n.message}
249 − </p>
250 − <p className="text-[10px] text-gray-400 mt-1">
251 − {timeAgo(n.createdAt)}
252 − </p>
253 − </div>
254 −
255 − {/* Mark read button */}
256 − {!n.read && (
257 − <button
258 − onClick={(e) => {
259 − e.stopPropagation();
260 − markRead(n.id);
261 − }}
262 − className="p-1.5 text-gray-400 hover:text-green-500 min-w-[32px] min-h-[32px] flex items-center justify-center rounded-lg hover:bg-green-500/10 transition-colors"
263 − title="Marquer comme lu"
264 − >
265 − <Check className="w-3.5 h-3.5" />
266 − </button>
267 − )}
268 − </div>
269 − </motion.div>
270 − );
271 − })
272 − )}
273 − </div>
274 − </motion.div>
271 + </motion.li>
272 + );
273 + })}
274 + </ul>
275 + )}
276 + </div>
277 + </motion.div>
278 + </>
275 279 )}
276 280 </AnimatePresence>
277 281 </div>
added components/layout/ShellContext.tsx +171 −0
@@ -0,0 +1,171 @@
1 +"use client";
2 +
3 +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
4 +import { PREF_KEYS } from "@/lib/prefs";
5 +
6 +export interface Crumb {
7 + id: string;
8 + name: string;
9 +}
10 +
11 +export interface ShellHeader {
12 + title?: string;
13 + /** Fil d'Ariane (pages Fichiers / Dossier) — l'accueil "Fichiers" est ajouté automatiquement. */
14 + crumbs?: Crumb[];
15 +}
16 +
17 +interface ShellContextType {
18 + sidebarCollapsed: boolean;
19 + toggleSidebar: () => void;
20 + mobileNavOpen: boolean;
21 + setMobileNavOpen: (v: boolean) => void;
22 + commandOpen: boolean;
23 + openCommand: (initialQuery?: string) => void;
24 + closeCommand: () => void;
25 + commandQuery: string;
26 + prefsOpen: boolean;
27 + setPrefsOpen: (v: boolean) => void;
28 + header: ShellHeader;
29 + setHeader: (h: ShellHeader) => void;
30 + /** Utilisateur de session (chargé une fois pour tout le shell) */
31 + session: SessionUser | null;
32 + isAdmin: boolean;
33 + refreshSession: () => void;
34 +}
35 +
36 +export interface SessionUser {
37 + id: string;
38 + name?: string | null;
39 + email?: string | null;
40 + role?: string;
41 + avatarPath?: string | null;
42 + twoFactorEnabled?: boolean;
43 +}
44 +
45 +const ShellContext = createContext<ShellContextType | null>(null);
46 +
47 +export function useShell(): ShellContextType {
48 + const ctx = useContext(ShellContext);
49 + if (!ctx) {
50 + // Contexte absent (page hors AppShell) → no-op sûr
51 + return {
52 + sidebarCollapsed: false, toggleSidebar: () => {},
53 + mobileNavOpen: false, setMobileNavOpen: () => {},
54 + commandOpen: false, openCommand: () => {}, closeCommand: () => {}, commandQuery: "",
55 + prefsOpen: false, setPrefsOpen: () => {},
56 + header: {}, setHeader: () => {},
57 + session: null, isAdmin: false, refreshSession: () => {},
58 + };
59 + }
60 + return ctx;
61 +}
62 +
63 +/** Déclare l'en-tête de la barre supérieure pour la page courante. */
64 +export function useShellHeader(header: ShellHeader) {
65 + const { setHeader } = useShell();
66 + const key = JSON.stringify(header);
67 + useEffect(() => {
68 + setHeader(header);
69 + return () => setHeader({});
70 + // eslint-disable-next-line react-hooks/exhaustive-deps
71 + }, [key]);
72 +}
73 +
74 +export function ShellProvider({ children }: { children: React.ReactNode }) {
75 + const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
76 + const [mobileNavOpen, setMobileNavOpen] = useState(false);
77 + const [commandOpen, setCommandOpen] = useState(false);
78 + const [commandQuery, setCommandQuery] = useState("");
79 + const [prefsOpen, setPrefsOpen] = useState(false);
80 + const [header, setHeader] = useState<ShellHeader>({});
81 + const [session, setSession] = useState<SessionUser | null>(null);
82 +
83 + useEffect(() => {
84 + try {
85 + setSidebarCollapsed(localStorage.getItem(PREF_KEYS.sidebar) === "collapsed");
86 + } catch {}
87 + }, []);
88 +
89 + const refreshSession = useCallback(() => {
90 + fetch("/api/auth/session")
91 + .then((r) => r.json())
92 + .then((d) => {
93 + if (d?.isLoggedIn) {
94 + setSession({ id: d.userId, name: d.name, email: d.email, role: d.role || d.userRole, avatarPath: d.avatarPath, twoFactorEnabled: d.twoFactorEnabled });
95 + } else setSession(null);
96 + })
97 + .catch(() => {});
98 + }, []);
99 +
100 + useEffect(() => { refreshSession(); }, [refreshSession]);
101 +
102 + const toggleSidebar = useCallback(() => {
103 + setSidebarCollapsed((c) => {
104 + const next = !c;
105 + try { localStorage.setItem(PREF_KEYS.sidebar, next ? "collapsed" : "expanded"); } catch {}
106 + return next;
107 + });
108 + }, []);
109 +
110 + const openCommand = useCallback((q = "") => {
111 + setCommandQuery(q);
112 + setCommandOpen(true);
113 + }, []);
114 + const closeCommand = useCallback(() => setCommandOpen(false), []);
115 +
116 + // Raccourcis globaux : ⌘K / Ctrl+K, "/" hors champ de saisie, ⌘, pour les préférences
117 + useEffect(() => {
118 + const onKey = (e: KeyboardEvent) => {
119 + const t = e.target as HTMLElement | null;
120 + const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);
121 + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
122 + e.preventDefault();
123 + setCommandOpen((o) => !o);
124 + return;
125 + }
126 + if ((e.metaKey || e.ctrlKey) && e.key === ",") {
127 + e.preventDefault();
128 + setPrefsOpen(true);
129 + return;
130 + }
131 + if (!typing && e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
132 + e.preventDefault();
133 + openCommand("");
134 + }
135 + };
136 + window.addEventListener("keydown", onKey);
137 + // Événements custom (boutons, autres composants)
138 + const onOpen = (e: Event) => openCommand((e as CustomEvent<string>).detail || "");
139 + const onPrefs = () => setPrefsOpen(true);
140 + window.addEventListener("spb:command", onOpen);
141 + window.addEventListener("spb:prefs", onPrefs);
142 + return () => {
143 + window.removeEventListener("keydown", onKey);
144 + window.removeEventListener("spb:command", onOpen);
145 + window.removeEventListener("spb:prefs", onPrefs);
146 + };
147 + }, [openCommand]);
148 +
149 + const value = useMemo<ShellContextType>(() => ({
150 + sidebarCollapsed, toggleSidebar,
151 + mobileNavOpen, setMobileNavOpen,
152 + commandOpen, openCommand, closeCommand, commandQuery,
153 + prefsOpen, setPrefsOpen,
154 + header, setHeader,
155 + session, isAdmin: session?.role === "admin", refreshSession,
156 + }), [sidebarCollapsed, toggleSidebar, mobileNavOpen, commandOpen, openCommand, closeCommand, commandQuery, prefsOpen, header, session, refreshSession]);
157 +
158 + return <ShellContext.Provider value={value}>{children}</ShellContext.Provider>;
159 +}
160 +
161 +/** Ouvre la palette de commandes depuis n'importe où (sans contexte). */
162 +export function openCommandPalette(query = "") {
163 + window.dispatchEvent(new CustomEvent("spb:command", { detail: query }));
164 +}
165 +export function openPreferences() {
166 + window.dispatchEvent(new Event("spb:prefs"));
167 +}
168 +/** Bascule le panneau Assistant IA (écouté par AiPanel). */
169 +export function toggleAiPanel() {
170 + window.dispatchEvent(new Event("spb:ai-toggle"));
171 +}
modified components/layout/Sidebar.tsx +263 −241
@@ -2,116 +2,104 @@
2 2
3 3 import Link from "next/link";
4 4 import { usePathname, useRouter } from "next/navigation";
5 −import { useState, useEffect } from "react";
5 +import { useState, useEffect, useRef } from "react";
6 +import { motion, AnimatePresence, LayoutGroup } from "framer-motion";
6 7 import {
7 − Cloud,
8 − FolderOpen,
9 − LayoutDashboard,
10 − Share2,
11 − Activity,
12 − LogOut,
13 − Star,
14 − Tag,
15 − ChevronDown,
16 − ChevronRight,
17 − Users,
18 − HardDrive,
19 − Key,
20 − Settings,
21 − KeyRound,
22 − Lock,
23 − Trash2,
24 − Clock,
25 − Home,
26 − Network,
27 − Shield,
28 − Camera,
8 + FolderOpen, LayoutDashboard, Share2, Activity, LogOut, Star, Tag, ChevronDown, ChevronRight,
9 + Users, HardDrive, Key, Settings, KeyRound, Lock, Trash2, Clock, Home, Network, Shield, Camera,
10 + PanelLeftClose, PanelLeftOpen, SlidersHorizontal, Sparkles, type LucideIcon,
29 11 } from "lucide-react";
30 −import ThemeToggle from "../theme/ThemeToggle";
31 −import NotificationBell from "./NotificationBell";
32 12 import FolderTree from "./FolderTree";
33 13 import SharedBadge from "../ui/SharedBadge";
34 14 import ChangePasswordModal from "../auth/ChangePasswordModal";
35 15 import UserAvatar from "../ui/UserAvatar";
16 +import Logo from "../ui/Logo";
17 +import { useShell, toggleAiPanel } from "./ShellContext";
18 +import { formatFileSize } from "@/lib/utils";
19 +import { STORAGE_QUOTA_BYTES } from "@/lib/prefs";
36 20
37 −const userNavItems = [
21 +interface NavItem { href: string; label: string; icon: LucideIcon; match?: (p: string) => boolean }
22 +
23 +const primaryNav: NavItem[] = [
38 24 { href: "/dashboard", label: "Accueil", icon: Home },
39 − { href: "/dashboard/files", label: "Fichiers", icon: FolderOpen },
25 + { href: "/dashboard/files", label: "Fichiers", icon: FolderOpen, match: (p) => p.startsWith("/dashboard/files") || p.startsWith("/dashboard/folder") },
40 26 { href: "/dashboard/favorites", label: "Favoris", icon: Star },
41 − { href: "/dashboard/recent", label: "Recents", icon: Clock },
27 + { href: "/dashboard/recent", label: "Récents", icon: Clock },
28 + { href: "/dashboard/shared-with-me", label: "Partagé avec moi", icon: Share2, match: (p) => p.startsWith("/dashboard/shared") },
42 29 { href: "/dashboard/graph", label: "Visualisation", icon: Network },
43 − { href: "/dashboard/shared-with-me", label: "Partage avec moi", icon: Share2 },
30 +];
31 +
32 +const toolsNav: NavItem[] = [
44 33 { href: "/dashboard/passwords", label: "Mots de passe", icon: Lock },
45 34 { href: "/dashboard/trash", label: "Corbeille", icon: Trash2 },
46 35 { href: "/admin/snapshots", label: "Snapshots", icon: Camera },
47 36 { href: "/dashboard/security", label: "Sécurité", icon: Shield },
48 37 ];
49 38
50 −const adminNavItems = [
39 +const adminNav: NavItem[] = [
51 40 { href: "/admin", label: "Dashboard", icon: LayoutDashboard },
52 − { href: "/admin/shares", label: "Liens partagés", icon: Share2 },
53 − { href: "/admin/activity", label: "Activité", icon: Activity },
54 −];
55 −
56 −const adminItems = [
57 41 { href: "/admin/users", label: "Utilisateurs", icon: Users },
58 42 { href: "/admin/shared-spaces", label: "Espaces partagés", icon: Users },
59 − { href: "/admin/snapshots", label: "Snapshots", icon: Clock },
43 + { href: "/admin/shares", label: "Liens partagés", icon: Share2 },
44 + { href: "/admin/activity", label: "Activité", icon: Activity },
60 45 { href: "/admin/backup", label: "Backups", icon: HardDrive },
61 46 { href: "/admin/api-keys", label: "Clés API", icon: Key },
62 47 { href: "/admin/settings", label: "Paramètres", icon: Settings },
63 48 ];
64 49
65 −interface TagData {
66 − id: string;
67 − name: string;
68 − color: string;
69 − _count?: { files: number };
70 −}
71 −
72 −interface SharedSpaceData {
73 − id: string;
74 − name: string;
75 − quotaBytes: number;
76 −}
50 +interface TagData { id: string; name: string; color: string; _count?: { files: number } }
51 +interface SharedSpaceData { id: string; name: string; quotaBytes: number }
77 52
78 53 interface SidebarProps {
79 54 onNavigate?: () => void;
55 + /** Mode rail (icônes seules) — desktop uniquement */
56 + collapsed?: boolean;
57 + /** Afficher le bouton replier/déplier */
58 + showCollapse?: boolean;
80 59 }
81 60
82 −export default function Sidebar({ onNavigate }: SidebarProps) {
61 +function SectionHeader({ open, onToggle, icon: Icon, label, collapsed }: { open: boolean; onToggle: () => void; icon: LucideIcon; label: string; collapsed?: boolean }) {
62 + if (collapsed) return <div className="mx-3 my-2 border-t border-line dark:border-white/[0.07]" />;
63 + return (
64 + <button
65 + onClick={onToggle}
66 + className="group flex items-center gap-2 px-3 py-2 w-full text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors rounded-lg"
67 + >
68 + <Icon className="w-3.5 h-3.5" />
69 + <span className="flex-1 text-left">{label}</span>
70 + <ChevronDown className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? "" : "-rotate-90"}`} />
71 + </button>
72 + );
73 +}
74 +
75 +export default function Sidebar({ onNavigate, collapsed = false, showCollapse = false }: SidebarProps) {
83 76 const pathname = usePathname();
84 77 const router = useRouter();
78 + const { isAdmin, session, toggleSidebar, setPrefsOpen } = useShell();
85 79 const [tags, setTags] = useState<TagData[]>([]);
86 80 const [sharedSpaces, setSharedSpaces] = useState<SharedSpaceData[]>([]);
87 81 const [showTags, setShowTags] = useState(false);
88 82 const [showAdmin, setShowAdmin] = useState(false);
89 83 const [showSpaces, setShowSpaces] = useState(true);
90 84 const [showFolders, setShowFolders] = useState(true);
91 − const [isAdmin, setIsAdmin] = useState(false);
85 + const [showTools, setShowTools] = useState(true);
92 86 const [showChangePassword, setShowChangePassword] = useState(false);
93 − const [sessionUser, setSessionUser] = useState<{ id: string; name?: string; email?: string; avatarPath?: string } | null>(null);
87 + const [userMenu, setUserMenu] = useState(false);
88 + const [storageUsed, setStorageUsed] = useState<number | null>(null);
89 + const userMenuRef = useRef<HTMLDivElement>(null);
94 90
95 91 useEffect(() => {
96 − fetch("/api/auth/session")
97 − .then((res) => res.json())
98 − .then((data) => {
99 − if (data.role === "admin") setIsAdmin(true);
100 − if (data.isLoggedIn) {
101 − setSessionUser({ id: data.userId, name: data.name, email: data.email, avatarPath: data.avatarPath });
102 − }
103 − })
104 − .catch(() => {});
105 −
106 − fetch("/api/tags")
107 − .then((res) => res.json())
108 − .then(setTags)
109 − .catch(() => {});
92 + fetch("/api/tags").then((r) => r.json()).then((d) => Array.isArray(d) && setTags(d)).catch(() => {});
93 + fetch("/api/shared-spaces/mine").then((r) => r.json()).then((d) => Array.isArray(d) && setSharedSpaces(d)).catch(() => {});
94 + fetch("/api/admin/stats").then((r) => r.json()).then((d) => typeof d?.storageUsed === "number" && setStorageUsed(d.storageUsed)).catch(() => {});
95 + }, []);
110 96
111 − fetch("/api/shared-spaces/mine")
112 − .then((res) => res.json())
113 − .then((data) => { if (Array.isArray(data)) setSharedSpaces(data); })
114 − .catch(() => {});
97 + useEffect(() => {
98 + const onClick = (e: MouseEvent) => {
99 + if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) setUserMenu(false);
100 + };
101 + document.addEventListener("mousedown", onClick);
102 + return () => document.removeEventListener("mousedown", onClick);
115 103 }, []);
116 104
117 105 const handleLogout = async () => {
@@ -120,198 +108,232 @@ export default function Sidebar({ onNavigate }: SidebarProps) {
120 108 onNavigate?.();
121 109 };
122 110
123 − const handleLinkClick = () => {
124 − onNavigate?.();
125 − };
111 + const isActive = (item: NavItem) => (item.match ? item.match(pathname) : pathname === item.href);
112 + const isAdminActive = adminNav.some((i) => pathname === i.href) && !pathname.startsWith("/admin/snapshots");
113 +
114 + const storagePct = storageUsed !== null ? Math.min(100, (storageUsed / STORAGE_QUOTA_BYTES) * 100) : 0;
126 115
127 − const isAdminActive = adminItems.some((i) => pathname === i.href);
128 − const navItems = isAdmin ? [...userNavItems, ...adminNavItems] : userNavItems;
116 + const NavLink = ({ item, group }: { item: NavItem; group: string }) => {
117 + const active = isActive(item);
118 + return (
119 + <Link
120 + href={item.href}
121 + onClick={onNavigate}
122 + title={collapsed ? item.label : undefined}
123 + className={`relative flex items-center gap-3 rounded-xl text-[13.5px] transition-colors duration-150 ${
124 + collapsed ? "justify-center w-11 h-11 mx-auto" : "px-3 h-10"
125 + } ${active ? "text-gray-900 dark:text-white font-medium" : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100/80 dark:hover:bg-white/[0.06]"}`}
126 + >
127 + {active && (
128 + <motion.span
129 + layoutId={`nav-pill-${group}`}
130 + className="absolute inset-0 rounded-xl bg-blue-500/10 dark:bg-blue-400/10 ring-1 ring-blue-500/15 dark:ring-blue-400/15"
131 + transition={{ type: "spring", stiffness: 420, damping: 34 }}
132 + />
133 + )}
134 + {active && !collapsed && (
135 + <motion.span layoutId={`nav-bar-${group}`} className="absolute left-0 top-2.5 bottom-2.5 w-[3px] rounded-full bg-blue-500" />
136 + )}
137 + <item.icon className={`relative w-[18px] h-[18px] shrink-0 ${active ? "text-blue-600 dark:text-blue-300" : ""} ${item.href === "/dashboard/favorites" && active ? "text-amber-400 fill-amber-400/30" : ""}`} />
138 + {!collapsed && <span className="relative truncate">{item.label}</span>}
139 + </Link>
140 + );
141 + };
129 142
130 143 return (
131 − <div className="h-full flex flex-col bg-white dark:bg-[#0a0a0a] border-r border-gray-200 dark:border-white/10 transition-colors">
132 − {/* Logo */}
133 − <div className="px-4 py-4 flex items-center gap-2">
134 − <div className="w-9 h-9 rounded-xl bg-gray-100 dark:bg-white/10 flex items-center justify-center flex-shrink-0">
135 − <Cloud className="w-5 h-5 text-gray-700 dark:text-white" />
136 − </div>
137 − <span className="text-gray-900 dark:text-white font-semibold flex-1 text-base">SPB Cloud</span>
138 − <NotificationBell />
139 − <ThemeToggle />
144 + <aside className={`h-full flex flex-col bg-surface dark:bg-canvas border-r border-line dark:border-white/[0.07] transition-colors ${collapsed ? "w-[72px]" : "w-full"}`}>
145 + {/* Marque */}
146 + <div className={`flex items-center h-16 shrink-0 ${collapsed ? "justify-center px-2" : "px-4 gap-2"}`}>
147 + <Link href="/dashboard" onClick={onNavigate} className="flex items-center min-w-0" aria-label="Accueil SPB Cloud">
148 + <Logo size={34} wordmark={!collapsed} wordmarkClassName="text-[15px]" />
149 + </Link>
150 + {!collapsed && <span className="flex-1" />}
151 + {showCollapse && !collapsed && (
152 + <button
153 + onClick={toggleSidebar}
154 + className="hidden lg:flex w-9 h-9 items-center justify-center rounded-xl text-gray-400 hover:text-gray-900 hover:bg-gray-100 dark:hover:text-white dark:hover:bg-white/10 transition-colors"
155 + title="Replier la barre latérale (⌘B)"
156 + >
157 + <PanelLeftClose className="w-[18px] h-[18px]" />
158 + </button>
159 + )}
140 160 </div>
161 + {showCollapse && collapsed && (
162 + <button
163 + onClick={toggleSidebar}
164 + className="mx-auto mb-1 w-11 h-9 flex items-center justify-center rounded-xl text-gray-400 hover:text-gray-900 hover:bg-gray-100 dark:hover:text-white dark:hover:bg-white/10 transition-colors"
165 + title="Déplier la barre latérale (⌘B)"
166 + >
167 + <PanelLeftOpen className="w-[18px] h-[18px]" />
168 + </button>
169 + )}
141 170
142 171 {/* Navigation */}
143 − <nav className="flex-1 px-2 py-1 space-y-0.5 overflow-auto overscroll-contain">
144 − {navItems.map((item) => {
145 − const isActive =
146 − pathname === item.href ||
147 − (item.href === "/dashboard" && pathname === "/dashboard") ||
148 − (item.href === "/dashboard/files" && pathname.startsWith("/dashboard/files")) ||
149 − (item.href === "/dashboard/files" && pathname.startsWith("/dashboard/folder"));
150 − return (
151 − <Link
152 − key={item.href}
153 − href={item.href}
154 − onClick={handleLinkClick}
155 − className={`flex items-center gap-3 px-3 py-3 rounded-xl text-sm transition-all duration-200 min-h-[44px] ${
156 − isActive
157 − ? "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white font-medium border-l-[3px] border-l-blue-700 pl-[9px]"
158 − : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-white/5 active:bg-gray-100 dark:active:bg-white/10 hover:translate-x-0.5"
159 − }`}
160 − >
161 − <item.icon className={`w-5 h-5 flex-shrink-0 ${item.href === "/dashboard/favorites" && pathname === item.href ? "text-yellow-400" : ""}`} />
162 − {item.label}
163 − </Link>
164 − );
165 − })}
172 + <LayoutGroup id="sidebar">
173 + <nav className={`flex-1 overflow-y-auto overscroll-contain scrollbar-thin ${collapsed ? "px-2 space-y-1" : "px-3 space-y-0.5"} pb-3`}>
174 + {primaryNav.map((item) => <NavLink key={item.href} item={item} group="primary" />)}
166 175
167 − {/* Folder tree */}
168 − <div className="pt-3">
169 − <button
170 − onClick={() => setShowFolders(!showFolders)}
171 − className="flex items-center gap-2 px-3 py-2.5 w-full text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors min-h-[40px] rounded-lg active:bg-gray-50 dark:active:bg-white/5"
172 − >
173 − {showFolders ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
174 − <FolderOpen className="w-3.5 h-3.5" />
175 − <span className="uppercase tracking-wider font-medium">Dossiers</span>
176 − </button>
177 − {showFolders && <FolderTree onNavigate={handleLinkClick} />}
178 − </div>
176 + {/* Dossiers */}
177 + {!collapsed && (
178 + <div className="pt-3">
179 + <SectionHeader open={showFolders} onToggle={() => setShowFolders(!showFolders)} icon={FolderOpen} label="Dossiers" />
180 + <AnimatePresence initial={false}>
181 + {showFolders && (
182 + <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }} className="overflow-hidden">
183 + <FolderTree onNavigate={onNavigate} />
184 + </motion.div>
185 + )}
186 + </AnimatePresence>
187 + </div>
188 + )}
179 189
180 − {/* Shared Spaces Section */}
181 − {sharedSpaces.length > 0 && (
182 − <div className="pt-3">
183 − <button
184 − onClick={() => setShowSpaces(!showSpaces)}
185 − className="flex items-center gap-2 px-3 py-2.5 w-full text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors min-h-[40px] rounded-lg active:bg-gray-50 dark:active:bg-white/5"
186 − >
187 − {showSpaces ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
188 − <Users className="w-3.5 h-3.5" />
189 − <span className="uppercase tracking-wider font-medium">Espaces partagés</span>
190 − </button>
191 − {showSpaces && (
192 − <div className="space-y-0.5 ml-1">
193 − {sharedSpaces.map((space) => (
194 − <Link
195 − key={space.id}
196 − href={`/dashboard/shared-space/${space.id}`}
197 − onClick={handleLinkClick}
198 − className={`flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl transition-colors min-h-[40px] ${
199 − pathname === `/dashboard/shared-space/${space.id}`
200 − ? "bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-300 font-medium"
201 − : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-white/5 active:bg-gray-100 dark:active:bg-white/10"
202 − }`}
203 − >
204 − <Users className="w-4 h-4 flex-shrink-0 text-blue-500" />
205 − <span className="truncate flex-1">{space.name}</span>
206 − <SharedBadge />
207 − </Link>
208 − ))}
209 − </div>
210 − )}
211 − </div>
212 − )}
190 + {/* Espaces partagés */}
191 + {sharedSpaces.length > 0 && !collapsed && (
192 + <div className="pt-3">
193 + <SectionHeader open={showSpaces} onToggle={() => setShowSpaces(!showSpaces)} icon={Users} label="Espaces partagés" />
194 + {showSpaces && (
195 + <div className="space-y-0.5">
196 + {sharedSpaces.map((space) => {
197 + const active = pathname === `/dashboard/shared-space/${space.id}`;
198 + return (
199 + <Link
200 + key={space.id}
201 + href={`/dashboard/shared-space/${space.id}`}
202 + onClick={onNavigate}
203 + className={`flex items-center gap-2.5 px-3 h-9 text-[13px] rounded-xl transition-colors ${active ? "bg-blue-500/10 text-blue-700 dark:text-blue-300 font-medium" : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100/80 dark:hover:bg-white/[0.06]"}`}
204 + >
205 + <Users className="w-4 h-4 shrink-0 text-blue-500" />
206 + <span className="truncate flex-1">{space.name}</span>
207 + <SharedBadge />
208 + </Link>
209 + );
210 + })}
211 + </div>
212 + )}
213 + </div>
214 + )}
213 215
214 − {/* Admin Section — admin only */}
215 − {isAdmin && (
216 + {/* Outils */}
216 217 <div className="pt-3">
217 − <button
218 − onClick={() => setShowAdmin(!showAdmin)}
219 − className={`flex items-center gap-2 px-3 py-2.5 w-full text-xs transition-colors min-h-[40px] rounded-lg active:bg-gray-50 dark:active:bg-white/5 ${
220 − isAdminActive ? "text-gray-700 dark:text-gray-300" : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
221 − }`}
222 − >
223 − {showAdmin ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
224 − <Settings className="w-3.5 h-3.5" />
225 − <span className="uppercase tracking-wider font-medium">Administration</span>
226 − </button>
227 − {(showAdmin || isAdminActive) && (
228 − <div className="space-y-0.5 ml-1">
229 − {adminItems.map((item) => (
230 − <Link
231 − key={item.href}
232 − href={item.href}
233 − onClick={handleLinkClick}
234 − className={`flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl transition-colors min-h-[40px] ${
235 − pathname === item.href
236 − ? "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white font-medium"
237 − : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-white/5 active:bg-gray-100 dark:active:bg-white/10"
238 − }`}
239 − >
240 − <item.icon className="w-4 h-4 flex-shrink-0" />
241 − {item.label}
242 − </Link>
243 − ))}
218 + <SectionHeader open={showTools} onToggle={() => setShowTools(!showTools)} icon={SlidersHorizontal} label="Outils" collapsed={collapsed} />
219 + {(showTools || collapsed) && (
220 + <div className={collapsed ? "space-y-1" : "space-y-0.5"}>
221 + {toolsNav.map((item) => <NavLink key={item.href} item={item} group="tools" />)}
244 222 </div>
245 223 )}
246 224 </div>
247 − )}
248 225
249 − {/* Tags Section */}
250 − {tags.length > 0 && (
251 − <div className="pt-3">
252 − <button
253 − onClick={() => setShowTags(!showTags)}
254 − className="flex items-center gap-2 px-3 py-2.5 w-full text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors min-h-[40px] rounded-lg active:bg-gray-50 dark:active:bg-white/5"
255 − >
256 − {showTags ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
257 − <Tag className="w-3.5 h-3.5" />
258 − <span className="uppercase tracking-wider font-medium">Tags</span>
259 − </button>
260 − {showTags && (
261 − <div className="space-y-0.5 ml-1">
262 − {tags.map((tag) => (
263 − <Link
264 − key={tag.id}
265 − href={`/dashboard?tagId=${tag.id}`}
266 − onClick={handleLinkClick}
267 − className="flex items-center gap-2.5 px-3 py-2.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-white/5 active:bg-gray-100 dark:active:bg-white/10 rounded-xl transition-colors min-h-[40px]"
268 − >
269 − <span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
270 − <span className="truncate">{tag.name}</span>
271 − {tag._count && <span className="text-xs text-gray-400 dark:text-gray-600 ml-auto tabular-nums">{tag._count.files}</span>}
272 − </Link>
273 − ))}
274 − </div>
275 − )}
276 − </div>
277 − )}
278 − </nav>
226 + {/* Administration */}
227 + {isAdmin && (
228 + <div className="pt-3">
229 + <SectionHeader open={showAdmin || isAdminActive} onToggle={() => setShowAdmin(!showAdmin)} icon={Settings} label="Administration" collapsed={collapsed} />
230 + {(showAdmin || isAdminActive || collapsed) && (
231 + <div className={collapsed ? "space-y-1" : "space-y-0.5"}>
232 + {adminNav.map((item) => <NavLink key={item.href} item={item} group="admin" />)}
233 + </div>
234 + )}
235 + </div>
236 + )}
279 237
280 − {/* Footer */}
281 − <div className="px-2 py-3 border-t border-gray-200 dark:border-white/10 space-y-0.5">
282 − {/* Current user */}
283 − {sessionUser && (
284 − <div className="flex items-center gap-3 px-3 py-2.5 mb-1">
285 − <UserAvatar userId={sessionUser.id} name={sessionUser.name} avatarPath={sessionUser.avatarPath} size="sm" />
286 − <div className="min-w-0 flex-1">
287 − <p className="text-sm font-medium text-gray-900 dark:text-white truncate">
288 − {sessionUser.name || "Utilisateur"}
289 − </p>
290 − {sessionUser.email && (
291 − <p className="text-[10px] text-gray-400 dark:text-gray-500 truncate">
292 − {sessionUser.email}
293 − </p>
238 + {/* Tags */}
239 + {tags.length > 0 && !collapsed && (
240 + <div className="pt-3">
241 + <SectionHeader open={showTags} onToggle={() => setShowTags(!showTags)} icon={Tag} label="Tags" />
242 + {showTags && (
243 + <div className="space-y-0.5">
244 + {tags.map((tag) => (
245 + <Link
246 + key={tag.id}
247 + href={`/dashboard/files?tagId=${tag.id}`}
248 + onClick={onNavigate}
249 + className="flex items-center gap-2.5 px-3 h-9 text-[13px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100/80 dark:hover:bg-white/[0.06] rounded-xl transition-colors"
250 + >
251 + <span className="w-2.5 h-2.5 rounded-full shrink-0 ring-2 ring-white/40 dark:ring-black/30" style={{ backgroundColor: tag.color }} />
252 + <span className="truncate flex-1">{tag.name}</span>
253 + {tag._count && <span className="text-[11px] text-gray-400 dark:text-gray-600 tabular-nums">{tag._count.files}</span>}
254 + </Link>
255 + ))}
256 + </div>
294 257 )}
295 258 </div>
296 − </div>
259 + )}
260 + </nav>
261 + </LayoutGroup>
262 +
263 + {/* Pied : stockage + utilisateur */}
264 + <div className={`shrink-0 border-t border-line dark:border-white/[0.07] ${collapsed ? "p-2" : "p-3"} space-y-2`}>
265 + {!collapsed && storageUsed !== null && (
266 + <Link href="/admin" onClick={onNavigate} className="block rounded-2xl p-3 bg-gray-50 dark:bg-white/[0.03] border border-line dark:border-white/[0.06] hover:border-blue-500/30 transition-colors group">
267 + <div className="flex items-center justify-between text-[12px] mb-2">
268 + <span className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 font-medium"><HardDrive className="w-3.5 h-3.5" /> Stockage</span>
269 + <span className="text-gray-400 tabular-nums">{storagePct.toFixed(1)}%</span>
270 + </div>
271 + <div className="h-1.5 rounded-full bg-gray-200 dark:bg-white/10 overflow-hidden">
272 + <motion.div initial={{ width: 0 }} animate={{ width: `${Math.max(1.5, storagePct)}%` }} transition={{ duration: 0.8, ease: "easeOut" }} className={`h-full rounded-full ${storagePct > 90 ? "bg-rose-500" : storagePct > 70 ? "bg-amber-500" : "bg-accent-gradient"}`} />
273 + </div>
274 + <p className="text-[11px] text-gray-400 mt-1.5 tabular-nums">{formatFileSize(storageUsed)} sur {formatFileSize(STORAGE_QUOTA_BYTES)}</p>
275 + </Link>
297 276 )}
298 − <button
299 − onClick={() => setShowChangePassword(true)}
300 − className="flex items-center gap-3 px-3 py-3 rounded-xl text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-white/5 active:bg-gray-100 dark:active:bg-white/10 transition-colors w-full min-h-[44px]"
301 − >
302 − <KeyRound className="w-5 h-5" />
303 − Changer le mot de passe
304 − </button>
305 − <button
306 − onClick={handleLogout}
307 − className="flex items-center gap-3 px-3 py-3 rounded-xl text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/5 active:bg-red-100 dark:active:bg-red-500/10 transition-colors w-full min-h-[44px]"
308 − >
309 − <LogOut className="w-5 h-5" />
310 − Déconnexion
311 − </button>
277 +
278 + <div className="relative" ref={userMenuRef}>
279 + <button
280 + onClick={() => setUserMenu((v) => !v)}
281 + className={`w-full flex items-center gap-3 rounded-xl hover:bg-gray-100 dark:hover:bg-white/[0.06] transition-colors ${collapsed ? "justify-center h-11" : "px-2 h-12"}`}
282 + title={collapsed ? session?.name || "Compte" : undefined}
283 + >
284 + {session ? (
285 + <UserAvatar userId={session.id} name={session.name} avatarPath={session.avatarPath} size="md" />
286 + ) : (
287 + <span className="w-8 h-8 rounded-full bg-gray-200 dark:bg-white/10 animate-pulse" />
288 + )}
289 + {!collapsed && (
290 + <>
291 + <span className="min-w-0 flex-1 text-left">
292 + <span className="block text-[13px] font-medium text-gray-900 dark:text-white truncate">{session?.name || "Utilisateur"}</span>
293 + <span className="block text-[11px] text-gray-400 truncate">{session?.email || (isAdmin ? "Administrateur" : "")}</span>
294 + </span>
295 + <ChevronRight className={`w-4 h-4 text-gray-400 transition-transform ${userMenu ? "rotate-90" : ""}`} />
296 + </>
297 + )}
298 + </button>
299 +
300 + <AnimatePresence>
301 + {userMenu && (
302 + <motion.div
303 + initial={{ opacity: 0, y: 6, scale: 0.98 }}
304 + animate={{ opacity: 1, y: 0, scale: 1 }}
305 + exit={{ opacity: 0, y: 6, scale: 0.98 }}
306 + transition={{ duration: 0.15 }}
307 + className={`absolute bottom-full mb-2 z-50 w-60 glass rounded-2xl shadow-pop p-1.5 ${collapsed ? "left-full ml-2 bottom-0" : "left-0"}`}
308 + >
309 + <div className="px-3 py-2 border-b border-line dark:border-white/10 mb-1">
310 + <p className="text-[13px] font-medium text-gray-900 dark:text-white truncate">{session?.name || "Utilisateur"}</p>
311 + <p className="text-[11px] text-gray-400 truncate">{session?.email || ""}</p>
312 + </div>
313 + {[
314 + { icon: SlidersHorizontal, label: "Préférences", hint: "⌘,", onClick: () => { setUserMenu(false); setPrefsOpen(true); } },
315 + { icon: Sparkles, label: "Assistant IA", onClick: () => { setUserMenu(false); toggleAiPanel(); } },
316 + { icon: KeyRound, label: "Changer le mot de passe", onClick: () => { setUserMenu(false); setShowChangePassword(true); } },
317 + { icon: Shield, label: "Sécurité & appareils", onClick: () => { setUserMenu(false); router.push("/dashboard/security"); onNavigate?.(); } },
318 + ].map((it) => (
319 + <button key={it.label} onClick={it.onClick} className="w-full flex items-center gap-2.5 px-3 h-9 rounded-xl text-[13px] text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.08] transition-colors">
320 + <it.icon className="w-4 h-4 text-gray-400" />
321 + <span className="flex-1 text-left">{it.label}</span>
322 + {"hint" in it && it.hint && <kbd>{it.hint}</kbd>}
323 + </button>
324 + ))}
325 + <div className="my-1 border-t border-line dark:border-white/10" />
326 + <button onClick={handleLogout} className="w-full flex items-center gap-2.5 px-3 h-9 rounded-xl text-[13px] text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors">
327 + <LogOut className="w-4 h-4" />
328 + Déconnexion
329 + </button>
330 + </motion.div>
331 + )}
332 + </AnimatePresence>
333 + </div>
312 334 </div>
313 335
314 336 <ChangePasswordModal isOpen={showChangePassword} onClose={() => setShowChangePassword(false)} />
315 − </div>
337 + </aside>
316 338 );
317 339 }
added components/layout/TopBar.tsx +126 −0
@@ -0,0 +1,126 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { usePathname, useRouter } from "next/navigation";
5 +import { Menu, Search, Upload, Sparkles, ChevronRight, Home } from "lucide-react";
6 +import ThemeToggle from "../theme/ThemeToggle";
7 +import NotificationBell from "./NotificationBell";
8 +import Kbd, { modKey } from "../ui/Kbd";
9 +import { useShell, toggleAiPanel } from "./ShellContext";
10 +import { useEffect, useState } from "react";
11 +
12 +const routeTitles: Record<string, string> = {
13 + "/dashboard": "Accueil",
14 + "/dashboard/files": "Fichiers",
15 + "/dashboard/favorites": "Favoris",
16 + "/dashboard/recent": "Récents",
17 + "/dashboard/graph": "Visualisation",
18 + "/dashboard/shared-with-me": "Partagé avec moi",
19 + "/dashboard/passwords": "Mots de passe",
20 + "/dashboard/trash": "Corbeille",
21 + "/dashboard/security": "Sécurité",
22 + "/admin": "Administration",
23 + "/admin/users": "Utilisateurs",
24 + "/admin/shared-spaces": "Espaces partagés",
25 + "/admin/shares": "Liens partagés",
26 + "/admin/activity": "Activité",
27 + "/admin/snapshots": "Snapshots",
28 + "/admin/backup": "Backups",
29 + "/admin/api-keys": "Clés API",
30 + "/admin/settings": "Paramètres",
31 +};
32 +
33 +export default function TopBar() {
34 + const pathname = usePathname();
35 + const router = useRouter();
36 + const { header, openCommand, setMobileNavOpen } = useShell();
37 + const [mod, setMod] = useState("⌘");
38 + useEffect(() => setMod(modKey()), []);
39 +
40 + const isFiles = pathname.startsWith("/dashboard/files") || pathname.startsWith("/dashboard/folder");
41 + const title = header.title || routeTitles[pathname] || (pathname.startsWith("/dashboard/shared-space") ? "Espace partagé" : isFiles ? "Fichiers" : "SPB Cloud");
42 + const crumbs = header.crumbs;
43 +
44 + return (
45 + <header className="h-14 lg:h-16 shrink-0 flex items-center gap-2 px-3 sm:px-4 lg:px-6 border-b border-line dark:border-white/[0.07] bg-surface/70 dark:bg-canvas/70 backdrop-blur-xl safe-top z-30">
46 + {/* Mobile : menu */}
47 + <button
48 + onClick={() => setMobileNavOpen(true)}
49 + className="lg:hidden w-10 h-10 -ml-1 flex items-center justify-center rounded-xl text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
50 + aria-label="Ouvrir le menu"
51 + >
52 + <Menu className="w-5 h-5" />
53 + </button>
54 +
55 + {/* Titre / fil d'Ariane */}
56 + <div className="flex-1 min-w-0 flex items-center">
57 + {crumbs ? (
58 + <nav className="flex items-center gap-1 text-sm overflow-x-auto scrollbar-none whitespace-nowrap" aria-label="Fil d'Ariane">
59 + <Link href="/dashboard/files" className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors px-1.5 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5" data-crumb-root>
60 + <Home className="w-4 h-4" />
61 + <span className={crumbs.length ? "hidden sm:inline" : "font-semibold text-gray-900 dark:text-white"}>Fichiers</span>
62 + </Link>
63 + {crumbs.map((c, i) => {
64 + const last = i === crumbs.length - 1;
65 + return (
66 + <span key={c.id} className="flex items-center gap-1">
67 + <ChevronRight className="w-3.5 h-3.5 text-gray-300 dark:text-gray-600 shrink-0" />
68 + <Link
69 + href={`/dashboard/folder/${c.id}`}
70 + data-crumb-id={c.id}
71 + className={`px-1.5 py-1 rounded-lg transition-colors truncate max-w-[10rem] sm:max-w-[16rem] ${last ? "font-semibold text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/5"}`}
72 + aria-current={last ? "page" : undefined}
73 + >
74 + {c.name}
75 + </Link>
76 + </span>
77 + );
78 + })}
79 + </nav>
80 + ) : (
81 + <h1 className="text-[15px] lg:text-base font-semibold tracking-tight text-gray-900 dark:text-white truncate">{title}</h1>
82 + )}
83 + </div>
84 +
85 + {/* Recherche / palette */}
86 + <button
87 + onClick={() => openCommand("")}
88 + className="hidden md:flex items-center gap-2 h-10 w-64 lg:w-80 px-3 rounded-xl bg-gray-100/80 dark:bg-white/[0.06] border border-transparent hover:border-line dark:hover:border-white/10 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-sm text-left"
89 + aria-label="Rechercher ou exécuter une commande"
90 + >
91 + <Search className="w-4 h-4 shrink-0" />
92 + <span className="flex-1 truncate">Rechercher, naviguer, agir…</span>
93 + <span className="flex items-center gap-0.5"><Kbd>{mod}</Kbd><Kbd>K</Kbd></span>
94 + </button>
95 + <button
96 + onClick={() => openCommand("")}
97 + className="md:hidden w-10 h-10 flex items-center justify-center rounded-xl text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
98 + aria-label="Rechercher"
99 + >
100 + <Search className="w-5 h-5" />
101 + </button>
102 +
103 + {/* Actions */}
104 + <div className="flex items-center gap-0.5 sm:gap-1">
105 + <button
106 + onClick={() => router.push(isFiles ? `${pathname}?upload=1` : "/dashboard/files?upload=1")}
107 + className="hidden sm:flex items-center gap-1.5 h-10 px-3 rounded-xl text-white bg-accent-gradient text-sm font-medium shadow-[inset_0_1px_0_rgb(255_255_255/.18)] hover:brightness-110 hover:shadow-glow transition-all"
108 + title="Envoyer des fichiers"
109 + >
110 + <Upload className="w-4 h-4" />
111 + <span className="hidden lg:inline">Envoyer</span>
112 + </button>
113 + <button
114 + onClick={toggleAiPanel}
115 + className="hidden sm:flex w-10 h-10 items-center justify-center rounded-xl text-gray-500 hover:text-blue-600 hover:bg-blue-500/10 dark:text-gray-400 dark:hover:text-blue-300 transition-colors"
116 + title="Assistant IA"
117 + aria-label="Assistant IA"
118 + >
119 + <Sparkles className="w-[18px] h-[18px]" />
120 + </button>
121 + <NotificationBell />
122 + <ThemeToggle />
123 + </div>
124 + </header>
125 + );
126 +}
modified components/preview/PreviewEngine.tsx +158 −83
@@ -1,8 +1,8 @@
1 1 "use client";
2 2
3 3 import { useState, useCallback, useEffect } from "react";
4 −import { motion, AnimatePresence } from "framer-motion";
5 −import { Info, ChevronUp } from "lucide-react";
4 +import { motion, AnimatePresence, type PanInfo } from "framer-motion";
5 +import { Info, ChevronUp, X } from "lucide-react";
6 6 import PreviewToolbar from "./PreviewToolbar";
7 7 import PreviewNavigation from "./PreviewNavigation";
8 8 import MetadataEditor from "./editors/MetadataEditor";
@@ -16,88 +16,145 @@ interface PreviewEngineProps {
16 16 files?: FileData[];
17 17 onClose: () => void;
18 18 onNavigate?: (file: FileData) => void;
19 + /** Appelé après une modification (favori, version restaurée, sauvegarde) pour rafraîchir la liste parente */
20 + onChanged?: () => void;
19 21 }
20 22
21 −export default function PreviewEngine({
22 − file,
23 − files = [],
24 − onClose,
25 − onNavigate,
26 −}: PreviewEngineProps) {
23 +function useCoarsePointer() {
24 + const [coarse, setCoarse] = useState(false);
25 + useEffect(() => {
26 + const mq = window.matchMedia("(pointer: coarse)");
27 + const upd = () => setCoarse(mq.matches);
28 + upd();
29 + mq.addEventListener("change", upd);
30 + return () => mq.removeEventListener("change", upd);
31 + }, []);
32 + return coarse;
33 +}
34 +
35 +export default function PreviewEngine({ file, files = [], onClose, onNavigate, onChanged }: PreviewEngineProps) {
27 36 const [fullscreen, setFullscreen] = useState(false);
28 37 const [isEditing, setIsEditing] = useState(false);
29 38 const [showInfo, setShowInfo] = useState(false);
30 39 const [showMobileInfo, setShowMobileInfo] = useState(false);
40 + const [isFavorite, setIsFavorite] = useState(!!file?.metadata?.isFavorite);
41 + const [direction, setDirection] = useState(0);
42 + const coarse = useCoarsePointer();
31 43
32 − // Préchargement des images voisines → navigation suivant/précédent instantanée
44 + useEffect(() => { setIsFavorite(!!file?.metadata?.isFavorite); }, [file]);
45 +
46 + // Préchargement des images voisines
33 47 useEffect(() => {
34 48 if (!file || files.length < 2) return;
35 49 const idx = files.findIndex((f) => f.id === file.id);
36 50 if (idx === -1) return;
37 − const neighbors = [files[idx - 1], files[idx + 1]].filter(
38 − (f): f is FileData => !!f && f.mimeType.startsWith("image/")
39 − );
40 − neighbors.forEach((f) => {
41 − const img = new Image();
42 − img.src = `/api/files/${f.id}/preview?w=2048`;
43 − });
51 + [files[idx - 1], files[idx + 1]]
52 + .filter((f): f is FileData => !!f && f.mimeType.startsWith("image/"))
53 + .forEach((f) => { const img = new Image(); img.src = `/api/files/${f.id}/preview?w=2048`; });
44 54 }, [file, files]);
45 55
56 + // Échap : quitter l'édition / le plein écran / fermer
57 + useEffect(() => {
58 + const onKey = (e: KeyboardEvent) => {
59 + if (e.key !== "Escape") return;
60 + const t = e.target as HTMLElement | null;
61 + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
62 + if (isEditing) return;
63 + if (fullscreen) { setFullscreen(false); return; }
64 + onClose();
65 + };
66 + window.addEventListener("keydown", onKey);
67 + return () => window.removeEventListener("keydown", onKey);
68 + }, [isEditing, fullscreen, onClose]);
69 +
70 + // Bloquer le défilement du fond en plein écran
71 + useEffect(() => {
72 + if (!fullscreen) return;
73 + const prev = document.body.style.overflow;
74 + document.body.style.overflow = "hidden";
75 + return () => { document.body.style.overflow = prev; };
76 + }, [fullscreen]);
77 +
46 78 const handleSave = useCallback(
47 79 async (data: Blob | string, asNewVersion?: boolean) => {
48 80 if (!file) return;
49 81 try {
50 82 if (typeof data === "string") {
51 − const res = await fetch(`/api/files/${file.id}/content`, {
52 − method: "PUT",
53 − headers: { "Content-Type": "text/plain" },
54 − body: data,
55 − });
83 + const res = await fetch(`/api/files/${file.id}/content`, { method: "PUT", headers: { "Content-Type": "text/plain" }, body: data });
56 84 if (!res.ok) throw new Error("Save failed");
57 85 toast.success("Fichier sauvegardé");
58 86 } else {
59 87 const formData = new FormData();
60 88 formData.append("file", data, file.name);
61 89 if (asNewVersion) formData.append("asNewVersion", "true");
62 −
63 − const res = await fetch(`/api/files/${file.id}/save-edit`, {
64 − method: "POST",
65 − body: formData,
66 − });
90 + const res = await fetch(`/api/files/${file.id}/save-edit`, { method: "POST", body: formData });
67 91 if (!res.ok) throw new Error("Save failed");
68 92 toast.success(asNewVersion ? "Nouvelle version créée" : "Fichier sauvegardé");
69 93 }
94 + onChanged?.();
70 95 } catch {
71 96 toast.error("Erreur lors de la sauvegarde");
72 97 }
73 98 },
74 − [file]
99 + [file, onChanged]
75 100 );
76 101
77 102 const handleToggleFavorite = useCallback(async () => {
78 103 if (!file) return;
79 104 try {
80 − await fetch(`/api/files/${file.id}/favorite`, { method: "POST" });
81 − toast.success("Favori mis à jour");
105 + const res = await fetch(`/api/files/${file.id}/favorite`, { method: "POST" });
106 + if (res.ok) {
107 + const meta = await res.json().catch(() => null);
108 + const fav = typeof meta?.isFavorite === "boolean" ? meta.isFavorite : !isFavorite;
109 + setIsFavorite(fav);
110 + toast.success(fav ? "Ajouté aux favoris" : "Retiré des favoris");
111 + onChanged?.();
112 + }
82 113 } catch {
83 114 toast.error("Erreur");
84 115 }
85 − }, [file]);
116 + }, [file, isFavorite, onChanged]);
86 117
87 118 if (!file) return null;
88 119
89 120 const plugin = getPlugin(file.mimeType);
90 121 const PluginComponent = plugin.component;
122 + const idx = files.findIndex((f) => f.id === file.id);
123 + const canSwipe = coarse && files.length > 1 && !!onNavigate && !isEditing;
124 +
125 + const navigateTo = (f: FileData, dir: number) => {
126 + setDirection(dir);
127 + setIsEditing(false);
128 + onNavigate?.(f);
129 + };
130 +
131 + const onSwipeEnd = (_: unknown, info: PanInfo) => {
132 + if (!canSwipe) return;
133 + const threshold = 70;
134 + if ((info.offset.x < -threshold || info.velocity.x < -500) && idx < files.length - 1) navigateTo(files[idx + 1], 1);
135 + else if ((info.offset.x > threshold || info.velocity.x > 500) && idx > 0) navigateTo(files[idx - 1], -1);
136 + };
137 +
138 + const infoPanel = (
139 + <>
140 + <MetadataEditor file={file} onUpdate={onChanged} />
141 + <VersionHistory fileId={file.id} onRestore={onChanged} />
142 + </>
143 + );
91 144
92 145 return (
93 146 <AnimatePresence>
94 147 <motion.div
148 + key="preview-root"
95 149 initial={{ opacity: 0 }}
96 150 animate={{ opacity: 1 }}
97 151 exit={{ opacity: 0 }}
98 − className={`fixed inset-0 z-50 flex flex-col bg-white dark:bg-[#0a0a0a] transition-colors ${
99 − fullscreen ? "lg:left-64" : "lg:relative lg:inset-auto lg:h-full"
152 + transition={{ duration: 0.16 }}
153 + className={`flex flex-col bg-canvas transition-colors ${
154 + fullscreen ? "fixed inset-0 z-[60]" : "fixed inset-0 z-50 lg:relative lg:inset-auto lg:z-auto lg:h-full lg:w-full"
100 155 }`}
156 + role="dialog"
157 + aria-label={`Aperçu : ${file.name}`}
101 158 >
102 159 <PreviewToolbar
103 160 file={file}
@@ -108,70 +165,88 @@ export default function PreviewEngine({
108 165 onToggleFullscreen={() => setFullscreen(!fullscreen)}
109 166 onToggleEdit={() => setIsEditing(!isEditing)}
110 167 onToggleFavorite={handleToggleFavorite}
111 − isFavorite={file.metadata?.isFavorite}
168 + isFavorite={isFavorite}
169 + onToggleInfo={fullscreen ? () => setShowInfo(!showInfo) : undefined}
170 + infoOpen={showInfo}
171 + position={files.length > 1 && idx >= 0 ? { index: idx + 1, total: files.length } : undefined}
112 172 />
113 173
114 − <div className="flex-1 flex overflow-hidden">
115 − {/* Main preview area */}
116 − <div className="flex-1 relative overflow-hidden">
117 − <PluginComponent
118 − file={file}
119 − isEditing={isEditing}
120 − onSave={handleSave}
121 − fullscreen={fullscreen}
122 − />
174 + <div className="flex-1 flex overflow-hidden min-h-0">
175 + {/* Zone principale */}
176 + <div className="flex-1 relative overflow-hidden min-w-0">
177 + <AnimatePresence mode="wait" initial={false} custom={direction}>
178 + <motion.div
179 + key={file.id}
180 + custom={direction}
181 + initial={{ opacity: 0, x: direction * 40 }}
182 + animate={{ opacity: 1, x: 0 }}
183 + exit={{ opacity: 0, x: direction * -40 }}
184 + transition={{ duration: 0.18, ease: [0.2, 0.8, 0.2, 1] }}
185 + className="absolute inset-0"
186 + drag={canSwipe ? "x" : false}
187 + dragConstraints={{ left: 0, right: 0 }}
188 + dragElastic={0.12}
189 + dragDirectionLock
190 + onDragEnd={onSwipeEnd}
191 + >
192 + <PluginComponent file={file} isEditing={isEditing} onSave={handleSave} fullscreen={fullscreen} />
193 + </motion.div>
194 + </AnimatePresence>
123 195
124 196 {files.length > 1 && onNavigate && !isEditing && (
125 − <PreviewNavigation
126 − files={files}
127 − currentFile={file}
128 − onNavigate={(f) => {
129 − setIsEditing(false);
130 − onNavigate(f);
131 − }}
132 − />
197 + <PreviewNavigation files={files} currentFile={file} onNavigate={(f) => navigateTo(f, files.indexOf(f) > idx ? 1 : -1)} />
133 198 )}
134 − </div>
135 199
136 − {/* Info panel toggle - only visible on desktop in fullscreen */}
137 − {fullscreen && (
138 − <div className="hidden lg:flex flex-col border-l border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-[#0a0a0a]">
139 − <button
140 − onClick={() => setShowInfo(!showInfo)}
141 − className={`min-w-[44px] min-h-[44px] flex items-center justify-center p-2.5 transition-colors ${showInfo ? "text-gray-900 dark:text-white bg-gray-200 dark:bg-white/10" : "text-gray-400 dark:text-gray-500 hover:text-gray-900 dark:hover:text-white"}`}
142 − title="Infos & tags"
143 − >
144 − <Info className="w-4 h-4" />
200 + {/* Bouton fermer flottant (mobile plein écran, si la barre est masquée par un plugin) */}
201 + {fullscreen && (
202 + <button onClick={() => setFullscreen(false)} className="lg:hidden absolute top-3 right-3 z-20 w-10 h-10 rounded-full glass flex items-center justify-center text-gray-700 dark:text-gray-200" aria-label="Quitter le plein écran">
203 + <X className="w-5 h-5" />
145 204 </button>
146 − </div>
147 − )}
148 − </div>
205 + )}
206 + </div>
149 207
150 − {/* Mobile info toggle button */}
151 − <div className="lg:hidden border-t border-gray-200 dark:border-white/10">
152 − <button
153 − onClick={() => setShowMobileInfo(!showMobileInfo)}
154 − className="w-full flex items-center justify-center gap-2 py-3 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
155 − >
156 − <Info className="w-4 h-4" />
157 − <span>{showMobileInfo ? "Masquer les infos" : "Voir les infos"}</span>
158 − <ChevronUp className={`w-4 h-4 transition-transform ${showMobileInfo ? "rotate-180" : ""}`} />
159 − </button>
208 + {/* Panneau infos desktop en plein écran */}
209 + <AnimatePresence initial={false}>
210 + {fullscreen && showInfo && (
211 + <motion.aside
212 + initial={{ width: 0, opacity: 0 }}
213 + animate={{ width: 320, opacity: 1 }}
214 + exit={{ width: 0, opacity: 0 }}
215 + transition={{ type: "spring", stiffness: 380, damping: 36 }}
216 + className="hidden lg:block shrink-0 overflow-hidden border-l border-line dark:border-white/[0.07] bg-surface dark:bg-canvas"
217 + >
218 + <div className="w-[320px] h-full overflow-auto">{infoPanel}</div>
219 + </motion.aside>
220 + )}
221 + </AnimatePresence>
160 222 </div>
161 223
162 − {/* Mobile info panel (bottom slide-up) */}
163 − {showMobileInfo && (
164 − <div className="lg:hidden overflow-auto max-h-64 border-t border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-[#0a0a0a]">
165 − <MetadataEditor file={file} />
166 − <VersionHistory fileId={file.id} />
224 + {/* Mobile : bascule infos */}
225 + {!fullscreen && (
226 + <div className="lg:hidden border-t border-line dark:border-white/[0.07] bg-surface/80 dark:bg-canvas/80 backdrop-blur pb-safe shrink-0">
227 + <button
228 + onClick={() => setShowMobileInfo(!showMobileInfo)}
229 + className="w-full flex items-center justify-center gap-2 h-11 text-[13px] text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
230 + aria-expanded={showMobileInfo}
231 + >
232 + <Info className="w-4 h-4" />
233 + <span>{showMobileInfo ? "Masquer les infos" : "Infos, tags & versions"}</span>
234 + <ChevronUp className={`w-4 h-4 transition-transform ${showMobileInfo ? "rotate-180" : ""}`} />
235 + </button>
236 + <AnimatePresence initial={false}>
237 + {showMobileInfo && (
238 + <motion.div initial={{ height: 0 }} animate={{ height: "auto" }} exit={{ height: 0 }} transition={{ duration: 0.2 }} className="overflow-hidden">
239 + <div className="max-h-[45dvh] overflow-auto border-t border-line dark:border-white/[0.07]">{infoPanel}</div>
240 + </motion.div>
241 + )}
242 + </AnimatePresence>
167 243 </div>
168 244 )}
169 245
170 − {/* Desktop info sidebar (metadata + versions) */}
171 − {((fullscreen && showInfo) || !fullscreen) && (
172 − <div className={`hidden lg:block ${fullscreen ? "w-72 border-l border-gray-200 dark:border-white/10 overflow-auto bg-gray-50 dark:bg-[#0a0a0a]" : "border-t border-gray-200 dark:border-white/10 overflow-auto max-h-64 lg:max-h-none"}`}>
173 − <MetadataEditor file={file} />
174 − <VersionHistory fileId={file.id} />
246 + {/* Desktop, panneau latéral (non plein écran) : infos sous l'aperçu */}
247 + {!fullscreen && (
248 + <div className="hidden lg:block border-t border-line dark:border-white/[0.07] overflow-auto max-h-[45%] shrink-0 bg-surface/60 dark:bg-transparent">
249 + {infoPanel}
175 250 </div>
176 251 )}
177 252 </motion.div>
modified components/preview/PreviewNavigation.tsx +10 −23
@@ -10,11 +10,7 @@ interface PreviewNavigationProps {
10 10 onNavigate: (file: FileData) => void;
11 11 }
12 12
13 −export default function PreviewNavigation({
14 − files,
15 − currentFile,
16 − onNavigate,
17 −}: PreviewNavigationProps) {
13 +export default function PreviewNavigation({ files, currentFile, onNavigate }: PreviewNavigationProps) {
18 14 const currentIndex = files.findIndex((f) => f.id === currentFile.id);
19 15
20 16 const goPrev = useCallback(() => {
@@ -27,15 +23,8 @@ export default function PreviewNavigation({
27 23
28 24 useEffect(() => {
29 25 const handleKey = (e: KeyboardEvent) => {
30 − // Don't capture if typing in an input / editable field
31 26 const t = e.target as HTMLElement | null;
32 − if (
33 − t instanceof HTMLInputElement ||
34 − t instanceof HTMLTextAreaElement ||
35 − t?.isContentEditable
36 − )
37 − return;
38 − // Flèches simples OU Alt+flèches : précédent / suivant
27 + if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || t?.isContentEditable) return;
39 28 if (e.key === "ArrowLeft") { e.preventDefault(); goPrev(); }
40 29 if (e.key === "ArrowRight") { e.preventDefault(); goNext(); }
41 30 };
@@ -45,26 +34,24 @@ export default function PreviewNavigation({
45 34
46 35 if (files.length <= 1) return null;
47 36
37 + const btn = "absolute top-1/2 -translate-y-1/2 z-10 w-11 h-11 sm:w-10 sm:h-10 rounded-full glass shadow-pop flex items-center justify-center text-gray-700 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-300 transition-all hover:scale-105 active:scale-95";
38 +
48 39 return (
49 40 <>
50 41 {currentIndex > 0 && (
51 − <button
52 − onClick={goPrev}
53 − className="absolute left-2 top-1/2 -translate-y-1/2 p-2 bg-black/50 rounded-full text-white hover:bg-black/70 transition-colors backdrop-blur-sm z-10"
54 − title="Précédent (Alt+←)"
55 − >
42 + <button onClick={goPrev} className={`${btn} left-3`} title="Précédent (←)" aria-label="Fichier précédent">
56 43 <ChevronLeft className="w-5 h-5" />
57 44 </button>
58 45 )}
59 46 {currentIndex < files.length - 1 && (
60 − <button
61 − onClick={goNext}
62 − className="absolute right-2 top-1/2 -translate-y-1/2 p-2 bg-black/50 rounded-full text-white hover:bg-black/70 transition-colors backdrop-blur-sm z-10"
63 − title="Suivant (Alt+→)"
64 − >
47 + <button onClick={goNext} className={`${btn} right-3`} title="Suivant (→)" aria-label="Fichier suivant">
65 48 <ChevronRight className="w-5 h-5" />
66 49 </button>
67 50 )}
51 + {/* Indicateur de position (mobile) */}
52 + <div className="sm:hidden absolute bottom-3 left-1/2 -translate-x-1/2 z-10 px-2.5 h-6 rounded-full glass text-[11px] text-gray-600 dark:text-gray-300 flex items-center tabular-nums pointer-events-none">
53 + {currentIndex + 1} / {files.length}
54 + </div>
68 55 </>
69 56 );
70 57 }
modified components/preview/PreviewToolbar.tsx +59 −55
@@ -1,6 +1,6 @@
1 1 "use client";
2 2
3 −import { X, Download, Maximize2, Minimize2, Edit3, Eye, Star } from "lucide-react";
3 +import { X, Download, Maximize2, Minimize2, Edit3, Eye, Star, Info, ChevronLeft } from "lucide-react";
4 4 import { formatFileSize, formatDate } from "@/lib/utils";
5 5 import type { FileData } from "@/types/files";
6 6
@@ -14,71 +14,75 @@ interface PreviewToolbarProps {
14 14 onToggleEdit: () => void;
15 15 onToggleFavorite?: () => void;
16 16 isFavorite?: boolean;
17 + onToggleInfo?: () => void;
18 + infoOpen?: boolean;
19 + position?: { index: number; total: number };
20 +}
21 +
22 +function IconBtn({ onClick, title, active, children, className = "", danger }: { onClick: () => void; title: string; active?: boolean; children: React.ReactNode; className?: string; danger?: boolean }) {
23 + return (
24 + <button
25 + onClick={onClick}
26 + title={title}
27 + aria-label={title}
28 + aria-pressed={active}
29 + className={`w-10 h-10 sm:w-9 sm:h-9 flex items-center justify-center rounded-xl transition-colors ${
30 + active
31 + ? "text-blue-600 dark:text-blue-300 bg-blue-500/10"
32 + : danger
33 + ? "text-gray-500 hover:text-rose-600 hover:bg-rose-500/10"
34 + : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10"
35 + } ${className}`}
36 + >
37 + {children}
38 + </button>
39 + );
17 40 }
18 41
19 42 export default function PreviewToolbar({
20 − file,
21 − fullscreen,
22 − isEditing,
23 − supportsEdit,
24 − onClose,
25 − onToggleFullscreen,
26 − onToggleEdit,
27 − onToggleFavorite,
28 − isFavorite,
43 + file, fullscreen, isEditing, supportsEdit, onClose, onToggleFullscreen, onToggleEdit, onToggleFavorite, isFavorite, onToggleInfo, infoOpen, position,
29 44 }: PreviewToolbarProps) {
30 45 return (
31 − <div className="flex items-center justify-between px-3 sm:px-4 py-2.5 sm:py-3 border-b border-gray-200 dark:border-white/10 flex-shrink-0 bg-white dark:bg-[#0a0a0a] transition-colors">
32 − <div className="flex-1 min-w-0 mr-2">
33 − <h3 className="text-sm font-medium text-gray-900 dark:text-white truncate max-w-[200px] sm:max-w-none">{file.name}</h3>
34 − <p className="text-xs text-gray-500">
35 − {formatFileSize(file.size)} - {formatDate(file.createdAt)}
46 + <div className="flex items-center gap-2 px-2 sm:px-3 h-14 border-b border-line dark:border-white/[0.07] shrink-0 glass rounded-none safe-top">
47 + {/* Retour (mobile) */}
48 + <button onClick={onClose} className="sm:hidden w-10 h-10 -ml-1 flex items-center justify-center rounded-xl text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10" aria-label="Fermer l'aperçu">
49 + <ChevronLeft className="w-5 h-5" />
50 + </button>
51 +
52 + <div className="flex-1 min-w-0">
53 + <h3 className="text-[14px] font-semibold text-gray-900 dark:text-white truncate tracking-tight" title={file.name}>{file.name}</h3>
54 + <p className="text-[11.5px] text-gray-500 dark:text-gray-400 truncate tabular-nums">
55 + {formatFileSize(file.size)} · {formatDate(file.updatedAt || file.createdAt)}
56 + {position && <span className="hidden sm:inline"> · {position.index}/{position.total}</span>}
57 + {isEditing && <span className="ml-1.5 text-blue-600 dark:text-blue-300 font-medium">· édition</span>}
36 58 </p>
37 59 </div>
38 − <div className="flex items-center gap-1.5">
60 +
61 + <div className="flex items-center gap-0.5">
39 62 {onToggleFavorite && (
40 − <button
41 − onClick={onToggleFavorite}
42 − className="p-2.5 transition-colors rounded-lg"
43 − title={isFavorite ? "Retirer des favoris" : "Ajouter aux favoris"}
44 − >
45 − <Star
46 − className={`w-4 h-4 ${isFavorite ? "text-yellow-400 fill-yellow-400" : "text-gray-400 hover:text-yellow-400"}`}
47 − />
48 − </button>
63 + <IconBtn onClick={onToggleFavorite} title={isFavorite ? "Retirer des favoris" : "Ajouter aux favoris"}>
64 + <Star className={`w-[18px] h-[18px] ${isFavorite ? "text-amber-400 fill-amber-400" : ""}`} />
65 + </IconBtn>
49 66 )}
50 67 {supportsEdit && (
51 − <button
52 − onClick={onToggleEdit}
53 − className={`p-2.5 transition-colors rounded-lg ${
54 − isEditing ? "text-blue-400 bg-blue-400/10" : "text-gray-400 hover:text-gray-900 dark:hover:text-white"
55 − }`}
56 − title={isEditing ? "Mode aperçu" : "Mode édition"}
57 − >
58 − {isEditing ? <Eye className="w-4 h-4" /> : <Edit3 className="w-4 h-4" />}
59 − </button>
68 + <IconBtn onClick={onToggleEdit} title={isEditing ? "Mode aperçu" : "Modifier"} active={isEditing}>
69 + {isEditing ? <Eye className="w-[18px] h-[18px]" /> : <Edit3 className="w-[18px] h-[18px]" />}
70 + </IconBtn>
71 + )}
72 + <IconBtn onClick={() => window.open(`/api/files/${file.id}/download`, "_blank")} title="Télécharger">
73 + <Download className="w-[18px] h-[18px]" />
74 + </IconBtn>
75 + {onToggleInfo && (
76 + <IconBtn onClick={onToggleInfo} title="Infos, tags & versions" active={infoOpen} className="hidden lg:flex">
77 + <Info className="w-[18px] h-[18px]" />
78 + </IconBtn>
60 79 )}
61 − <button
62 − onClick={() => window.open(`/api/files/${file.id}/download`, "_blank")}
63 − className="p-2.5 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors rounded-lg"
64 − title="Télécharger"
65 − >
66 − <Download className="w-4 h-4" />
67 − </button>
68 − <button
69 − onClick={onToggleFullscreen}
70 − className="p-2.5 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors hidden lg:flex items-center justify-center rounded-lg"
71 − title={fullscreen ? "Réduire" : "Plein écran"}
72 − >
73 − {fullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
74 − </button>
75 − <button
76 − onClick={onClose}
77 − className="p-2.5 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors rounded-lg"
78 − title="Fermer"
79 − >
80 − <X className="w-4 h-4" />
81 − </button>
80 + <IconBtn onClick={onToggleFullscreen} title={fullscreen ? "Réduire" : "Plein écran"} className="hidden lg:flex">
81 + {fullscreen ? <Minimize2 className="w-[18px] h-[18px]" /> : <Maximize2 className="w-[18px] h-[18px]" />}
82 + </IconBtn>
83 + <IconBtn onClick={onClose} title="Fermer (Échap)" className="hidden sm:flex">
84 + <X className="w-[18px] h-[18px]" />
85 + </IconBtn>
82 86 </div>
83 87 </div>
84 88 );
modified components/preview/VersionHistory.tsx +51 −56
@@ -1,10 +1,12 @@
1 1 "use client";
2 2
3 3 import { useState, useEffect, useCallback } from "react";
4 −import { Clock, Download, RotateCcw, GitBranch } from "lucide-react";
4 +import { Clock, Download, RotateCcw, GitBranch, Camera } from "lucide-react";
5 5 import { formatFileSize, formatDate } from "@/lib/utils";
6 6 import type { FileVersionData } from "@/types/files";
7 7 import toast from "react-hot-toast";
8 +import Modal from "../ui/Modal";
9 +import Button from "../ui/Button";
8 10
9 11 interface VersionHistoryProps {
10 12 fileId: string;
@@ -14,6 +16,8 @@ interface VersionHistoryProps {
14 16 export default function VersionHistory({ fileId, onRestore }: VersionHistoryProps) {
15 17 const [versions, setVersions] = useState<FileVersionData[]>([]);
16 18 const [loading, setLoading] = useState(true);
19 + const [busy, setBusy] = useState(false);
20 + const [confirm, setConfirm] = useState<{ id: string; num: number } | null>(null);
17 21
18 22 const loadVersions = useCallback(async () => {
19 23 try {
@@ -24,101 +28,92 @@ export default function VersionHistory({ fileId, onRestore }: VersionHistoryProp
24 28 }, [fileId]);
25 29
26 30 useEffect(() => {
31 + setLoading(true);
27 32 loadVersions();
28 33 }, [loadVersions]);
29 34
30 35 const createSnapshot = async () => {
36 + setBusy(true);
31 37 try {
32 38 const res = await fetch(`/api/files/${fileId}/versions`, {
33 39 method: "POST",
34 40 headers: { "Content-Type": "application/json" },
35 − body: JSON.stringify({ changeNote: "Manual snapshot" }),
41 + body: JSON.stringify({ changeNote: "Snapshot manuel" }),
36 42 });
37 43 if (res.ok) {
38 44 toast.success("Snapshot créé");
39 45 loadVersions();
40 − }
46 + } else toast.error("Impossible de créer le snapshot");
41 47 } catch {
42 48 toast.error("Erreur");
43 49 }
50 + setBusy(false);
44 51 };
45 52
46 − const restoreVersion = async (versionId: string, versionNum: number) => {
47 − if (!confirm(`Restaurer la version ${versionNum} ? La version actuelle sera sauvegardée.`)) return;
53 + const restoreVersion = async () => {
54 + if (!confirm) return;
55 + setBusy(true);
48 56 try {
49 − const res = await fetch(`/api/files/${fileId}/versions/${versionId}/restore`, {
50 − method: "POST",
51 − });
57 + const res = await fetch(`/api/files/${fileId}/versions/${confirm.id}/restore`, { method: "POST" });
52 58 if (res.ok) {
53 − toast.success(`Version ${versionNum} restaurée`);
59 + toast.success(`Version ${confirm.num} restaurée`);
54 60 loadVersions();
55 61 onRestore?.();
56 − }
62 + } else toast.error("Erreur de restauration");
57 63 } catch {
58 64 toast.error("Erreur de restauration");
59 65 }
66 + setBusy(false);
67 + setConfirm(null);
60 68 };
61 69
62 − if (loading) {
63 − return (
64 − <div className="p-4 flex justify-center">
65 − <div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" />
66 − </div>
67 − );
68 − }
69 −
70 70 return (
71 − <div className="p-4 border-t border-white/10">
71 + <section className="p-4 border-t border-line dark:border-white/[0.07]">
72 72 <div className="flex items-center justify-between mb-3">
73 − <h4 className="text-xs font-medium text-gray-400 flex items-center gap-1.5">
73 + <h4 className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 flex items-center gap-1.5">
74 74 <GitBranch className="w-3.5 h-3.5" />
75 − Versions ({versions.length})
75 + Versions {!loading && <span className="tabular-nums">({versions.length})</span>}
76 76 </h4>
77 − <button
78 − onClick={createSnapshot}
79 − className="text-[10px] px-2 py-1 bg-white/10 hover:bg-white/20 text-gray-300 rounded transition-colors"
80 − >
81 − Snapshot
82 − </button>
77 + <Button size="xs" variant="secondary" onClick={createSnapshot} loading={busy} title="Créer un snapshot de la version actuelle">
78 + <Camera className="w-3.5 h-3.5" /> Snapshot
79 + </Button>
83 80 </div>
84 81
85 − {versions.length === 0 ? (
86 − <p className="text-xs text-gray-600">Aucune version précédente</p>
82 + {loading ? (
83 + <div className="space-y-1.5">
84 + {[0, 1].map((i) => <div key={i} className="h-12 rounded-xl bg-gray-100 dark:bg-white/5 animate-pulse" />)}
85 + </div>
86 + ) : versions.length === 0 ? (
87 + <p className="text-[12px] text-gray-400">Aucune version précédente. Les modifications créent des versions automatiquement.</p>
87 88 ) : (
88 − <div className="space-y-1.5 max-h-60 overflow-auto">
89 + <ol className="relative space-y-1.5 max-h-60 overflow-auto">
89 90 {versions.map((v) => (
90 − <div
91 − key={v.id}
92 − className="flex items-center gap-2 p-2 bg-white/5 rounded-lg group hover:bg-white/10 transition-colors"
93 − >
94 − <Clock className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
91 + <li key={v.id} className="group flex items-center gap-2.5 p-2 rounded-xl bg-gray-50 dark:bg-white/[0.03] hover:bg-gray-100 dark:hover:bg-white/[0.06] border border-transparent hover:border-line dark:hover:border-white/10 transition-colors">
92 + <span className="w-8 h-8 rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-300 text-[11px] font-semibold flex items-center justify-center shrink-0 tabular-nums">v{v.versionNum}</span>
95 93 <div className="flex-1 min-w-0">
96 − <p className="text-xs text-gray-300">v{v.versionNum}</p>
97 − <p className="text-[10px] text-gray-500 truncate">
98 − {v.changeNote || "—"} · {formatFileSize(v.size)}
99 − </p>
100 − <p className="text-[10px] text-gray-600">{formatDate(v.createdAt)}</p>
94 + <p className="text-[12.5px] text-gray-800 dark:text-gray-100 truncate">{v.changeNote || "Sans note"}</p>
95 + <p className="text-[11px] text-gray-400 flex items-center gap-1 tabular-nums"><Clock className="w-3 h-3" />{formatDate(v.createdAt)} · {formatFileSize(v.size)}</p>
101 96 </div>
102 − <div className="hidden group-hover:flex items-center gap-1">
103 − <button
104 − onClick={() => window.open(`/api/files/${fileId}/versions/${v.id}`, "_blank")}
105 − className="p-1 text-gray-500 hover:text-white transition-colors"
106 − title="Télécharger cette version"
107 − >
108 − <Download className="w-3 h-3" />
97 + <div className="flex items-center gap-0.5 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
98 + <button onClick={() => window.open(`/api/files/${fileId}/versions/${v.id}`, "_blank")} className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200/60 dark:hover:bg-white/10 transition-colors" title="Télécharger cette version" aria-label="Télécharger">
99 + <Download className="w-3.5 h-3.5" />
109 100 </button>
110 − <button
111 − onClick={() => restoreVersion(v.id, v.versionNum)}
112 − className="p-1 text-gray-500 hover:text-orange-400 transition-colors"
113 − title="Restaurer cette version"
114 − >
115 − <RotateCcw className="w-3 h-3" />
101 + <button onClick={() => setConfirm({ id: v.id, num: v.versionNum })} className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:text-amber-600 hover:bg-amber-500/10 transition-colors" title="Restaurer cette version" aria-label="Restaurer">
102 + <RotateCcw className="w-3.5 h-3.5" />
116 103 </button>
117 104 </div>
118 − </div>
105 + </li>
119 106 ))}
120 − </div>
107 + </ol>
121 108 )}
122 − </div>
109 +
110 + <Modal isOpen={!!confirm} onClose={() => setConfirm(null)} title={`Restaurer la version ${confirm?.num ?? ""}`}>
111 + <p className="text-sm text-gray-600 dark:text-gray-300 mb-5">La version actuelle sera d&apos;abord sauvegardée comme nouvelle version, puis remplacée par la version {confirm?.num}.</p>
112 + <div className="flex gap-2 justify-end">
113 + <Button variant="ghost" onClick={() => setConfirm(null)}>Annuler</Button>
114 + <Button variant="accent" onClick={restoreVersion} loading={busy}><RotateCcw className="w-4 h-4" /> Restaurer</Button>
115 + </div>
116 + </Modal>
117 + </section>
123 118 );
124 119 }
modified components/preview/editors/MetadataEditor.tsx +74 −44
@@ -1,9 +1,10 @@
1 1 "use client";
2 2
3 −import { useState, useEffect, useCallback } from "react";
4 −import { Star, FileText, Tag } from "lucide-react";
3 +import { useState, useEffect, useCallback, useRef } from "react";
4 +import { Star, FileText, Tag, Check, Info, HardDrive, Calendar, Hash } from "lucide-react";
5 5 import TagSelector from "@/components/tags/TagSelector";
6 6 import type { FileData } from "@/types/files";
7 +import { formatFileSize, formatDate } from "@/lib/utils";
7 8 import toast from "react-hot-toast";
8 9
9 10 interface MetadataEditorProps {
@@ -13,9 +14,12 @@ interface MetadataEditorProps {
13 14
14 15 export default function MetadataEditor({ file, onUpdate }: MetadataEditorProps) {
15 16 const [description, setDescription] = useState(file.metadata?.description || "");
17 + const [savedDescription, setSavedDescription] = useState(file.metadata?.description || "");
16 18 const [isFavorite, setIsFavorite] = useState(file.metadata?.isFavorite || false);
17 19 const [tags, setTags] = useState(file.tags || []);
18 − const [, setSaving] = useState(false);
20 + const [saving, setSaving] = useState(false);
21 + const [justSaved, setJustSaved] = useState(false);
22 + const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
19 23
20 24 const loadMetadata = useCallback(async () => {
21 25 try {
@@ -26,93 +30,119 @@ export default function MetadataEditor({ file, onUpdate }: MetadataEditorProps)
26 30 if (metaRes.ok) {
27 31 const meta = await metaRes.json();
28 32 setDescription(meta.description || "");
29 − setIsFavorite(meta.isFavorite);
30 − }
31 − if (tagsRes.ok) {
32 − setTags(await tagsRes.json());
33 + setSavedDescription(meta.description || "");
34 + setIsFavorite(!!meta.isFavorite);
33 35 }
36 + if (tagsRes.ok) setTags(await tagsRes.json());
34 37 } catch {}
35 38 }, [file.id]);
36 39
37 40 useEffect(() => {
41 + setDescription(file.metadata?.description || "");
42 + setSavedDescription(file.metadata?.description || "");
43 + setIsFavorite(!!file.metadata?.isFavorite);
44 + setTags(file.tags || []);
38 45 loadMetadata();
39 − }, [loadMetadata]);
46 + }, [file.id, loadMetadata]); // eslint-disable-line react-hooks/exhaustive-deps
40 47
41 48 const saveDescription = useCallback(async () => {
49 + if (description === savedDescription) return;
42 50 setSaving(true);
43 51 try {
44 − await fetch(`/api/files/${file.id}/metadata`, {
52 + const res = await fetch(`/api/files/${file.id}/metadata`, {
45 53 method: "PATCH",
46 54 headers: { "Content-Type": "application/json" },
47 55 body: JSON.stringify({ description }),
48 56 });
49 − toast.success("Description sauvegardée");
57 + if (!res.ok) throw new Error();
58 + setSavedDescription(description);
59 + setJustSaved(true);
60 + if (savedTimer.current) clearTimeout(savedTimer.current);
61 + savedTimer.current = setTimeout(() => setJustSaved(false), 1800);
50 62 onUpdate?.();
51 63 } catch {
52 − toast.error("Erreur");
64 + toast.error("Impossible d'enregistrer la description");
53 65 }
54 66 setSaving(false);
55 − }, [file.id, description, onUpdate]);
67 + }, [file.id, description, savedDescription, onUpdate]);
56 68
57 69 const toggleFavorite = useCallback(async () => {
58 70 try {
59 71 const res = await fetch(`/api/files/${file.id}/favorite`, { method: "POST" });
60 72 if (res.ok) {
61 73 const meta = await res.json();
62 − setIsFavorite(meta.isFavorite);
74 + setIsFavorite(!!meta.isFavorite);
63 75 onUpdate?.();
64 76 }
65 77 } catch {}
66 78 }, [file.id, onUpdate]);
67 79
80 + const ext = file.name.includes(".") ? file.name.split(".").pop()?.toUpperCase() : null;
81 +
68 82 return (
69 − <div className="p-4 space-y-4 border-t border-white/10">
70 − {/* Favorite */}
71 − <div className="flex items-center justify-between">
72 − <span className="text-xs text-gray-400 flex items-center gap-1.5">
73 − <Star className="w-3.5 h-3.5" />
74 − Favori
75 − </span>
76 − <button
77 − onClick={toggleFavorite}
78 − className="p-1 transition-colors"
79 − >
80 − <Star
81 − className={`w-5 h-5 ${
82 − isFavorite ? "text-yellow-400 fill-yellow-400" : "text-gray-600 hover:text-yellow-400"
83 − }`}
84 − />
85 − </button>
83 + <section className="p-4 space-y-4">
84 + {/* Fiche */}
85 + <div>
86 + <h4 className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 flex items-center gap-1.5 mb-2">
87 + <Info className="w-3.5 h-3.5" /> Informations
88 + </h4>
89 + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-[12.5px]">
90 + <dt className="text-gray-400 flex items-center gap-1.5"><Hash className="w-3.5 h-3.5" /> Type</dt>
91 + <dd className="text-gray-800 dark:text-gray-100 truncate" title={file.mimeType}>{ext ? `${ext} · ` : ""}{file.mimeType}</dd>
92 + <dt className="text-gray-400 flex items-center gap-1.5"><HardDrive className="w-3.5 h-3.5" /> Taille</dt>
93 + <dd className="text-gray-800 dark:text-gray-100 tabular-nums">{formatFileSize(file.size)}</dd>
94 + <dt className="text-gray-400 flex items-center gap-1.5"><Calendar className="w-3.5 h-3.5" /> Ajouté</dt>
95 + <dd className="text-gray-800 dark:text-gray-100 tabular-nums">{formatDate(file.createdAt)}</dd>
96 + {file.updatedAt && file.updatedAt !== file.createdAt && (
97 + <>
98 + <dt className="text-gray-400 flex items-center gap-1.5"><Calendar className="w-3.5 h-3.5" /> Modifié</dt>
99 + <dd className="text-gray-800 dark:text-gray-100 tabular-nums">{formatDate(file.updatedAt)}</dd>
100 + </>
101 + )}
102 + </dl>
86 103 </div>
87 104
105 + {/* Favori */}
106 + <button
107 + onClick={toggleFavorite}
108 + className={`w-full flex items-center justify-between px-3 h-11 rounded-xl border transition-colors ${
109 + isFavorite ? "border-amber-400/50 bg-amber-400/10" : "border-line dark:border-white/10 hover:bg-gray-50 dark:hover:bg-white/5"
110 + }`}
111 + aria-pressed={isFavorite}
112 + >
113 + <span className="text-[13px] text-gray-700 dark:text-gray-200 flex items-center gap-2">
114 + <Star className={`w-4 h-4 ${isFavorite ? "text-amber-400 fill-amber-400" : "text-gray-400"}`} />
115 + {isFavorite ? "Dans vos favoris" : "Ajouter aux favoris"}
116 + </span>
117 + <span className={`text-[11px] font-medium ${isFavorite ? "text-amber-600 dark:text-amber-300" : "text-gray-400"}`}>{isFavorite ? "Retirer" : "Ajouter"}</span>
118 + </button>
119 +
88 120 {/* Description */}
89 121 <div>
90 − <label className="text-xs text-gray-400 flex items-center gap-1.5 mb-1.5">
91 − <FileText className="w-3.5 h-3.5" />
92 − Description
122 + <label className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 flex items-center justify-between mb-1.5">
123 + <span className="flex items-center gap-1.5"><FileText className="w-3.5 h-3.5" /> Description</span>
124 + <span className={`normal-case tracking-normal font-normal transition-opacity ${saving || justSaved ? "opacity-100" : "opacity-0"}`}>
125 + {saving ? "Enregistrement…" : <span className="text-emerald-600 dark:text-emerald-400 flex items-center gap-1"><Check className="w-3 h-3" /> Enregistré</span>}
126 + </span>
93 127 </label>
94 128 <textarea
95 129 value={description}
96 130 onChange={(e) => setDescription(e.target.value)}
97 131 onBlur={saveDescription}
98 − placeholder="Ajouter une description..."
132 + onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") (e.currentTarget as HTMLTextAreaElement).blur(); }}
133 + placeholder="Ajouter une description, des notes…"
99 134 rows={3}
100 − className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 placeholder:text-gray-600 resize-none focus:outline-none focus:border-white/20"
135 + className="field resize-none text-[13px] leading-relaxed"
101 136 />
102 137 </div>
103 138
104 139 {/* Tags */}
105 140 <div>
106 − <label className="text-xs text-gray-400 flex items-center gap-1.5 mb-1.5">
107 − <Tag className="w-3.5 h-3.5" />
108 − Tags
141 + <label className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 flex items-center gap-1.5 mb-1.5">
142 + <Tag className="w-3.5 h-3.5" /> Tags
109 143 </label>
110 − <TagSelector
111 − fileId={file.id}
112 − currentTags={tags}
113 − onTagsChange={loadMetadata}
114 − />
144 + <TagSelector fileId={file.id} currentTags={tags} onTagsChange={() => { loadMetadata(); onUpdate?.(); }} />
115 145 </div>
116 − </div>
146 + </section>
117 147 );
118 148 }
modified components/preview/plugins/CsvJsonPreview.tsx +7 −7
@@ -108,7 +108,7 @@ function CsvViewer({ content }: { content: string }) {
108 108 return (
109 109 <div className="flex flex-col h-full">
110 110 {/* Stats */}
111 − <div className="flex items-center gap-3 px-3 py-2 border-b border-white/10 bg-white/5 text-xs text-gray-400">
111 + <div className="flex items-center gap-3 px-3 py-2 border-b border-line dark:border-white/10 bg-white/5 text-xs text-gray-400">
112 112 <span>{rows.length} lignes</span>
113 113 <span>{headers.length} colonnes</span>
114 114 {filterText && <span>{filteredRows.length} résultats</span>}
@@ -119,13 +119,13 @@ function CsvViewer({ content }: { content: string }) {
119 119 <table className="w-full text-sm border-collapse">
120 120 <thead className="sticky top-0 z-10">
121 121 <tr>
122 − <th className="border border-white/10 px-2 py-1.5 text-left text-gray-500 bg-[#1a1a1a] text-xs w-10">
122 + <th className="border border-line dark:border-white/10 px-2 py-1.5 text-left text-gray-500 bg-gray-100 dark:bg-elevated text-xs w-10">
123 123 #
124 124 </th>
125 125 {headers.map((header, i) => (
126 126 <th
127 127 key={i}
128 − className="border border-white/10 px-3 py-1.5 text-left text-gray-300 bg-[#1a1a1a] text-xs"
128 + className="border border-line dark:border-white/10 px-3 py-1.5 text-left text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-elevated text-xs"
129 129 >
130 130 <div className="flex items-center gap-1">
131 131 <button
@@ -151,7 +151,7 @@ function CsvViewer({ content }: { content: string }) {
151 151 value={filterText}
152 152 onChange={(e) => { setFilterText(e.target.value); setPage(0); }}
153 153 placeholder="Filtrer..."
154 − className="mt-1 w-full bg-white/10 border border-white/10 rounded px-2 py-0.5 text-xs text-white placeholder:text-gray-500"
154 + className="mt-1 w-full bg-white/10 border border-line dark:border-white/10 rounded px-2 py-0.5 text-xs text-white placeholder:text-gray-500"
155 155 autoFocus
156 156 />
157 157 )}
@@ -162,11 +162,11 @@ function CsvViewer({ content }: { content: string }) {
162 162 <tbody>
163 163 {pageRows.map((row, i) => (
164 164 <tr key={i} className="hover:bg-white/5">
165 − <td className="border border-white/10 px-2 py-1 text-gray-600 text-xs">
165 + <td className="border border-line dark:border-white/10 px-2 py-1 text-gray-600 text-xs">
166 166 {page * pageSize + i + 1}
167 167 </td>
168 168 {row.map((cell, j) => (
169 − <td key={j} className="border border-white/10 px-3 py-1 text-gray-400 text-xs">
169 + <td key={j} className="border border-line dark:border-white/10 px-3 py-1 text-gray-400 text-xs">
170 170 {cell}
171 171 </td>
172 172 ))}
@@ -178,7 +178,7 @@ function CsvViewer({ content }: { content: string }) {
178 178
179 179 {/* Pagination */}
180 180 {totalPages > 1 && (
181 − <div className="flex items-center justify-center gap-2 px-3 py-2 border-t border-white/10 bg-white/5">
181 + <div className="flex items-center justify-center gap-2 px-3 py-2 border-t border-line dark:border-white/10 bg-white/5">
182 182 <button
183 183 onClick={() => setPage((p) => Math.max(0, p - 1))}
184 184 disabled={page === 0}
modified components/preview/plugins/MarkdownPreview.tsx +1 −1
@@ -37,7 +37,7 @@ export default function MarkdownPreview({ file }: PreviewPluginProps) {
37 37 prose-a:text-blue-400
38 38 prose-strong:text-gray-200
39 39 prose-code:text-green-400 prose-code:bg-white/10 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs
40 − prose-pre:bg-[#1a1a1a] prose-pre:border prose-pre:border-white/10
40 + prose-pre:bg-gray-900 prose-pre:border prose-pre:border-white/10
41 41 prose-blockquote:border-gray-600
42 42 prose-table:border-collapse
43 43 prose-th:border prose-th:border-white/10 prose-th:bg-white/5 prose-th:px-3 prose-th:py-2
modified components/preview/plugins/PdfPreview.tsx +1 −1
@@ -379,7 +379,7 @@ export default function PdfPreview({ file, isEditing }: PreviewPluginProps) {
379 379 </div>
380 380
381 381 {/* Canvas + Overlay */}
382 − <div ref={containerRef} className="flex-1 overflow-auto flex justify-center p-4 bg-gray-900/50">
382 + <div ref={containerRef} className="flex-1 overflow-auto flex justify-center p-4 bg-gray-200/60 dark:bg-gray-900/50">
383 383 {loading ? (
384 384 <div className="flex items-center justify-center h-full">
385 385 <div className="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin" />
modified components/preview/plugins/WordPreview.tsx +2 −2
@@ -111,9 +111,9 @@ export default function WordPreview({ file, isEditing, onSave }: PreviewPluginPr
111 111 }
112 112
113 113 return (
114 − <div className="h-full overflow-auto bg-gray-50 dark:bg-[#1a1a1a]">
114 + <div className="h-full overflow-auto bg-gray-100 dark:bg-canvas">
115 115 <div className="max-w-3xl mx-auto my-6 px-4 sm:px-6">
116 − <div className="bg-white dark:bg-[#242424] shadow-md rounded-sm px-8 py-10 sm:px-14 sm:py-12 min-h-[500px]">
116 + <div className="bg-white dark:bg-elevated shadow-md rounded-sm px-8 py-10 sm:px-14 sm:py-12 min-h-[500px]">
117 117 <div className="flex items-center justify-between mb-6 pb-4 border-b border-gray-100 dark:border-white/10">
118 118 <div className="flex items-center gap-2">
119 119 <FileText className="w-4 h-4 text-blue-500" />
modified components/preview/plugins/WordTiptapEditor.tsx +3 −3
@@ -84,9 +84,9 @@ export default function WordTiptapEditor({ initialHtml, onSave }: WordTiptapEdit
84 84 );
85 85
86 86 return (
87 − <div className="absolute inset-0 flex flex-col bg-gray-50 dark:bg-[#1a1a1a]">
87 + <div className="absolute inset-0 flex flex-col bg-gray-100 dark:bg-canvas">
88 88 {/* Toolbar — sticky so it never scrolls out of view */}
89 − <div className="flex items-center gap-0.5 px-3 py-2 bg-white dark:bg-[#242424] border-b border-gray-200 dark:border-white/10 overflow-x-auto shrink-0 flex-nowrap">
89 + <div className="flex items-center gap-0.5 px-3 py-2 bg-white dark:bg-elevated border-b border-gray-200 dark:border-white/10 overflow-x-auto shrink-0 flex-nowrap">
90 90 {btn(editor.isActive("bold"), () => editor.chain().focus().toggleBold().run(), "Gras", <Bold className="w-3.5 h-3.5" />)}
91 91 {btn(editor.isActive("italic"), () => editor.chain().focus().toggleItalic().run(), "Italique", <Italic className="w-3.5 h-3.5" />)}
92 92 {btn(editor.isActive("underline"), () => editor.chain().focus().toggleUnderline().run(), "Souligné", <Underline className="w-3.5 h-3.5" />)}
@@ -138,7 +138,7 @@ export default function WordTiptapEditor({ initialHtml, onSave }: WordTiptapEdit
138 138 {/* Editor content area */}
139 139 <div className="flex-1 overflow-auto py-6">
140 140 <div className="max-w-3xl mx-auto px-4 sm:px-6">
141 − <div className="bg-white dark:bg-[#242424] shadow-md rounded-sm px-8 py-10 sm:px-14 sm:py-12 min-h-[500px]">
141 + <div className="bg-white dark:bg-elevated shadow-md rounded-sm px-8 py-10 sm:px-14 sm:py-12 min-h-[500px]">
142 142 <EditorContent editor={editor} />
143 143 </div>
144 144 </div>
modified components/tags/TagBadge.tsx +8 −13
@@ -12,23 +12,18 @@ interface TagBadgeProps {
12 12 export default function TagBadge({ name, color, onRemove, size = "sm" }: TagBadgeProps) {
13 13 return (
14 14 <span
15 − className={`inline-flex items-center gap-1 rounded-full border border-white/10 ${
16 − size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-xs"
15 + className={`inline-flex items-center gap-1.5 rounded-full font-medium leading-none ring-1 ring-inset ${
16 + size === "sm" ? "px-2 py-1 text-[10.5px]" : "px-2.5 py-1.5 text-xs"
17 17 }`}
18 − style={{ backgroundColor: `${color}20`, color }}
18 + style={{ backgroundColor: `${color}1f`, color, boxShadow: `inset 0 0 0 1px ${color}33` }}
19 19 >
20 − <span
21 − className={`rounded-full ${size === "sm" ? "w-1.5 h-1.5" : "w-2 h-2"}`}
22 − style={{ backgroundColor: color }}
23 − />
24 − {name}
20 + <span className={`rounded-full shrink-0 ${size === "sm" ? "w-1.5 h-1.5" : "w-2 h-2"}`} style={{ backgroundColor: color }} />
21 + <span className="truncate max-w-[9rem]">{name}</span>
25 22 {onRemove && (
26 23 <button
27 − onClick={(e) => {
28 − e.stopPropagation();
29 − onRemove();
30 − }}
31 − className="hover:opacity-70 transition-opacity"
24 + onClick={(e) => { e.stopPropagation(); onRemove(); }}
25 + className="-mr-0.5 rounded-full hover:bg-black/10 dark:hover:bg-white/15 p-0.5 transition-colors"
26 + aria-label={`Retirer le tag ${name}`}
32 27 >
33 28 <X className={size === "sm" ? "w-2.5 h-2.5" : "w-3 h-3"} />
34 29 </button>
modified components/tags/TagSelector.tsx +79 −63
@@ -1,7 +1,8 @@
1 1 "use client";
2 2
3 3 import { useState, useEffect, useRef } from "react";
4 −import { Plus } from "lucide-react";
4 +import { Plus, Check } from "lucide-react";
5 +import { motion, AnimatePresence } from "framer-motion";
5 6 import TagBadge from "./TagBadge";
6 7
7 8 interface TagData {
@@ -25,7 +26,8 @@ export default function TagSelector({ fileId, currentTags, onTagsChange }: TagSe
25 26 const [allTags, setAllTags] = useState<TagData[]>([]);
26 27 const [isOpen, setIsOpen] = useState(false);
27 28 const [newTagName, setNewTagName] = useState("");
28 − const [newTagColor, setNewTagColor] = useState(TAG_COLORS[0]);
29 + const [newTagColor, setNewTagColor] = useState(TAG_COLORS[5]);
30 + const [busy, setBusy] = useState(false);
29 31 const ref = useRef<HTMLDivElement>(null);
30 32
31 33 useEffect(() => {
@@ -37,9 +39,7 @@ export default function TagSelector({ fileId, currentTags, onTagsChange }: TagSe
37 39
38 40 useEffect(() => {
39 41 const handleClick = (e: MouseEvent) => {
40 − if (ref.current && !ref.current.contains(e.target as Node)) {
41 − setIsOpen(false);
42 − }
42 + if (ref.current && !ref.current.contains(e.target as Node)) setIsOpen(false);
43 43 };
44 44 document.addEventListener("mousedown", handleClick);
45 45 return () => document.removeEventListener("mousedown", handleClick);
@@ -49,11 +49,13 @@ export default function TagSelector({ fileId, currentTags, onTagsChange }: TagSe
49 49 const availableTags = allTags.filter((t) => !currentTagIds.includes(t.id));
50 50
51 51 const addTag = async (tagId: string) => {
52 + setBusy(true);
52 53 await fetch(`/api/files/${fileId}/tags`, {
53 54 method: "POST",
54 55 headers: { "Content-Type": "application/json" },
55 56 body: JSON.stringify({ tagId }),
56 57 });
58 + setBusy(false);
57 59 onTagsChange();
58 60 };
59 61
@@ -63,13 +65,16 @@ export default function TagSelector({ fileId, currentTags, onTagsChange }: TagSe
63 65 };
64 66
65 67 const createAndAddTag = async () => {
66 − if (!newTagName.trim()) return;
68 + if (!newTagName.trim() || busy) return;
69 + setBusy(true);
67 70 const res = await fetch("/api/tags", {
68 71 method: "POST",
69 72 headers: { "Content-Type": "application/json" },
70 73 body: JSON.stringify({ name: newTagName.trim(), color: newTagColor }),
71 74 });
72 75 const tag = await res.json();
76 + setBusy(false);
77 + if (!tag?.id) return;
73 78 setAllTags((prev) => [...prev, tag]);
74 79 await addTag(tag.id);
75 80 setNewTagName("");
@@ -80,76 +85,87 @@ export default function TagSelector({ fileId, currentTags, onTagsChange }: TagSe
80 85 <div ref={ref} className="relative">
81 86 <div className="flex flex-wrap gap-1.5 items-center">
82 87 {currentTags.map((ft) => (
83 − <TagBadge
84 − key={ft.id}
85 − name={ft.tag.name}
86 − color={ft.tag.color}
87 − onRemove={() => removeTag(ft.tag.id)}
88 − size="md"
89 − />
88 + <TagBadge key={ft.id} name={ft.tag.name} color={ft.tag.color} onRemove={() => removeTag(ft.tag.id)} size="md" />
90 89 ))}
91 90 <button
92 91 onClick={() => setIsOpen(!isOpen)}
93 − className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:text-white border border-dashed border-white/20 hover:border-white/40 rounded-full transition-colors"
92 + className={`inline-flex items-center gap-1 px-2.5 h-7 text-xs rounded-full border border-dashed transition-colors ${
93 + isOpen
94 + ? "border-blue-500 text-blue-600 dark:text-blue-300 bg-blue-500/10"
95 + : "border-gray-300 dark:border-white/20 text-gray-500 hover:text-gray-900 dark:hover:text-white hover:border-gray-400 dark:hover:border-white/40"
96 + }`}
94 97 >
95 98 <Plus className="w-3 h-3" />
96 99 Tag
97 100 </button>
98 101 </div>
99 102
100 − {isOpen && (
101 − <div className="absolute top-full left-0 mt-2 w-56 bg-[#1a1a1a] border border-white/10 rounded-lg shadow-xl z-50 p-2">
102 − {availableTags.length > 0 && (
103 − <div className="mb-2">
104 − <p className="text-[10px] text-gray-500 uppercase tracking-wider px-2 mb-1">Tags existants</p>
105 − {availableTags.map((tag) => (
106 − <button
107 − key={tag.id}
108 − onClick={() => addTag(tag.id)}
109 − className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-gray-300 hover:bg-white/10 rounded transition-colors"
110 − >
111 − <span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: tag.color }} />
112 − {tag.name}
113 − </button>
114 − ))}
115 − </div>
116 − )}
103 + <AnimatePresence>
104 + {isOpen && (
105 + <motion.div
106 + initial={{ opacity: 0, y: 4, scale: 0.98 }}
107 + animate={{ opacity: 1, y: 0, scale: 1 }}
108 + exit={{ opacity: 0, y: 4, scale: 0.98 }}
109 + transition={{ duration: 0.15 }}
110 + className="absolute top-full left-0 mt-2 w-64 glass rounded-2xl shadow-pop z-50 p-2"
111 + >
112 + {availableTags.length > 0 && (
113 + <div className="mb-2">
114 + <p className="text-[10.5px] font-semibold text-gray-400 uppercase tracking-[0.12em] px-2 py-1">Tags existants</p>
115 + <div className="max-h-40 overflow-auto scrollbar-thin">
116 + {availableTags.map((tag) => (
117 + <button
118 + key={tag.id}
119 + onClick={() => addTag(tag.id)}
120 + disabled={busy}
121 + className="flex items-center gap-2.5 w-full px-2 h-9 text-[13px] text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.08] rounded-lg transition-colors disabled:opacity-50"
122 + >
123 + <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: tag.color }} />
124 + <span className="truncate">{tag.name}</span>
125 + </button>
126 + ))}
127 + </div>
128 + </div>
129 + )}
117 130
118 − <div className="border-t border-white/10 pt-2">
119 − <p className="text-[10px] text-gray-500 uppercase tracking-wider px-2 mb-1">Nouveau tag</p>
120 − <div className="flex items-center gap-1 px-1">
121 − <input
122 − type="text"
123 − value={newTagName}
124 − onChange={(e) => setNewTagName(e.target.value)}
125 − onKeyDown={(e) => e.key === "Enter" && createAndAddTag()}
126 − placeholder="Nom..."
127 − className="flex-1 bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-white placeholder:text-gray-600"
128 − autoFocus
129 − />
130 − <div className="flex gap-0.5">
131 − {TAG_COLORS.slice(0, 4).map((c) => (
131 + <div className={availableTags.length > 0 ? "border-t border-line dark:border-white/10 pt-2" : ""}>
132 + <p className="text-[10.5px] font-semibold text-gray-400 uppercase tracking-[0.12em] px-2 py-1">Nouveau tag</p>
133 + <div className="px-1 space-y-2">
134 + <input
135 + type="text"
136 + value={newTagName}
137 + onChange={(e) => setNewTagName(e.target.value)}
138 + onKeyDown={(e) => e.key === "Enter" && createAndAddTag()}
139 + placeholder="Nom du tag…"
140 + className="field h-9 !py-1.5 text-xs"
141 + autoFocus
142 + />
143 + <div className="flex items-center gap-1.5">
144 + {TAG_COLORS.map((c) => (
145 + <button
146 + key={c}
147 + onClick={() => setNewTagColor(c)}
148 + className="w-6 h-6 rounded-full flex items-center justify-center transition-transform hover:scale-110"
149 + style={{ backgroundColor: c }}
150 + aria-label={`Couleur ${c}`}
151 + >
152 + {newTagColor === c && <Check className="w-3 h-3 text-white drop-shadow" />}
153 + </button>
154 + ))}
132 155 <button
133 − key={c}
134 − onClick={() => setNewTagColor(c)}
135 − className={`w-4 h-4 rounded-full border-2 ${
136 − newTagColor === c ? "border-white" : "border-transparent"
137 − }`}
138 − style={{ backgroundColor: c }}
139 − />
140 − ))}
156 + onClick={createAndAddTag}
157 + disabled={!newTagName.trim() || busy}
158 + className="ml-auto w-8 h-8 rounded-lg bg-accent-gradient text-white flex items-center justify-center disabled:opacity-40 transition-opacity"
159 + aria-label="Créer le tag"
160 + >
161 + <Plus className="w-4 h-4" />
162 + </button>
163 + </div>
141 164 </div>
142 − <button
143 − onClick={createAndAddTag}
144 − disabled={!newTagName.trim()}
145 − className="p-1 text-gray-400 hover:text-white disabled:opacity-30"
146 − >
147 − <Plus className="w-3.5 h-3.5" />
148 − </button>
149 165 </div>
150 − </div>
151 − </div>
152 − )}
166 + </motion.div>
167 + )}
168 + </AnimatePresence>
153 169 </div>
154 170 );
155 171 }
modified components/theme/DynamicToaster.tsx +14 −13
@@ -1,27 +1,28 @@
1 1 "use client";
2 2
3 3 import { Toaster } from "react-hot-toast";
4 −import { useTheme } from "./ThemeProvider";
5 4
6 5 export function DynamicToaster() {
7 − const { theme } = useTheme();
8 −
9 6 return (
10 7 <Toaster
11 8 position="bottom-right"
9 + gutter={8}
10 + containerStyle={{ bottom: "calc(1rem + env(safe-area-inset-bottom, 0px))" }}
12 11 toastOptions={{
12 + duration: 3200,
13 13 style: {
14 − background: theme === "dark" ? "#1f2937" : "#ffffff",
15 − color: theme === "dark" ? "#e5e7eb" : "#111827",
16 − border: theme === "dark"
17 − ? "1px solid rgba(255,255,255,0.1)"
18 − : "1px solid rgba(0,0,0,0.1)",
19 − borderRadius: "12px",
20 − fontSize: "14px",
21 − boxShadow: theme === "dark"
22 − ? "0 4px 12px rgba(0,0,0,0.5)"
23 − : "0 4px 12px rgba(0,0,0,0.1)",
14 + background: "var(--toast-bg)",
15 + color: "var(--toast-color)",
16 + border: "1px solid var(--toast-border)",
17 + borderRadius: "14px",
18 + fontSize: "13.5px",
19 + fontWeight: 500,
20 + padding: "10px 14px",
21 + boxShadow: "var(--toast-shadow)",
22 + maxWidth: "min(92vw, 420px)",
24 23 },
24 + success: { iconTheme: { primary: "rgb(16 185 129)", secondary: "#fff" } },
25 + error: { iconTheme: { primary: "rgb(244 63 94)", secondary: "#fff" } },
25 26 }}
26 27 />
27 28 );
added components/theme/PreferencesModal.tsx +116 −0
@@ -0,0 +1,116 @@
1 +"use client";
2 +
3 +import { Sun, Moon, Monitor, Check, Rows3, AlignJustify, Zap, ZapOff } from "lucide-react";
4 +import Modal from "../ui/Modal";
5 +import { useTheme } from "./ThemeProvider";
6 +import { useShell } from "../layout/ShellContext";
7 +import { ACCENTS, type ThemePref } from "@/lib/prefs";
8 +
9 +const themeOptions: { id: ThemePref; label: string; icon: typeof Sun }[] = [
10 + { id: "light", label: "Clair", icon: Sun },
11 + { id: "dark", label: "Sombre", icon: Moon },
12 + { id: "system", label: "Système", icon: Monitor },
13 +];
14 +
15 +export default function PreferencesModal() {
16 + const { prefsOpen, setPrefsOpen } = useShell();
17 + const { themePref, setThemePref, accent, setAccent, density, setDensity, reduceMotion, setReduceMotion } = useTheme();
18 +
19 + return (
20 + <Modal isOpen={prefsOpen} onClose={() => setPrefsOpen(false)} title="Apparence" description="Vos préférences sont enregistrées sur cet appareil." maxWidth="max-w-lg">
21 + <div className="space-y-6">
22 + {/* Thème */}
23 + <section>
24 + <h3 className="text-[12px] font-semibold uppercase tracking-[0.12em] text-gray-400 mb-2.5">Thème</h3>
25 + <div className="grid grid-cols-3 gap-2">
26 + {themeOptions.map((o) => {
27 + const on = themePref === o.id;
28 + return (
29 + <button
30 + key={o.id}
31 + onClick={() => setThemePref(o.id)}
32 + className={`relative flex flex-col items-center gap-2 p-3 rounded-2xl border transition-all ${on ? "border-blue-500 bg-blue-500/10 shadow-glow" : "border-line dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20"}`}
33 + >
34 + <span className={`w-full h-12 rounded-xl overflow-hidden border border-black/5 dark:border-white/10 ${o.id === "light" ? "bg-[#f5f6f8]" : o.id === "dark" ? "bg-[#0a0b10]" : "bg-gradient-to-r from-[#f5f6f8] to-[#0a0b10]"}`}>
35 + <span className={`block mt-2 ml-2 w-1/2 h-2 rounded-full ${o.id === "dark" ? "bg-white/20" : "bg-black/10"}`} />
36 + <span className={`block mt-1.5 ml-2 w-2/3 h-2 rounded-full ${o.id === "dark" ? "bg-white/10" : "bg-black/5"}`} />
37 + </span>
38 + <span className="flex items-center gap-1.5 text-[13px] font-medium text-gray-800 dark:text-gray-100"><o.icon className="w-3.5 h-3.5" />{o.label}</span>
39 + {on && <Check className="absolute top-2 right-2 w-4 h-4 text-blue-600 dark:text-blue-300" />}
40 + </button>
41 + );
42 + })}
43 + </div>
44 + </section>
45 +
46 + {/* Accent */}
47 + <section>
48 + <h3 className="text-[12px] font-semibold uppercase tracking-[0.12em] text-gray-400 mb-2.5">Couleur d&apos;accent</h3>
49 + <div className="flex flex-wrap gap-2.5">
50 + {ACCENTS.map((a) => {
51 + const on = accent === a.id;
52 + return (
53 + <button
54 + key={a.id}
55 + onClick={() => setAccent(a.id)}
56 + title={`${a.label} — ${a.hint}`}
57 + className={`group flex items-center gap-2 pl-1.5 pr-3 h-10 rounded-full border transition-all ${on ? "border-gray-900 dark:border-white bg-gray-900/5 dark:bg-white/10" : "border-line dark:border-white/10 hover:border-gray-300 dark:hover:border-white/25"}`}
58 + >
59 + <span className="w-7 h-7 rounded-full flex items-center justify-center shadow-inner" style={{ background: `linear-gradient(135deg, ${a.hex}, ${a.hex}cc)` }}>
60 + {on && <Check className="w-4 h-4 text-white drop-shadow" />}
61 + </span>
62 + <span className="text-[13px] font-medium text-gray-800 dark:text-gray-100">{a.label}</span>
63 + </button>
64 + );
65 + })}
66 + </div>
67 + </section>
68 +
69 + {/* Densité */}
70 + <section>
71 + <h3 className="text-[12px] font-semibold uppercase tracking-[0.12em] text-gray-400 mb-2.5">Densité des listes</h3>
72 + <div className="grid grid-cols-2 gap-2">
73 + {[
74 + { id: "comfortable" as const, label: "Confortable", icon: Rows3, hint: "Lignes aérées" },
75 + { id: "compact" as const, label: "Compacte", icon: AlignJustify, hint: "Plus de fichiers à l'écran" },
76 + ].map((d) => {
77 + const on = density === d.id;
78 + return (
79 + <button key={d.id} onClick={() => setDensity(d.id)} className={`flex items-center gap-3 p-3 rounded-2xl border text-left transition-all ${on ? "border-blue-500 bg-blue-500/10" : "border-line dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20"}`}>
80 + <d.icon className={`w-5 h-5 ${on ? "text-blue-600 dark:text-blue-300" : "text-gray-400"}`} />
81 + <span>
82 + <span className="block text-[13px] font-medium text-gray-800 dark:text-gray-100">{d.label}</span>
83 + <span className="block text-[11.5px] text-gray-400">{d.hint}</span>
84 + </span>
85 + </button>
86 + );
87 + })}
88 + </div>
89 + </section>
90 +
91 + {/* Mouvement */}
92 + <section>
93 + <button
94 + onClick={() => setReduceMotion(!reduceMotion)}
95 + className="w-full flex items-center gap-3 p-3 rounded-2xl border border-line dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20 transition-colors text-left"
96 + role="switch"
97 + aria-checked={reduceMotion}
98 + >
99 + {reduceMotion ? <ZapOff className="w-5 h-5 text-gray-400" /> : <Zap className="w-5 h-5 text-blue-500" />}
100 + <span className="flex-1">
101 + <span className="block text-[13px] font-medium text-gray-800 dark:text-gray-100">Réduire les animations</span>
102 + <span className="block text-[11.5px] text-gray-400">Désactive les transitions et effets de mouvement</span>
103 + </span>
104 + <span className={`relative w-11 h-6 rounded-full transition-colors ${reduceMotion ? "bg-blue-500" : "bg-gray-300 dark:bg-white/15"}`}>
105 + <span className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${reduceMotion ? "translate-x-[22px]" : "translate-x-0.5"}`} />
106 + </span>
107 + </button>
108 + </section>
109 +
110 + <p className="text-[11.5px] text-gray-400 text-center">
111 + Raccourcis : <kbd>⌘K</kbd> palette · <kbd>⌘B</kbd> barre latérale · <kbd>⌘,</kbd> préférences · <kbd>/</kbd> recherche
112 + </p>
113 + </div>
114 + </Modal>
115 + );
116 +}
modified components/theme/ThemeProvider.tsx +123 −22
@@ -1,60 +1,161 @@
1 1 "use client";
2 2
3 −import { createContext, useContext, useEffect, useState } from "react";
3 +import { createContext, useContext, useEffect, useMemo, useState, useCallback } from "react";
4 +import { MotionConfig } from "framer-motion";
5 +import {
6 + ACCENTS,
7 + DEFAULT_ACCENT,
8 + DEFAULT_THEME,
9 + PREF_KEYS,
10 + isAccentId,
11 + type AccentId,
12 + type DensityPref,
13 + type ThemePref,
14 +} from "@/lib/prefs";
4 15
5 16 type Theme = "dark" | "light";
6 17
7 18 interface ThemeContextType {
19 + /** Thème effectif (résolu) */
8 20 theme: Theme;
21 + /** Préférence brute (light | dark | system) */
22 + themePref: ThemePref;
23 + setThemePref: (t: ThemePref) => void;
9 24 toggleTheme: () => void;
25 + accent: AccentId;
26 + setAccent: (a: AccentId) => void;
27 + density: DensityPref;
28 + setDensity: (d: DensityPref) => void;
29 + reduceMotion: boolean;
30 + setReduceMotion: (v: boolean) => void;
31 + mounted: boolean;
10 32 }
11 33
12 34 const ThemeContext = createContext<ThemeContextType>({
13 35 theme: "light",
36 + themePref: "light",
37 + setThemePref: () => {},
14 38 toggleTheme: () => {},
39 + accent: DEFAULT_ACCENT,
40 + setAccent: () => {},
41 + density: "comfortable",
42 + setDensity: () => {},
43 + reduceMotion: false,
44 + setReduceMotion: () => {},
45 + mounted: false,
15 46 });
16 47
17 48 export function useTheme() {
18 49 return useContext(ThemeContext);
19 50 }
20 51
52 +function systemPrefersDark(): boolean {
53 + return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
54 +}
55 +
56 +function resolveTheme(pref: ThemePref): Theme {
57 + if (pref === "system") return systemPrefersDark() ? "dark" : "light";
58 + return pref;
59 +}
60 +
21 61 export default function ThemeProvider({ children }: { children: React.ReactNode }) {
62 + const [themePref, setThemePrefState] = useState<ThemePref>(DEFAULT_THEME);
22 63 const [theme, setTheme] = useState<Theme>("light");
64 + const [accent, setAccentState] = useState<AccentId>(DEFAULT_ACCENT);
65 + const [density, setDensityState] = useState<DensityPref>("comfortable");
66 + const [reduceMotion, setReduceMotionState] = useState(false);
23 67 const [mounted, setMounted] = useState(false);
24 68
69 + // Lecture initiale (le script inline de layout.tsx a déjà appliqué les classes)
25 70 useEffect(() => {
26 − const stored = localStorage.getItem("spb-theme") as Theme | null;
27 − if (stored === "light" || stored === "dark") {
28 − setTheme(stored);
29 − }
71 + try {
72 + const t = localStorage.getItem(PREF_KEYS.theme);
73 + const pref: ThemePref = t === "dark" || t === "light" || t === "system" ? t : DEFAULT_THEME;
74 + setThemePrefState(pref);
75 + setTheme(resolveTheme(pref));
76 + const a = localStorage.getItem(PREF_KEYS.accent);
77 + if (isAccentId(a)) setAccentState(a);
78 + const d = localStorage.getItem(PREF_KEYS.density);
79 + if (d === "compact" || d === "comfortable") setDensityState(d);
80 + const m = localStorage.getItem(PREF_KEYS.motion);
81 + setReduceMotionState(m === "reduced");
82 + } catch {}
30 83 setMounted(true);
31 84 }, []);
32 85
86 + // Suivre le système si "system"
87 + useEffect(() => {
88 + if (themePref !== "system") return;
89 + const mq = window.matchMedia("(prefers-color-scheme: dark)");
90 + const onChange = () => setTheme(mq.matches ? "dark" : "light");
91 + mq.addEventListener("change", onChange);
92 + return () => mq.removeEventListener("change", onChange);
93 + }, [themePref]);
94 +
95 + // Appliquer au DOM
33 96 useEffect(() => {
34 97 if (!mounted) return;
35 98 const root = document.documentElement;
36 − if (theme === "dark") {
37 − root.classList.add("dark");
38 − root.classList.remove("light");
39 − } else {
40 − root.classList.remove("dark");
41 − root.classList.add("light");
42 − }
43 − localStorage.setItem("spb-theme", theme);
99 + root.classList.toggle("dark", theme === "dark");
100 + root.classList.toggle("light", theme !== "dark");
101 + root.style.colorScheme = theme;
102 + const meta = document.querySelector('meta[name="theme-color"]');
103 + if (meta) meta.setAttribute("content", theme === "dark" ? "#0a0b10" : "#f5f6f8");
44 104 }, [theme, mounted]);
45 105
46 − const toggleTheme = () => {
47 − setTheme((prev) => (prev === "dark" ? "light" : "dark"));
48 − };
106 + useEffect(() => {
107 + if (!mounted) return;
108 + document.documentElement.setAttribute("data-accent", accent);
109 + }, [accent, mounted]);
110 +
111 + useEffect(() => {
112 + if (!mounted) return;
113 + document.documentElement.setAttribute("data-density", density);
114 + }, [density, mounted]);
115 +
116 + useEffect(() => {
117 + if (!mounted) return;
118 + document.documentElement.setAttribute("data-motion", reduceMotion ? "reduced" : "normal");
119 + }, [reduceMotion, mounted]);
120 +
121 + const setThemePref = useCallback((pref: ThemePref) => {
122 + setThemePrefState(pref);
123 + setTheme(resolveTheme(pref));
124 + try { localStorage.setItem(PREF_KEYS.theme, pref); } catch {}
125 + }, []);
126 +
127 + const toggleTheme = useCallback(() => {
128 + setThemePref(theme === "dark" ? "light" : "dark");
129 + }, [theme, setThemePref]);
130 +
131 + const setAccent = useCallback((a: AccentId) => {
132 + if (!ACCENTS.some((x) => x.id === a)) return;
133 + setAccentState(a);
134 + try { localStorage.setItem(PREF_KEYS.accent, a); } catch {}
135 + }, []);
136 +
137 + const setDensity = useCallback((d: DensityPref) => {
138 + setDensityState(d);
139 + try { localStorage.setItem(PREF_KEYS.density, d); } catch {}
140 + }, []);
49 141
50 − // Prevent flash of wrong theme
51 − if (!mounted) {
52 − return <>{children}</>;
53 − }
142 + const setReduceMotion = useCallback((v: boolean) => {
143 + setReduceMotionState(v);
144 + try { localStorage.setItem(PREF_KEYS.motion, v ? "reduced" : "normal"); } catch {}
145 + }, []);
146 +
147 + const value = useMemo<ThemeContextType>(
148 + () => ({
149 + theme, themePref, setThemePref, toggleTheme,
150 + accent, setAccent, density, setDensity,
151 + reduceMotion, setReduceMotion, mounted,
152 + }),
153 + [theme, themePref, setThemePref, toggleTheme, accent, setAccent, density, setDensity, reduceMotion, setReduceMotion, mounted]
154 + );
54 155
55 156 return (
56 − <ThemeContext.Provider value={{ theme, toggleTheme }}>
57 − {children}
157 + <ThemeContext.Provider value={value}>
158 + <MotionConfig reducedMotion={reduceMotion ? "always" : "user"}>{children}</MotionConfig>
58 159 </ThemeContext.Provider>
59 160 );
60 161 }
modified components/theme/ThemeToggle.tsx +19 −19
@@ -1,38 +1,38 @@
1 1 "use client";
2 2
3 −import { motion } from "framer-motion";
3 +import { motion, AnimatePresence } from "framer-motion";
4 4 import { Sun, Moon } from "lucide-react";
5 5 import { useTheme } from "./ThemeProvider";
6 6
7 7 interface ThemeToggleProps {
8 8 className?: string;
9 + size?: "sm" | "md";
9 10 }
10 11
11 −export default function ThemeToggle({ className = "" }: ThemeToggleProps) {
12 +export default function ThemeToggle({ className = "", size = "md" }: ThemeToggleProps) {
12 13 const { theme, toggleTheme } = useTheme();
14 + const dim = size === "sm" ? "w-9 h-9 min-w-[36px] min-h-[36px]" : "w-10 h-10 min-w-[40px] min-h-[40px]";
13 15
14 16 return (
15 17 <motion.button
16 18 whileTap={{ scale: 0.9 }}
17 19 onClick={toggleTheme}
18 − className={`relative flex items-center justify-center min-w-[44px] min-h-[44px] w-10 h-10 rounded-xl transition-colors
19 − dark:bg-white/10 dark:hover:bg-white/20 dark:active:bg-white/25 dark:text-yellow-300
20 − bg-gray-100 hover:bg-gray-200 active:bg-gray-300 text-gray-600
21 − ${className}`}
22 − title={theme === "dark" ? "Mode clair" : "Mode sombre"}
20 + className={`relative flex items-center justify-center ${dim} rounded-xl transition-colors text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 ${className}`}
21 + title={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
22 + aria-label={theme === "dark" ? "Mode clair" : "Mode sombre"}
23 23 >
24 − <motion.div
25 − key={theme}
26 − initial={{ rotate: -90, opacity: 0 }}
27 − animate={{ rotate: 0, opacity: 1 }}
28 − transition={{ duration: 0.2 }}
29 − >
30 − {theme === "dark" ? (
31 − <Sun className="w-5 h-5" />
32 − ) : (
33 − <Moon className="w-5 h-5" />
34 − )}
35 − </motion.div>
24 + <AnimatePresence mode="wait" initial={false}>
25 + <motion.span
26 + key={theme}
27 + initial={{ rotate: -60, opacity: 0, scale: 0.7 }}
28 + animate={{ rotate: 0, opacity: 1, scale: 1 }}
29 + exit={{ rotate: 60, opacity: 0, scale: 0.7 }}
30 + transition={{ duration: 0.2 }}
31 + className="flex"
32 + >
33 + {theme === "dark" ? <Sun className="w-[18px] h-[18px]" /> : <Moon className="w-[18px] h-[18px]" />}
34 + </motion.span>
35 + </AnimatePresence>
36 36 </motion.button>
37 37 );
38 38 }
modified components/ui/Button.tsx +34 −24
@@ -2,41 +2,49 @@
2 2
3 3 import { motion } from "framer-motion";
4 4 import { Loader2 } from "lucide-react";
5 −import { ButtonHTMLAttributes } from "react";
5 +import { ButtonHTMLAttributes, forwardRef } from "react";
6 6
7 7 interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
8 − variant?: "primary" | "secondary" | "danger" | "ghost";
9 − size?: "sm" | "md" | "lg";
8 + variant?: "primary" | "secondary" | "danger" | "ghost" | "outline" | "accent";
9 + size?: "xs" | "sm" | "md" | "lg" | "icon";
10 10 loading?: boolean;
11 11 }
12 12
13 −const variants = {
14 − primary: "bg-gray-900 text-white hover:bg-gray-800 active:bg-gray-700 dark:bg-white dark:text-black dark:hover:bg-gray-200 dark:active:bg-gray-300",
15 − secondary: "bg-gray-100 text-gray-700 hover:bg-gray-200 active:bg-gray-300 border border-gray-200 dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:active:bg-white/25 dark:border-white/10",
16 − danger: "bg-red-50 text-red-600 hover:bg-red-100 active:bg-red-200 border border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:hover:bg-red-500/20 dark:active:bg-red-500/30 dark:border-red-500/20",
17 − ghost: "text-gray-500 hover:text-gray-900 hover:bg-gray-100 active:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/5 dark:active:bg-white/10",
13 +const variants: Record<NonNullable<ButtonProps["variant"]>, string> = {
14 + // Noir/blanc net — action principale neutre
15 + primary:
16 + "bg-gray-900 text-white hover:bg-gray-800 active:bg-gray-700 shadow-card dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100 dark:active:bg-gray-200",
17 + // Accent dégradé — action « héro » (Upload, Créer…)
18 + accent:
19 + "text-white bg-accent-gradient shadow-[0_1px_2px_rgb(0_0_0/.15),inset_0_1px_0_rgb(255_255_255/.18)] hover:brightness-110 active:brightness-95 hover:shadow-glow",
20 + secondary:
21 + "bg-gray-100 text-gray-800 hover:bg-gray-200 active:bg-gray-300 border border-gray-200/80 dark:bg-white/[0.07] dark:text-white dark:hover:bg-white/[0.12] dark:active:bg-white/[0.16] dark:border-white/10",
22 + outline:
23 + "bg-transparent text-gray-700 border border-gray-300 hover:bg-gray-50 hover:border-gray-400 dark:text-gray-200 dark:border-white/15 dark:hover:bg-white/5 dark:hover:border-white/25",
24 + danger:
25 + "bg-rose-50 text-rose-600 hover:bg-rose-100 active:bg-rose-200 border border-rose-200 dark:bg-rose-500/10 dark:text-rose-400 dark:hover:bg-rose-500/20 dark:active:bg-rose-500/30 dark:border-rose-500/20",
26 + ghost:
27 + "text-gray-600 hover:text-gray-900 hover:bg-gray-100 active:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/[0.07] dark:active:bg-white/10",
18 28 };
19 29
20 −// Mobile-optimized: minimum 44px touch targets
21 −const sizes = {
22 − sm: "px-3 py-2 text-xs min-h-[36px] sm:min-h-[32px]",
23 − md: "px-4 py-2.5 text-sm min-h-[44px] sm:min-h-[40px]",
24 − lg: "px-6 py-3.5 text-base min-h-[48px]",
30 +// Cibles tactiles ≥ 40px sur mobile
31 +const sizes: Record<NonNullable<ButtonProps["size"]>, string> = {
32 + xs: "px-2.5 py-1.5 text-xs min-h-[32px] rounded-lg gap-1.5",
33 + sm: "px-3 py-2 text-[13px] min-h-[38px] sm:min-h-[34px] rounded-xl gap-1.5",
34 + md: "px-4 py-2.5 text-sm min-h-[44px] sm:min-h-[40px] rounded-xl gap-2",
35 + lg: "px-6 py-3.5 text-base min-h-[48px] rounded-2xl gap-2",
36 + icon: "w-10 h-10 min-w-[40px] min-h-[40px] rounded-xl p-0",
25 37 };
26 38
27 −export default function Button({
28 − variant = "primary",
29 − size = "md",
30 − loading,
31 − children,
32 − className = "",
33 − disabled,
34 − ...props
35 −}: ButtonProps) {
39 +const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
40 + { variant = "primary", size = "md", loading, children, className = "", disabled, ...props },
41 + ref
42 +) {
36 43 return (
37 44 <motion.button
45 + ref={ref}
38 46 whileTap={{ scale: 0.97 }}
39 − className={`inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed select-none ${variants[variant]} ${sizes[size]} ${className}`}
47 + className={`inline-flex items-center justify-center font-medium transition-all duration-200 ease-spring disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none select-none whitespace-nowrap ${variants[variant]} ${sizes[size]} ${className}`}
40 48 disabled={disabled || loading}
41 49 {...(props as Record<string, unknown>)}
42 50 >
@@ -44,4 +52,6 @@ export default function Button({
44 52 {children}
45 53 </motion.button>
46 54 );
47 −}
55 +});
56 +
57 +export default Button;
added components/ui/Card.tsx +28 −0
@@ -0,0 +1,28 @@
1 +"use client";
2 +
3 +import { HTMLAttributes } from "react";
4 +
5 +interface CardProps extends HTMLAttributes<HTMLDivElement> {
6 + hover?: boolean;
7 + padding?: "none" | "sm" | "md" | "lg";
8 +}
9 +
10 +const paddings = { none: "", sm: "p-3", md: "p-4 sm:p-5", lg: "p-5 sm:p-6" };
11 +
12 +/** Carte standard du design system (surface + bordure + ombre douce). */
13 +export default function Card({ hover, padding = "md", className = "", children, ...props }: CardProps) {
14 + return (
15 + <div className={`card ${hover ? "card-hover" : ""} ${paddings[padding]} ${className}`} {...props}>
16 + {children}
17 + </div>
18 + );
19 +}
20 +
21 +export function CardTitle({ children, className = "", action }: { children: React.ReactNode; className?: string; action?: React.ReactNode }) {
22 + return (
23 + <div className={`flex items-center justify-between gap-3 mb-3 ${className}`}>
24 + <h2 className="text-[13px] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 flex items-center gap-2">{children}</h2>
25 + {action}
26 + </div>
27 + );
28 +}
added components/ui/Kbd.tsx +15 −0
@@ -0,0 +1,15 @@
1 +"use client";
2 +
3 +/** Touche clavier stylée. Sur macOS, affiche ⌘ pour "mod". */
4 +export default function Kbd({ children, className = "" }: { children: React.ReactNode; className?: string }) {
5 + return <kbd className={`inline-flex items-center justify-center min-w-[20px] ${className}`}>{children}</kbd>;
6 +}
7 +
8 +export function isMac(): boolean {
9 + if (typeof navigator === "undefined") return true;
10 + return /Mac|iPhone|iPad|iPod/.test(navigator.platform) || /Mac/.test(navigator.userAgent);
11 +}
12 +
13 +export function modKey(): string {
14 + return isMac() ? "⌘" : "Ctrl";
15 +}
added components/ui/Logo.tsx +33 −0
@@ -0,0 +1,33 @@
1 +"use client";
2 +
3 +interface LogoProps {
4 + size?: number;
5 + className?: string;
6 + /** Afficher le mot-symbole à côté */
7 + wordmark?: boolean;
8 + wordmarkClassName?: string;
9 +}
10 +
11 +/** Marque SPB Cloud : tuile dégradée d'accent + glyphe nuage. */
12 +export default function Logo({ size = 36, className = "", wordmark, wordmarkClassName = "" }: LogoProps) {
13 + return (
14 + <span className={`inline-flex items-center gap-2.5 ${className}`}>
15 + <span
16 + className="relative inline-flex items-center justify-center rounded-[28%] bg-accent-gradient text-white shadow-[0_2px_8px_rgb(var(--accent-600)/.35),inset_0_1px_0_rgb(255_255_255/.25)] shrink-0"
17 + style={{ width: size, height: size }}
18 + aria-hidden
19 + >
20 + <svg viewBox="0 0 24 24" width={size * 0.58} height={size * 0.58} fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round">
21 + <path d="M17.5 19a4.5 4.5 0 0 0 .6-8.96A7 7 0 0 0 4.7 12.2 3.5 3.5 0 0 0 6 19h11.5z" />
22 + <path d="M12 13v4" className="opacity-80" />
23 + <path d="M9.8 15.2 12 13l2.2 2.2" className="opacity-80" />
24 + </svg>
25 + </span>
26 + {wordmark && (
27 + <span className={`font-semibold tracking-tight text-gray-900 dark:text-white ${wordmarkClassName}`}>
28 + SPB <span className="text-accent-gradient">Cloud</span>
29 + </span>
30 + )}
31 + </span>
32 + );
33 +}
modified components/ui/Modal.tsx +44 −23
@@ -8,33 +8,44 @@ interface ModalProps {
8 8 isOpen: boolean;
9 9 onClose: () => void;
10 10 title?: string;
11 + description?: string;
11 12 children: React.ReactNode;
12 13 maxWidth?: string;
14 + /** Pied de modale (boutons) — optionnel, sinon mettre les boutons dans children */
15 + footer?: React.ReactNode;
13 16 }
14 17
15 18 export default function Modal({
16 19 isOpen,
17 20 onClose,
18 21 title,
22 + description,
19 23 children,
20 24 maxWidth = "max-w-md",
25 + footer,
21 26 }: ModalProps) {
22 27 useEffect(() => {
23 − if (isOpen) {
24 − document.body.style.overflow = "hidden";
25 − } else {
26 − document.body.style.overflow = "";
27 − }
28 + if (!isOpen) return;
29 + const prev = document.body.style.overflow;
30 + document.body.style.overflow = "hidden";
31 + const onKey = (e: KeyboardEvent) => {
32 + if (e.key === "Escape") onClose();
33 + };
34 + window.addEventListener("keydown", onKey);
28 35 return () => {
29 − document.body.style.overflow = "";
36 + document.body.style.overflow = prev;
37 + window.removeEventListener("keydown", onKey);
30 38 };
31 − }, [isOpen]);
39 + }, [isOpen, onClose]);
32 40
33 41 return (
34 42 <AnimatePresence>
35 43 {isOpen && (
36 44 <div
37 − className="fixed inset-0 z-50 flex items-end sm:items-center justify-center sm:p-4"
45 + className="fixed inset-0 z-[70] flex items-end sm:items-center justify-center sm:p-4"
46 + role="dialog"
47 + aria-modal="true"
48 + aria-label={title}
38 49 onMouseDown={(e) => e.stopPropagation()}
39 50 onClick={(e) => e.stopPropagation()}
40 51 onPointerDown={(e) => e.stopPropagation()}
@@ -43,34 +54,44 @@ export default function Modal({
43 54 initial={{ opacity: 0 }}
44 55 animate={{ opacity: 1 }}
45 56 exit={{ opacity: 0 }}
46 − className="absolute inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm"
57 + transition={{ duration: 0.18 }}
58 + className="absolute inset-0 bg-gray-950/40 dark:bg-black/65 backdrop-blur-sm"
47 59 onClick={onClose}
48 60 />
49 − {/* Bottom sheet on mobile, centered card on desktop */}
61 + {/* Feuille basse sur mobile, carte centrée sur desktop */}
50 62 <motion.div
51 − initial={{ opacity: 0, y: 100 }}
52 − animate={{ opacity: 1, y: 0 }}
53 − exit={{ opacity: 0, y: 100 }}
54 − transition={{ type: "spring", damping: 25, stiffness: 300 }}
55 − className={`relative w-full ${maxWidth} bg-white dark:bg-gray-900 border border-gray-200 dark:border-white/10 rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden transition-colors max-h-[90vh] flex flex-col`}
63 + initial={{ opacity: 0, y: 40, scale: 0.98 }}
64 + animate={{ opacity: 1, y: 0, scale: 1 }}
65 + exit={{ opacity: 0, y: 40, scale: 0.98 }}
66 + transition={{ type: "spring", damping: 28, stiffness: 340 }}
67 + className={`relative w-full ${maxWidth} bg-elevated border border-line dark:border-white/10 rounded-t-3xl sm:rounded-3xl shadow-pop overflow-hidden max-h-[92dvh] flex flex-col`}
56 68 >
57 − {/* Drag handle on mobile */}
58 − <div className="sm:hidden flex justify-center pt-2 pb-1">
59 − <div className="w-10 h-1 rounded-full bg-gray-300 dark:bg-gray-600" />
69 + {/* Poignée mobile */}
70 + <div className="sm:hidden flex justify-center pt-2.5 pb-1">
71 + <div className="w-10 h-1 rounded-full bg-gray-300 dark:bg-white/20" />
60 72 </div>
61 73
62 74 {title && (
63 − <div className="flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-gray-200 dark:border-white/10">
64 − <h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">{title}</h2>
75 + <div className="flex items-start justify-between gap-3 px-5 sm:px-6 pt-4 sm:pt-5 pb-3">
76 + <div className="min-w-0">
77 + <h2 className="text-base sm:text-[17px] font-semibold text-gray-900 dark:text-white tracking-tight">{title}</h2>
78 + {description && <p className="text-[13px] text-gray-500 dark:text-gray-400 mt-0.5">{description}</p>}
79 + </div>
65 80 <button
66 81 onClick={onClose}
67 − className="text-gray-400 hover:text-gray-700 dark:hover:text-white transition-colors p-2 -mr-1 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-xl active:bg-gray-100 dark:active:bg-white/10"
82 + className="text-gray-400 hover:text-gray-800 dark:hover:text-white transition-colors -mr-2 -mt-1 w-9 h-9 flex items-center justify-center rounded-xl hover:bg-gray-100 dark:hover:bg-white/10 shrink-0"
83 + aria-label="Fermer"
68 84 >
69 − <X className="w-5 h-5" />
85 + <X className="w-[18px] h-[18px]" />
70 86 </button>
71 87 </div>
72 88 )}
73 − <div className="p-4 sm:p-6 overflow-auto flex-1">{children}</div>
89 + <div className={`px-5 sm:px-6 ${title ? "pb-5 sm:pb-6" : "py-5 sm:py-6"} overflow-auto flex-1 pb-safe`}>{children}</div>
90 + {footer && (
91 + <div className="px-5 sm:px-6 py-3.5 border-t border-line dark:border-white/10 bg-gray-50/60 dark:bg-white/[0.02] flex items-center justify-end gap-2 pb-safe">
92 + {footer}
93 + </div>
94 + )}
74 95 </motion.div>
75 96 </div>
76 97 )}
added components/ui/PageHeader.tsx +51 −0
@@ -0,0 +1,51 @@
1 +"use client";
2 +
3 +import type { LucideIcon } from "lucide-react";
4 +
5 +interface PageHeaderProps {
6 + title: string;
7 + subtitle?: string;
8 + icon?: LucideIcon;
9 + /** Classe de teinte de l'icône, ex. "text-amber-500 bg-amber-500/10" */
10 + tone?: string;
11 + actions?: React.ReactNode;
12 + className?: string;
13 + /** Style compact (barre d'explorateur) */
14 + compact?: boolean;
15 +}
16 +
17 +/** En-tête de page standard SPB Cloud v3. */
18 +export default function PageHeader({ title, subtitle, icon: Icon, tone = "text-blue-600 bg-blue-500/10 dark:text-blue-300", actions, className = "", compact }: PageHeaderProps) {
19 + return (
20 + <div className={`flex items-start sm:items-center justify-between gap-3 ${compact ? "py-3" : "py-4 sm:py-5"} ${className}`}>
21 + <div className="flex items-center gap-3 min-w-0">
22 + {Icon && (
23 + <div className={`w-10 h-10 rounded-2xl flex items-center justify-center shrink-0 ${tone}`}>
24 + <Icon className="w-5 h-5" />
25 + </div>
26 + )}
27 + <div className="min-w-0">
28 + <h1 className={`${compact ? "text-lg" : "text-xl sm:text-2xl"} font-semibold tracking-tight text-gray-900 dark:text-white truncate`}>{title}</h1>
29 + {subtitle && <p className="text-[13px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">{subtitle}</p>}
30 + </div>
31 + </div>
32 + {actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
33 + </div>
34 + );
35 +}
36 +
37 +export function EmptyState({ icon: Icon, title, description, action }: { icon: LucideIcon; title: string; description?: string; action?: React.ReactNode }) {
38 + return (
39 + <div className="flex flex-col items-center justify-center text-center py-16 px-6">
40 + <div className="relative mb-5">
41 + <div className="absolute inset-0 rounded-full bg-blue-500/10 blur-2xl scale-150" />
42 + <div className="relative w-16 h-16 rounded-3xl bg-gray-100 dark:bg-white/5 border border-line dark:border-white/10 flex items-center justify-center">
43 + <Icon className="w-7 h-7 text-gray-400 dark:text-gray-500" />
44 + </div>
45 + </div>
46 + <p className="text-base font-medium text-gray-800 dark:text-gray-200">{title}</p>
47 + {description && <p className="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-sm">{description}</p>}
48 + {action && <div className="mt-5">{action}</div>}
49 + </div>
50 + );
51 +}
modified components/ui/SearchBar.tsx +47 −18
@@ -1,50 +1,79 @@
1 1 "use client";
2 2
3 3 import { Search, X } from "lucide-react";
4 −import { useState, useCallback } from "react";
4 +import { useState, useCallback, useEffect, useRef } from "react";
5 +import Kbd from "./Kbd";
5 6
6 7 interface SearchBarProps {
7 8 onSearch: (query: string) => void;
8 9 placeholder?: string;
10 + /** Délai de debounce en ms (0 = immédiat) */
11 + debounce?: number;
12 + autoFocus?: boolean;
13 + className?: string;
9 14 }
10 15
11 16 export default function SearchBar({
12 17 onSearch,
13 − placeholder = "Rechercher...",
18 + placeholder = "Rechercher…",
19 + debounce = 200,
20 + autoFocus,
21 + className = "",
14 22 }: SearchBarProps) {
15 23 const [query, setQuery] = useState("");
24 + const [focused, setFocused] = useState(false);
25 + const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
16 26
17 − const handleChange = useCallback(
27 + const emit = useCallback(
18 28 (value: string) => {
19 − setQuery(value);
20 − onSearch(value);
29 + if (timer.current) clearTimeout(timer.current);
30 + if (debounce <= 0) { onSearch(value); return; }
31 + timer.current = setTimeout(() => onSearch(value), debounce);
21 32 },
22 − [onSearch]
33 + [onSearch, debounce]
23 34 );
24 35
36 + useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
37 +
38 + const handleChange = (value: string) => {
39 + setQuery(value);
40 + emit(value);
41 + };
42 +
25 43 const handleClear = () => {
26 44 setQuery("");
45 + if (timer.current) clearTimeout(timer.current);
27 46 onSearch("");
28 47 };
29 48
30 49 return (
31 − <div className="relative">
32 − <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500 pointer-events-none" />
50 + <div className={`relative ${className}`}>
51 + <Search className={`absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none transition-colors ${focused ? "text-blue-500" : "text-gray-400 dark:text-gray-500"}`} />
33 52 <input
34 − type="text"
53 + type="search"
35 54 value={query}
36 55 onChange={(e) => handleChange(e.target.value)}
56 + onFocus={() => setFocused(true)}
57 + onBlur={() => setFocused(false)}
58 + onKeyDown={(e) => { if (e.key === "Escape" && query) { e.preventDefault(); handleClear(); } }}
37 59 placeholder={placeholder}
38 − className="w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl pl-10 pr-10 py-3 sm:py-2.5 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/50 dark:focus:border-white/20 transition-all"
60 + autoFocus={autoFocus}
61 + aria-label={placeholder}
62 + className="field pl-10 pr-16 h-10 [&::-webkit-search-cancel-button]:hidden"
39 63 />
40 − {query && (
41 − <button
42 − onClick={handleClear}
43 − className="absolute right-1.5 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 p-2 min-w-[36px] min-h-[36px] flex items-center justify-center rounded-lg active:bg-gray-200 dark:active:bg-white/10"
44 − >
45 − <X className="w-4 h-4" />
46 − </button>
47 − )}
64 + <div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-1">
65 + {query ? (
66 + <button
67 + onClick={handleClear}
68 + className="text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-200 w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-200/70 dark:hover:bg-white/10 transition-colors"
69 + aria-label="Effacer la recherche"
70 + >
71 + <X className="w-4 h-4" />
72 + </button>
73 + ) : (
74 + <span className="hidden md:inline-flex pointer-events-none pr-1"><Kbd>/</Kbd></span>
75 + )}
76 + </div>
48 77 </div>
49 78 );
50 79 }
modified components/ui/SharedBadge.tsx +5 −2
@@ -1,6 +1,9 @@
1 −export default function SharedBadge() {
1 +import { Users } from "lucide-react";
2 +
3 +export default function SharedBadge({ className = "" }: { className?: string }) {
2 4 return (
3 − <span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400 leading-none whitespace-nowrap">
5 + <span className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-semibold rounded-md bg-blue-500/10 text-blue-700 dark:text-blue-300 ring-1 ring-inset ring-blue-500/20 leading-none whitespace-nowrap ${className}`}>
6 + <Users className="w-2.5 h-2.5" />
4 7 Partagé
5 8 </span>
6 9 );
modified components/ui/Skeleton.tsx +81 −62
@@ -3,19 +3,18 @@
3 3 interface SkeletonProps {
4 4 className?: string;
5 5 variant?: "text" | "circular" | "rectangular";
6 + style?: React.CSSProperties;
6 7 }
7 8
8 −export default function Skeleton({ className = "", variant = "rectangular" }: SkeletonProps) {
9 − const baseClasses = "animate-pulse relative overflow-hidden bg-gray-200 dark:bg-white/10";
10 −
9 +export default function Skeleton({ className = "", variant = "rectangular", style }: SkeletonProps) {
10 + const baseClasses = "relative overflow-hidden bg-gray-200/80 dark:bg-white/[0.07]";
11 11 const variantClasses = {
12 − text: "rounded h-4 w-full",
12 + text: "rounded-md h-4 w-full",
13 13 circular: "rounded-full",
14 14 rectangular: "rounded-xl",
15 15 };
16 −
17 16 return (
18 − <div className={`${baseClasses} ${variantClasses[variant]} ${className}`}>
17 + <div className={`${baseClasses} ${variantClasses[variant]} ${className}`} style={style} aria-hidden>
19 18 <div className="absolute inset-0 animate-shimmer" />
20 19 </div>
21 20 );
@@ -25,7 +24,7 @@ export default function Skeleton({ className = "", variant = "rectangular" }: Sk
25 24
26 25 export function SkeletonCard() {
27 26 return (
28 − <div className="p-3 sm:p-4 rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10">
27 + <div className="card p-3 sm:p-4">
29 28 <div className="flex items-center gap-3">
30 29 <Skeleton variant="circular" className="w-10 h-10 flex-shrink-0" />
31 30 <div className="flex-1 space-y-2">
@@ -39,10 +38,10 @@ export function SkeletonCard() {
39 38
40 39 export function SkeletonStat() {
41 40 return (
42 − <div className="flex items-center gap-3 p-3 sm:p-4 rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10">
43 − <Skeleton variant="circular" className="w-10 h-10 flex-shrink-0" />
41 + <div className="card p-3.5 sm:p-4 flex items-center gap-3">
42 + <Skeleton className="w-10 h-10 flex-shrink-0 rounded-xl" />
44 43 <div className="flex-1 space-y-2">
45 − <Skeleton variant="text" className="h-6 w-16" />
44 + <Skeleton variant="text" className="h-5 w-14" />
46 45 <Skeleton variant="text" className="h-3 w-20" />
47 46 </div>
48 47 </div>
@@ -51,83 +50,103 @@ export function SkeletonStat() {
51 50
52 51 export function SkeletonFileCard() {
53 52 return (
54 − <div className="flex-shrink-0 w-36 sm:w-44 p-3 rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10">
55 − <Skeleton className="w-full h-20 rounded-xl mb-2" />
56 − <Skeleton variant="text" className="h-3 w-4/5 mb-1" />
57 − <Skeleton variant="text" className="h-2 w-1/3" />
53 + <div className="card shrink-0 w-[150px] sm:w-[176px] p-2">
54 + <Skeleton className="w-full aspect-[4/3] rounded-xl mb-2" />
55 + <Skeleton variant="text" className="h-3 w-4/5 mb-1.5 mx-1" />
56 + <Skeleton variant="text" className="h-2.5 w-1/3 mx-1" />
58 57 </div>
59 58 );
60 59 }
61 60
62 61 export function SkeletonActivity() {
63 62 return (
64 − <div className="flex items-center gap-3 px-4 py-3">
65 − <Skeleton variant="circular" className="w-2 h-2 flex-shrink-0" />
66 − <div className="flex-1 space-y-1.5">
63 + <div className="flex items-start gap-3 px-1 py-2.5">
64 + <Skeleton className="w-9 h-9 flex-shrink-0 rounded-xl" />
65 + <div className="flex-1 space-y-1.5 pt-1">
67 66 <Skeleton variant="text" className="h-3 w-3/4" />
68 − <Skeleton variant="text" className="h-2 w-1/3" />
67 + <Skeleton variant="text" className="h-2.5 w-1/3" />
69 68 </div>
70 − <Skeleton variant="text" className="h-3 w-16 flex-shrink-0" />
69 + <Skeleton variant="text" className="h-3 w-12 flex-shrink-0 mt-1" />
71 70 </div>
72 71 );
73 72 }
74 73
75 −export function DashboardSkeleton() {
74 +export function SkeletonRow() {
76 75 return (
77 − <div className="p-3 sm:p-4 lg:p-6 space-y-5 sm:space-y-6">
78 − {/* Welcome skeleton */}
79 − <div className="space-y-2">
80 − <Skeleton variant="text" className="h-7 w-64" />
81 − <Skeleton variant="text" className="h-4 w-48" />
82 − </div>
76 + <div className="flex items-center gap-3 px-2 h-11">
77 + <Skeleton className="w-8 h-8 rounded-lg flex-shrink-0" />
78 + <Skeleton variant="text" className="h-3 flex-1" />
79 + <Skeleton variant="text" className="h-3 w-12 flex-shrink-0" />
80 + </div>
81 + );
82 +}
83 83
84 − {/* Quick actions skeleton */}
85 − <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
86 − {[...Array(4)].map((_, i) => (
87 − <div key={i} className="flex flex-col items-center gap-2 p-4 rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 min-h-[88px] justify-center">
88 − <Skeleton variant="rectangular" className="w-10 h-10 rounded-xl" />
89 − <Skeleton variant="text" className="h-3 w-16" />
90 − </div>
91 − ))}
84 +export function DashboardSkeleton() {
85 + return (
86 + <div className="p-3 sm:p-5 lg:p-6 space-y-4 sm:space-y-5 max-w-[1400px] mx-auto" aria-busy="true" aria-label="Chargement du tableau de bord">
87 + {/* Bandeau */}
88 + <div className="card p-5 sm:p-7 flex flex-col lg:flex-row lg:items-end gap-5 lg:gap-8 rounded-3xl">
89 + <div className="flex-1 space-y-3">
90 + <Skeleton variant="text" className="h-3 w-40" />
91 + <Skeleton variant="text" className="h-8 w-72" />
92 + <Skeleton variant="text" className="h-4 w-56" />
93 + </div>
94 + <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 lg:w-[560px] shrink-0">
95 + {[...Array(4)].map((_, i) => <Skeleton key={i} className="h-16 rounded-2xl" />)}
96 + </div>
92 97 </div>
93 98
94 − {/* Stats skeleton */}
95 − <div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-3">
96 − {[...Array(4)].map((_, i) => (
97 − <SkeletonStat key={i} />
98 − ))}
99 + {/* KPI */}
100 + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2 sm:gap-3">
101 + {[...Array(5)].map((_, i) => <SkeletonStat key={i} />)}
99 102 </div>
100 103
101 − {/* Storage skeleton */}
102 − <div className="p-4 rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10">
103 − <div className="flex items-center justify-between mb-3">
104 − <Skeleton variant="text" className="h-4 w-24" />
105 − <Skeleton variant="text" className="h-3 w-32" />
104 + {/* Stockage + activité */}
105 + <div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-5">
106 + <div className="card p-4 sm:p-5 lg:col-span-5">
107 + <Skeleton variant="text" className="h-3 w-24 mb-4" />
108 + <div className="flex flex-col sm:flex-row sm:items-center gap-5">
109 + <Skeleton variant="circular" className="w-[168px] h-[168px] mx-auto sm:mx-0 shrink-0" />
110 + <div className="flex-1 space-y-2.5">
111 + {[...Array(5)].map((_, i) => <Skeleton key={i} variant="text" className="h-3.5" />)}
112 + </div>
113 + </div>
114 + </div>
115 + <div className="card p-4 sm:p-5 lg:col-span-7">
116 + <Skeleton variant="text" className="h-3 w-24 mb-4" />
117 + <div className="flex items-baseline gap-4 mb-3">
118 + <Skeleton variant="text" className="h-7 w-16" />
119 + <Skeleton variant="text" className="h-7 w-16" />
120 + </div>
121 + <div className="flex items-end gap-1.5 h-[150px] ml-8">
122 + {[...Array(14)].map((_, i) => <Skeleton key={i} className="flex-1 rounded-t-[4px]" style={{ height: `${20 + ((i * 37) % 70)}%` }} />)}
123 + </div>
106 124 </div>
107 − <Skeleton className="w-full h-2.5 rounded-full" />
108 − <Skeleton variant="text" className="h-3 w-20 mt-2" />
109 125 </div>
110 126
111 − {/* Recent files skeleton */}
127 + {/* Reprendre */}
112 128 <div>
113 − <div className="flex items-center justify-between mb-3">
114 − <Skeleton variant="text" className="h-4 w-32" />
115 − <Skeleton variant="text" className="h-3 w-16" />
116 − </div>
117 − <div className="flex gap-2 sm:gap-3 overflow-hidden">
118 − {[...Array(5)].map((_, i) => (
119 − <SkeletonFileCard key={i} />
120 − ))}
129 + <Skeleton variant="text" className="h-3 w-28 mb-3" />
130 + <div className="flex gap-2.5 sm:gap-3 overflow-hidden">
131 + {[...Array(6)].map((_, i) => <SkeletonFileCard key={i} />)}
121 132 </div>
122 133 </div>
123 134
124 − {/* Activity skeleton */}
125 − <div>
126 − <Skeleton variant="text" className="h-4 w-36 mb-3" />
127 − <div className="rounded-2xl bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 divide-y divide-gray-100 dark:divide-white/5">
128 − {[...Array(4)].map((_, i) => (
129 − <SkeletonActivity key={i} />
130 − ))}
135 + {/* Timeline + latéral */}
136 + <div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-5">
137 + <div className="card p-4 sm:p-5 lg:col-span-7">
138 + <Skeleton variant="text" className="h-3 w-32 mb-3" />
139 + {[...Array(6)].map((_, i) => <SkeletonActivity key={i} />)}
140 + </div>
141 + <div className="lg:col-span-5 space-y-4 sm:space-y-5">
142 + <div className="card p-4 sm:p-5">
143 + <Skeleton variant="text" className="h-3 w-20 mb-3" />
144 + {[...Array(4)].map((_, i) => <SkeletonRow key={i} />)}
145 + </div>
146 + <div className="card p-4 sm:p-5">
147 + <Skeleton variant="text" className="h-3 w-36 mb-3" />
148 + {[...Array(4)].map((_, i) => <SkeletonRow key={i} />)}
149 + </div>
131 150 </div>
132 151 </div>
133 152 </div>
modified components/ui/TopLoader.tsx +27 −37
@@ -3,59 +3,49 @@
3 3 import { useEffect, useState, useRef } from "react";
4 4 import { usePathname } from "next/navigation";
5 5
6 +/** Barre de progression de navigation (2 px, dégradé d'accent + lueur). */
6 7 export default function TopLoader() {
7 8 const pathname = usePathname();
8 9 const [loading, setLoading] = useState(false);
9 10 const [progress, setProgress] = useState(0);
10 11 const [visible, setVisible] = useState(false);
11 12 const prevPathname = useRef(pathname);
12 − const timerRef = useRef<NodeJS.Timeout | null>(null);
13 − const completeTimerRef = useRef<NodeJS.Timeout | null>(null);
13 + const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
14 14
15 15 useEffect(() => {
16 − if (pathname !== prevPathname.current) {
17 − prevPathname.current = pathname;
18 −
19 − // Start loading
20 − setLoading(true);
21 − setVisible(true);
22 − setProgress(0);
23 −
24 − // Animate to 80% quickly
25 − if (timerRef.current) clearTimeout(timerRef.current);
26 − timerRef.current = setTimeout(() => setProgress(30), 50);
27 − timerRef.current = setTimeout(() => setProgress(60), 200);
28 − timerRef.current = setTimeout(() => setProgress(80), 400);
29 −
30 − // Complete after a short delay (page has loaded since pathname changed)
31 − if (completeTimerRef.current) clearTimeout(completeTimerRef.current);
32 − completeTimerRef.current = setTimeout(() => {
33 − setProgress(100);
34 − setTimeout(() => {
35 − setLoading(false);
36 − setTimeout(() => {
37 − setVisible(false);
38 − setProgress(0);
39 − }, 200);
40 − }, 150);
41 − }, 500);
42 − }
43 −
44 − return () => {
45 − if (timerRef.current) clearTimeout(timerRef.current);
46 − if (completeTimerRef.current) clearTimeout(completeTimerRef.current);
47 − };
16 + if (pathname === prevPathname.current) return;
17 + prevPathname.current = pathname;
18 +
19 + timers.current.forEach(clearTimeout);
20 + timers.current = [];
21 + const t = (fn: () => void, ms: number) => timers.current.push(setTimeout(fn, ms));
22 +
23 + setLoading(true);
24 + setVisible(true);
25 + setProgress(0);
26 + t(() => setProgress(30), 40);
27 + t(() => setProgress(62), 180);
28 + t(() => setProgress(82), 380);
29 + t(() => {
30 + setProgress(100);
31 + t(() => {
32 + setLoading(false);
33 + t(() => { setVisible(false); setProgress(0); }, 220);
34 + }, 140);
35 + }, 520);
36 +
37 + return () => { timers.current.forEach(clearTimeout); };
48 38 }, [pathname]);
49 39
50 40 if (!visible) return null;
51 41
52 42 return (
53 − <div className="fixed top-0 left-0 right-0 z-50 h-[2px] pointer-events-none">
43 + <div className="fixed top-0 left-0 right-0 z-[90] h-[2px] pointer-events-none" aria-hidden>
54 44 <div
55 − className="h-full bg-gradient-to-r from-blue-700 to-blue-500 transition-all ease-out shadow-[0_0_8px_rgba(29,78,216,0.4)]"
45 + className="h-full bg-accent-gradient transition-all ease-out shadow-[0_0_10px_rgb(var(--accent-500)/0.7),0_0_2px_rgb(var(--accent-400)/0.9)]"
56 46 style={{
57 47 width: `${progress}%`,
58 − transitionDuration: progress === 100 ? "150ms" : "500ms",
48 + transitionDuration: progress === 100 ? "140ms" : "480ms",
59 49 opacity: loading ? 1 : 0,
60 50 }}
61 51 />
modified components/ui/UserAvatar.tsx +2 −2
@@ -61,7 +61,7 @@ export default function UserAvatar({
61 61 if (avatarPath) {
62 62 return (
63 63 <div
64 − className="relative rounded-full overflow-hidden flex-shrink-0"
64 + className="relative rounded-full overflow-hidden flex-shrink-0 ring-2 ring-white/70 dark:ring-white/10"
65 65 style={{ width: px, height: px }}
66 66 >
67 67 <Image
@@ -81,7 +81,7 @@ export default function UserAvatar({
81 81
82 82 return (
83 83 <div
84 − className={`${bgColor} rounded-full flex items-center justify-center flex-shrink-0 text-white font-medium ${textSizeMap[size]}`}
84 + className={`${bgColor} rounded-full flex items-center justify-center flex-shrink-0 text-white font-semibold ring-2 ring-white/70 dark:ring-white/10 shadow-inner ${textSizeMap[size]}`}
85 85 style={{ width: px, height: px }}
86 86 >
87 87 {initials}
added lib/prefs.ts +37 −0
@@ -0,0 +1,37 @@
1 +/**
2 + * Préférences d'apparence SPB Cloud v3 (persistées en localStorage).
3 + * Le script inline de app/layout.tsx applique ces valeurs AVANT le premier
4 + * rendu (pas de flash) — garder les clés/valeurs synchronisées avec lui.
5 + */
6 +
7 +export type ThemePref = "light" | "dark" | "system";
8 +export type AccentId = "aurore" | "ocean" | "glacier" | "foret" | "corail" | "ambre" | "amethyste";
9 +export type DensityPref = "comfortable" | "compact";
10 +
11 +export const PREF_KEYS = {
12 + theme: "spb-theme",
13 + accent: "spb-accent",
14 + density: "spb-density",
15 + motion: "spb-motion",
16 + sidebar: "spb-sidebar",
17 +} as const;
18 +
19 +export const ACCENTS: { id: AccentId; label: string; hex: string; hint: string }[] = [
20 + { id: "aurore", label: "Aurore", hex: "#6366f1", hint: "Indigo — défaut" },
21 + { id: "ocean", label: "Océan", hex: "#3b82f6", hint: "Bleu classique" },
22 + { id: "glacier", label: "Glacier", hex: "#06b6d4", hint: "Cyan" },
23 + { id: "foret", label: "Forêt", hex: "#10b981", hint: "Émeraude" },
24 + { id: "corail", label: "Corail", hex: "#f43f5e", hint: "Rose" },
25 + { id: "ambre", label: "Ambre", hex: "#f59e0b", hint: "Doré" },
26 + { id: "amethyste", label: "Améthyste", hex: "#8b5cf6", hint: "Violet" },
27 +];
28 +
29 +export const DEFAULT_ACCENT: AccentId = "aurore";
30 +export const DEFAULT_THEME: ThemePref = "light";
31 +
32 +/** Quota d'affichage du stockage (500 Go — pas de quota dur côté serveur). */
33 +export const STORAGE_QUOTA_BYTES = 500 * 1024 * 1024 * 1024;
34 +
35 +export function isAccentId(v: unknown): v is AccentId {
36 + return typeof v === "string" && ACCENTS.some((a) => a.id === v);
37 +}
modified middleware.ts +7 −3
@@ -11,6 +11,9 @@ const publicPaths = [
11 11 "/api/v1/",
12 12 "/_next",
13 13 "/favicon.ico",
14 + "/manifest.webmanifest",
15 + "/icons/",
16 + "/icon.svg",
14 17 ];
15 18
16 19 // CSRF-exempt paths (public endpoints that accept POST without session)
@@ -23,7 +26,7 @@ const csrfExemptPaths = [
23 26 // --- Edge-compatible rate limiter ---
24 27 const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
25 28 const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
26 −const RATE_LIMIT_MAX = 100; // 100 requests per minute per IP
29 +const RATE_LIMIT_MAX = 600; // 600 requêtes/min/IP (une grille de 50 vignettes = 50 requêtes)
27 30
28 31 function cleanupRateLimitMap() {
29 32 const now = Date.now();
@@ -111,8 +114,9 @@ export async function middleware(request: NextRequest) {
111 114 return response;
112 115 }
113 116
114 − // --- API rate limiting ---
115 − if (pathname.startsWith("/api/")) {
117 + // --- API rate limiting (les aperçus/flux de médias sont exemptés : contenu statique, coûteux en volume) ---
118 + const isMediaPath = /^\/api\/files\/[^/]+\/(preview|stream|content)$/.test(pathname) || pathname.startsWith("/api/users/") && pathname.endsWith("/avatar");
119 + if (pathname.startsWith("/api/") && !isMediaPath) {
116 120 const ip = getClientIp(request);
117 121 cleanupRateLimitMap();
118 122
modified prisma/dev.db-shm +0 −0

Binary file not shown.

modified prisma/dev.db-wal +0 −0

Binary file not shown.

added public/icons/apple-touch-icon.png +0 −0

Binary file not shown.

added public/icons/icon-192.png +0 −0

Binary file not shown.

added public/icons/icon-512.png +0 −0

Binary file not shown.

added public/icons/icon-maskable-512.png +0 −0

Binary file not shown.

added public/icons/icon.svg +20 −0
@@ -0,0 +1,20 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
2 + <defs>
3 + <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4 + <stop offset="0" stop-color="#6366f1"/>
5 + <stop offset="0.6" stop-color="#4f46e5"/>
6 + <stop offset="1" stop-color="#4338ca"/>
7 + </linearGradient>
8 + <radialGradient id="hi" cx="0.3" cy="0.15" r="0.8">
9 + <stop offset="0" stop-color="#ffffff" stop-opacity="0.28"/>
10 + <stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
11 + </radialGradient>
12 + </defs>
13 + <rect width="512" height="512" rx="128" fill="url(#g)"/>
14 + <rect width="512" height="512" rx="128" fill="url(#hi)"/>
15 + <g transform="translate(64 64) scale(16)" fill="none" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
16 + <path d="M17.5 19a4.5 4.5 0 0 0 .6-8.96A7 7 0 0 0 4.7 12.2 3.5 3.5 0 0 0 6 19h11.5z"/>
17 + <path d="M12 13v4" opacity="0.85"/>
18 + <path d="M9.8 15.2 12 13l2.2 2.2" opacity="0.85"/>
19 + </g>
20 +</svg>
added public/manifest.webmanifest +22 −0
@@ -0,0 +1,22 @@
1 +{
2 + "name": "SPB Cloud",
3 + "short_name": "SPB Cloud",
4 + "description": "Cloud personnel privé, chiffré et local-first.",
5 + "lang": "fr",
6 + "start_url": "/dashboard",
7 + "scope": "/",
8 + "display": "standalone",
9 + "orientation": "any",
10 + "background_color": "#0a0b10",
11 + "theme_color": "#6366f1",
12 + "icons": [
13 + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
14 + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
15 + { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
16 + ],
17 + "shortcuts": [
18 + { "name": "Fichiers", "url": "/dashboard/files", "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] },
19 + { "name": "Envoyer", "url": "/dashboard/files?upload=1", "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] },
20 + { "name": "Favoris", "url": "/dashboard/favorites", "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] }
21 + ]
22 +}
modified server.ts +6 −2
@@ -29,8 +29,12 @@ app.prepare().then(() => {
29 29 .then((created) => { if (created) console.log("> [Snapshot] Snapshot quotidien créé"); })
30 30 .catch((err) => console.error("> [Snapshot] Erreur:", err));
31 31 };
32 − runSnapshot();
33 − setInterval(runSnapshot, 60 * 60 * 1000); // re-check every hour
32 + if (process.env.SNAPSHOT_DISABLED === "1") {
33 + console.log("> [Snapshot] désactivé (SNAPSHOT_DISABLED=1)");
34 + } else {
35 + runSnapshot();
36 + setInterval(runSnapshot, 60 * 60 * 1000); // re-check every hour
37 + }
34 38 });
35 39 }).catch((err) => {
36 40 console.error("Failed to start server:", err);
modified tailwind.config.ts +88 −12
@@ -1,5 +1,17 @@
1 1 import type { Config } from "tailwindcss";
2 2
3 +/**
4 + * SPB Cloud v3 — design system "Aurora"
5 + *
6 + * - `gray` → neutre froid maison (remplace la palette Tailwind, ré-harmonise
7 + * les ~22k lignes existantes sans les toucher).
8 + * - `blue` → ACCENT dynamique (variables CSS --accent-*), sélectionnable par
9 + * l'utilisateur (Préférences → Accent). Tout `text-blue-500`,
10 + * `bg-blue-600/10`… suit l'accent choisi.
11 + * - `canvas` / `surface` / `elevated` / `line` → surfaces sémantiques.
12 + */
13 +const accent = (n: number) => `rgb(var(--accent-${n}) / <alpha-value>)`;
14 +
3 15 const config: Config = {
4 16 darkMode: "class",
5 17 content: [
@@ -10,24 +22,88 @@ const config: Config = {
10 22 theme: {
11 23 extend: {
12 24 fontFamily: {
13 − sans: ["Inter", "system-ui", "sans-serif"],
25 + sans: ["var(--font-geist)", "Inter", "system-ui", "-apple-system", "sans-serif"],
26 + mono: ["var(--font-geist-mono)", "ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
14 27 },
15 28 colors: {
16 29 background: "var(--background)",
17 30 foreground: "var(--foreground)",
18 − cloud: {
19 − 50: "#f9fafb",
20 − 100: "#f3f4f6",
21 − 200: "#e5e7eb",
22 − 300: "#d1d5db",
23 − 400: "#9ca3af",
31 + canvas: "rgb(var(--canvas) / <alpha-value>)",
32 + surface: "rgb(var(--surface) / <alpha-value>)",
33 + elevated: "rgb(var(--elevated) / <alpha-value>)",
34 + line: "rgb(var(--line) / <alpha-value>)",
35 + accent: {
36 + 50: accent(50), 100: accent(100), 200: accent(200), 300: accent(300),
37 + 400: accent(400), 500: accent(500), 600: accent(600), 700: accent(700),
38 + 800: accent(800), 900: accent(900), 950: accent(950),
39 + DEFAULT: accent(500),
40 + },
41 + blue: {
42 + 50: accent(50), 100: accent(100), 200: accent(200), 300: accent(300),
43 + 400: accent(400), 500: accent(500), 600: accent(600), 700: accent(700),
44 + 800: accent(800), 900: accent(900), 950: accent(950),
45 + },
46 + gray: {
47 + 50: "#f7f8fa",
48 + 100: "#eef0f3",
49 + 200: "#dfe3e8",
50 + 300: "#c8cdd5",
51 + 400: "#9aa1ad",
24 52 500: "#6b7280",
25 − 600: "#4b5563",
26 − 700: "#374151",
27 − 800: "#1f2937",
28 − 900: "#111827",
29 − 950: "#0a0a0a",
53 + 600: "#4f5663",
54 + 700: "#3a404b",
55 + 800: "#23272f",
56 + 900: "#15181e",
57 + 950: "#0b0c10",
30 58 },
59 + cloud: {
60 + 50: "#f7f8fa", 100: "#eef0f3", 200: "#dfe3e8", 300: "#c8cdd5", 400: "#9aa1ad",
61 + 500: "#6b7280", 600: "#4f5663", 700: "#3a404b", 800: "#23272f", 900: "#15181e", 950: "#0b0c10",
62 + },
63 + },
64 + borderRadius: {
65 + "2xl": "1rem",
66 + "3xl": "1.25rem",
67 + "4xl": "1.75rem",
68 + },
69 + boxShadow: {
70 + card: "0 1px 2px rgb(15 23 42 / 0.04), 0 1px 3px rgb(15 23 42 / 0.06)",
71 + "card-hover": "0 4px 12px rgb(15 23 42 / 0.08), 0 1px 3px rgb(15 23 42 / 0.06)",
72 + pop: "0 12px 40px -8px rgb(15 23 42 / 0.25), 0 2px 8px rgb(15 23 42 / 0.08)",
73 + glow: "0 0 0 1px rgb(var(--accent-500) / 0.35), 0 8px 30px -6px rgb(var(--accent-500) / 0.45)",
74 + "inner-hi": "inset 0 1px 0 0 rgb(255 255 255 / 0.06)",
75 + },
76 + backgroundImage: {
77 + "accent-gradient":
78 + "linear-gradient(135deg, rgb(var(--accent-500)) 0%, rgb(var(--accent-600)) 60%, rgb(var(--accent-700)) 100%)",
79 + aurora:
80 + "radial-gradient(1200px 600px at 10% -10%, rgb(var(--accent-500) / 0.25), transparent 60%), radial-gradient(900px 500px at 110% 10%, rgb(var(--accent-300) / 0.18), transparent 55%), radial-gradient(700px 500px at 50% 120%, rgb(var(--accent-700) / 0.22), transparent 60%)",
81 + grid: "linear-gradient(to right, rgb(var(--line) / 0.7) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--line) / 0.7) 1px, transparent 1px)",
82 + },
83 + keyframes: {
84 + shimmer: { "0%": { backgroundPosition: "-200% 0" }, "100%": { backgroundPosition: "200% 0" } },
85 + "fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
86 + "slide-up": { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
87 + "scale-in": { from: { opacity: "0", transform: "scale(0.96)" }, to: { opacity: "1", transform: "scale(1)" } },
88 + float: { "0%,100%": { transform: "translateY(0)" }, "50%": { transform: "translateY(-6px)" } },
89 + "pulse-ring": { "0%": { transform: "scale(1)", opacity: "0.6" }, "100%": { transform: "scale(1.8)", opacity: "0" } },
90 + drift: {
91 + "0%,100%": { transform: "translate(0,0) scale(1)" },
92 + "33%": { transform: "translate(40px,-30px) scale(1.05)" },
93 + "66%": { transform: "translate(-30px,20px) scale(0.97)" },
94 + },
95 + },
96 + animation: {
97 + shimmer: "shimmer 1.6s linear infinite",
98 + "fade-in": "fade-in .2s ease-out both",
99 + "slide-up": "slide-up .25s cubic-bezier(.2,.8,.2,1) both",
100 + "scale-in": "scale-in .18s cubic-bezier(.2,.8,.2,1) both",
101 + float: "float 6s ease-in-out infinite",
102 + "pulse-ring": "pulse-ring 1.6s cubic-bezier(.2,.8,.2,1) infinite",
103 + drift: "drift 22s ease-in-out infinite",
104 + },
105 + transitionTimingFunction: {
106 + spring: "cubic-bezier(.2,.8,.2,1)",
31 107 },
32 108 },
33 109 },
34 110