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
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ébergé conçu pour offrir un contrôle total sur vos données. Aucune dépendance à Google, Dropbox ou iCloud. Vos fichiers restent chez vous, chiffré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'accès à l'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'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'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'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'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'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'intelligence d'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 "Authorization: Bearer spb_…" 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'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'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'espace ; les membres perdront l'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'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'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'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'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'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'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'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'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'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'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'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 : | |
| 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'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'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 | |