feat: AI improvements, shared spaces, password manager & UX enhancements
## New Features ### Password Manager - Full credential vault with AES-256-GCM encryption (lib/crypto.ts) - Password generator (length, charset options) + strength checker - Reveal/copy/edit/delete per entry with favicon icons - API routes: GET/POST /api/passwords, GET/PATCH/DELETE /api/passwords/[id] - Sidebar link added under "Mots de passe" ### Shared Spaces - Users can now create shared folders (not just admin) - CreateSharedFolderModal with user picker - Admin management page at /admin/shared-spaces - SharedSpaceManagement component + SharedBadge UI - User-facing shared space view at /dashboard/shared-space/[id] - Full API: list/create/update/delete spaces, members, storage quota ### Change Password - ChangePasswordModal with current/new/confirm fields + strength indicator - POST /api/auth/change-password with bcrypt verify & hash - Button in sidebar footer ## AI Improvements - Upgraded to claude-sonnet-4-6 with streaming SSE chat - Multi-turn conversation history support - react-markdown rendering for assistant messages - File mention detection: auto-attach PDF/image/text content to chat - New Analyser tab with file picker; Search results have Analyser/Download/Summarize actions - Fixed: legacy files with userId=null now included in all AI queries ## Other Improvements - Word document preview via mammoth.js (WordPreview plugin) - Colored file type icons in FileCard - Proper try/catch on all password API routes - Better error handling in frontend (network errors, non-JSON responses) - lib/prisma.ts cleanup for hot-reload stability Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
41 changed files +3,608 −283
added
app/admin/shared-spaces/page.tsx
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import AppShell from "@/components/layout/AppShell"; | |
| 4 | +import SharedSpaceManagement from "@/components/admin/SharedSpaceManagement"; | |
| 5 | + | |
| 6 | +export default function SharedSpacesPage() { | |
| 7 | + return ( | |
| 8 | + <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> | |
| 14 | + <SharedSpaceManagement /> | |
| 15 | + </div> | |
| 16 | + </AppShell> | |
| 17 | + ); | |
| 18 | +} | |
modified
app/api/ai/chat/route.ts
+181 −7
@@ -1,20 +1,194 @@ | ||
| 1 | 1 | import { NextRequest, NextResponse } from "next/server"; |
| 2 | 2 | import { getIronSession } from "iron-session"; |
| 3 | 3 | import { SessionData, sessionOptions } from "@/lib/session"; |
| 4 | −import { aiChat } from "@/lib/ai"; | |
| 4 | +import { buildFileContext, ChatMessage } from "@/lib/ai"; | |
| 5 | +import { getFilePath } from "@/lib/storage"; | |
| 6 | +import { existsSync, readFileSync } from "fs"; | |
| 7 | +import { prisma } from "@/lib/prisma"; | |
| 8 | +import Anthropic from "@anthropic-ai/sdk"; | |
| 9 | + | |
| 10 | +const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); | |
| 11 | +const MODEL = "claude-sonnet-4-6"; | |
| 12 | + | |
| 13 | +// Find a file mentioned by name in the user message | |
| 14 | +async function findMentionedFile(message: string, userId?: string) { | |
| 15 | + const where = userId | |
| 16 | + ? { OR: [{ userId }, { userId: null }] } | |
| 17 | + : {}; | |
| 18 | + | |
| 19 | + const files = await prisma.file.findMany({ | |
| 20 | + where, | |
| 21 | + select: { id: true, name: true, mimeType: true, storagePath: true, ocrText: true }, | |
| 22 | + orderBy: { createdAt: "desc" }, | |
| 23 | + }); | |
| 24 | + | |
| 25 | + const msgLower = message.toLowerCase(); | |
| 26 | + // Sort by name length desc so longer/more specific names match first | |
| 27 | + const sorted = [...files].sort((a, b) => b.name.length - a.name.length); | |
| 28 | + return sorted.find((f) => msgLower.includes(f.name.toLowerCase())) ?? null; | |
| 29 | +} | |
| 30 | + | |
| 31 | +// Build content blocks for a file to send to Claude | |
| 32 | +function buildFileBlocks(file: { | |
| 33 | + name: string; | |
| 34 | + mimeType: string; | |
| 35 | + storagePath: string; | |
| 36 | + ocrText: string | null; | |
| 37 | +}): Anthropic.ContentBlockParam[] { | |
| 38 | + const filePath = getFilePath(file.storagePath); | |
| 39 | + if (!existsSync(filePath)) return []; | |
| 40 | + | |
| 41 | + try { | |
| 42 | + // PDFs — send as native document block (Claude reads PDFs natively) | |
| 43 | + if (file.mimeType === "application/pdf") { | |
| 44 | + const base64 = readFileSync(filePath).toString("base64"); | |
| 45 | + return [ | |
| 46 | + { | |
| 47 | + type: "document", | |
| 48 | + source: { type: "base64", media_type: "application/pdf", data: base64 }, | |
| 49 | + } as Anthropic.ContentBlockParam, | |
| 50 | + ]; | |
| 51 | + } | |
| 52 | + | |
| 53 | + // Images — send as image block | |
| 54 | + if (file.mimeType.startsWith("image/")) { | |
| 55 | + const supported = ["image/jpeg", "image/png", "image/gif", "image/webp"]; | |
| 56 | + if (supported.includes(file.mimeType)) { | |
| 57 | + const base64 = readFileSync(filePath).toString("base64"); | |
| 58 | + return [ | |
| 59 | + { | |
| 60 | + type: "image", | |
| 61 | + source: { | |
| 62 | + type: "base64", | |
| 63 | + media_type: file.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp", | |
| 64 | + data: base64, | |
| 65 | + }, | |
| 66 | + }, | |
| 67 | + ]; | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + // Text / code / markdown / JSON — read directly | |
| 72 | + if ( | |
| 73 | + file.mimeType.startsWith("text/") || | |
| 74 | + file.mimeType.includes("json") || | |
| 75 | + file.mimeType.includes("xml") || | |
| 76 | + file.mimeType.includes("markdown") | |
| 77 | + ) { | |
| 78 | + const content = readFileSync(filePath, "utf-8").substring(0, 50000); | |
| 79 | + return [ | |
| 80 | + { type: "text", text: `Contenu du fichier "${file.name}":\n\`\`\`\n${content}\n\`\`\`` }, | |
| 81 | + ]; | |
| 82 | + } | |
| 83 | + | |
| 84 | + // Fallback: use OCR text if available | |
| 85 | + if (file.ocrText) { | |
| 86 | + return [ | |
| 87 | + { type: "text", text: `Texte extrait du fichier "${file.name}" (OCR):\n${file.ocrText}` }, | |
| 88 | + ]; | |
| 89 | + } | |
| 90 | + } catch {} | |
| 91 | + return []; | |
| 92 | +} | |
| 5 | 93 | |
| 6 | 94 | export async function POST(request: NextRequest) { |
| 7 | − const response = NextResponse.next(); | |
| 8 | − const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 95 | + const tempResponse = new NextResponse(); | |
| 96 | + const session = await getIronSession<SessionData>(request, tempResponse, sessionOptions); | |
| 9 | 97 | if (!session.isLoggedIn) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); |
| 10 | 98 | |
| 11 | − const { message, context } = await request.json(); | |
| 99 | + const { message, history = [], stream: wantStream = true } = await request.json(); | |
| 12 | 100 | if (!message) return NextResponse.json({ error: "message required" }, { status: 400 }); |
| 13 | 101 | |
| 14 | 102 | try { |
| 15 | − const reply = await aiChat(message, context); | |
| 16 | − return NextResponse.json({ reply }); | |
| 103 | + const [fileContext, mentionedFile] = await Promise.all([ | |
| 104 | + buildFileContext(session.userId!), | |
| 105 | + findMentionedFile(message, session.userId), | |
| 106 | + ]); | |
| 107 | + | |
| 108 | + const systemPrompt = `Tu es l'assistant IA de SPB Cloud, un cloud personnel privé. Tu aides l'utilisateur avec ses fichiers et réponds toujours en français. Sois concis et précis. Tu peux utiliser le markdown (gras, listes, code) pour structurer tes réponses. Quand on te donne un fichier, lis-en le contenu et réponds directement en te basant dessus. | |
| 109 | + | |
| 110 | +${fileContext}`; | |
| 111 | + | |
| 112 | + // Build user message — attach file content if a file is mentioned | |
| 113 | + let userContent: Anthropic.ContentBlockParam[]; | |
| 114 | + if (mentionedFile) { | |
| 115 | + const fileBlocks = buildFileBlocks(mentionedFile); | |
| 116 | + if (fileBlocks.length > 0) { | |
| 117 | + userContent = [...fileBlocks, { type: "text", text: message }]; | |
| 118 | + } else { | |
| 119 | + // File found but couldn't read it — mention ocrText if available | |
| 120 | + const fallback = mentionedFile.ocrText | |
| 121 | + ? `[Contenu OCR de "${mentionedFile.name}"]\n${mentionedFile.ocrText}\n\n` | |
| 122 | + : ""; | |
| 123 | + userContent = [{ type: "text", text: fallback + message }]; | |
| 124 | + } | |
| 125 | + } else { | |
| 126 | + userContent = [{ type: "text", text: message }]; | |
| 127 | + } | |
| 128 | + | |
| 129 | + const messages: Anthropic.MessageParam[] = [ | |
| 130 | + ...(history as ChatMessage[]).map((m) => ({ | |
| 131 | + role: m.role as "user" | "assistant", | |
| 132 | + content: m.content, | |
| 133 | + })), | |
| 134 | + { role: "user" as const, content: userContent }, | |
| 135 | + ]; | |
| 136 | + | |
| 137 | + if (!wantStream) { | |
| 138 | + const response = await anthropic.messages.create({ | |
| 139 | + model: MODEL, | |
| 140 | + max_tokens: 4096, | |
| 141 | + system: systemPrompt, | |
| 142 | + messages, | |
| 143 | + }); | |
| 144 | + const reply = response.content[0].type === "text" ? response.content[0].text : ""; | |
| 145 | + return NextResponse.json({ reply }); | |
| 146 | + } | |
| 147 | + | |
| 148 | + // Streaming | |
| 149 | + const encoder = new TextEncoder(); | |
| 150 | + const readable = new ReadableStream({ | |
| 151 | + async start(controller) { | |
| 152 | + try { | |
| 153 | + const anthropicStream = anthropic.messages.stream({ | |
| 154 | + model: MODEL, | |
| 155 | + max_tokens: 4096, | |
| 156 | + system: systemPrompt, | |
| 157 | + messages, | |
| 158 | + }); | |
| 159 | + | |
| 160 | + for await (const chunk of anthropicStream) { | |
| 161 | + if ( | |
| 162 | + chunk.type === "content_block_delta" && | |
| 163 | + chunk.delta.type === "text_delta" | |
| 164 | + ) { | |
| 165 | + controller.enqueue( | |
| 166 | + encoder.encode(`data: ${JSON.stringify({ token: chunk.delta.text })}\n\n`) | |
| 167 | + ); | |
| 168 | + } | |
| 169 | + } | |
| 170 | + controller.enqueue(encoder.encode("data: [DONE]\n\n")); | |
| 171 | + } catch (err) { | |
| 172 | + controller.enqueue( | |
| 173 | + encoder.encode(`data: ${JSON.stringify({ error: (err as Error).message })}\n\n`) | |
| 174 | + ); | |
| 175 | + } finally { | |
| 176 | + controller.close(); | |
| 177 | + } | |
| 178 | + }, | |
| 179 | + }); | |
| 180 | + | |
| 181 | + return new Response(readable, { | |
| 182 | + headers: { | |
| 183 | + "Content-Type": "text/event-stream", | |
| 184 | + "Cache-Control": "no-cache", | |
| 185 | + "Connection": "keep-alive", | |
| 186 | + }, | |
| 187 | + }); | |
| 17 | 188 | } catch (error) { |
| 18 | − return NextResponse.json({ error: "Chat failed", details: (error as Error).message }, { status: 500 }); | |
| 189 | + return NextResponse.json( | |
| 190 | + { error: "Chat failed", details: (error as Error).message }, | |
| 191 | + { status: 500 } | |
| 192 | + ); | |
| 19 | 193 | } |
| 20 | 194 | } |
added
app/api/ai/files/route.ts
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +import { NextRequest, NextResponse } from "next/server"; | |
| 2 | +import { getIronSession } from "iron-session"; | |
| 3 | +import { SessionData, sessionOptions } from "@/lib/session"; | |
| 4 | +import { prisma } from "@/lib/prisma"; | |
| 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.isLoggedIn) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | |
| 10 | + | |
| 11 | + const { searchParams } = new URL(request.url); | |
| 12 | + const q = searchParams.get("q") || ""; | |
| 13 | + | |
| 14 | + // Include files owned by user + legacy files with no owner (userId=null) | |
| 15 | + const ownerFilter = session.userId | |
| 16 | + ? { OR: [{ userId: session.userId as string }, { userId: null as null }] } | |
| 17 | + : {}; | |
| 18 | + | |
| 19 | + const files = await prisma.file.findMany({ | |
| 20 | + where: { | |
| 21 | + AND: [ | |
| 22 | + ownerFilter, | |
| 23 | + ...(q ? [{ name: { contains: q } }] : []), | |
| 24 | + ], | |
| 25 | + }, | |
| 26 | + select: { | |
| 27 | + id: true, | |
| 28 | + name: true, | |
| 29 | + mimeType: true, | |
| 30 | + size: true, | |
| 31 | + aiTags: true, | |
| 32 | + aiDescription: true, | |
| 33 | + ocrText: true, | |
| 34 | + folder: { select: { name: true } }, | |
| 35 | + }, | |
| 36 | + take: 30, | |
| 37 | + orderBy: { createdAt: "desc" }, | |
| 38 | + }); | |
| 39 | + | |
| 40 | + return NextResponse.json(files); | |
| 41 | +} | |
added
app/api/auth/change-password/route.ts
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +import { NextRequest, NextResponse } from "next/server"; | |
| 2 | +import { getIronSession } from "iron-session"; | |
| 3 | +import { SessionData, sessionOptions } from "@/lib/session"; | |
| 4 | +import { prisma } from "@/lib/prisma"; | |
| 5 | +import bcrypt from "bcryptjs"; | |
| 6 | + | |
| 7 | +export async function POST(request: NextRequest) { | |
| 8 | + const response = NextResponse.next(); | |
| 9 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 10 | + if (!session.isLoggedIn || !session.userId) { | |
| 11 | + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | |
| 12 | + } | |
| 13 | + | |
| 14 | + const { currentPassword, newPassword } = await request.json(); | |
| 15 | + if (!currentPassword || !newPassword) { | |
| 16 | + return NextResponse.json({ error: "Champs requis" }, { status: 400 }); | |
| 17 | + } | |
| 18 | + if (newPassword.length < 6) { | |
| 19 | + return NextResponse.json({ error: "Le nouveau mot de passe doit faire au moins 6 caractères" }, { status: 400 }); | |
| 20 | + } | |
| 21 | + | |
| 22 | + const user = await prisma.user.findUnique({ | |
| 23 | + where: { id: session.userId }, | |
| 24 | + select: { id: true, passwordHash: true }, | |
| 25 | + }); | |
| 26 | + | |
| 27 | + if (!user) { | |
| 28 | + return NextResponse.json({ error: "Utilisateur introuvable" }, { status: 404 }); | |
| 29 | + } | |
| 30 | + | |
| 31 | + const valid = await bcrypt.compare(currentPassword, user.passwordHash); | |
| 32 | + if (!valid) { | |
| 33 | + return NextResponse.json({ error: "Mot de passe actuel incorrect" }, { status: 400 }); | |
| 34 | + } | |
| 35 | + | |
| 36 | + const newHash = await bcrypt.hash(newPassword, 12); | |
| 37 | + await prisma.user.update({ | |
| 38 | + where: { id: session.userId }, | |
| 39 | + data: { passwordHash: newHash }, | |
| 40 | + }); | |
| 41 | + | |
| 42 | + return NextResponse.json({ success: true }); | |
| 43 | +} | |
modified
app/api/auth/login/route.ts
+2 −2
@@ -12,10 +12,10 @@ export async function POST(request: NextRequest) { | ||
| 12 | 12 | if (!allowed) return NextResponse.json({ error: "Trop de tentatives. Réessayez dans 1 minute." }, { status: 429 }); |
| 13 | 13 | |
| 14 | 14 | const body = await request.json(); |
| 15 | − const { password, email } = body; | |
| 15 | + const { password, username } = body; | |
| 16 | 16 | if (!password) return NextResponse.json({ error: "Mot de passe requis" }, { status: 400 }); |
| 17 | 17 | |
| 18 | − const { valid, user } = await verifyPassword(password, email || undefined); | |
| 18 | + const { valid, user } = await verifyPassword(password, username || undefined); | |
| 19 | 19 | if (!valid || !user) return NextResponse.json({ error: "Identifiants incorrects" }, { status: 401 }); |
| 20 | 20 | |
| 21 | 21 | const response = NextResponse.json({ success: true, requires2FA: user.twoFactorEnabled }); |
modified
app/api/files/route.ts
+18 −0
@@ -88,6 +88,24 @@ export async function POST(request: NextRequest) { | ||
| 88 | 88 | ); |
| 89 | 89 | } |
| 90 | 90 | |
| 91 | + // Quota check for shared spaces | |
| 92 | + if (folderId && folderId !== "root") { | |
| 93 | + const folder = await prisma.folder.findUnique({ where: { id: folderId } }); | |
| 94 | + if (folder?.sharedSpaceId) { | |
| 95 | + const space = await prisma.sharedSpace.findUnique({ where: { id: folder.sharedSpaceId } }); | |
| 96 | + if (space) { | |
| 97 | + const agg = await prisma.file.aggregate({ | |
| 98 | + _sum: { size: true }, | |
| 99 | + where: { folder: { sharedSpaceId: folder.sharedSpaceId } }, | |
| 100 | + }); | |
| 101 | + const usedBytes = Number(agg._sum.size || 0); | |
| 102 | + if (usedBytes + file.size > Number(space.quotaBytes)) { | |
| 103 | + return NextResponse.json({ error: "Quota de l'espace partagé dépassé (500 Go max)" }, { status: 413 }); | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 91 | 109 | const buffer = Buffer.from(await file.arrayBuffer()); |
| 92 | 110 | const mimeType = mime.lookup(file.name) || file.type || "application/octet-stream"; |
| 93 | 111 | const { storagePath, size } = await saveFile(buffer, file.name); |
modified
app/api/folders/route.ts
+10 −4
@@ -25,11 +25,16 @@ export async function GET(request: NextRequest) { | ||
| 25 | 25 | return NextResponse.json(folders); |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | + const sharedSpaceId = searchParams.get("sharedSpaceId"); | |
| 28 | 29 | const where: Record<string, unknown> = {}; |
| 29 | − if (parentId === "root" || parentId === null) { | |
| 30 | − where.parentId = null; | |
| 30 | + | |
| 31 | + if (sharedSpaceId) { | |
| 32 | + where.sharedSpaceId = sharedSpaceId; | |
| 33 | + if (parentId === "root" || parentId === null) where.parentId = null; | |
| 34 | + else if (parentId) where.parentId = parentId; | |
| 31 | 35 | } else { |
| 32 | − where.parentId = parentId; | |
| 36 | + if (parentId === "root" || parentId === null) where.parentId = null; | |
| 37 | + else where.parentId = parentId; | |
| 33 | 38 | } |
| 34 | 39 | |
| 35 | 40 | const folders = await prisma.folder.findMany({ |
@@ -45,7 +50,7 @@ export async function GET(request: NextRequest) { | ||
| 45 | 50 | |
| 46 | 51 | export async function POST(request: NextRequest) { |
| 47 | 52 | const body = await request.json(); |
| 48 | − const { name, parentId } = body; | |
| 53 | + const { name, parentId, sharedSpaceId } = body; | |
| 49 | 54 | |
| 50 | 55 | if (!name || !name.trim()) { |
| 51 | 56 | return NextResponse.json({ error: "Folder name required" }, { status: 400 }); |
@@ -55,6 +60,7 @@ export async function POST(request: NextRequest) { | ||
| 55 | 60 | data: { |
| 56 | 61 | name: name.trim(), |
| 57 | 62 | parentId: parentId && parentId !== "root" ? parentId : null, |
| 63 | + sharedSpaceId: sharedSpaceId || null, | |
| 58 | 64 | }, |
| 59 | 65 | }); |
| 60 | 66 | |
added
app/api/passwords/[id]/route.ts
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +import { NextRequest, NextResponse } from "next/server"; | |
| 2 | +import { getIronSession } from "iron-session"; | |
| 3 | +import { SessionData, sessionOptions } from "@/lib/session"; | |
| 4 | +import { prisma } from "@/lib/prisma"; | |
| 5 | +import { encrypt, decrypt } from "@/lib/crypto"; | |
| 6 | + | |
| 7 | +async function getAuthorized(request: NextRequest, id: string) { | |
| 8 | + const response = NextResponse.next(); | |
| 9 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 10 | + if (!session.isLoggedIn || !session.userId) return null; | |
| 11 | + const entry = await prisma.passwordEntry.findFirst({ where: { id, userId: session.userId } }); | |
| 12 | + return entry; | |
| 13 | +} | |
| 14 | + | |
| 15 | +// Reveal decrypted password for a single entry | |
| 16 | +export async function GET(request: NextRequest, { params }: { params: { id: string } }) { | |
| 17 | + try { | |
| 18 | + const entry = await getAuthorized(request, params.id); | |
| 19 | + if (!entry) return NextResponse.json({ error: "Not found" }, { status: 404 }); | |
| 20 | + return NextResponse.json({ ...entry, password: decrypt(entry.password) }); | |
| 21 | + } catch (err) { | |
| 22 | + console.error("[GET /api/passwords/[id]]", err); | |
| 23 | + return NextResponse.json({ error: "Decrypt failed" }, { status: 500 }); | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) { | |
| 28 | + try { | |
| 29 | + const entry = await getAuthorized(request, params.id); | |
| 30 | + if (!entry) return NextResponse.json({ error: "Not found" }, { status: 404 }); | |
| 31 | + | |
| 32 | + const { siteName, siteUrl, username, password, notes } = await request.json(); | |
| 33 | + const data: Record<string, unknown> = {}; | |
| 34 | + if (siteName !== undefined) data.siteName = siteName.trim(); | |
| 35 | + if (siteUrl !== undefined) data.siteUrl = siteUrl?.trim() || null; | |
| 36 | + if (username !== undefined) data.username = username?.trim() || null; | |
| 37 | + if (password !== undefined) data.password = encrypt(password); | |
| 38 | + if (notes !== undefined) data.notes = notes?.trim() || null; | |
| 39 | + | |
| 40 | + const updated = await prisma.passwordEntry.update({ where: { id: params.id }, data }); | |
| 41 | + return NextResponse.json({ ...updated, password: "••••••••" }); | |
| 42 | + } catch (err) { | |
| 43 | + console.error("[PATCH /api/passwords/[id]]", err); | |
| 44 | + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) { | |
| 49 | + try { | |
| 50 | + const entry = await getAuthorized(request, params.id); | |
| 51 | + if (!entry) return NextResponse.json({ error: "Not found" }, { status: 404 }); | |
| 52 | + await prisma.passwordEntry.delete({ where: { id: params.id } }); | |
| 53 | + return NextResponse.json({ success: true }); | |
| 54 | + } catch (err) { | |
| 55 | + console.error("[DELETE /api/passwords/[id]]", err); | |
| 56 | + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); | |
| 57 | + } | |
| 58 | +} | |
added
app/api/passwords/route.ts
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +import { NextRequest, NextResponse } from "next/server"; | |
| 2 | +import { getIronSession } from "iron-session"; | |
| 3 | +import { SessionData, sessionOptions } from "@/lib/session"; | |
| 4 | +import { prisma } from "@/lib/prisma"; | |
| 5 | +import { encrypt, decrypt } from "@/lib/crypto"; | |
| 6 | + | |
| 7 | +export async function GET(request: NextRequest) { | |
| 8 | + try { | |
| 9 | + const response = NextResponse.next(); | |
| 10 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 11 | + if (!session.isLoggedIn || !session.userId) { | |
| 12 | + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | |
| 13 | + } | |
| 14 | + | |
| 15 | + const entries = await prisma.passwordEntry.findMany({ | |
| 16 | + where: { userId: session.userId }, | |
| 17 | + orderBy: { siteName: "asc" }, | |
| 18 | + }); | |
| 19 | + | |
| 20 | + const { searchParams } = new URL(request.url); | |
| 21 | + const withPasswords = searchParams.get("reveal") === "1"; | |
| 22 | + | |
| 23 | + return NextResponse.json( | |
| 24 | + entries.map((e) => ({ | |
| 25 | + ...e, | |
| 26 | + password: withPasswords ? (() => { try { return decrypt(e.password); } catch { return ""; } })() : "••••••••", | |
| 27 | + })) | |
| 28 | + ); | |
| 29 | + } catch (err) { | |
| 30 | + console.error("[GET /api/passwords]", err); | |
| 31 | + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +export async function POST(request: NextRequest) { | |
| 36 | + try { | |
| 37 | + const response = NextResponse.next(); | |
| 38 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 39 | + if (!session.isLoggedIn || !session.userId) { | |
| 40 | + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | |
| 41 | + } | |
| 42 | + | |
| 43 | + const body = await request.json(); | |
| 44 | + const { siteName, siteUrl, username, password, notes } = body; | |
| 45 | + if (!siteName || !password) { | |
| 46 | + return NextResponse.json({ error: "siteName et password requis" }, { status: 400 }); | |
| 47 | + } | |
| 48 | + | |
| 49 | + const encryptedPassword = encrypt(password); | |
| 50 | + | |
| 51 | + const entry = await prisma.passwordEntry.create({ | |
| 52 | + data: { | |
| 53 | + userId: session.userId, | |
| 54 | + siteName: siteName.trim(), | |
| 55 | + siteUrl: siteUrl?.trim() || null, | |
| 56 | + username: username?.trim() || null, | |
| 57 | + password: encryptedPassword, | |
| 58 | + notes: notes?.trim() || null, | |
| 59 | + }, | |
| 60 | + }); | |
| 61 | + | |
| 62 | + return NextResponse.json({ ...entry, password: "••••••••" }, { status: 201 }); | |
| 63 | + } catch (err) { | |
| 64 | + console.error("[POST /api/passwords]", err); | |
| 65 | + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); | |
| 66 | + } | |
| 67 | +} | |
added
app/api/shared-spaces/[id]/members/route.ts
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import { NextRequest, NextResponse } from "next/server"; | |
| 2 | +import { getIronSession } from "iron-session"; | |
| 3 | +import { SessionData, sessionOptions } from "@/lib/session"; | |
| 4 | +import { prisma } from "@/lib/prisma"; | |
| 5 | +import { requireAdmin } from "@/lib/auth"; | |
| 6 | + | |
| 7 | +export async function POST(request: NextRequest, { params }: { params: { id: string } }) { | |
| 8 | + const response = NextResponse.next(); | |
| 9 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 10 | + if (!session.userId || !(await requireAdmin(session.userId))) { | |
| 11 | + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | |
| 12 | + } | |
| 13 | + | |
| 14 | + const { userId } = await request.json(); | |
| 15 | + if (!userId) return NextResponse.json({ error: "userId requis" }, { status: 400 }); | |
| 16 | + | |
| 17 | + const member = await prisma.sharedSpaceMember.create({ | |
| 18 | + data: { sharedSpaceId: params.id, userId }, | |
| 19 | + include: { user: { select: { id: true, name: true, username: true } } }, | |
| 20 | + }); | |
| 21 | + | |
| 22 | + return NextResponse.json(member, { status: 201 }); | |
| 23 | +} | |
| 24 | + | |
| 25 | +export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) { | |
| 26 | + const response = NextResponse.next(); | |
| 27 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 28 | + if (!session.userId || !(await requireAdmin(session.userId))) { | |
| 29 | + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | |
| 30 | + } | |
| 31 | + | |
| 32 | + const { userId } = await request.json(); | |
| 33 | + await prisma.sharedSpaceMember.deleteMany({ | |
| 34 | + where: { sharedSpaceId: params.id, userId }, | |
| 35 | + }); | |
| 36 | + | |
| 37 | + return NextResponse.json({ success: true }); | |
| 38 | +} | |
added
app/api/shared-spaces/[id]/route.ts
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +import { NextRequest, NextResponse } from "next/server"; | |
| 2 | +import { getIronSession } from "iron-session"; | |
| 3 | +import { SessionData, sessionOptions } from "@/lib/session"; | |
| 4 | +import { prisma } from "@/lib/prisma"; | |
| 5 | +import { requireAdmin } from "@/lib/auth"; | |
| 6 | + | |
| 7 | +export async function GET(request: NextRequest, { params }: { params: { id: string } }) { | |
| 8 | + const response = NextResponse.next(); | |
| 9 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 10 | + if (!session.userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | |
| 11 | + | |
| 12 | + const space = await prisma.sharedSpace.findUnique({ | |
| 13 | + where: { id: params.id }, | |
| 14 | + include: { | |
| 15 | + members: { include: { user: { select: { id: true, name: true, username: true } } } }, | |
| 16 | + folders: { select: { id: true, name: true, parentId: true } }, | |
| 17 | + }, | |
| 18 | + }); | |
| 19 | + | |
| 20 | + if (!space) return NextResponse.json({ error: "Not found" }, { status: 404 }); | |
| 21 | + | |
| 22 | + const agg = await prisma.file.aggregate({ | |
| 23 | + _sum: { size: true }, | |
| 24 | + where: { folder: { sharedSpaceId: params.id } }, | |
| 25 | + }); | |
| 26 | + | |
| 27 | + return NextResponse.json({ | |
| 28 | + ...space, | |
| 29 | + quotaBytes: Number(space.quotaBytes), | |
| 30 | + usedBytes: Number(agg._sum.size || 0), | |
| 31 | + }); | |
| 32 | +} | |
| 33 | + | |
| 34 | +export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) { | |
| 35 | + const response = NextResponse.next(); | |
| 36 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 37 | + if (!session.userId || !(await requireAdmin(session.userId))) { | |
| 38 | + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | |
| 39 | + } | |
| 40 | + | |
| 41 | + const { name, quotaBytes } = await request.json(); | |
| 42 | + const data: Record<string, unknown> = {}; | |
| 43 | + if (name) data.name = name; | |
| 44 | + if (quotaBytes) data.quotaBytes = BigInt(quotaBytes); | |
| 45 | + | |
| 46 | + const space = await prisma.sharedSpace.update({ where: { id: params.id }, data }); | |
| 47 | + return NextResponse.json({ ...space, quotaBytes: Number(space.quotaBytes) }); | |
| 48 | +} | |
| 49 | + | |
| 50 | +export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) { | |
| 51 | + const response = NextResponse.next(); | |
| 52 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 53 | + if (!session.userId || !(await requireAdmin(session.userId))) { | |
| 54 | + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | |
| 55 | + } | |
| 56 | + | |
| 57 | + await prisma.sharedSpace.delete({ where: { id: params.id } }); | |
| 58 | + return NextResponse.json({ success: true }); | |
| 59 | +} | |
added
app/api/shared-spaces/[id]/storage/route.ts
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +import { NextRequest, NextResponse } from "next/server"; | |
| 2 | +import { getIronSession } from "iron-session"; | |
| 3 | +import { SessionData, sessionOptions } from "@/lib/session"; | |
| 4 | +import { prisma } from "@/lib/prisma"; | |
| 5 | + | |
| 6 | +export async function GET(request: NextRequest, { params }: { params: { id: string } }) { | |
| 7 | + const response = NextResponse.next(); | |
| 8 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 9 | + if (!session.userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | |
| 10 | + | |
| 11 | + const space = await prisma.sharedSpace.findUnique({ where: { id: params.id } }); | |
| 12 | + if (!space) return NextResponse.json({ error: "Not found" }, { status: 404 }); | |
| 13 | + | |
| 14 | + const agg = await prisma.file.aggregate({ | |
| 15 | + _sum: { size: true }, | |
| 16 | + where: { folder: { sharedSpaceId: params.id } }, | |
| 17 | + }); | |
| 18 | + | |
| 19 | + const quotaBytes = Number(space.quotaBytes); | |
| 20 | + const usedBytes = Number(agg._sum.size || 0); | |
| 21 | + const percent = quotaBytes > 0 ? Math.round((usedBytes / quotaBytes) * 100) : 0; | |
| 22 | + | |
| 23 | + return NextResponse.json({ quotaBytes, usedBytes, percent }); | |
| 24 | +} | |
added
app/api/shared-spaces/mine/route.ts
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import { NextRequest, NextResponse } from "next/server"; | |
| 2 | +import { getIronSession } from "iron-session"; | |
| 3 | +import { SessionData, sessionOptions } from "@/lib/session"; | |
| 4 | +import { prisma } from "@/lib/prisma"; | |
| 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: "Unauthorized" }, { status: 401 }); | |
| 10 | + | |
| 11 | + const memberships = await prisma.sharedSpaceMember.findMany({ | |
| 12 | + where: { userId: session.userId }, | |
| 13 | + include: { sharedSpace: true }, | |
| 14 | + }); | |
| 15 | + | |
| 16 | + return NextResponse.json( | |
| 17 | + memberships.map((m) => ({ | |
| 18 | + ...m.sharedSpace, | |
| 19 | + quotaBytes: Number(m.sharedSpace.quotaBytes), | |
| 20 | + })) | |
| 21 | + ); | |
| 22 | +} | |
added
app/api/shared-spaces/route.ts
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +import { NextRequest, NextResponse } from "next/server"; | |
| 2 | +import { getIronSession } from "iron-session"; | |
| 3 | +import { SessionData, sessionOptions } from "@/lib/session"; | |
| 4 | +import { prisma } from "@/lib/prisma"; | |
| 5 | +import { requireAdmin } from "@/lib/auth"; | |
| 6 | + | |
| 7 | +export async function GET(request: NextRequest) { | |
| 8 | + const response = NextResponse.next(); | |
| 9 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 10 | + if (!session.userId || !(await requireAdmin(session.userId))) { | |
| 11 | + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | |
| 12 | + } | |
| 13 | + | |
| 14 | + const spaces = await prisma.sharedSpace.findMany({ | |
| 15 | + orderBy: { createdAt: "desc" }, | |
| 16 | + include: { | |
| 17 | + _count: { select: { members: true, folders: true } }, | |
| 18 | + members: { include: { user: { select: { id: true, name: true, username: true } } } }, | |
| 19 | + }, | |
| 20 | + }); | |
| 21 | + | |
| 22 | + const result = await Promise.all( | |
| 23 | + spaces.map(async (space) => { | |
| 24 | + const agg = await prisma.file.aggregate({ | |
| 25 | + _sum: { size: true }, | |
| 26 | + where: { folder: { sharedSpaceId: space.id } }, | |
| 27 | + }); | |
| 28 | + return { | |
| 29 | + ...space, | |
| 30 | + quotaBytes: Number(space.quotaBytes), | |
| 31 | + usedBytes: Number(agg._sum.size || 0), | |
| 32 | + }; | |
| 33 | + }) | |
| 34 | + ); | |
| 35 | + | |
| 36 | + return NextResponse.json(result); | |
| 37 | +} | |
| 38 | + | |
| 39 | +export async function POST(request: NextRequest) { | |
| 40 | + const response = NextResponse.next(); | |
| 41 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 42 | + // Any logged-in user can create a shared space | |
| 43 | + if (!session.isLoggedIn || !session.userId) { | |
| 44 | + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | |
| 45 | + } | |
| 46 | + | |
| 47 | + const { name, memberIds, quotaBytes } = await request.json(); | |
| 48 | + if (!name) return NextResponse.json({ error: "Nom requis" }, { status: 400 }); | |
| 49 | + | |
| 50 | + // Always include the creator as a member | |
| 51 | + const allMemberIds = Array.from(new Set([session.userId, ...(memberIds || [])])); | |
| 52 | + | |
| 53 | + const space = await prisma.sharedSpace.create({ | |
| 54 | + data: { | |
| 55 | + name, | |
| 56 | + quotaBytes: quotaBytes ? BigInt(quotaBytes) : BigInt(536870912000), | |
| 57 | + members: { create: allMemberIds.map((userId: string) => ({ userId })) }, | |
| 58 | + }, | |
| 59 | + include: { | |
| 60 | + _count: { select: { members: true, folders: true } }, | |
| 61 | + members: { include: { user: { select: { id: true, name: true, username: true } } } }, | |
| 62 | + }, | |
| 63 | + }); | |
| 64 | + | |
| 65 | + return NextResponse.json({ ...space, quotaBytes: Number(space.quotaBytes), usedBytes: 0 }, { status: 201 }); | |
| 66 | +} | |
modified
app/api/users/[id]/route.ts
+2 −2
@@ -12,14 +12,14 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st | ||
| 12 | 12 | const body = await request.json(); |
| 13 | 13 | const data: Record<string, unknown> = {}; |
| 14 | 14 | if (body.name !== undefined) data.name = body.name; |
| 15 | − if (body.email !== undefined) data.email = body.email; | |
| 15 | + if (body.username !== undefined) data.username = body.username; | |
| 16 | 16 | if (body.role !== undefined) data.role = body.role; |
| 17 | 17 | if (body.password) data.passwordHash = await hashPassword(body.password); |
| 18 | 18 | |
| 19 | 19 | const user = await prisma.user.update({ |
| 20 | 20 | where: { id: params.id }, |
| 21 | 21 | data, |
| 22 | − select: { id: true, email: true, name: true, role: true }, | |
| 22 | + select: { id: true, username: true, name: true, role: true }, | |
| 23 | 23 | }); |
| 24 | 24 | return NextResponse.json(user); |
| 25 | 25 | } |
added
app/api/users/public/route.ts
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import { NextRequest, NextResponse } from "next/server"; | |
| 2 | +import { getIronSession } from "iron-session"; | |
| 3 | +import { SessionData, sessionOptions } from "@/lib/session"; | |
| 4 | +import { prisma } from "@/lib/prisma"; | |
| 5 | + | |
| 6 | +// Returns minimal user info (id, name, username) for any logged-in user | |
| 7 | +// Used for the shared folder member picker | |
| 8 | +export async function GET(request: NextRequest) { | |
| 9 | + const response = NextResponse.next(); | |
| 10 | + const session = await getIronSession<SessionData>(request, response, sessionOptions); | |
| 11 | + if (!session.isLoggedIn || !session.userId) { | |
| 12 | + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | |
| 13 | + } | |
| 14 | + | |
| 15 | + const users = await prisma.user.findMany({ | |
| 16 | + where: { id: { not: session.userId } }, // exclude current user (they're added automatically) | |
| 17 | + select: { id: true, name: true, username: true }, | |
| 18 | + orderBy: { username: "asc" }, | |
| 19 | + }); | |
| 20 | + | |
| 21 | + return NextResponse.json(users); | |
| 22 | +} | |
modified
app/api/users/route.ts
+7 −7
@@ -10,7 +10,7 @@ export async function GET(request: NextRequest) { | ||
| 10 | 10 | if (!session.userId || !(await requireAdmin(session.userId))) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); |
| 11 | 11 | |
| 12 | 12 | const users = await prisma.user.findMany({ |
| 13 | − select: { id: true, email: true, name: true, role: true, twoFactorEnabled: true, createdAt: true, _count: { select: { files: true, folders: true } } }, | |
| 13 | + select: { id: true, username: true, email: true, name: true, role: true, twoFactorEnabled: true, createdAt: true, _count: { select: { files: true, folders: true } } }, | |
| 14 | 14 | orderBy: { createdAt: "asc" }, |
| 15 | 15 | }); |
| 16 | 16 | return NextResponse.json(users); |
@@ -22,15 +22,15 @@ export async function POST(request: NextRequest) { | ||
| 22 | 22 | if (!session.userId || !(await requireAdmin(session.userId))) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); |
| 23 | 23 | |
| 24 | 24 | const body = await request.json(); |
| 25 | − const { email, name, password, role } = body; | |
| 26 | − if (!email || !password) return NextResponse.json({ error: "Email et mot de passe requis" }, { status: 400 }); | |
| 25 | + const { username, name, password, role } = body; | |
| 26 | + if (!username || !password) return NextResponse.json({ error: "Nom d'utilisateur et mot de passe requis" }, { status: 400 }); | |
| 27 | 27 | |
| 28 | − const existing = await prisma.user.findUnique({ where: { email } }); | |
| 29 | − if (existing) return NextResponse.json({ error: "Email d\u00e9j\u00e0 utilis\u00e9" }, { status: 409 }); | |
| 28 | + const existing = await prisma.user.findUnique({ where: { username } }); | |
| 29 | + if (existing) return NextResponse.json({ error: "Nom d'utilisateur déjà utilisé" }, { status: 409 }); | |
| 30 | 30 | |
| 31 | 31 | const user = await prisma.user.create({ |
| 32 | − data: { email, name: name || null, passwordHash: await hashPassword(password), role: role || "user" }, | |
| 33 | − select: { id: true, email: true, name: true, role: true, createdAt: true }, | |
| 32 | + data: { username, name: name || null, passwordHash: await hashPassword(password), role: role || "user" }, | |
| 33 | + select: { id: true, username: true, name: true, role: true, createdAt: true }, | |
| 34 | 34 | }); |
| 35 | 35 | return NextResponse.json(user, { status: 201 }); |
| 36 | 36 | } |
modified
app/api/v1/files/route.ts
+39 −1
@@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { NextRequest, NextResponse } from "next/server"; |
| 2 | 2 | import { prisma } from "@/lib/prisma"; |
| 3 | +import { saveFile } from "@/lib/storage"; | |
| 3 | 4 | import { createHash } from "crypto"; |
| 5 | +import mime from "mime-types"; | |
| 4 | 6 | |
| 5 | 7 | async function validateApiKey(request: NextRequest) { |
| 6 | 8 | const auth = request.headers.get("authorization"); |
@@ -22,7 +24,7 @@ export async function GET(request: NextRequest) { | ||
| 22 | 24 | const folderId = searchParams.get("folderId"); |
| 23 | 25 | const where: Record<string, unknown> = {}; |
| 24 | 26 | if (folderId && folderId !== "root") where.folderId = folderId; |
| 25 | − else where.folderId = null; | |
| 27 | + else if (!folderId) where.folderId = null; | |
| 26 | 28 | |
| 27 | 29 | const files = await prisma.file.findMany({ |
| 28 | 30 | where, |
@@ -33,3 +35,39 @@ export async function GET(request: NextRequest) { | ||
| 33 | 35 | |
| 34 | 36 | return NextResponse.json({ files: files.map((f) => ({ ...f, size: Number(f.size) })) }); |
| 35 | 37 | } |
| 38 | + | |
| 39 | +export async function POST(request: NextRequest) { | |
| 40 | + const apiKey = await validateApiKey(request); | |
| 41 | + if (!apiKey) return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); | |
| 42 | + if (!apiKey.permissions.includes("write") && !apiKey.permissions.includes("admin")) { | |
| 43 | + return NextResponse.json({ error: "API key does not have write permission" }, { status: 403 }); | |
| 44 | + } | |
| 45 | + | |
| 46 | + const formData = await request.formData(); | |
| 47 | + const file = formData.get("file") as File | null; | |
| 48 | + const folderId = formData.get("folderId") as string | null; | |
| 49 | + | |
| 50 | + if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 }); | |
| 51 | + | |
| 52 | + const maxSize = parseInt(process.env.MAX_FILE_SIZE || "524288000"); | |
| 53 | + if (file.size > maxSize) { | |
| 54 | + return NextResponse.json({ error: `File too large. Max ${Math.round(maxSize / 1024 / 1024)}MB` }, { status: 413 }); | |
| 55 | + } | |
| 56 | + | |
| 57 | + const buffer = Buffer.from(await file.arrayBuffer()); | |
| 58 | + const mimeType = mime.lookup(file.name) || file.type || "application/octet-stream"; | |
| 59 | + const { storagePath, size } = await saveFile(buffer, file.name); | |
| 60 | + | |
| 61 | + const dbFile = await prisma.file.create({ | |
| 62 | + data: { | |
| 63 | + name: file.name, | |
| 64 | + storagePath, | |
| 65 | + size: BigInt(size), | |
| 66 | + mimeType, | |
| 67 | + userId: apiKey.userId, | |
| 68 | + folderId: folderId && folderId !== "root" ? folderId : null, | |
| 69 | + }, | |
| 70 | + }); | |
| 71 | + | |
| 72 | + return NextResponse.json({ ...dbFile, size: Number(dbFile.size) }, { status: 201 }); | |
| 73 | +} | |
added
app/api/v1/folders/route.ts
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +import { NextRequest, NextResponse } from "next/server"; | |
| 2 | +import { prisma } from "@/lib/prisma"; | |
| 3 | +import { createHash } from "crypto"; | |
| 4 | + | |
| 5 | +async function validateApiKey(request: NextRequest) { | |
| 6 | + const auth = request.headers.get("authorization"); | |
| 7 | + if (!auth?.startsWith("Bearer ")) return null; | |
| 8 | + const rawKey = auth.substring(7); | |
| 9 | + const keyHash = createHash("sha256").update(rawKey).digest("hex"); | |
| 10 | + const apiKey = await prisma.apiKey.findUnique({ where: { keyHash } }); | |
| 11 | + if (!apiKey) return null; | |
| 12 | + if (apiKey.expiresAt && apiKey.expiresAt < new Date()) return null; | |
| 13 | + await prisma.apiKey.update({ where: { id: apiKey.id }, data: { lastUsedAt: new Date() } }); | |
| 14 | + return apiKey; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export async function GET(request: NextRequest) { | |
| 18 | + const apiKey = await validateApiKey(request); | |
| 19 | + if (!apiKey) return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); | |
| 20 | + | |
| 21 | + const folders = await prisma.folder.findMany({ | |
| 22 | + select: { id: true, name: true, parentId: true, createdAt: true }, | |
| 23 | + orderBy: { name: "asc" }, | |
| 24 | + }); | |
| 25 | + | |
| 26 | + return NextResponse.json({ folders }); | |
| 27 | +} | |
| 28 | + | |
| 29 | +export async function POST(request: NextRequest) { | |
| 30 | + const apiKey = await validateApiKey(request); | |
| 31 | + if (!apiKey) return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); | |
| 32 | + if (!apiKey.permissions.includes("write") && !apiKey.permissions.includes("admin")) { | |
| 33 | + return NextResponse.json({ error: "API key does not have write permission" }, { status: 403 }); | |
| 34 | + } | |
| 35 | + | |
| 36 | + const { name, parentId } = await request.json(); | |
| 37 | + if (!name) return NextResponse.json({ error: "Name required" }, { status: 400 }); | |
| 38 | + | |
| 39 | + const folder = await prisma.folder.create({ | |
| 40 | + data: { | |
| 41 | + name, | |
| 42 | + parentId: parentId || null, | |
| 43 | + userId: apiKey.userId, | |
| 44 | + }, | |
| 45 | + }); | |
| 46 | + | |
| 47 | + return NextResponse.json(folder, { status: 201 }); | |
| 48 | +} | |
added
app/dashboard/passwords/page.tsx
+662 −0
@@ -0,0 +1,662 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useState, useEffect, useCallback } from "react"; | |
| 4 | +import AppShell from "@/components/layout/AppShell"; | |
| 5 | +import { motion, AnimatePresence } from "framer-motion"; | |
| 6 | +import { | |
| 7 | + Lock, Plus, Eye, EyeOff, Copy, Trash2, Edit2, Search, | |
| 8 | + RefreshCw, Shield, ShieldAlert, ShieldCheck, ShieldX, | |
| 9 | + Zap, Key, Globe, User, FileText, Check, X, ChevronDown, | |
| 10 | +} from "lucide-react"; | |
| 11 | +import toast from "react-hot-toast"; | |
| 12 | +import Modal from "@/components/ui/Modal"; | |
| 13 | +import Button from "@/components/ui/Button"; | |
| 14 | + | |
| 15 | +// ─── Types ──────────────────────────────────────────────────────────────────── | |
| 16 | +interface PasswordEntry { | |
| 17 | + id: string; | |
| 18 | + siteName: string; | |
| 19 | + siteUrl: string | null; | |
| 20 | + username: string | null; | |
| 21 | + password: string; | |
| 22 | + notes: string | null; | |
| 23 | + createdAt: string; | |
| 24 | +} | |
| 25 | + | |
| 26 | +// ─── Password Strength Logic ────────────────────────────────────────────────── | |
| 27 | +function analyzePassword(pwd: string) { | |
| 28 | + if (!pwd) return { score: 0, label: "", color: "", checks: [] }; | |
| 29 | + | |
| 30 | + const checks = [ | |
| 31 | + { label: "Au moins 8 caractères", ok: pwd.length >= 8 }, | |
| 32 | + { label: "Au moins 12 caractères", ok: pwd.length >= 12 }, | |
| 33 | + { label: "Majuscules (A-Z)", ok: /[A-Z]/.test(pwd) }, | |
| 34 | + { label: "Minuscules (a-z)", ok: /[a-z]/.test(pwd) }, | |
| 35 | + { label: "Chiffres (0-9)", ok: /[0-9]/.test(pwd) }, | |
| 36 | + { label: "Symboles (!@#$...)", ok: /[^A-Za-z0-9]/.test(pwd) }, | |
| 37 | + ]; | |
| 38 | + | |
| 39 | + const score = checks.filter((c) => c.ok).length; | |
| 40 | + | |
| 41 | + 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 | + | |
| 45 | + return { score, label: labels[score] || "", color: colors[score] || "", barColor: bars[score] || "", checks }; | |
| 46 | +} | |
| 47 | + | |
| 48 | +// ─── Password Generator ─────────────────────────────────────────────────────── | |
| 49 | +function generatePassword(opts: { length: number; upper: boolean; lower: boolean; numbers: boolean; symbols: boolean }) { | |
| 50 | + const sets = [ | |
| 51 | + opts.upper ? "ABCDEFGHIJKLMNOPQRSTUVWXYZ" : "", | |
| 52 | + opts.lower ? "abcdefghijklmnopqrstuvwxyz" : "", | |
| 53 | + opts.numbers ? "0123456789" : "", | |
| 54 | + opts.symbols ? "!@#$%^&*()-_=+[]{}|;:,.<>?" : "", | |
| 55 | + ].join(""); | |
| 56 | + if (!sets) return ""; | |
| 57 | + return Array.from({ length: opts.length }, () => sets[Math.floor(Math.random() * sets.length)]).join(""); | |
| 58 | +} | |
| 59 | + | |
| 60 | +// ─── Site Icon ──────────────────────────────────────────────────────────────── | |
| 61 | +function SiteIcon({ name, url }: { name: string; url?: string | null }) { | |
| 62 | + const colors = ["bg-blue-500", "bg-red-500", "bg-green-500", "bg-purple-500", "bg-orange-500", "bg-pink-500", "bg-teal-500", "bg-indigo-500"]; | |
| 63 | + const color = colors[name.charCodeAt(0) % colors.length]; | |
| 64 | + if (url) { | |
| 65 | + try { | |
| 66 | + const domain = new URL(url.startsWith("http") ? url : `https://${url}`).hostname; | |
| 67 | + return ( | |
| 68 | + // eslint-disable-next-line @next/next/no-img-element | |
| 69 | + <img | |
| 70 | + src={`https://www.google.com/s2/favicons?domain=${domain}&sz=32`} | |
| 71 | + alt={name} | |
| 72 | + className="w-8 h-8 rounded-lg" | |
| 73 | + onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} | |
| 74 | + /> | |
| 75 | + ); | |
| 76 | + } catch {} | |
| 77 | + } | |
| 78 | + 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`}> | |
| 80 | + {name[0]?.toUpperCase()} | |
| 81 | + </div> | |
| 82 | + ); | |
| 83 | +} | |
| 84 | + | |
| 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"; | |
| 86 | + | |
| 87 | +// ─── Main Page ──────────────────────────────────────────────────────────────── | |
| 88 | +export default function PasswordsPage() { | |
| 89 | + const [entries, setEntries] = useState<PasswordEntry[]>([]); | |
| 90 | + const [loading, setLoading] = useState(true); | |
| 91 | + const [search, setSearch] = useState(""); | |
| 92 | + const [tab, setTab] = useState<"vault" | "generator" | "checker">("vault"); | |
| 93 | + | |
| 94 | + // Modal state | |
| 95 | + const [showAdd, setShowAdd] = useState(false); | |
| 96 | + const [editEntry, setEditEntry] = useState<PasswordEntry | null>(null); | |
| 97 | + const [form, setForm] = useState({ siteName: "", siteUrl: "", username: "", password: "", notes: "" }); | |
| 98 | + const [formShowPw, setFormShowPw] = useState(false); | |
| 99 | + const [saving, setSaving] = useState(false); | |
| 100 | + | |
| 101 | + // Revealed passwords per entry | |
| 102 | + const [revealed, setRevealed] = useState<Record<string, string>>({}); | |
| 103 | + const [revealing, setRevealing] = useState<string | null>(null); | |
| 104 | + | |
| 105 | + // Generator state | |
| 106 | + const [genOpts, setGenOpts] = useState({ length: 16, upper: true, lower: true, numbers: true, symbols: true }); | |
| 107 | + const [genResult, setGenResult] = useState(""); | |
| 108 | + const [genCopied, setGenCopied] = useState(false); | |
| 109 | + | |
| 110 | + // Checker state | |
| 111 | + const [checkerPwd, setCheckerPwd] = useState(""); | |
| 112 | + const [checkerShow, setCheckerShow] = useState(false); | |
| 113 | + | |
| 114 | + // ── Fetch ── | |
| 115 | + const fetchEntries = useCallback(async () => { | |
| 116 | + setLoading(true); | |
| 117 | + try { | |
| 118 | + const res = await fetch("/api/passwords"); | |
| 119 | + if (res.ok) { | |
| 120 | + setEntries(await res.json()); | |
| 121 | + } else if (res.status === 401) { | |
| 122 | + toast.error("Session expirée, veuillez vous reconnecter"); | |
| 123 | + } else { | |
| 124 | + toast.error("Erreur lors du chargement"); | |
| 125 | + } | |
| 126 | + } catch { | |
| 127 | + toast.error("Erreur réseau"); | |
| 128 | + } | |
| 129 | + setLoading(false); | |
| 130 | + }, []); | |
| 131 | + | |
| 132 | + useEffect(() => { fetchEntries(); }, [fetchEntries]); | |
| 133 | + useEffect(() => { setGenResult(generatePassword(genOpts)); }, [genOpts]); | |
| 134 | + | |
| 135 | + // ── Reveal password ── | |
| 136 | + const revealPassword = async (id: string) => { | |
| 137 | + if (revealed[id]) { | |
| 138 | + setRevealed((prev) => { const n = { ...prev }; delete n[id]; return n; }); | |
| 139 | + return; | |
| 140 | + } | |
| 141 | + setRevealing(id); | |
| 142 | + const res = await fetch(`/api/passwords/${id}`); | |
| 143 | + if (res.ok) { | |
| 144 | + const data = await res.json(); | |
| 145 | + setRevealed((prev) => ({ ...prev, [id]: data.password })); | |
| 146 | + } | |
| 147 | + setRevealing(null); | |
| 148 | + }; | |
| 149 | + | |
| 150 | + // ── Copy ── | |
| 151 | + const copyText = (text: string, label = "Copié !") => { | |
| 152 | + navigator.clipboard.writeText(text).then(() => toast.success(label)); | |
| 153 | + }; | |
| 154 | + | |
| 155 | + // ── Save (add or edit) ── | |
| 156 | + const handleSave = async () => { | |
| 157 | + if (!form.siteName.trim() || !form.password.trim()) return; | |
| 158 | + setSaving(true); | |
| 159 | + try { | |
| 160 | + const method = editEntry ? "PATCH" : "POST"; | |
| 161 | + const url = editEntry ? `/api/passwords/${editEntry.id}` : "/api/passwords"; | |
| 162 | + const res = await fetch(url, { | |
| 163 | + method, | |
| 164 | + headers: { "Content-Type": "application/json" }, | |
| 165 | + body: JSON.stringify(form), | |
| 166 | + }); | |
| 167 | + if (res.ok) { | |
| 168 | + toast.success(editEntry ? "Modifié" : "Ajouté"); | |
| 169 | + setShowAdd(false); | |
| 170 | + setEditEntry(null); | |
| 171 | + setForm({ siteName: "", siteUrl: "", username: "", password: "", notes: "" }); | |
| 172 | + setFormShowPw(false); | |
| 173 | + fetchEntries(); | |
| 174 | + setRevealed({}); | |
| 175 | + } else { | |
| 176 | + let errMsg = "Erreur"; | |
| 177 | + try { | |
| 178 | + const d = await res.json(); | |
| 179 | + errMsg = d.error || errMsg; | |
| 180 | + } catch {} | |
| 181 | + toast.error(errMsg); | |
| 182 | + } | |
| 183 | + } catch (e) { | |
| 184 | + console.error("handleSave error:", e); | |
| 185 | + toast.error("Erreur réseau"); | |
| 186 | + } finally { | |
| 187 | + setSaving(false); | |
| 188 | + } | |
| 189 | + }; | |
| 190 | + | |
| 191 | + const openEdit = async (entry: PasswordEntry) => { | |
| 192 | + // Fetch decrypted password for editing | |
| 193 | + const res = await fetch(`/api/passwords/${entry.id}`); | |
| 194 | + const data = res.ok ? await res.json() : entry; | |
| 195 | + setForm({ siteName: data.siteName, siteUrl: data.siteUrl || "", username: data.username || "", password: data.password, notes: data.notes || "" }); | |
| 196 | + setEditEntry(entry); | |
| 197 | + setShowAdd(true); | |
| 198 | + setFormShowPw(false); | |
| 199 | + }; | |
| 200 | + | |
| 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 | + }; | |
| 206 | + | |
| 207 | + // ── Generator ── | |
| 208 | + const regenerate = () => setGenResult(generatePassword(genOpts)); | |
| 209 | + const copyGen = () => { | |
| 210 | + copyText(genResult, "Mot de passe copié !"); | |
| 211 | + setGenCopied(true); | |
| 212 | + setTimeout(() => setGenCopied(false), 2000); | |
| 213 | + }; | |
| 214 | + | |
| 215 | + // ── Filter ── | |
| 216 | + const filtered = entries.filter((e) => | |
| 217 | + e.siteName.toLowerCase().includes(search.toLowerCase()) || | |
| 218 | + (e.username || "").toLowerCase().includes(search.toLowerCase()) | |
| 219 | + ); | |
| 220 | + | |
| 221 | + const analysis = analyzePassword(checkerPwd); | |
| 222 | + | |
| 223 | + // ── Render ── | |
| 224 | + return ( | |
| 225 | + <AppShell> | |
| 226 | + <div className="flex flex-col h-full overflow-hidden"> | |
| 227 | + {/* 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> | |
| 260 | + </div> | |
| 261 | + | |
| 262 | + {/* ── VAULT TAB ── */} | |
| 263 | + {tab === "vault" && ( | |
| 264 | + <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"> | |
| 267 | + <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" /> | |
| 269 | + <input | |
| 270 | + type="text" | |
| 271 | + value={search} | |
| 272 | + 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" | |
| 275 | + /> | |
| 276 | + </div> | |
| 277 | + <Button size="sm" onClick={() => { setEditEntry(null); setForm({ siteName: "", siteUrl: "", username: "", password: "", notes: "" }); setShowAdd(true); }}> | |
| 278 | + <Plus className="w-4 h-4" /> | |
| 279 | + <span className="hidden sm:inline">Ajouter</span> | |
| 280 | + </Button> | |
| 281 | + </div> | |
| 282 | + | |
| 283 | + {/* List */} | |
| 284 | + <div className="flex-1 overflow-auto px-4 lg:px-6 py-4 space-y-2"> | |
| 285 | + {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" /> | |
| 288 | + </div> | |
| 289 | + )} | |
| 290 | + | |
| 291 | + {!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> | |
| 298 | + )} | |
| 299 | + | |
| 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"} | |
| 341 | + > | |
| 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 | + })} | |
| 361 | + </div> | |
| 362 | + </div> | |
| 363 | + )} | |
| 364 | + | |
| 365 | + {/* ── GENERATOR TAB ── */} | |
| 366 | + {tab === "generator" && ( | |
| 367 | + <div className="flex-1 overflow-auto px-4 lg:px-6 py-6"> | |
| 368 | + <div className="max-w-lg mx-auto space-y-6"> | |
| 369 | + {/* 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> | |
| 372 | + <div className="flex items-center gap-3"> | |
| 373 | + <p className="flex-1 text-lg font-mono font-semibold text-gray-900 dark:text-white break-all leading-tight"> | |
| 374 | + {genResult || <span className="text-gray-400 text-sm">Sélectionnez des options</span>} | |
| 375 | + </p> | |
| 376 | + </div> | |
| 377 | + {genResult && ( | |
| 378 | + <div className="mt-3"> | |
| 379 | + {(() => { const a = analyzePassword(genResult); return ( | |
| 380 | + <div className="flex items-center gap-2"> | |
| 381 | + <div className="flex-1 bg-gray-200 dark:bg-white/10 rounded-full h-1.5"> | |
| 382 | + <div className={`h-1.5 rounded-full transition-all ${a.barColor}`} style={{ width: `${(a.score / 6) * 100}%` }} /> | |
| 383 | + </div> | |
| 384 | + <span className={`text-xs font-medium ${a.color}`}>{a.label}</span> | |
| 385 | + </div> | |
| 386 | + ); })()} | |
| 387 | + </div> | |
| 388 | + )} | |
| 389 | + <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 | + > | |
| 394 | + <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 | + > | |
| 401 | + {genCopied ? <><Check className="w-4 h-4" /> Copié !</> : <><Copy className="w-4 h-4" /> Copier</>} | |
| 402 | + </button> | |
| 403 | + </div> | |
| 404 | + </div> | |
| 405 | + | |
| 406 | + {/* Options */} | |
| 407 | + <div className="space-y-4"> | |
| 408 | + {/* Length */} | |
| 409 | + <div> | |
| 410 | + <div className="flex justify-between mb-2"> | |
| 411 | + <label className="text-sm font-medium text-gray-700 dark:text-gray-300">Longueur</label> | |
| 412 | + <span className="text-sm font-semibold text-gray-900 dark:text-white">{genOpts.length}</span> | |
| 413 | + </div> | |
| 414 | + <input | |
| 415 | + type="range" | |
| 416 | + min={6} | |
| 417 | + max={64} | |
| 418 | + value={genOpts.length} | |
| 419 | + onChange={(e) => setGenOpts({ ...genOpts, length: Number(e.target.value) })} | |
| 420 | + className="w-full accent-gray-900 dark:accent-white" | |
| 421 | + /> | |
| 422 | + <div className="flex justify-between text-xs text-gray-400 mt-1"> | |
| 423 | + <span>6</span><span>64</span> | |
| 424 | + </div> | |
| 425 | + </div> | |
| 426 | + | |
| 427 | + {/* Character types */} | |
| 428 | + <div className="space-y-2"> | |
| 429 | + <p className="text-sm font-medium text-gray-700 dark:text-gray-300">Types de caractères</p> | |
| 430 | + {([ | |
| 431 | + { key: "upper", label: "Majuscules", example: "A-Z" }, | |
| 432 | + { key: "lower", label: "Minuscules", example: "a-z" }, | |
| 433 | + { key: "numbers", label: "Chiffres", example: "0-9" }, | |
| 434 | + { key: "symbols", label: "Symboles", example: "!@#$" }, | |
| 435 | + ] 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"> | |
| 437 | + <div> | |
| 438 | + <span className="text-sm text-gray-700 dark:text-gray-300">{label}</span> | |
| 439 | + <span className="text-xs text-gray-400 ml-2">{example}</span> | |
| 440 | + </div> | |
| 441 | + <div | |
| 442 | + 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"}`} | |
| 444 | + > | |
| 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> | |
| 449 | + </label> | |
| 450 | + ))} | |
| 451 | + </div> | |
| 452 | + | |
| 453 | + {/* Use in vault shortcut */} | |
| 454 | + <button | |
| 455 | + onClick={() => { | |
| 456 | + setForm((f) => ({ ...f, password: genResult })); | |
| 457 | + setTab("vault"); | |
| 458 | + setShowAdd(true); | |
| 459 | + setFormShowPw(true); | |
| 460 | + }} | |
| 461 | + 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" | |
| 463 | + > | |
| 464 | + <Key className="w-4 h-4" /> Utiliser ce mot de passe dans le coffre | |
| 465 | + </button> | |
| 466 | + </div> | |
| 467 | + </div> | |
| 468 | + </div> | |
| 469 | + )} | |
| 470 | + | |
| 471 | + {/* ── CHECKER TAB ── */} | |
| 472 | + {tab === "checker" && ( | |
| 473 | + <div className="flex-1 overflow-auto px-4 lg:px-6 py-6"> | |
| 474 | + <div className="max-w-lg mx-auto space-y-6"> | |
| 475 | + {/* Input */} | |
| 476 | + <div> | |
| 477 | + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> | |
| 478 | + Entrez un mot de passe à analyser | |
| 479 | + </label> | |
| 480 | + <div className="relative"> | |
| 481 | + <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> | |
| 482 | + <input | |
| 483 | + type={checkerShow ? "text" : "password"} | |
| 484 | + value={checkerPwd} | |
| 485 | + onChange={(e) => setCheckerPwd(e.target.value)} | |
| 486 | + placeholder="Votre mot de passe..." | |
| 487 | + className={`${inputCls} pl-10 pr-10`} | |
| 488 | + /> | |
| 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"> | |
| 490 | + {checkerShow ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} | |
| 491 | + </button> | |
| 492 | + </div> | |
| 493 | + </div> | |
| 494 | + | |
| 495 | + {checkerPwd && ( | |
| 496 | + <AnimatePresence> | |
| 497 | + <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-5"> | |
| 498 | + {/* Score bar */} | |
| 499 | + <div className="bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-5"> | |
| 500 | + <div className="flex items-center justify-between mb-3"> | |
| 501 | + <p className="text-sm font-medium text-gray-700 dark:text-gray-300">Niveau de sécurité</p> | |
| 502 | + <span className={`text-lg font-bold ${analysis.color}`}>{analysis.label}</span> | |
| 503 | + </div> | |
| 504 | + | |
| 505 | + {/* Score bars */} | |
| 506 | + <div className="flex gap-1 mb-3"> | |
| 507 | + {Array.from({ length: 6 }).map((_, i) => ( | |
| 508 | + <div key={i} className={`flex-1 h-2 rounded-full transition-all ${i < analysis.score ? analysis.barColor : "bg-gray-200 dark:bg-white/10"}`} /> | |
| 509 | + ))} | |
| 510 | + </div> | |
| 511 | + | |
| 512 | + {/* Stats */} | |
| 513 | + <div className="grid grid-cols-2 gap-2 text-xs text-gray-500 dark:text-gray-400"> | |
| 514 | + <span>Longueur : <strong className="text-gray-900 dark:text-white">{checkerPwd.length} caractères</strong></span> | |
| 515 | + <span>Score : <strong className="text-gray-900 dark:text-white">{analysis.score}/6</strong></span> | |
| 516 | + </div> | |
| 517 | + </div> | |
| 518 | + | |
| 519 | + {/* Checks */} | |
| 520 | + <div className="bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-5"> | |
| 521 | + <p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Critères de sécurité</p> | |
| 522 | + <div className="space-y-2"> | |
| 523 | + {analysis.checks.map((c) => ( | |
| 524 | + <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"}`}> | |
| 526 | + {c.ok ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />} | |
| 527 | + </div> | |
| 528 | + <span className={`text-sm ${c.ok ? "text-gray-700 dark:text-gray-300" : "text-gray-400 dark:text-gray-500"}`}>{c.label}</span> | |
| 529 | + </div> | |
| 530 | + ))} | |
| 531 | + </div> | |
| 532 | + </div> | |
| 533 | + | |
| 534 | + {/* Recommendation */} | |
| 535 | + <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" | |
| 539 | + }`}> | |
| 540 | + {analysis.score >= 5 ? <ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5" /> | |
| 541 | + : analysis.score >= 3 ? <Shield className="w-5 h-5 flex-shrink-0 mt-0.5" /> | |
| 542 | + : <ShieldX className="w-5 h-5 flex-shrink-0 mt-0.5" />} | |
| 543 | + <p className="text-sm"> | |
| 544 | + {analysis.score >= 5 | |
| 545 | + ? "Excellent ! Ce mot de passe est très sécurisé." | |
| 546 | + : analysis.score >= 4 | |
| 547 | + ? "Bon mot de passe. Ajoutez des symboles pour l'améliorer." | |
| 548 | + : analysis.score >= 3 | |
| 549 | + ? "Mot de passe moyen. Augmentez la longueur et la diversité des caractères." | |
| 550 | + : analysis.score >= 2 | |
| 551 | + ? "Mot de passe faible. Utilisez au moins 12 caractères avec majuscules, chiffres et symboles." | |
| 552 | + : "Mot de passe très faible ! Changez-le immédiatement avec notre générateur."} | |
| 553 | + </p> | |
| 554 | + </div> | |
| 555 | + | |
| 556 | + {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 | + > | |
| 561 | + <Zap className="w-4 h-4" /> Générer un mot de passe sécurisé | |
| 562 | + </button> | |
| 563 | + )} | |
| 564 | + </motion.div> | |
| 565 | + </AnimatePresence> | |
| 566 | + )} | |
| 567 | + | |
| 568 | + {!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> | |
| 573 | + )} | |
| 574 | + </div> | |
| 575 | + </div> | |
| 576 | + )} | |
| 577 | + </div> | |
| 578 | + | |
| 579 | + {/* ── Add / Edit Modal ── */} | |
| 580 | + <Modal | |
| 581 | + isOpen={showAdd} | |
| 582 | + onClose={() => { setShowAdd(false); setEditEntry(null); setFormShowPw(false); }} | |
| 583 | + title={editEntry ? "Modifier l'identifiant" : "Nouvel identifiant"} | |
| 584 | + > | |
| 585 | + <div className="space-y-3"> | |
| 586 | + <div> | |
| 587 | + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Site / Service *</label> | |
| 588 | + <div className="relative"> | |
| 589 | + <Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> | |
| 590 | + <input type="text" value={form.siteName} onChange={(e) => setForm({ ...form, siteName: e.target.value })} placeholder="Facebook, Gmail, Netflix..." className={`${inputCls} pl-9`} autoFocus /> | |
| 591 | + </div> | |
| 592 | + </div> | |
| 593 | + <div> | |
| 594 | + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">URL (optionnel)</label> | |
| 595 | + <div className="relative"> | |
| 596 | + <Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> | |
| 597 | + <input type="url" value={form.siteUrl} onChange={(e) => setForm({ ...form, siteUrl: e.target.value })} placeholder="https://facebook.com" className={`${inputCls} pl-9`} /> | |
| 598 | + </div> | |
| 599 | + </div> | |
| 600 | + <div> | |
| 601 | + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Identifiant / Email</label> | |
| 602 | + <div className="relative"> | |
| 603 | + <User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> | |
| 604 | + <input type="text" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} placeholder="user@example.com" className={`${inputCls} pl-9`} autoComplete="off" /> | |
| 605 | + </div> | |
| 606 | + </div> | |
| 607 | + <div> | |
| 608 | + <div className="flex items-center justify-between mb-1"> | |
| 609 | + <label className="text-xs font-medium text-gray-500 dark:text-gray-400">Mot de passe *</label> | |
| 610 | + <button | |
| 611 | + type="button" | |
| 612 | + 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" | |
| 614 | + > | |
| 615 | + <Zap className="w-3 h-3" /> Générer | |
| 616 | + </button> | |
| 617 | + </div> | |
| 618 | + <div className="relative"> | |
| 619 | + <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> | |
| 620 | + <input | |
| 621 | + type={formShowPw ? "text" : "password"} | |
| 622 | + value={form.password} | |
| 623 | + onChange={(e) => setForm({ ...form, password: e.target.value })} | |
| 624 | + placeholder="••••••••" | |
| 625 | + className={`${inputCls} pl-9 pr-9`} | |
| 626 | + autoComplete="new-password" | |
| 627 | + /> | |
| 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"> | |
| 629 | + {formShowPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} | |
| 630 | + </button> | |
| 631 | + </div> | |
| 632 | + {/* Inline strength bar in modal */} | |
| 633 | + {form.password && (() => { const a = analyzePassword(form.password); return ( | |
| 634 | + <div className="mt-1.5 flex items-center gap-2"> | |
| 635 | + <div className="flex gap-1 flex-1"> | |
| 636 | + {Array.from({ length: 6 }).map((_, i) => ( | |
| 637 | + <div key={i} className={`flex-1 h-1 rounded-full ${i < a.score ? a.barColor : "bg-gray-200 dark:bg-white/10"}`} /> | |
| 638 | + ))} | |
| 639 | + </div> | |
| 640 | + <span className={`text-xs ${a.color}`}>{a.label}</span> | |
| 641 | + </div> | |
| 642 | + ); })()} | |
| 643 | + </div> | |
| 644 | + <div> | |
| 645 | + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes (optionnel)</label> | |
| 646 | + <div className="relative"> | |
| 647 | + <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`} /> | |
| 649 | + </div> | |
| 650 | + </div> | |
| 651 | + </div> | |
| 652 | + | |
| 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> | |
| 658 | + </div> | |
| 659 | + </Modal> | |
| 660 | + </AppShell> | |
| 661 | + ); | |
| 662 | +} | |
added
app/dashboard/shared-space/[id]/page.tsx
+237 −0
@@ -0,0 +1,237 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useState, useEffect, useCallback } from "react"; | |
| 4 | +import { useParams } from "next/navigation"; | |
| 5 | +import AppShell from "@/components/layout/AppShell"; | |
| 6 | +import FolderCard from "@/components/folders/FolderCard"; | |
| 7 | +import FileCard from "@/components/files/FileCard"; | |
| 8 | +import FileUpload from "@/components/files/FileUpload"; | |
| 9 | +import PreviewEngine from "@/components/preview/PreviewEngine"; | |
| 10 | +import Button from "@/components/ui/Button"; | |
| 11 | +import SharedBadge from "@/components/ui/SharedBadge"; | |
| 12 | +import Modal from "@/components/ui/Modal"; | |
| 13 | +import { formatFileSize } from "@/lib/utils"; | |
| 14 | +import { Grid, List, Upload, Plus, Users } from "lucide-react"; | |
| 15 | +import { motion, AnimatePresence } from "framer-motion"; | |
| 16 | +import toast from "react-hot-toast"; | |
| 17 | + | |
| 18 | +interface SpaceData { | |
| 19 | + id: string; | |
| 20 | + name: string; | |
| 21 | + quotaBytes: number; | |
| 22 | + usedBytes: number; | |
| 23 | + members: { id: string; user: { id: string; name: string | null; username: string | null } }[]; | |
| 24 | + folders: { id: string; name: string; parentId: string | null }[]; | |
| 25 | +} | |
| 26 | + | |
| 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 | +interface FolderData { | |
| 37 | + id: string; | |
| 38 | + name: string; | |
| 39 | + parentId: string | null; | |
| 40 | + sharedSpaceId?: string | null; | |
| 41 | + _count?: { files: number; children: number }; | |
| 42 | + createdAt: string; | |
| 43 | +} | |
| 44 | + | |
| 45 | +export default function SharedSpacePage() { | |
| 46 | + const params = useParams(); | |
| 47 | + const id = params.id as string; | |
| 48 | + | |
| 49 | + const [space, setSpace] = useState<SpaceData | null>(null); | |
| 50 | + const [folders, setFolders] = useState<FolderData[]>([]); | |
| 51 | + const [files, setFiles] = useState<FileData[]>([]); | |
| 52 | + const [currentFolderId, setCurrentFolderId] = useState<string | null>(null); | |
| 53 | + const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); | |
| 54 | + const [showUpload, setShowUpload] = useState(false); | |
| 55 | + const [showCreateFolder, setShowCreateFolder] = useState(false); | |
| 56 | + const [newFolderName, setNewFolderName] = useState(""); | |
| 57 | + const [previewFile, setPreviewFile] = useState<FileData | null>(null); | |
| 58 | + const [loading, setLoading] = useState(true); | |
| 59 | + | |
| 60 | + const fetchData = useCallback(async () => { | |
| 61 | + setLoading(true); | |
| 62 | + try { | |
| 63 | + const [spaceRes, foldersRes, filesRes] = await Promise.all([ | |
| 64 | + fetch(`/api/shared-spaces/${id}`), | |
| 65 | + fetch(`/api/folders?sharedSpaceId=${id}&parentId=${currentFolderId || "root"}`), | |
| 66 | + currentFolderId ? fetch(`/api/files?folderId=${currentFolderId}`) : Promise.resolve(null), | |
| 67 | + ]); | |
| 68 | + if (spaceRes.ok) setSpace(await spaceRes.json()); | |
| 69 | + if (foldersRes.ok) setFolders(await foldersRes.json()); | |
| 70 | + if (filesRes?.ok) setFiles(await filesRes.json()); | |
| 71 | + else setFiles([]); | |
| 72 | + } catch { | |
| 73 | + // ignore | |
| 74 | + } finally { | |
| 75 | + setLoading(false); | |
| 76 | + } | |
| 77 | + }, [id, currentFolderId]); | |
| 78 | + | |
| 79 | + useEffect(() => { fetchData(); }, [fetchData]); | |
| 80 | + | |
| 81 | + const handleCreateFolder = async () => { | |
| 82 | + if (!newFolderName.trim()) return; | |
| 83 | + const res = await fetch("/api/folders", { | |
| 84 | + method: "POST", | |
| 85 | + headers: { "Content-Type": "application/json" }, | |
| 86 | + body: JSON.stringify({ name: newFolderName.trim(), sharedSpaceId: id, parentId: currentFolderId || null }), | |
| 87 | + }); | |
| 88 | + if (res.ok) { | |
| 89 | + toast.success("Dossier créé"); | |
| 90 | + setShowCreateFolder(false); | |
| 91 | + setNewFolderName(""); | |
| 92 | + fetchData(); | |
| 93 | + } else { | |
| 94 | + toast.error("Erreur"); | |
| 95 | + } | |
| 96 | + }; | |
| 97 | + | |
| 98 | + const percent = space ? Math.min(Math.round((space.usedBytes / space.quotaBytes) * 100), 100) : 0; | |
| 99 | + | |
| 100 | + 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"; | |
| 101 | + | |
| 102 | + return ( | |
| 103 | + <AppShell> | |
| 104 | + <div className="flex flex-col h-full overflow-x-hidden"> | |
| 105 | + {/* Space header */} | |
| 106 | + <div className="px-4 lg:px-6 py-4 border-b border-gray-200 dark:border-white/10"> | |
| 107 | + <div className="flex items-center gap-3 mb-3"> | |
| 108 | + <div className="w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-500/20 flex items-center justify-center flex-shrink-0"> | |
| 109 | + <Users className="w-4 h-4 text-purple-600 dark:text-purple-400" /> | |
| 110 | + </div> | |
| 111 | + <div className="flex items-center gap-2 min-w-0"> | |
| 112 | + <h1 className="text-lg font-semibold text-gray-900 dark:text-white truncate"> | |
| 113 | + {space?.name || "Espace partagé"} | |
| 114 | + </h1> | |
| 115 | + <SharedBadge /> | |
| 116 | + </div> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + {space && ( | |
| 120 | + <div> | |
| 121 | + <div className="flex justify-between text-xs text-gray-500 mb-1"> | |
| 122 | + <span>{space.members.length} membre(s)</span> | |
| 123 | + <span>{formatFileSize(space.usedBytes)} / {formatFileSize(space.quotaBytes)}</span> | |
| 124 | + </div> | |
| 125 | + <div className="w-full bg-gray-100 dark:bg-white/10 rounded-full h-1.5"> | |
| 126 | + <div | |
| 127 | + className={`h-1.5 rounded-full transition-all ${percent > 90 ? "bg-red-500" : percent > 70 ? "bg-yellow-500" : "bg-purple-500"}`} | |
| 128 | + style={{ width: `${percent}%` }} | |
| 129 | + /> | |
| 130 | + </div> | |
| 131 | + </div> | |
| 132 | + )} | |
| 133 | + </div> | |
| 134 | + | |
| 135 | + {/* Toolbar */} | |
| 136 | + <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"> | |
| 137 | + <div className="flex items-center gap-1 bg-gray-100 dark:bg-white/5 rounded-lg p-1"> | |
| 138 | + <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"}`}> | |
| 139 | + <Grid className="w-4 h-4" /> | |
| 140 | + </button> | |
| 141 | + <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"}`}> | |
| 142 | + <List className="w-4 h-4" /> | |
| 143 | + </button> | |
| 144 | + </div> | |
| 145 | + <Button variant="ghost" size="sm" onClick={() => setShowCreateFolder(true)}> | |
| 146 | + <Plus className="w-4 h-4" /><span className="hidden sm:inline">Dossier</span> | |
| 147 | + </Button> | |
| 148 | + <Button size="sm" onClick={() => setShowUpload(!showUpload)}> | |
| 149 | + <Upload className="w-4 h-4" /><span className="hidden sm:inline">Upload</span> | |
| 150 | + </Button> | |
| 151 | + </div> | |
| 152 | + | |
| 153 | + {/* Upload */} | |
| 154 | + <AnimatePresence> | |
| 155 | + {showUpload && currentFolderId && ( | |
| 156 | + <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"> | |
| 157 | + <FileUpload folderId={currentFolderId} onUploadComplete={fetchData} /> | |
| 158 | + </motion.div> | |
| 159 | + )} | |
| 160 | + {showUpload && !currentFolderId && ( | |
| 161 | + <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"> | |
| 162 | + <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"> | |
| 163 | + Ouvre un dossier pour uploader des fichiers dans cet espace. | |
| 164 | + </p> | |
| 165 | + </motion.div> | |
| 166 | + )} | |
| 167 | + </AnimatePresence> | |
| 168 | + | |
| 169 | + {/* Content */} | |
| 170 | + <div className="flex-1 overflow-auto px-4 lg:px-6 py-4"> | |
| 171 | + {currentFolderId && ( | |
| 172 | + <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"> | |
| 173 | + ← Retour à l'espace | |
| 174 | + </button> | |
| 175 | + )} | |
| 176 | + | |
| 177 | + {loading ? ( | |
| 178 | + <div className="flex items-center justify-center h-32"> | |
| 179 | + <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" /> | |
| 180 | + </div> | |
| 181 | + ) : ( | |
| 182 | + <> | |
| 183 | + {folders.length > 0 && ( | |
| 184 | + <div className="mb-6"> | |
| 185 | + <h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Dossiers</h3> | |
| 186 | + <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"}> | |
| 187 | + {folders.map((folder) => ( | |
| 188 | + <FolderCard key={folder.id} folder={folder} onRefresh={fetchData} viewMode={viewMode} /> | |
| 189 | + ))} | |
| 190 | + </div> | |
| 191 | + </div> | |
| 192 | + )} | |
| 193 | + {files.length > 0 && ( | |
| 194 | + <div> | |
| 195 | + <h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Fichiers ({files.length})</h3> | |
| 196 | + <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"}> | |
| 197 | + {files.map((file) => ( | |
| 198 | + <FileCard key={file.id} file={file} onPreview={setPreviewFile} onRefresh={fetchData} viewMode={viewMode} /> | |
| 199 | + ))} | |
| 200 | + </div> | |
| 201 | + </div> | |
| 202 | + )} | |
| 203 | + {folders.length === 0 && files.length === 0 && ( | |
| 204 | + <div className="flex flex-col items-center justify-center h-40 text-center"> | |
| 205 | + <Users className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" /> | |
| 206 | + <p className="text-gray-500 dark:text-gray-400 text-sm">Espace vide. Créez un dossier pour commencer.</p> | |
| 207 | + </div> | |
| 208 | + )} | |
| 209 | + </> | |
| 210 | + )} | |
| 211 | + </div> | |
| 212 | + </div> | |
| 213 | + | |
| 214 | + {/* Create folder modal */} | |
| 215 | + <Modal isOpen={showCreateFolder} onClose={() => { setShowCreateFolder(false); setNewFolderName(""); }} title="Nouveau dossier"> | |
| 216 | + <input | |
| 217 | + type="text" | |
| 218 | + placeholder="Nom du dossier" | |
| 219 | + value={newFolderName} | |
| 220 | + onChange={(e) => setNewFolderName(e.target.value)} | |
| 221 | + onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()} | |
| 222 | + className={inputClasses} | |
| 223 | + autoFocus | |
| 224 | + /> | |
| 225 | + <div className="flex gap-2 justify-end"> | |
| 226 | + <Button variant="ghost" onClick={() => { setShowCreateFolder(false); setNewFolderName(""); }}>Annuler</Button> | |
| 227 | + <Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>Créer</Button> | |
| 228 | + </div> | |
| 229 | + </Modal> | |
| 230 | + | |
| 231 | + {/* Preview */} | |
| 232 | + {previewFile && ( | |
| 233 | + <PreviewEngine file={previewFile} onClose={() => setPreviewFile(null)} /> | |
| 234 | + )} | |
| 235 | + </AppShell> | |
| 236 | + ); | |
| 237 | +} | |
modified
app/login/page.tsx
+10 −9
@@ -3,11 +3,11 @@ | ||
| 3 | 3 | import { useState } from "react"; |
| 4 | 4 | import { useRouter } from "next/navigation"; |
| 5 | 5 | import { motion } from "framer-motion"; |
| 6 | −import { Cloud, Lock, Eye, EyeOff, Loader2, Mail, Shield } from "lucide-react"; | |
| 6 | +import { Cloud, Lock, Eye, EyeOff, Loader2, User, Shield } from "lucide-react"; | |
| 7 | 7 | import ThemeToggle from "@/components/theme/ThemeToggle"; |
| 8 | 8 | |
| 9 | 9 | export default function LoginPage() { |
| 10 | − const [email, setEmail] = useState(""); | |
| 10 | + const [username, setUsername] = useState(""); | |
| 11 | 11 | const [password, setPassword] = useState(""); |
| 12 | 12 | const [totpCode, setTotpCode] = useState(""); |
| 13 | 13 | const [error, setError] = useState(""); |
@@ -27,7 +27,7 @@ export default function LoginPage() { | ||
| 27 | 27 | const res = await fetch("/api/auth/login", { |
| 28 | 28 | method: "POST", |
| 29 | 29 | headers: { "Content-Type": "application/json" }, |
| 30 | − body: JSON.stringify({ password, email: email || undefined }), | |
| 30 | + body: JSON.stringify({ password, username: username || undefined }), | |
| 31 | 31 | }); |
| 32 | 32 | |
| 33 | 33 | const data = await res.json(); |
@@ -103,13 +103,14 @@ export default function LoginPage() { | ||
| 103 | 103 | {!needs2FA ? ( |
| 104 | 104 | <form onSubmit={handleSubmit} className="space-y-4"> |
| 105 | 105 | <div className="relative"> |
| 106 | − <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" /> | |
| 106 | + <User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" /> | |
| 107 | 107 | <input |
| 108 | − type="email" | |
| 109 | − value={email} | |
| 110 | − onChange={(e) => setEmail(e.target.value)} | |
| 111 | − placeholder="Email (optionnel)" | |
| 108 | + type="text" | |
| 109 | + value={username} | |
| 110 | + onChange={(e) => setUsername(e.target.value)} | |
| 111 | + placeholder="Nom d'utilisateur" | |
| 112 | 112 | className={inputClasses} |
| 113 | + autoComplete="username" | |
| 113 | 114 | /> |
| 114 | 115 | </div> |
| 115 | 116 | |
@@ -121,7 +122,7 @@ export default function LoginPage() { | ||
| 121 | 122 | onChange={(e) => setPassword(e.target.value)} |
| 122 | 123 | placeholder="Mot de passe" |
| 123 | 124 | className={inputClasses} |
| 124 | − autoFocus | |
| 125 | + autoComplete="current-password" | |
| 125 | 126 | /> |
| 126 | 127 | <button |
| 127 | 128 | type="button" |
added
components/admin/SharedSpaceManagement.tsx
+288 −0
@@ -0,0 +1,288 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useState, useEffect } from "react"; | |
| 4 | +import { motion } from "framer-motion"; | |
| 5 | +import { Plus, Trash2, Edit2, Users, UserPlus, UserMinus } from "lucide-react"; | |
| 6 | +import Button from "../ui/Button"; | |
| 7 | +import Modal from "../ui/Modal"; | |
| 8 | +import toast from "react-hot-toast"; | |
| 9 | +import { formatFileSize } from "@/lib/utils"; | |
| 10 | + | |
| 11 | +interface MemberData { | |
| 12 | + id: string; | |
| 13 | + user: { id: string; name: string | null; username: string | null }; | |
| 14 | +} | |
| 15 | + | |
| 16 | +interface SpaceData { | |
| 17 | + id: string; | |
| 18 | + name: string; | |
| 19 | + quotaBytes: number; | |
| 20 | + usedBytes: number; | |
| 21 | + createdAt: string; | |
| 22 | + _count: { members: number; folders: number }; | |
| 23 | + members: MemberData[]; | |
| 24 | +} | |
| 25 | + | |
| 26 | +interface UserOption { | |
| 27 | + id: string; | |
| 28 | + name: string | null; | |
| 29 | + username: string | null; | |
| 30 | +} | |
| 31 | + | |
| 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"; | |
| 33 | + | |
| 34 | +export default function SharedSpaceManagement() { | |
| 35 | + const [spaces, setSpaces] = useState<SpaceData[]>([]); | |
| 36 | + const [users, setUsers] = useState<UserOption[]>([]); | |
| 37 | + const [loading, setLoading] = useState(true); | |
| 38 | + const [showCreate, setShowCreate] = useState(false); | |
| 39 | + const [editSpace, setEditSpace] = useState<SpaceData | null>(null); | |
| 40 | + const [form, setForm] = useState({ name: "", memberIds: [] as string[] }); | |
| 41 | + | |
| 42 | + const fetchData = async () => { | |
| 43 | + const [spacesRes, usersRes] = await Promise.all([ | |
| 44 | + fetch("/api/shared-spaces"), | |
| 45 | + fetch("/api/users"), | |
| 46 | + ]); | |
| 47 | + if (spacesRes.ok) setSpaces(await spacesRes.json()); | |
| 48 | + if (usersRes.ok) setUsers(await usersRes.json()); | |
| 49 | + setLoading(false); | |
| 50 | + }; | |
| 51 | + | |
| 52 | + useEffect(() => { fetchData(); }, []); | |
| 53 | + | |
| 54 | + const handleCreate = async () => { | |
| 55 | + const res = await fetch("/api/shared-spaces", { | |
| 56 | + method: "POST", | |
| 57 | + headers: { "Content-Type": "application/json" }, | |
| 58 | + body: JSON.stringify(form), | |
| 59 | + }); | |
| 60 | + if (res.ok) { | |
| 61 | + toast.success("Espace créé"); | |
| 62 | + setShowCreate(false); | |
| 63 | + setForm({ name: "", memberIds: [] }); | |
| 64 | + fetchData(); | |
| 65 | + } else { | |
| 66 | + toast.error("Erreur lors de la création"); | |
| 67 | + } | |
| 68 | + }; | |
| 69 | + | |
| 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 | + if (res.ok) { toast.success("Espace supprimé"); fetchData(); } | |
| 74 | + else toast.error("Erreur"); | |
| 75 | + }; | |
| 76 | + | |
| 77 | + const handleRename = async () => { | |
| 78 | + if (!editSpace) return; | |
| 79 | + const res = await fetch(`/api/shared-spaces/${editSpace.id}`, { | |
| 80 | + method: "PATCH", | |
| 81 | + headers: { "Content-Type": "application/json" }, | |
| 82 | + body: JSON.stringify({ name: editSpace.name }), | |
| 83 | + }); | |
| 84 | + if (res.ok) { toast.success("Renommé"); setEditSpace(null); fetchData(); } | |
| 85 | + else toast.error("Erreur"); | |
| 86 | + }; | |
| 87 | + | |
| 88 | + const handleAddMember = async (spaceId: string, userId: string) => { | |
| 89 | + const res = await fetch(`/api/shared-spaces/${spaceId}/members`, { | |
| 90 | + method: "POST", | |
| 91 | + headers: { "Content-Type": "application/json" }, | |
| 92 | + body: JSON.stringify({ userId }), | |
| 93 | + }); | |
| 94 | + if (res.ok) { toast.success("Membre ajouté"); fetchData(); } | |
| 95 | + else toast.error("Déjà membre ou erreur"); | |
| 96 | + }; | |
| 97 | + | |
| 98 | + const handleRemoveMember = async (spaceId: string, userId: string) => { | |
| 99 | + const res = await fetch(`/api/shared-spaces/${spaceId}/members`, { | |
| 100 | + method: "DELETE", | |
| 101 | + headers: { "Content-Type": "application/json" }, | |
| 102 | + body: JSON.stringify({ userId }), | |
| 103 | + }); | |
| 104 | + if (res.ok) { toast.success("Membre retiré"); fetchData(); } | |
| 105 | + else toast.error("Erreur"); | |
| 106 | + }; | |
| 107 | + | |
| 108 | + const toggleMember = (userId: string) => { | |
| 109 | + setForm((f) => ({ | |
| 110 | + ...f, | |
| 111 | + memberIds: f.memberIds.includes(userId) | |
| 112 | + ? f.memberIds.filter((id) => id !== userId) | |
| 113 | + : [...f.memberIds, userId], | |
| 114 | + })); | |
| 115 | + }; | |
| 116 | + | |
| 117 | + if (loading) return <div className="text-gray-500 text-sm p-4">Chargement...</div>; | |
| 118 | + | |
| 119 | + return ( | |
| 120 | + <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 | |
| 124 | + </Button> | |
| 125 | + </div> | |
| 126 | + | |
| 127 | + {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> | |
| 132 | + )} | |
| 133 | + | |
| 134 | + <div className="space-y-4"> | |
| 135 | + {spaces.map((space) => { | |
| 136 | + const percent = space.quotaBytes > 0 ? Math.min(Math.round((space.usedBytes / space.quotaBytes) * 100), 100) : 0; | |
| 137 | + const memberUserIds = space.members.map((m) => m.user.id); | |
| 138 | + const nonMembers = users.filter((u) => !memberUserIds.includes(u.id)); | |
| 139 | + | |
| 140 | + return ( | |
| 141 | + <motion.div | |
| 142 | + key={space.id} | |
| 143 | + initial={{ opacity: 0, y: 10 }} | |
| 144 | + 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" | |
| 146 | + > | |
| 147 | + {/* Header */} | |
| 148 | + <div className="flex items-start justify-between gap-3 mb-4"> | |
| 149 | + <div className="flex items-center gap-2 min-w-0"> | |
| 150 | + <div className="w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-500/20 flex items-center justify-center flex-shrink-0"> | |
| 151 | + <Users className="w-4 h-4 text-purple-600 dark:text-purple-400" /> | |
| 152 | + </div> | |
| 153 | + <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> | |
| 156 | + </div> | |
| 157 | + </div> | |
| 158 | + <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"> | |
| 160 | + <Edit2 className="w-4 h-4" /> | |
| 161 | + </button> | |
| 162 | + <button onClick={() => handleDelete(space.id)} className="p-2 text-gray-400 hover:text-red-500 transition-colors"> | |
| 163 | + <Trash2 className="w-4 h-4" /> | |
| 164 | + </button> | |
| 165 | + </div> | |
| 166 | + </div> | |
| 167 | + | |
| 168 | + {/* Storage quota */} | |
| 169 | + <div className="mb-4"> | |
| 170 | + <div className="flex justify-between text-xs text-gray-500 mb-1"> | |
| 171 | + <span>Stockage utilisé</span> | |
| 172 | + <span>{formatFileSize(space.usedBytes)} / {formatFileSize(space.quotaBytes)}</span> | |
| 173 | + </div> | |
| 174 | + <div className="w-full bg-gray-100 dark:bg-white/10 rounded-full h-1.5"> | |
| 175 | + <div | |
| 176 | + className={`h-1.5 rounded-full transition-all ${percent > 90 ? "bg-red-500" : percent > 70 ? "bg-yellow-500" : "bg-purple-500"}`} | |
| 177 | + style={{ width: `${percent}%` }} | |
| 178 | + /> | |
| 179 | + </div> | |
| 180 | + <p className="text-xs text-gray-400 mt-1">{percent}% utilisé</p> | |
| 181 | + </div> | |
| 182 | + | |
| 183 | + {/* Members */} | |
| 184 | + <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"> | |
| 187 | + {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> | |
| 196 | + </div> | |
| 197 | + <button onClick={() => handleRemoveMember(space.id, m.user.id)} className="p-1 text-gray-400 hover:text-red-500 transition-colors"> | |
| 198 | + <UserMinus className="w-3.5 h-3.5" /> | |
| 199 | + </button> | |
| 200 | + </div> | |
| 201 | + ))} | |
| 202 | + {space.members.length === 0 && ( | |
| 203 | + <p className="text-xs text-gray-400 px-2">Aucun membre pour l'instant</p> | |
| 204 | + )} | |
| 205 | + </div> | |
| 206 | + | |
| 207 | + {/* Add member */} | |
| 208 | + {nonMembers.length > 0 && ( | |
| 209 | + <div className="flex gap-2"> | |
| 210 | + <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" | |
| 212 | + defaultValue="" | |
| 213 | + onChange={(e) => { | |
| 214 | + if (e.target.value) { handleAddMember(space.id, e.target.value); e.target.value = ""; } | |
| 215 | + }} | |
| 216 | + > | |
| 217 | + <option value="" disabled>Ajouter un membre...</option> | |
| 218 | + {nonMembers.map((u) => ( | |
| 219 | + <option key={u.id} value={u.id}>{u.name || u.username}</option> | |
| 220 | + ))} | |
| 221 | + </select> | |
| 222 | + <button className="p-2 text-purple-500 hover:text-purple-700 transition-colors"> | |
| 223 | + <UserPlus className="w-5 h-5" /> | |
| 224 | + </button> | |
| 225 | + </div> | |
| 226 | + )} | |
| 227 | + </div> | |
| 228 | + </motion.div> | |
| 229 | + ); | |
| 230 | + })} | |
| 231 | + </div> | |
| 232 | + | |
| 233 | + {/* Create modal */} | |
| 234 | + <Modal isOpen={showCreate} onClose={() => { setShowCreate(false); setForm({ name: "", memberIds: [] }); }} title="Nouvel espace partagé"> | |
| 235 | + <input | |
| 236 | + type="text" | |
| 237 | + placeholder="Nom de l'espace" | |
| 238 | + value={form.name} | |
| 239 | + onChange={(e) => setForm({ ...form, name: e.target.value })} | |
| 240 | + className={inputClasses} | |
| 241 | + autoFocus | |
| 242 | + /> | |
| 243 | + {users.length > 0 && ( | |
| 244 | + <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"> | |
| 247 | + {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"> | |
| 249 | + <input | |
| 250 | + type="checkbox" | |
| 251 | + checked={form.memberIds.includes(u.id)} | |
| 252 | + onChange={() => toggleMember(u.id)} | |
| 253 | + className="rounded" | |
| 254 | + /> | |
| 255 | + <span className="text-sm text-gray-700 dark:text-gray-300">{u.name || u.username}</span> | |
| 256 | + </label> | |
| 257 | + ))} | |
| 258 | + </div> | |
| 259 | + </div> | |
| 260 | + )} | |
| 261 | + <p className="text-xs text-gray-400 mb-4">Quota : 500 Go par défaut</p> | |
| 262 | + <div className="flex gap-2 justify-end"> | |
| 263 | + <Button variant="ghost" onClick={() => { setShowCreate(false); setForm({ name: "", memberIds: [] }); }}>Annuler</Button> | |
| 264 | + <Button onClick={handleCreate} disabled={!form.name.trim()}>Créer</Button> | |
| 265 | + </div> | |
| 266 | + </Modal> | |
| 267 | + | |
| 268 | + {/* Edit modal */} | |
| 269 | + <Modal isOpen={!!editSpace} onClose={() => setEditSpace(null)} title="Modifier l'espace"> | |
| 270 | + {editSpace && ( | |
| 271 | + <> | |
| 272 | + <input | |
| 273 | + type="text" | |
| 274 | + value={editSpace.name} | |
| 275 | + onChange={(e) => setEditSpace({ ...editSpace, name: e.target.value })} | |
| 276 | + className={inputClasses} | |
| 277 | + autoFocus | |
| 278 | + /> | |
| 279 | + <div className="flex gap-2 justify-end"> | |
| 280 | + <Button variant="ghost" onClick={() => setEditSpace(null)}>Annuler</Button> | |
| 281 | + <Button onClick={handleRename}>Enregistrer</Button> | |
| 282 | + </div> | |
| 283 | + </> | |
| 284 | + )} | |
| 285 | + </Modal> | |
| 286 | + </div> | |
| 287 | + ); | |
| 288 | +} | |
modified
components/admin/UserManagement.tsx
+49 −25
@@ -9,6 +9,7 @@ import toast from "react-hot-toast"; | ||
| 9 | 9 | |
| 10 | 10 | interface UserData { |
| 11 | 11 | id: string; |
| 12 | + username: string | null; | |
| 12 | 13 | email: string | null; |
| 13 | 14 | name: string | null; |
| 14 | 15 | role: string; |
@@ -22,7 +23,7 @@ export default function UserManagement() { | ||
| 22 | 23 | const [loading, setLoading] = useState(true); |
| 23 | 24 | const [showCreate, setShowCreate] = useState(false); |
| 24 | 25 | const [editUser, setEditUser] = useState<UserData | null>(null); |
| 25 | − const [form, setForm] = useState({ email: "", name: "", password: "", role: "user" }); | |
| 26 | + const [form, setForm] = useState({ username: "", name: "", password: "", role: "user" }); | |
| 26 | 27 | |
| 27 | 28 | const fetchUsers = async () => { |
| 28 | 29 | const res = await fetch("/api/users"); |
@@ -41,7 +42,7 @@ export default function UserManagement() { | ||
| 41 | 42 | if (res.ok) { |
| 42 | 43 | toast.success("Utilisateur créé"); |
| 43 | 44 | setShowCreate(false); |
| 44 | − setForm({ email: "", name: "", password: "", role: "user" }); | |
| 45 | + setForm({ username: "", name: "", password: "", role: "user" }); | |
| 45 | 46 | fetchUsers(); |
| 46 | 47 | } else { |
| 47 | 48 | const data = await res.json(); |
@@ -53,7 +54,7 @@ export default function UserManagement() { | ||
| 53 | 54 | if (!editUser) return; |
| 54 | 55 | const data: Record<string, string> = {}; |
| 55 | 56 | if (form.name) data.name = form.name; |
| 56 | − if (form.email) data.email = form.email; | |
| 57 | + if (form.username) data.username = form.username; | |
| 57 | 58 | if (form.role) data.role = form.role; |
| 58 | 59 | if (form.password) data.password = form.password; |
| 59 | 60 | |
@@ -117,13 +118,13 @@ export default function UserManagement() { | ||
| 117 | 118 | {/* First line: avatar + name/email */} |
| 118 | 119 | <div className="flex items-center gap-3 flex-1 min-w-0"> |
| 119 | 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"> |
| 120 | − {(user.name || user.email || "U")[0].toUpperCase()} | |
| 121 | + {(user.name || user.username || "U")[0].toUpperCase()} | |
| 121 | 122 | </div> |
| 122 | 123 | <div className="flex-1 min-w-0"> |
| 123 | 124 | <p className="text-sm font-medium text-gray-900 dark:text-white truncate"> |
| 124 | − {user.name || user.email || user.id} | |
| 125 | + {user.name || user.username || user.id} | |
| 125 | 126 | </p> |
| 126 | − <p className="text-xs text-gray-500 truncate">{user.email}</p> | |
| 127 | + <p className="text-xs text-gray-500 truncate">@{user.username}</p> | |
| 127 | 128 | </div> |
| 128 | 129 | </div> |
| 129 | 130 | {/* Second line on mobile: role badge + file count + actions */} |
@@ -139,7 +140,7 @@ export default function UserManagement() { | ||
| 139 | 140 | </span> |
| 140 | 141 | <div className="flex gap-1 ml-auto"> |
| 141 | 142 | <button |
| 142 | − onClick={() => { setEditUser(user); setForm({ email: user.email || "", name: user.name || "", password: "", role: user.role }); }} | |
| 143 | + onClick={() => { setEditUser(user); setForm({ username: user.username || "", name: user.name || "", password: "", role: user.role }); }} | |
| 143 | 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" |
| 144 | 145 | > |
| 145 | 146 | <Edit2 className="w-4 h-4" /> |
@@ -160,32 +161,55 @@ export default function UserManagement() { | ||
| 160 | 161 | {/* Create Modal */} |
| 161 | 162 | <Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Nouvel utilisateur"> |
| 162 | 163 | <div className="space-y-3"> |
| 163 | − <input type="email" placeholder="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClasses} /> | |
| 164 | − <input type="text" placeholder="Nom" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClasses} /> | |
| 165 | − <input type="password" placeholder="Mot de passe" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className={inputClasses} /> | |
| 166 | − <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className={inputClasses}> | |
| 167 | − <option value="user">Utilisateur</option> | |
| 168 | − <option value="viewer">Lecteur</option> | |
| 169 | − <option value="admin">Admin</option> | |
| 170 | − </select> | |
| 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> | |
| 171 | 184 | <div className="flex gap-2 justify-end pt-2"> |
| 172 | 185 | <Button variant="ghost" onClick={() => setShowCreate(false)}>Annuler</Button> |
| 173 | − <Button onClick={handleCreate}>Créer</Button> | |
| 186 | + <Button onClick={handleCreate} disabled={!form.username || !form.password}>Créer</Button> | |
| 174 | 187 | </div> |
| 175 | 188 | </div> |
| 176 | 189 | </Modal> |
| 177 | 190 | |
| 178 | − {/* Edit Modal */} | |
| 179 | 191 | <Modal isOpen={!!editUser} onClose={() => setEditUser(null)} title="Modifier utilisateur"> |
| 180 | 192 | <div className="space-y-3"> |
| 181 | − <input type="email" placeholder="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClasses} /> | |
| 182 | − <input type="text" placeholder="Nom" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClasses} /> | |
| 183 | − <input type="password" placeholder="Nouveau mot de passe (vide = inchangé)" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className={inputClasses} /> | |
| 184 | − <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className={inputClasses}> | |
| 185 | − <option value="user">Utilisateur</option> | |
| 186 | − <option value="viewer">Lecteur</option> | |
| 187 | − <option value="admin">Admin</option> | |
| 188 | − </select> | |
| 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> | |
| 189 | 213 | <div className="flex gap-2 justify-end pt-2"> |
| 190 | 214 | <Button variant="ghost" onClick={() => setEditUser(null)}>Annuler</Button> |
| 191 | 215 | <Button onClick={handleUpdate}>Sauvegarder</Button> |
modified
components/ai/AiPanel.tsx
+633 −116
@@ -1,30 +1,148 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useState } from "react"; | |
| 3 | +import { useState, useEffect, useRef, useCallback } from "react"; | |
| 4 | 4 | import { motion, AnimatePresence } from "framer-motion"; |
| 5 | −import { Brain, Search, Tag, FileText, MessageSquare, Sparkles, X, Loader2 } from "lucide-react"; | |
| 6 | −import Button from "../ui/Button"; | |
| 5 | +import { | |
| 6 | + Brain, | |
| 7 | + Search, | |
| 8 | + FileText, | |
| 9 | + MessageSquare, | |
| 10 | + Sparkles, | |
| 11 | + X, | |
| 12 | + Loader2, | |
| 13 | + Send, | |
| 14 | + Trash2, | |
| 15 | + ScanText, | |
| 16 | + Tag, | |
| 17 | + Info, | |
| 18 | + ChevronDown, | |
| 19 | + Zap, | |
| 20 | + File, | |
| 21 | + Download, | |
| 22 | + FlaskConical, | |
| 23 | + AlertCircle, | |
| 24 | +} from "lucide-react"; | |
| 25 | +import ReactMarkdown from "react-markdown"; | |
| 7 | 26 | |
| 8 | 27 | interface AiResult { |
| 9 | 28 | fileId: string; |
| 10 | 29 | name: string; |
| 11 | 30 | relevance: string; |
| 12 | 31 | snippet: string; |
| 32 | + folder?: string; | |
| 33 | + mimeType?: string; | |
| 13 | 34 | } |
| 14 | 35 | |
| 36 | +interface ChatMessage { | |
| 37 | + role: "user" | "assistant"; | |
| 38 | + content: string; | |
| 39 | +} | |
| 40 | + | |
| 41 | +interface AnalyserFile { | |
| 42 | + id: string; | |
| 43 | + name: string; | |
| 44 | + mimeType: string; | |
| 45 | + size: number; | |
| 46 | + aiTags?: string | null; | |
| 47 | + aiDescription?: string | null; | |
| 48 | + ocrText?: string | null; | |
| 49 | + folder?: { name: string } | null; | |
| 50 | +} | |
| 51 | + | |
| 52 | +type Tab = "search" | "chat" | "analyse"; | |
| 53 | + | |
| 54 | +const relevanceColors: Record<string, string> = { | |
| 55 | + high: "bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400", | |
| 56 | + medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400", | |
| 57 | + low: "bg-gray-100 text-gray-600 dark:bg-gray-500/20 dark:text-gray-400", | |
| 58 | +}; | |
| 59 | + | |
| 60 | +const mimeIcon = (mime: string) => { | |
| 61 | + if (!mime) return "📁"; | |
| 62 | + if (mime.startsWith("image/")) return "🖼️"; | |
| 63 | + if (mime === "application/pdf") return "📄"; | |
| 64 | + if (mime.startsWith("video/")) return "🎬"; | |
| 65 | + if (mime.startsWith("audio/")) return "🎵"; | |
| 66 | + if (mime.includes("word") || mime.includes("document")) return "📝"; | |
| 67 | + if (mime.includes("spreadsheet") || mime === "text/csv") return "📊"; | |
| 68 | + if (mime.startsWith("text/")) return "📃"; | |
| 69 | + return "📁"; | |
| 70 | +}; | |
| 71 | + | |
| 72 | +const inputClasses = | |
| 73 | + "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-purple-400 dark:focus:border-purple-500 placeholder-gray-400 dark:placeholder-gray-500"; | |
| 74 | + | |
| 15 | 75 | export default function AiPanel() { |
| 16 | 76 | const [open, setOpen] = useState(false); |
| 17 | − const [tab, setTab] = useState<"search" | "chat">("search"); | |
| 77 | + const [tab, setTab] = useState<Tab>("chat"); | |
| 78 | + | |
| 79 | + // Search state | |
| 18 | 80 | const [query, setQuery] = useState(""); |
| 19 | − const [chatMessage, setChatMessage] = useState(""); | |
| 20 | 81 | const [searching, setSearching] = useState(false); |
| 21 | − const [chatting, setChatting] = useState(false); | |
| 22 | 82 | const [results, setResults] = useState<AiResult[]>([]); |
| 23 | 83 | const [answer, setAnswer] = useState(""); |
| 24 | − const [chatHistory, setChatHistory] = useState<Array<{ role: string; content: string }>>([]); | |
| 25 | 84 | |
| 26 | − const handleSearch = async () => { | |
| 27 | − if (!query.trim()) return; | |
| 85 | + // Chat state | |
| 86 | + const [chatInput, setChatInput] = useState(""); | |
| 87 | + const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]); | |
| 88 | + const [isStreaming, setIsStreaming] = useState(false); | |
| 89 | + const [streamingContent, setStreamingContent] = useState(""); | |
| 90 | + const chatEndRef = useRef<HTMLDivElement>(null); | |
| 91 | + | |
| 92 | + // Analyser state | |
| 93 | + const [analyserFiles, setAnalyserFiles] = useState<AnalyserFile[]>([]); | |
| 94 | + const [analyserLoading, setAnalyserLoading] = useState(false); | |
| 95 | + const [analyserError, setAnalyserError] = useState(false); | |
| 96 | + const [analyserQuery, setAnalyserQuery] = useState(""); | |
| 97 | + const [selectedFile, setSelectedFile] = useState<AnalyserFile | null>(null); | |
| 98 | + const [analysing, setAnalysing] = useState(false); | |
| 99 | + const [analyserResult, setAnalyserResult] = useState<{ type: string; content: string } | null>(null); | |
| 100 | + const [showFilePicker, setShowFilePicker] = useState(false); | |
| 101 | + | |
| 102 | + // Auto-scroll chat | |
| 103 | + useEffect(() => { | |
| 104 | + chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| 105 | + }, [chatHistory, streamingContent]); | |
| 106 | + | |
| 107 | + // Fetch files for analyser | |
| 108 | + const fetchAnalyserFiles = useCallback(async (q = "") => { | |
| 109 | + setAnalyserLoading(true); | |
| 110 | + setAnalyserError(false); | |
| 111 | + try { | |
| 112 | + const res = await fetch(`/api/ai/files?q=${encodeURIComponent(q)}`); | |
| 113 | + if (res.ok) { | |
| 114 | + const data = await res.json(); | |
| 115 | + setAnalyserFiles(Array.isArray(data) ? data : []); | |
| 116 | + } else { | |
| 117 | + setAnalyserError(true); | |
| 118 | + } | |
| 119 | + } catch { | |
| 120 | + setAnalyserError(true); | |
| 121 | + } finally { | |
| 122 | + setAnalyserLoading(false); | |
| 123 | + } | |
| 124 | + }, []); | |
| 125 | + | |
| 126 | + // Load files when switching to analyse tab, auto-open picker | |
| 127 | + useEffect(() => { | |
| 128 | + if (tab === "analyse") { | |
| 129 | + fetchAnalyserFiles(); | |
| 130 | + setShowFilePicker(true); | |
| 131 | + } | |
| 132 | + }, [tab, fetchAnalyserFiles]); | |
| 133 | + | |
| 134 | + // ─── Jump to Analyser with a specific file ───────────────── | |
| 135 | + const openInAnalyser = useCallback((file: AnalyserFile) => { | |
| 136 | + setSelectedFile(file); | |
| 137 | + setAnalyserResult(null); | |
| 138 | + setShowFilePicker(false); | |
| 139 | + setTab("analyse"); | |
| 140 | + }, []); | |
| 141 | + | |
| 142 | + // ─── Search ──────────────────────────────────────────────── | |
| 143 | + const handleSearch = useCallback(async (q?: string) => { | |
| 144 | + const searchQuery = q ?? query; | |
| 145 | + if (!searchQuery.trim()) return; | |
| 28 | 146 | setSearching(true); |
| 29 | 147 | setResults([]); |
| 30 | 148 | setAnswer(""); |
@@ -32,7 +150,7 @@ export default function AiPanel() { | ||
| 32 | 150 | const res = await fetch("/api/ai/search", { |
| 33 | 151 | method: "POST", |
| 34 | 152 | headers: { "Content-Type": "application/json" }, |
| 35 | − body: JSON.stringify({ query }), | |
| 153 | + body: JSON.stringify({ query: searchQuery }), | |
| 36 | 154 | }); |
| 37 | 155 | if (res.ok) { |
| 38 | 156 | const data = await res.json(); |
@@ -41,39 +159,127 @@ export default function AiPanel() { | ||
| 41 | 159 | } |
| 42 | 160 | } catch {} |
| 43 | 161 | setSearching(false); |
| 162 | + }, [query]); | |
| 163 | + | |
| 164 | + const quickSearch = (q: string) => { | |
| 165 | + setQuery(q); | |
| 166 | + handleSearch(q); | |
| 44 | 167 | }; |
| 45 | 168 | |
| 169 | + // ─── Chat (streaming) ────────────────────────────────────── | |
| 46 | 170 | const handleChat = async () => { |
| 47 | − if (!chatMessage.trim()) return; | |
| 48 | − const msg = chatMessage; | |
| 49 | − setChatMessage(""); | |
| 171 | + const msg = chatInput.trim(); | |
| 172 | + if (!msg || isStreaming) return; | |
| 173 | + setChatInput(""); | |
| 174 | + | |
| 175 | + const historyToSend = [...chatHistory]; | |
| 50 | 176 | setChatHistory((prev) => [...prev, { role: "user", content: msg }]); |
| 51 | − setChatting(true); | |
| 177 | + setIsStreaming(true); | |
| 178 | + setStreamingContent(""); | |
| 179 | + | |
| 52 | 180 | try { |
| 53 | 181 | const res = await fetch("/api/ai/chat", { |
| 54 | 182 | method: "POST", |
| 55 | 183 | headers: { "Content-Type": "application/json" }, |
| 56 | − body: JSON.stringify({ message: msg }), | |
| 184 | + body: JSON.stringify({ message: msg, history: historyToSend, stream: true }), | |
| 185 | + }); | |
| 186 | + | |
| 187 | + if (!res.ok || !res.body) throw new Error("Stream error"); | |
| 188 | + | |
| 189 | + const reader = res.body.getReader(); | |
| 190 | + const decoder = new TextDecoder(); | |
| 191 | + let accumulated = ""; | |
| 192 | + let buffer = ""; | |
| 193 | + | |
| 194 | + while (true) { | |
| 195 | + const { done, value } = await reader.read(); | |
| 196 | + if (done) break; | |
| 197 | + buffer += decoder.decode(value, { stream: true }); | |
| 198 | + const lines = buffer.split("\n\n"); | |
| 199 | + buffer = lines.pop() ?? ""; | |
| 200 | + for (const line of lines) { | |
| 201 | + if (!line.startsWith("data: ")) continue; | |
| 202 | + const data = line.slice(6).trim(); | |
| 203 | + if (data === "[DONE]") break; | |
| 204 | + try { | |
| 205 | + const parsed = JSON.parse(data); | |
| 206 | + if (parsed.token) { | |
| 207 | + accumulated += parsed.token; | |
| 208 | + setStreamingContent(accumulated); | |
| 209 | + } | |
| 210 | + } catch {} | |
| 211 | + } | |
| 212 | + } | |
| 213 | + | |
| 214 | + setChatHistory((prev) => [ | |
| 215 | + ...prev, | |
| 216 | + { role: "assistant", content: accumulated || "Désolé, aucune réponse." }, | |
| 217 | + ]); | |
| 218 | + } catch { | |
| 219 | + setChatHistory((prev) => [ | |
| 220 | + ...prev, | |
| 221 | + { role: "assistant", content: "Erreur de connexion. Réessayez." }, | |
| 222 | + ]); | |
| 223 | + } finally { | |
| 224 | + setIsStreaming(false); | |
| 225 | + setStreamingContent(""); | |
| 226 | + } | |
| 227 | + }; | |
| 228 | + | |
| 229 | + // ─── Analyser ────────────────────────────────────────────── | |
| 230 | + const handleAnalyse = async (action: "ocr" | "describe" | "tag") => { | |
| 231 | + if (!selectedFile) return; | |
| 232 | + setAnalysing(true); | |
| 233 | + setAnalyserResult(null); | |
| 234 | + const endpoint = | |
| 235 | + action === "ocr" ? "/api/ai/ocr" : action === "describe" ? "/api/ai/describe" : "/api/ai/tag"; | |
| 236 | + try { | |
| 237 | + const res = await fetch(endpoint, { | |
| 238 | + method: "POST", | |
| 239 | + headers: { "Content-Type": "application/json" }, | |
| 240 | + body: JSON.stringify({ fileId: selectedFile.id }), | |
| 57 | 241 | }); |
| 58 | 242 | if (res.ok) { |
| 59 | 243 | const data = await res.json(); |
| 60 | − setChatHistory((prev) => [...prev, { role: "assistant", content: data.reply }]); | |
| 244 | + if (action === "ocr") | |
| 245 | + setAnalyserResult({ type: "Texte extrait (OCR)", content: data.text || "Aucun texte trouvé." }); | |
| 246 | + else if (action === "describe") | |
| 247 | + setAnalyserResult({ type: "Description", content: data.description || "Impossible de décrire." }); | |
| 248 | + else { | |
| 249 | + const tags = Array.isArray(data.tags) ? data.tags : []; | |
| 250 | + setAnalyserResult({ type: "Tags générés", content: tags.join(", ") || "Aucun tag." }); | |
| 251 | + } | |
| 61 | 252 | } |
| 62 | − } catch {} | |
| 63 | − setChatting(false); | |
| 253 | + } catch { | |
| 254 | + setAnalyserResult({ type: "Erreur", content: "Impossible d'analyser ce fichier." }); | |
| 255 | + } | |
| 256 | + setAnalysing(false); | |
| 64 | 257 | }; |
| 65 | 258 | |
| 66 | − const relevanceColors: Record<string, string> = { | |
| 67 | − high: "bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400", | |
| 68 | − medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400", | |
| 69 | − low: "bg-gray-100 text-gray-600 dark:bg-gray-500/20 dark:text-gray-400", | |
| 259 | + const handleAnalyseAll = async () => { | |
| 260 | + if (!selectedFile) return; | |
| 261 | + setAnalysing(true); | |
| 262 | + setAnalyserResult(null); | |
| 263 | + try { | |
| 264 | + const [descRes, tagRes] = await Promise.all([ | |
| 265 | + fetch("/api/ai/describe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileId: selectedFile.id }) }), | |
| 266 | + fetch("/api/ai/tag", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileId: selectedFile.id }) }), | |
| 267 | + ]); | |
| 268 | + const desc = descRes.ok ? (await descRes.json()).description : ""; | |
| 269 | + const tags = tagRes.ok ? (await tagRes.json()).tags : []; | |
| 270 | + setAnalyserResult({ | |
| 271 | + type: "Analyse complète", | |
| 272 | + content: `**Description :** ${desc}\n\n**Tags :** ${Array.isArray(tags) ? tags.join(", ") : "—"}`, | |
| 273 | + }); | |
| 274 | + } catch { | |
| 275 | + setAnalyserResult({ type: "Erreur", content: "Erreur lors de l'analyse." }); | |
| 276 | + } | |
| 277 | + setAnalysing(false); | |
| 70 | 278 | }; |
| 71 | 279 | |
| 72 | − 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 placeholder-gray-400 dark:placeholder-gray-500"; | |
| 73 | − | |
| 74 | 280 | return ( |
| 75 | 281 | <> |
| 76 | − {/* AI Toggle Button - positioned above mobile safe area */} | |
| 282 | + {/* Toggle Button */} | |
| 77 | 283 | <button |
| 78 | 284 | onClick={() => setOpen(true)} |
| 79 | 285 | className="fixed bottom-20 right-4 sm:bottom-6 sm:right-6 z-40 w-14 h-14 rounded-full bg-purple-600 hover:bg-purple-700 text-white shadow-lg flex items-center justify-center transition-colors" |
@@ -82,7 +288,7 @@ export default function AiPanel() { | ||
| 82 | 288 | <Brain className="w-6 h-6" /> |
| 83 | 289 | </button> |
| 84 | 290 | |
| 85 | − {/* AI Panel */} | |
| 291 | + {/* Panel */} | |
| 86 | 292 | <AnimatePresence> |
| 87 | 293 | {open && ( |
| 88 | 294 | <> |
@@ -94,11 +300,11 @@ export default function AiPanel() { | ||
| 94 | 300 | onClick={() => setOpen(false)} |
| 95 | 301 | /> |
| 96 | 302 | <motion.div |
| 97 | − initial={{ x: 400, opacity: 0 }} | |
| 303 | + initial={{ x: 420, opacity: 0 }} | |
| 98 | 304 | animate={{ x: 0, opacity: 1 }} |
| 99 | − exit={{ x: 400, opacity: 0 }} | |
| 305 | + exit={{ x: 420, opacity: 0 }} | |
| 100 | 306 | transition={{ type: "spring", damping: 25, stiffness: 300 }} |
| 101 | − className="fixed right-0 top-0 bottom-0 w-full sm:w-96 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)]" | |
| 307 | + 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)]" | |
| 102 | 308 | > |
| 103 | 309 | {/* Header */} |
| 104 | 310 | <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))]"> |
@@ -115,120 +321,431 @@ export default function AiPanel() { | ||
| 115 | 321 | |
| 116 | 322 | {/* Tabs */} |
| 117 | 323 | <div className="flex border-b border-gray-200 dark:border-white/10"> |
| 118 | − <button | |
| 119 | − onClick={() => setTab("search")} | |
| 120 | − className={`flex-1 px-4 py-3 text-sm font-medium transition-colors flex items-center justify-center gap-1.5 ${tab === "search" ? "text-purple-600 dark:text-purple-400 border-b-2 border-purple-500" : "text-gray-500"}`} | |
| 121 | − > | |
| 122 | − <Search className="w-4 h-4" /> Recherche IA | |
| 123 | − </button> | |
| 124 | − <button | |
| 125 | − onClick={() => setTab("chat")} | |
| 126 | − className={`flex-1 px-4 py-3 text-sm font-medium transition-colors flex items-center justify-center gap-1.5 ${tab === "chat" ? "text-purple-600 dark:text-purple-400 border-b-2 border-purple-500" : "text-gray-500"}`} | |
| 127 | − > | |
| 128 | − <MessageSquare className="w-4 h-4" /> Chat | |
| 129 | − </button> | |
| 324 | + {(["chat", "search", "analyse"] as Tab[]).map((t) => { | |
| 325 | + const icons = { chat: MessageSquare, search: Search, analyse: ScanText }; | |
| 326 | + const labels = { chat: "Chat", search: "Recherche", analyse: "Analyser" }; | |
| 327 | + const Icon = icons[t]; | |
| 328 | + return ( | |
| 329 | + <button | |
| 330 | + key={t} | |
| 331 | + onClick={() => setTab(t)} | |
| 332 | + className={`flex-1 px-2 py-3 text-xs font-medium transition-colors flex items-center justify-center gap-1.5 ${ | |
| 333 | + tab === t | |
| 334 | + ? "text-purple-600 dark:text-purple-400 border-b-2 border-purple-500" | |
| 335 | + : "text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" | |
| 336 | + }`} | |
| 337 | + > | |
| 338 | + <Icon className="w-3.5 h-3.5" /> | |
| 339 | + {labels[t]} | |
| 340 | + </button> | |
| 341 | + ); | |
| 342 | + })} | |
| 130 | 343 | </div> |
| 131 | 344 | |
| 132 | − {/* Content */} | |
| 133 | − <div className="flex-1 overflow-auto p-4"> | |
| 134 | − {tab === "search" ? ( | |
| 135 | − <div className="space-y-4"> | |
| 345 | + {/* ── CHAT TAB ── */} | |
| 346 | + {tab === "chat" && ( | |
| 347 | + <div className="flex flex-col flex-1 min-h-0"> | |
| 348 | + <div className="flex-1 overflow-auto p-4 space-y-3"> | |
| 349 | + {chatHistory.length === 0 && !isStreaming && ( | |
| 350 | + <div className="text-center py-6"> | |
| 351 | + <Brain className="w-8 h-8 mx-auto mb-3 text-gray-300 dark:text-gray-600" /> | |
| 352 | + <p className="text-sm text-gray-500 mb-4">Posez une question sur vos fichiers</p> | |
| 353 | + <div className="space-y-2 text-left"> | |
| 354 | + {[ | |
| 355 | + "Résume mes fichiers récents", | |
| 356 | + "Quels sont mes documents importants ?", | |
| 357 | + "Aide-moi à organiser mes fichiers", | |
| 358 | + ].map((prompt) => ( | |
| 359 | + <button | |
| 360 | + key={prompt} | |
| 361 | + onClick={() => setChatInput(prompt)} | |
| 362 | + className="w-full text-left px-3 py-2 rounded-lg bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 text-xs text-gray-600 dark:text-gray-400 hover:bg-purple-50 dark:hover:bg-purple-500/10 hover:border-purple-200 dark:hover:border-purple-500/30 transition-colors" | |
| 363 | + > | |
| 364 | + <Zap className="w-3 h-3 inline mr-1.5 text-purple-400" /> | |
| 365 | + {prompt} | |
| 366 | + </button> | |
| 367 | + ))} | |
| 368 | + </div> | |
| 369 | + </div> | |
| 370 | + )} | |
| 371 | + | |
| 372 | + {chatHistory.map((msg, i) => ( | |
| 373 | + <div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}> | |
| 374 | + {msg.role === "assistant" && ( | |
| 375 | + <div className="w-6 h-6 rounded-full bg-purple-100 dark:bg-purple-500/20 flex items-center justify-center flex-shrink-0 mr-2 mt-0.5"> | |
| 376 | + <Brain className="w-3 h-3 text-purple-600 dark:text-purple-400" /> | |
| 377 | + </div> | |
| 378 | + )} | |
| 379 | + <div | |
| 380 | + className={`max-w-[82%] px-3 py-2.5 rounded-2xl text-sm leading-relaxed ${ | |
| 381 | + msg.role === "user" | |
| 382 | + ? "bg-purple-600 text-white rounded-tr-sm" | |
| 383 | + : "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-gray-100 rounded-tl-sm" | |
| 384 | + }`} | |
| 385 | + > | |
| 386 | + {msg.role === "assistant" ? ( | |
| 387 | + <div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-li:my-0.5 prose-headings:my-1"> | |
| 388 | + <ReactMarkdown>{msg.content}</ReactMarkdown> | |
| 389 | + </div> | |
| 390 | + ) : ( | |
| 391 | + msg.content | |
| 392 | + )} | |
| 393 | + </div> | |
| 394 | + </div> | |
| 395 | + ))} | |
| 396 | + | |
| 397 | + {isStreaming && ( | |
| 398 | + <div className="flex justify-start"> | |
| 399 | + <div className="w-6 h-6 rounded-full bg-purple-100 dark:bg-purple-500/20 flex items-center justify-center flex-shrink-0 mr-2 mt-0.5"> | |
| 400 | + <Brain className="w-3 h-3 text-purple-600 dark:text-purple-400" /> | |
| 401 | + </div> | |
| 402 | + <div className="max-w-[82%] 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"> | |
| 403 | + {streamingContent ? ( | |
| 404 | + <div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-1"> | |
| 405 | + <ReactMarkdown>{streamingContent}</ReactMarkdown> | |
| 406 | + </div> | |
| 407 | + ) : ( | |
| 408 | + <span className="flex items-center gap-1"> | |
| 409 | + {[0, 150, 300].map((d) => ( | |
| 410 | + <span key={d} className="w-1.5 h-1.5 bg-purple-500 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} /> | |
| 411 | + ))} | |
| 412 | + </span> | |
| 413 | + )} | |
| 414 | + </div> | |
| 415 | + </div> | |
| 416 | + )} | |
| 417 | + <div ref={chatEndRef} /> | |
| 418 | + </div> | |
| 419 | + | |
| 420 | + <div className="p-4 border-t border-gray-200 dark:border-white/10"> | |
| 421 | + {chatHistory.length > 0 && ( | |
| 422 | + <button | |
| 423 | + onClick={() => setChatHistory([])} | |
| 424 | + className="flex items-center gap-1 text-xs text-gray-400 hover:text-red-500 transition-colors mb-2" | |
| 425 | + > | |
| 426 | + <Trash2 className="w-3 h-3" /> Effacer la conversation | |
| 427 | + </button> | |
| 428 | + )} | |
| 136 | 429 | <div className="flex gap-2"> |
| 137 | 430 | <input |
| 138 | 431 | type="text" |
| 139 | − value={query} | |
| 140 | − onChange={(e) => setQuery(e.target.value)} | |
| 141 | − onKeyDown={(e) => e.key === "Enter" && handleSearch()} | |
| 142 | − placeholder="Rechercher avec l'IA..." | |
| 432 | + value={chatInput} | |
| 433 | + onChange={(e) => setChatInput(e.target.value)} | |
| 434 | + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleChat()} | |
| 435 | + placeholder="Votre message..." | |
| 143 | 436 | className={`${inputClasses} flex-1`} |
| 437 | + disabled={isStreaming} | |
| 144 | 438 | /> |
| 145 | − <Button size="sm" onClick={handleSearch} loading={searching}> | |
| 146 | − <Search className="w-4 h-4" /> | |
| 147 | − </Button> | |
| 439 | + <button | |
| 440 | + onClick={handleChat} | |
| 441 | + disabled={isStreaming || !chatInput.trim()} | |
| 442 | + className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed text-white transition-colors" | |
| 443 | + > | |
| 444 | + {isStreaming ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />} | |
| 445 | + </button> | |
| 446 | + </div> | |
| 447 | + </div> | |
| 448 | + </div> | |
| 449 | + )} | |
| 450 | + | |
| 451 | + {/* ── SEARCH TAB ── */} | |
| 452 | + {tab === "search" && ( | |
| 453 | + <div className="flex-1 overflow-auto p-4 space-y-4"> | |
| 454 | + <div className="flex gap-2"> | |
| 455 | + <input | |
| 456 | + type="text" | |
| 457 | + value={query} | |
| 458 | + onChange={(e) => setQuery(e.target.value)} | |
| 459 | + onKeyDown={(e) => e.key === "Enter" && handleSearch()} | |
| 460 | + placeholder="Ex: rapports financiers 2024..." | |
| 461 | + className={`${inputClasses} flex-1`} | |
| 462 | + /> | |
| 463 | + <button | |
| 464 | + onClick={() => handleSearch()} | |
| 465 | + disabled={searching || !query.trim()} | |
| 466 | + className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white transition-colors" | |
| 467 | + > | |
| 468 | + {searching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />} | |
| 469 | + </button> | |
| 470 | + </div> | |
| 471 | + | |
| 472 | + {/* Quick suggestions */} | |
| 473 | + {!answer && results.length === 0 && !searching && ( | |
| 474 | + <div className="space-y-2"> | |
| 475 | + <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Suggestions</p> | |
| 476 | + <div className="grid grid-cols-2 gap-2"> | |
| 477 | + {[ | |
| 478 | + { label: "Documents récents", query: "documents récents" }, | |
| 479 | + { label: "Images & Photos", query: "images et photos" }, | |
| 480 | + { label: "PDFs", query: "fichiers PDF" }, | |
| 481 | + { label: "Fichiers texte", query: "fichiers texte markdown" }, | |
| 482 | + ].map((s) => ( | |
| 483 | + <button | |
| 484 | + key={s.query} | |
| 485 | + onClick={() => quickSearch(s.query)} | |
| 486 | + 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" | |
| 487 | + > | |
| 488 | + <Search className="w-3 h-3 mb-1 text-purple-400" /> | |
| 489 | + {s.label} | |
| 490 | + </button> | |
| 491 | + ))} | |
| 492 | + </div> | |
| 493 | + </div> | |
| 494 | + )} | |
| 495 | + | |
| 496 | + {answer && ( | |
| 497 | + <div className="bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20 rounded-lg p-3.5"> | |
| 498 | + <p className="text-xs font-medium text-purple-600 dark:text-purple-400 mb-1 flex items-center gap-1"> | |
| 499 | + <Brain className="w-3 h-3" /> Synthèse IA | |
| 500 | + </p> | |
| 501 | + <p className="text-sm text-purple-800 dark:text-purple-300">{answer}</p> | |
| 502 | + </div> | |
| 503 | + )} | |
| 504 | + | |
| 505 | + {results.length > 0 && ( | |
| 506 | + <div className="space-y-2"> | |
| 507 | + <p className="text-xs font-medium text-gray-500">{results.length} résultat(s) trouvé(s)</p> | |
| 508 | + {results.map((r) => ( | |
| 509 | + <div | |
| 510 | + key={r.fileId} | |
| 511 | + className="p-3.5 bg-gray-50 dark:bg-white/5 rounded-lg border border-gray-200 dark:border-white/10" | |
| 512 | + > | |
| 513 | + <div className="flex items-start gap-3"> | |
| 514 | + <span className="text-base flex-shrink-0 mt-0.5">{mimeIcon(r.mimeType || "")}</span> | |
| 515 | + <div className="flex-1 min-w-0"> | |
| 516 | + <div className="flex items-center gap-2 mb-0.5 flex-wrap"> | |
| 517 | + <p className="text-sm font-medium text-gray-900 dark:text-white truncate">{r.name}</p> | |
| 518 | + <span className={`px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0 ${relevanceColors[r.relevance] || relevanceColors.low}`}> | |
| 519 | + {r.relevance} | |
| 520 | + </span> | |
| 521 | + </div> | |
| 522 | + {r.folder && <p className="text-[10px] text-gray-400 mb-0.5">📁 {r.folder}</p>} | |
| 523 | + <p className="text-xs text-gray-500">{r.snippet}</p> | |
| 524 | + </div> | |
| 525 | + </div> | |
| 526 | + {/* Actions on search results */} | |
| 527 | + <div className="flex gap-2 mt-2.5 pt-2.5 border-t border-gray-100 dark:border-white/10"> | |
| 528 | + <button | |
| 529 | + onClick={() => openInAnalyser({ | |
| 530 | + id: r.fileId, | |
| 531 | + name: r.name, | |
| 532 | + mimeType: r.mimeType || "", | |
| 533 | + size: 0, | |
| 534 | + })} | |
| 535 | + className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-500/10 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-500/20 transition-colors" | |
| 536 | + > | |
| 537 | + <FlaskConical className="w-3 h-3" /> Analyser | |
| 538 | + </button> | |
| 539 | + <button | |
| 540 | + onClick={() => window.open(`/api/files/${r.fileId}/download`, "_blank")} | |
| 541 | + 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" | |
| 542 | + > | |
| 543 | + <Download className="w-3 h-3" /> Télécharger | |
| 544 | + </button> | |
| 545 | + <button | |
| 546 | + onClick={() => { | |
| 547 | + setChatInput(`Résume le fichier ${r.name}`); | |
| 548 | + setTab("chat"); | |
| 549 | + }} | |
| 550 | + 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" | |
| 551 | + > | |
| 552 | + <MessageSquare className="w-3 h-3" /> Résumer | |
| 553 | + </button> | |
| 554 | + </div> | |
| 555 | + </div> | |
| 556 | + ))} | |
| 148 | 557 | </div> |
| 558 | + )} | |
| 559 | + </div> | |
| 560 | + )} | |
| 149 | 561 | |
| 150 | − {answer && ( | |
| 151 | − <div className="bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20 rounded-lg p-3.5"> | |
| 152 | − <p className="text-sm text-purple-800 dark:text-purple-300">{answer}</p> | |
| 562 | + {/* ── ANALYSE TAB ── */} | |
| 563 | + {tab === "analyse" && ( | |
| 564 | + <div className="flex-1 overflow-auto p-4 space-y-4"> | |
| 565 | + {/* File picker */} | |
| 566 | + <div> | |
| 567 | + <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Fichier à analyser</p> | |
| 568 | + <button | |
| 569 | + onClick={() => setShowFilePicker(!showFilePicker)} | |
| 570 | + 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" | |
| 571 | + > | |
| 572 | + <span className={selectedFile ? "text-gray-900 dark:text-white" : "text-gray-400"}> | |
| 573 | + {selectedFile ? ( | |
| 574 | + <span className="flex items-center gap-2"> | |
| 575 | + <span>{mimeIcon(selectedFile.mimeType)}</span> | |
| 576 | + <span className="truncate max-w-[240px]">{selectedFile.name}</span> | |
| 577 | + </span> | |
| 578 | + ) : "Choisir un fichier..."} | |
| 579 | + </span> | |
| 580 | + <ChevronDown className={`w-4 h-4 text-gray-400 transition-transform flex-shrink-0 ${showFilePicker ? "rotate-180" : ""}`} /> | |
| 581 | + </button> | |
| 582 | + | |
| 583 | + {showFilePicker && ( | |
| 584 | + <div className="mt-1 border border-gray-200 dark:border-white/10 rounded-lg overflow-hidden bg-white dark:bg-gray-800"> | |
| 585 | + <div className="p-2 border-b border-gray-100 dark:border-white/10"> | |
| 586 | + <input | |
| 587 | + type="text" | |
| 588 | + value={analyserQuery} | |
| 589 | + onChange={(e) => { | |
| 590 | + setAnalyserQuery(e.target.value); | |
| 591 | + fetchAnalyserFiles(e.target.value); | |
| 592 | + }} | |
| 593 | + placeholder="Filtrer par nom..." | |
| 594 | + 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" | |
| 595 | + autoFocus | |
| 596 | + /> | |
| 597 | + </div> | |
| 598 | + <div className="max-h-56 overflow-auto"> | |
| 599 | + {analyserLoading && ( | |
| 600 | + <div className="flex items-center justify-center p-4 gap-2 text-xs text-gray-400"> | |
| 601 | + <Loader2 className="w-4 h-4 animate-spin" /> Chargement... | |
| 602 | + </div> | |
| 603 | + )} | |
| 604 | + {analyserError && ( | |
| 605 | + <div className="flex items-center gap-2 p-3 text-xs text-red-500"> | |
| 606 | + <AlertCircle className="w-4 h-4" /> Impossible de charger les fichiers. | |
| 607 | + </div> | |
| 608 | + )} | |
| 609 | + {!analyserLoading && !analyserError && analyserFiles.length === 0 && ( | |
| 610 | + <p className="p-3 text-xs text-gray-400 text-center">Aucun fichier trouvé</p> | |
| 611 | + )} | |
| 612 | + {!analyserLoading && analyserFiles.map((f) => ( | |
| 613 | + <button | |
| 614 | + key={f.id} | |
| 615 | + onClick={() => { | |
| 616 | + setSelectedFile(f); | |
| 617 | + setShowFilePicker(false); | |
| 618 | + setAnalyserResult(null); | |
| 619 | + setAnalyserQuery(""); | |
| 620 | + }} | |
| 621 | + 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 ${ | |
| 622 | + selectedFile?.id === f.id | |
| 623 | + ? "bg-purple-50 dark:bg-purple-500/10" | |
| 624 | + : "hover:bg-gray-50 dark:hover:bg-white/5" | |
| 625 | + }`} | |
| 626 | + > | |
| 627 | + <span className="text-base flex-shrink-0">{mimeIcon(f.mimeType)}</span> | |
| 628 | + <div className="min-w-0 flex-1"> | |
| 629 | + <p className="text-xs font-medium text-gray-900 dark:text-white truncate">{f.name}</p> | |
| 630 | + {f.folder && <p className="text-[10px] text-gray-400">📁 {f.folder.name}</p>} | |
| 631 | + </div> | |
| 632 | + {f.aiDescription && ( | |
| 633 | + <span className="w-2 h-2 rounded-full bg-purple-400 flex-shrink-0" title="Déjà analysé" /> | |
| 634 | + )} | |
| 635 | + </button> | |
| 636 | + ))} | |
| 637 | + </div> | |
| 153 | 638 | </div> |
| 154 | 639 | )} |
| 640 | + </div> | |
| 155 | 641 | |
| 156 | − {results.map((r) => ( | |
| 157 | − <div key={r.fileId} className="flex items-start gap-3 p-3.5 bg-gray-50 dark:bg-white/5 rounded-lg"> | |
| 158 | − <FileText className="w-4 h-4 text-gray-400 mt-0.5 flex-shrink-0" /> | |
| 159 | − <div className="flex-1 min-w-0"> | |
| 160 | − <div className="flex items-center gap-2"> | |
| 161 | − <p className="text-sm font-medium text-gray-900 dark:text-white truncate">{r.name}</p> | |
| 162 | − <span className={`px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0 ${relevanceColors[r.relevance] || relevanceColors.low}`}> | |
| 163 | − {r.relevance} | |
| 164 | − </span> | |
| 642 | + {/* Analysis actions */} | |
| 643 | + {selectedFile && ( | |
| 644 | + <div className="space-y-3"> | |
| 645 | + <div className="p-3 bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20 rounded-lg"> | |
| 646 | + <div className="flex items-start justify-between gap-2"> | |
| 647 | + <div className="min-w-0"> | |
| 648 | + <p className="text-xs text-purple-600 dark:text-purple-400 font-medium mb-0.5">Fichier sélectionné</p> | |
| 649 | + <p className="text-sm font-medium text-purple-900 dark:text-purple-200 truncate">{selectedFile.name}</p> | |
| 650 | + {selectedFile.folder && <p className="text-xs text-purple-400 mt-0.5">📁 {selectedFile.folder.name}</p>} | |
| 165 | 651 | </div> |
| 166 | − <p className="text-xs text-gray-500 mt-0.5">{r.snippet}</p> | |
| 652 | + <button | |
| 653 | + onClick={() => window.open(`/api/files/${selectedFile.id}/download`, "_blank")} | |
| 654 | + 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" | |
| 655 | + title="Télécharger" | |
| 656 | + > | |
| 657 | + <Download className="w-3.5 h-3.5" /> | |
| 658 | + </button> | |
| 167 | 659 | </div> |
| 660 | + {selectedFile.aiDescription && ( | |
| 661 | + <p className="text-xs text-purple-600 dark:text-purple-400 mt-2 border-t border-purple-200 dark:border-purple-500/30 pt-2">{selectedFile.aiDescription}</p> | |
| 662 | + )} | |
| 663 | + {selectedFile.aiTags && (() => { | |
| 664 | + try { | |
| 665 | + const tags = JSON.parse(selectedFile.aiTags as string) as string[]; | |
| 666 | + return ( | |
| 667 | + <div className="flex flex-wrap gap-1 mt-1.5"> | |
| 668 | + {tags.map((t) => ( | |
| 669 | + <span key={t} className="px-1.5 py-0.5 bg-purple-200 dark:bg-purple-500/30 text-purple-700 dark:text-purple-300 rounded text-[10px]">{t}</span> | |
| 670 | + ))} | |
| 671 | + </div> | |
| 672 | + ); | |
| 673 | + } catch { return null; } | |
| 674 | + })()} | |
| 168 | 675 | </div> |
| 169 | − ))} | |
| 170 | 676 | |
| 171 | − {/* Quick actions */} | |
| 172 | − <div className="pt-2 space-y-2"> | |
| 173 | − <p className="text-xs text-gray-500 font-medium uppercase tracking-wider">Actions rapides</p> | |
| 174 | 677 | <div className="grid grid-cols-2 gap-2"> |
| 175 | − <button onClick={() => { setQuery("documents récents"); handleSearch(); }} 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-gray-100 dark:hover:bg-white/10"> | |
| 176 | − <FileText className="w-3 h-3 mb-1" /> Documents récents | |
| 678 | + {(selectedFile.mimeType.startsWith("image/") || selectedFile.mimeType === "application/pdf") && ( | |
| 679 | + <button | |
| 680 | + onClick={() => handleAnalyse("ocr")} | |
| 681 | + disabled={analysing} | |
| 682 | + 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" | |
| 683 | + > | |
| 684 | + <ScanText className="w-3.5 h-3.5 text-purple-500" /> OCR | |
| 685 | + </button> | |
| 686 | + )} | |
| 687 | + <button | |
| 688 | + onClick={() => handleAnalyse("describe")} | |
| 689 | + disabled={analysing} | |
| 690 | + 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" | |
| 691 | + > | |
| 692 | + <Info className="w-3.5 h-3.5 text-purple-500" /> Décrire | |
| 693 | + </button> | |
| 694 | + <button | |
| 695 | + onClick={() => handleAnalyse("tag")} | |
| 696 | + disabled={analysing} | |
| 697 | + 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" | |
| 698 | + > | |
| 699 | + <Tag className="w-3.5 h-3.5 text-purple-500" /> Auto-tagger | |
| 177 | 700 | </button> |
| 178 | − <button onClick={() => { setQuery("images et photos"); handleSearch(); }} 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-gray-100 dark:hover:bg-white/10"> | |
| 179 | − <Tag className="w-3 h-3 mb-1" /> Images & Photos | |
| 701 | + <button | |
| 702 | + onClick={handleAnalyseAll} | |
| 703 | + disabled={analysing} | |
| 704 | + className="flex items-center justify-center gap-2 px-3 py-2.5 bg-purple-600 hover:bg-purple-700 border border-transparent rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors col-span-full" | |
| 705 | + > | |
| 706 | + {analysing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5" />} | |
| 707 | + Analyse complète | |
| 180 | 708 | </button> |
| 181 | 709 | </div> |
| 182 | − </div> | |
| 183 | − </div> | |
| 184 | − ) : ( | |
| 185 | − <div className="flex flex-col h-full"> | |
| 186 | − <div className="flex-1 space-y-3 mb-4"> | |
| 187 | − {chatHistory.length === 0 && ( | |
| 188 | − <div className="text-center text-gray-500 py-8"> | |
| 189 | − <Brain className="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" /> | |
| 190 | − <p className="text-sm">Posez une question sur vos fichiers</p> | |
| 710 | + | |
| 711 | + {/* Also: ask Claude about this file */} | |
| 712 | + <button | |
| 713 | + onClick={() => { | |
| 714 | + setChatInput(`Résume le fichier ${selectedFile.name}`); | |
| 715 | + setTab("chat"); | |
| 716 | + }} | |
| 717 | + className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs text-purple-600 dark:text-purple-400 border border-purple-200 dark:border-purple-500/30 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-500/10 transition-colors" | |
| 718 | + > | |
| 719 | + <MessageSquare className="w-3.5 h-3.5" /> | |
| 720 | + Demander à Claude de résumer ce fichier | |
| 721 | + </button> | |
| 722 | + | |
| 723 | + {analysing && !analyserResult && ( | |
| 724 | + <div className="flex items-center justify-center py-6 gap-2 text-sm text-gray-500"> | |
| 725 | + <Loader2 className="w-4 h-4 animate-spin text-purple-500" /> Analyse en cours... | |
| 191 | 726 | </div> |
| 192 | 727 | )} |
| 193 | − {chatHistory.map((msg, i) => ( | |
| 194 | − <div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}> | |
| 195 | − <div className={`max-w-[80%] px-3 py-2 rounded-lg text-sm ${ | |
| 196 | − msg.role === "user" | |
| 197 | − ? "bg-purple-600 text-white" | |
| 198 | − : "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-gray-200" | |
| 199 | − }`}> | |
| 200 | − {msg.content} | |
| 201 | − </div> | |
| 202 | − </div> | |
| 203 | − ))} | |
| 204 | − {chatting && ( | |
| 205 | − <div className="flex justify-start"> | |
| 206 | − <div className="bg-gray-100 dark:bg-white/10 px-3 py-2 rounded-lg"> | |
| 207 | − <Loader2 className="w-4 h-4 animate-spin text-purple-500" /> | |
| 728 | + | |
| 729 | + {analyserResult && ( | |
| 730 | + <div className="bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg p-3.5"> | |
| 731 | + <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">{analyserResult.type}</p> | |
| 732 | + <div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-1"> | |
| 733 | + <ReactMarkdown>{analyserResult.content}</ReactMarkdown> | |
| 208 | 734 | </div> |
| 209 | 735 | </div> |
| 210 | 736 | )} |
| 211 | 737 | </div> |
| 212 | − </div> | |
| 213 | − )} | |
| 214 | − </div> | |
| 738 | + )} | |
| 215 | 739 | |
| 216 | − {/* Chat input */} | |
| 217 | − {tab === "chat" && ( | |
| 218 | − <div className="p-4 border-t border-gray-200 dark:border-white/10"> | |
| 219 | − <div className="flex gap-2"> | |
| 220 | − <input | |
| 221 | − type="text" | |
| 222 | − value={chatMessage} | |
| 223 | − onChange={(e) => setChatMessage(e.target.value)} | |
| 224 | − onKeyDown={(e) => e.key === "Enter" && handleChat()} | |
| 225 | − placeholder="Votre message..." | |
| 226 | − className={`${inputClasses} flex-1`} | |
| 227 | − /> | |
| 228 | − <Button size="sm" onClick={handleChat} loading={chatting}> | |
| 229 | − <MessageSquare className="w-4 h-4" /> | |
| 230 | − </Button> | |
| 231 | − </div> | |
| 740 | + {!selectedFile && !showFilePicker && ( | |
| 741 | + <div className="text-center py-8"> | |
| 742 | + <File className="w-10 h-10 mx-auto mb-3 text-gray-300 dark:text-gray-600" /> | |
| 743 | + <p className="text-sm text-gray-500 dark:text-gray-400"> | |
| 744 | + Sélectionnez un fichier ci-dessus pour l'analyser | |
| 745 | + </p> | |
| 746 | + <p className="text-xs text-gray-400 mt-1">OCR, description, tags automatiques</p> | |
| 747 | + </div> | |
| 748 | + )} | |
| 232 | 749 | </div> |
| 233 | 750 | )} |
| 234 | 751 | </motion.div> |
added
components/auth/ChangePasswordModal.tsx
+149 −0
@@ -0,0 +1,149 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useState } from "react"; | |
| 4 | +import { Eye, EyeOff, Lock, Check } from "lucide-react"; | |
| 5 | +import Modal from "../ui/Modal"; | |
| 6 | +import Button from "../ui/Button"; | |
| 7 | +import toast from "react-hot-toast"; | |
| 8 | + | |
| 9 | +interface ChangePasswordModalProps { | |
| 10 | + isOpen: boolean; | |
| 11 | + onClose: () => void; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProps) { | |
| 15 | + const [currentPassword, setCurrentPassword] = useState(""); | |
| 16 | + const [newPassword, setNewPassword] = useState(""); | |
| 17 | + const [confirmPassword, setConfirmPassword] = useState(""); | |
| 18 | + const [showCurrent, setShowCurrent] = useState(false); | |
| 19 | + const [showNew, setShowNew] = useState(false); | |
| 20 | + const [loading, setLoading] = useState(false); | |
| 21 | + | |
| 22 | + const handleClose = () => { | |
| 23 | + setCurrentPassword(""); | |
| 24 | + setNewPassword(""); | |
| 25 | + setConfirmPassword(""); | |
| 26 | + setShowCurrent(false); | |
| 27 | + setShowNew(false); | |
| 28 | + onClose(); | |
| 29 | + }; | |
| 30 | + | |
| 31 | + const passwordsMatch = newPassword && confirmPassword && newPassword === confirmPassword; | |
| 32 | + const newPasswordValid = newPassword.length >= 6; | |
| 33 | + const canSubmit = currentPassword && newPasswordValid && passwordsMatch; | |
| 34 | + | |
| 35 | + const handleSubmit = async () => { | |
| 36 | + if (!canSubmit) return; | |
| 37 | + setLoading(true); | |
| 38 | + try { | |
| 39 | + const res = await fetch("/api/auth/change-password", { | |
| 40 | + method: "POST", | |
| 41 | + headers: { "Content-Type": "application/json" }, | |
| 42 | + body: JSON.stringify({ currentPassword, newPassword }), | |
| 43 | + }); | |
| 44 | + const data = await res.json(); | |
| 45 | + if (res.ok) { | |
| 46 | + toast.success("Mot de passe modifié avec succès"); | |
| 47 | + handleClose(); | |
| 48 | + } else { | |
| 49 | + toast.error(data.error || "Erreur"); | |
| 50 | + } | |
| 51 | + } catch { | |
| 52 | + toast.error("Erreur réseau"); | |
| 53 | + } finally { | |
| 54 | + setLoading(false); | |
| 55 | + } | |
| 56 | + }; | |
| 57 | + | |
| 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 | + 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> | |
| 87 | + | |
| 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"} | |
| 97 | + value={newPassword} | |
| 98 | + onChange={(e) => setNewPassword(e.target.value)} | |
| 99 | + placeholder="Min. 6 caractères" | |
| 100 | + className={`${inputBase} ${newPassword && !newPasswordValid ? "border-red-400 dark:border-red-500" : ""}`} | |
| 101 | + /> | |
| 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> | |
| 109 | + </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 | + | |
| 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" | |
| 124 | + value={confirmPassword} | |
| 125 | + onChange={(e) => setConfirmPassword(e.target.value)} | |
| 126 | + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} | |
| 127 | + placeholder="••••••••" | |
| 128 | + className={`${inputBase} ${confirmPassword && !passwordsMatch ? "border-red-400 dark:border-red-500" : confirmPassword && passwordsMatch ? "border-green-400 dark:border-green-500" : ""}`} | |
| 129 | + /> | |
| 130 | + {confirmPassword && passwordsMatch && ( | |
| 131 | + <Check className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-green-500" /> | |
| 132 | + )} | |
| 133 | + </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 | + </div> | |
| 147 | + </Modal> | |
| 148 | + ); | |
| 149 | +} | |
modified
components/files/FileCard.tsx
+38 −24
@@ -45,12 +45,26 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = { | ||
| 45 | 45 | presentation: FileText, |
| 46 | 46 | }; |
| 47 | 47 | |
| 48 | +const iconColorMap: Record<string, string> = { | |
| 49 | + image: "text-emerald-400 dark:text-emerald-500", | |
| 50 | + video: "text-purple-400 dark:text-purple-500", | |
| 51 | + audio: "text-pink-400 dark:text-pink-500", | |
| 52 | + pdf: "text-red-400 dark:text-red-500", | |
| 53 | + code: "text-amber-400 dark:text-amber-500", | |
| 54 | + spreadsheet: "text-green-500 dark:text-green-400", | |
| 55 | + archive: "text-orange-400 dark:text-orange-500", | |
| 56 | + document: "text-blue-500 dark:text-blue-400", | |
| 57 | + presentation: "text-orange-500 dark:text-orange-400", | |
| 58 | + file: "text-gray-400 dark:text-gray-500", | |
| 59 | +}; | |
| 60 | + | |
| 48 | 61 | export default function FileCard({ file, onPreview, onRefresh, viewMode }: FileCardProps) { |
| 49 | 62 | const [showActions, setShowActions] = useState(false); |
| 50 | 63 | const menuRef = useRef<HTMLDivElement>(null); |
| 51 | 64 | |
| 52 | 65 | const iconType = getFileIcon(file.mimeType); |
| 53 | 66 | const Icon = iconMap[iconType] || File; |
| 67 | + const iconColor = iconColorMap[iconType] || "text-gray-400 dark:text-gray-500"; | |
| 54 | 68 | |
| 55 | 69 | useEffect(() => { |
| 56 | 70 | function handleClick(e: MouseEvent) { |
@@ -72,7 +86,7 @@ export default function FileCard({ file, onPreview, onRefresh, viewMode }: FileC | ||
| 72 | 86 | className="flex items-center gap-3 px-3 py-3.5 hover:bg-gray-100 dark:hover:bg-white/5 rounded-lg cursor-pointer group transition-colors" |
| 73 | 87 | onClick={() => onPreview(file)} |
| 74 | 88 | > |
| 75 | − <Icon className="w-5 h-5 text-gray-400 flex-shrink-0" /> | |
| 89 | + <Icon className={`w-5 h-5 flex-shrink-0 ${iconColor}`} /> | |
| 76 | 90 | <span className="text-sm text-gray-700 dark:text-gray-200 flex-1 truncate">{file.name}</span> |
| 77 | 91 | <span className="text-sm text-gray-500 hidden sm:block">{formatFileSize(file.size)}</span> |
| 78 | 92 | <span className="text-xs text-gray-500 hidden md:block">{formatDate(file.createdAt)}</span> |
@@ -102,11 +116,11 @@ export default function FileCard({ file, onPreview, onRefresh, viewMode }: FileC | ||
| 102 | 116 | <motion.div |
| 103 | 117 | initial={{ opacity: 0, scale: 0.95 }} |
| 104 | 118 | animate={{ opacity: 1, scale: 1 }} |
| 105 | − 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" | |
| 119 | + 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" : ""}`} | |
| 106 | 120 | onClick={() => onPreview(file)} |
| 107 | 121 | > |
| 108 | − {/* Preview thumbnail */} | |
| 109 | − <div className="aspect-square flex items-center justify-center bg-gray-50 dark:bg-white/[0.02] relative rounded-t-xl overflow-hidden"> | |
| 122 | + {/* Preview thumbnail — overflow-hidden stays here for image crop only */} | |
| 123 | + <div className="aspect-square flex items-center justify-center bg-gray-50 dark:bg-white/[0.02] rounded-t-xl overflow-hidden"> | |
| 110 | 124 | {isImage ? ( |
| 111 | 125 | <img |
| 112 | 126 | src={`/api/files/${file.id}/preview`} |
@@ -115,28 +129,28 @@ export default function FileCard({ file, onPreview, onRefresh, viewMode }: FileC | ||
| 115 | 129 | loading="lazy" |
| 116 | 130 | /> |
| 117 | 131 | ) : ( |
| 118 | − <Icon className="w-12 h-12 text-gray-300 dark:text-gray-600" /> | |
| 132 | + <Icon className={`w-12 h-12 ${iconColor}`} /> | |
| 119 | 133 | )} |
| 134 | + </div> | |
| 120 | 135 | |
| 121 | − {/* Actions button */} | |
| 122 | − <div className="absolute top-2 right-2" ref={menuRef}> | |
| 123 | − <button | |
| 124 | − onClick={(e) => { | |
| 125 | − e.stopPropagation(); | |
| 126 | − setShowActions(!showActions); | |
| 127 | − }} | |
| 128 | − 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" | |
| 129 | − > | |
| 130 | − <MoreVertical className="w-4 h-4" /> | |
| 131 | − </button> | |
| 132 | − {showActions && ( | |
| 133 | − <FileActions | |
| 134 | − file={file} | |
| 135 | − onClose={() => setShowActions(false)} | |
| 136 | − onRefresh={onRefresh} | |
| 137 | − /> | |
| 138 | − )} | |
| 139 | − </div> | |
| 136 | + {/* Actions button — outside overflow-hidden so dropdown is never clipped */} | |
| 137 | + <div className="absolute top-2 right-2 z-20" ref={menuRef}> | |
| 138 | + <button | |
| 139 | + onClick={(e) => { | |
| 140 | + e.stopPropagation(); | |
| 141 | + setShowActions(!showActions); | |
| 142 | + }} | |
| 143 | + 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" | |
| 144 | + > | |
| 145 | + <MoreVertical className="w-4 h-4" /> | |
| 146 | + </button> | |
| 147 | + {showActions && ( | |
| 148 | + <FileActions | |
| 149 | + file={file} | |
| 150 | + onClose={() => setShowActions(false)} | |
| 151 | + onRefresh={onRefresh} | |
| 152 | + /> | |
| 153 | + )} | |
| 140 | 154 | </div> |
| 141 | 155 | |
| 142 | 156 | {/* Info */} |
modified
components/files/FileExplorer.tsx
+9 −1
@@ -1,12 +1,13 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { useState, useEffect, useCallback } from "react"; |
| 4 | −import { Grid, List, Plus, Upload, CheckSquare, Square } from "lucide-react"; | |
| 4 | +import { Grid, List, Plus, Upload, CheckSquare, Square, Users } from "lucide-react"; | |
| 5 | 5 | import FileCard from "./FileCard"; |
| 6 | 6 | import FileUpload from "./FileUpload"; |
| 7 | 7 | import PreviewEngine from "../preview/PreviewEngine"; |
| 8 | 8 | import FolderCard from "../folders/FolderCard"; |
| 9 | 9 | import CreateFolderModal from "../folders/CreateFolderModal"; |
| 10 | +import CreateSharedFolderModal from "../folders/CreateSharedFolderModal"; | |
| 10 | 11 | import SearchBar from "../ui/SearchBar"; |
| 11 | 12 | import Button from "../ui/Button"; |
| 12 | 13 | import BulkActions from "./BulkActions"; |
@@ -25,6 +26,7 @@ interface FolderData { | ||
| 25 | 26 | id: string; |
| 26 | 27 | name: string; |
| 27 | 28 | parentId: string | null; |
| 29 | + sharedSpaceId?: string | null; | |
| 28 | 30 | createdAt: string; |
| 29 | 31 | _count?: { files: number; children: number }; |
| 30 | 32 | } |
@@ -39,6 +41,7 @@ export default function FileExplorer({ folderId }: FileExplorerProps) { | ||
| 39 | 41 | const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); |
| 40 | 42 | const [showUpload, setShowUpload] = useState(false); |
| 41 | 43 | const [showCreateFolder, setShowCreateFolder] = useState(false); |
| 44 | + const [showCreateSharedFolder, setShowCreateSharedFolder] = useState(false); | |
| 42 | 45 | const [previewFile, setPreviewFile] = useState<FileData | null>(null); |
| 43 | 46 | const [searchQuery, setSearchQuery] = useState(""); |
| 44 | 47 | const [loading, setLoading] = useState(true); |
@@ -124,6 +127,10 @@ export default function FileExplorer({ folderId }: FileExplorerProps) { | ||
| 124 | 127 | <Plus className="w-4 h-4" /> |
| 125 | 128 | <span className="hidden sm:inline">Dossier</span> |
| 126 | 129 | </Button> |
| 130 | + <Button variant="ghost" size="sm" onClick={() => setShowCreateSharedFolder(true)} className="min-w-[40px] min-h-[40px] text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300"> | |
| 131 | + <Users className="w-4 h-4" /> | |
| 132 | + <span className="hidden sm:inline">Partagé</span> | |
| 133 | + </Button> | |
| 127 | 134 | <Button size="sm" onClick={() => setShowUpload(!showUpload)} className="min-w-[40px] min-h-[40px]"> |
| 128 | 135 | <Upload className="w-4 h-4" /> |
| 129 | 136 | <span className="hidden sm:inline">Upload</span> |
@@ -228,6 +235,7 @@ export default function FileExplorer({ folderId }: FileExplorerProps) { | ||
| 228 | 235 | )} |
| 229 | 236 | |
| 230 | 237 | <CreateFolderModal isOpen={showCreateFolder} onClose={() => setShowCreateFolder(false)} parentId={folderId || null} onCreated={fetchData} /> |
| 238 | + <CreateSharedFolderModal isOpen={showCreateSharedFolder} onClose={() => setShowCreateSharedFolder(false)} onCreated={fetchData} /> | |
| 231 | 239 | </div> |
| 232 | 240 | ); |
| 233 | 241 | } |
added
components/folders/CreateSharedFolderModal.tsx
+222 −0
@@ -0,0 +1,222 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useState, useEffect } from "react"; | |
| 4 | +import { Users, Check, Search } from "lucide-react"; | |
| 5 | +import Modal from "../ui/Modal"; | |
| 6 | +import Button from "../ui/Button"; | |
| 7 | +import toast from "react-hot-toast"; | |
| 8 | +import { useRouter } from "next/navigation"; | |
| 9 | + | |
| 10 | +interface UserOption { | |
| 11 | + id: string; | |
| 12 | + name: string | null; | |
| 13 | + username: string | null; | |
| 14 | +} | |
| 15 | + | |
| 16 | +interface CreateSharedFolderModalProps { | |
| 17 | + isOpen: boolean; | |
| 18 | + onClose: () => void; | |
| 19 | + onCreated: () => void; | |
| 20 | +} | |
| 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-purple-400 dark:focus:border-purple-500"; | |
| 24 | + | |
| 25 | +export default function CreateSharedFolderModal({ | |
| 26 | + isOpen, | |
| 27 | + onClose, | |
| 28 | + onCreated, | |
| 29 | +}: CreateSharedFolderModalProps) { | |
| 30 | + const router = useRouter(); | |
| 31 | + const [name, setName] = useState(""); | |
| 32 | + const [users, setUsers] = useState<UserOption[]>([]); | |
| 33 | + const [selectedIds, setSelectedIds] = useState<string[]>([]); | |
| 34 | + const [userSearch, setUserSearch] = useState(""); | |
| 35 | + const [loading, setLoading] = useState(false); | |
| 36 | + const [loadingUsers, setLoadingUsers] = useState(false); | |
| 37 | + | |
| 38 | + useEffect(() => { | |
| 39 | + if (!isOpen) return; | |
| 40 | + setLoadingUsers(true); | |
| 41 | + fetch("/api/users/public") | |
| 42 | + .then((r) => r.ok ? r.json() : []) | |
| 43 | + .then((data) => setUsers(Array.isArray(data) ? data : [])) | |
| 44 | + .finally(() => setLoadingUsers(false)); | |
| 45 | + }, [isOpen]); | |
| 46 | + | |
| 47 | + const handleClose = () => { | |
| 48 | + setName(""); | |
| 49 | + setSelectedIds([]); | |
| 50 | + setUserSearch(""); | |
| 51 | + onClose(); | |
| 52 | + }; | |
| 53 | + | |
| 54 | + const toggleUser = (id: string) => { | |
| 55 | + setSelectedIds((prev) => | |
| 56 | + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] | |
| 57 | + ); | |
| 58 | + }; | |
| 59 | + | |
| 60 | + const handleCreate = async () => { | |
| 61 | + if (!name.trim()) return; | |
| 62 | + setLoading(true); | |
| 63 | + try { | |
| 64 | + const res = await fetch("/api/shared-spaces", { | |
| 65 | + method: "POST", | |
| 66 | + headers: { "Content-Type": "application/json" }, | |
| 67 | + body: JSON.stringify({ name: name.trim(), memberIds: selectedIds }), | |
| 68 | + }); | |
| 69 | + | |
| 70 | + if (res.ok) { | |
| 71 | + const space = await res.json(); | |
| 72 | + toast.success(`Dossier partagé "${name}" créé`); | |
| 73 | + handleClose(); | |
| 74 | + onCreated(); | |
| 75 | + // Navigate to the new shared space | |
| 76 | + router.push(`/dashboard/shared-space/${space.id}`); | |
| 77 | + } else { | |
| 78 | + const data = await res.json(); | |
| 79 | + toast.error(data.error || "Erreur lors de la création"); | |
| 80 | + } | |
| 81 | + } catch { | |
| 82 | + toast.error("Erreur réseau"); | |
| 83 | + } finally { | |
| 84 | + setLoading(false); | |
| 85 | + } | |
| 86 | + }; | |
| 87 | + | |
| 88 | + const filteredUsers = users.filter((u) => { | |
| 89 | + const q = userSearch.toLowerCase(); | |
| 90 | + return ( | |
| 91 | + u.username?.toLowerCase().includes(q) || | |
| 92 | + u.name?.toLowerCase().includes(q) | |
| 93 | + ); | |
| 94 | + }); | |
| 95 | + | |
| 96 | + return ( | |
| 97 | + <Modal isOpen={isOpen} onClose={handleClose} title="Nouveau dossier partagé"> | |
| 98 | + {/* Name */} | |
| 99 | + <div className="mb-4"> | |
| 100 | + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5"> | |
| 101 | + Nom du dossier | |
| 102 | + </label> | |
| 103 | + <input | |
| 104 | + type="text" | |
| 105 | + value={name} | |
| 106 | + onChange={(e) => setName(e.target.value)} | |
| 107 | + onKeyDown={(e) => e.key === "Enter" && handleCreate()} | |
| 108 | + placeholder="Ex: Projet Alpha, Famille..." | |
| 109 | + className={inputClasses} | |
| 110 | + autoFocus | |
| 111 | + /> | |
| 112 | + </div> | |
| 113 | + | |
| 114 | + {/* User picker */} | |
| 115 | + <div className="mb-4"> | |
| 116 | + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5 flex items-center gap-1.5"> | |
| 117 | + <Users className="w-3.5 h-3.5" /> | |
| 118 | + Partager avec | |
| 119 | + {selectedIds.length > 0 && ( | |
| 120 | + <span className="ml-auto text-purple-600 dark:text-purple-400 font-semibold"> | |
| 121 | + {selectedIds.length} sélectionné(s) | |
| 122 | + </span> | |
| 123 | + )} | |
| 124 | + </label> | |
| 125 | + | |
| 126 | + {loadingUsers ? ( | |
| 127 | + <div className="flex items-center justify-center py-6 text-sm text-gray-400"> | |
| 128 | + <div className="w-4 h-4 border-2 border-gray-300 dark:border-white/20 border-t-purple-500 rounded-full animate-spin mr-2" /> | |
| 129 | + Chargement des utilisateurs... | |
| 130 | + </div> | |
| 131 | + ) : users.length === 0 ? ( | |
| 132 | + <p className="text-xs text-gray-400 text-center py-4"> | |
| 133 | + Aucun autre utilisateur disponible. | |
| 134 | + </p> | |
| 135 | + ) : ( | |
| 136 | + <div className="border border-gray-200 dark:border-white/10 rounded-lg overflow-hidden"> | |
| 137 | + {/* Search within users */} | |
| 138 | + {users.length > 4 && ( | |
| 139 | + <div className="p-2 border-b border-gray-100 dark:border-white/10 relative"> | |
| 140 | + <Search className="w-3.5 h-3.5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" /> | |
| 141 | + <input | |
| 142 | + type="text" | |
| 143 | + value={userSearch} | |
| 144 | + onChange={(e) => setUserSearch(e.target.value)} | |
| 145 | + placeholder="Filtrer les utilisateurs..." | |
| 146 | + className="w-full pl-7 pr-3 py-1.5 text-xs bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded focus:outline-none text-gray-900 dark:text-white" | |
| 147 | + /> | |
| 148 | + </div> | |
| 149 | + )} | |
| 150 | + | |
| 151 | + <div className="max-h-44 overflow-auto"> | |
| 152 | + {filteredUsers.length === 0 && ( | |
| 153 | + <p className="text-xs text-gray-400 text-center py-3">Aucun résultat</p> | |
| 154 | + )} | |
| 155 | + {filteredUsers.map((u) => { | |
| 156 | + const isSelected = selectedIds.includes(u.id); | |
| 157 | + const displayName = u.name || u.username || u.id; | |
| 158 | + const initial = displayName[0].toUpperCase(); | |
| 159 | + return ( | |
| 160 | + <button | |
| 161 | + key={u.id} | |
| 162 | + onClick={() => toggleUser(u.id)} | |
| 163 | + className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors border-b border-gray-50 dark:border-white/5 last:border-0 ${ | |
| 164 | + isSelected | |
| 165 | + ? "bg-purple-50 dark:bg-purple-500/10" | |
| 166 | + : "hover:bg-gray-50 dark:hover:bg-white/5" | |
| 167 | + }`} | |
| 168 | + > | |
| 169 | + {/* Avatar */} | |
| 170 | + <div className={`w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 text-xs font-semibold ${ | |
| 171 | + isSelected | |
| 172 | + ? "bg-purple-500 text-white" | |
| 173 | + : "bg-gray-200 dark:bg-white/10 text-gray-600 dark:text-gray-300" | |
| 174 | + }`}> | |
| 175 | + {isSelected ? <Check className="w-3.5 h-3.5" /> : initial} | |
| 176 | + </div> | |
| 177 | + | |
| 178 | + <div className="flex-1 min-w-0"> | |
| 179 | + {u.name && <p className="text-sm font-medium text-gray-900 dark:text-white truncate">{u.name}</p>} | |
| 180 | + <p className={`text-xs truncate ${u.name ? "text-gray-400" : "text-sm font-medium text-gray-900 dark:text-white"}`}> | |
| 181 | + @{u.username} | |
| 182 | + </p> | |
| 183 | + </div> | |
| 184 | + | |
| 185 | + {/* Checkbox indicator */} | |
| 186 | + <div className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center transition-colors ${ | |
| 187 | + isSelected | |
| 188 | + ? "bg-purple-500 border-purple-500" | |
| 189 | + : "border-gray-300 dark:border-white/20" | |
| 190 | + }`}> | |
| 191 | + {isSelected && <Check className="w-3 h-3 text-white" />} | |
| 192 | + </div> | |
| 193 | + </button> | |
| 194 | + ); | |
| 195 | + })} | |
| 196 | + </div> | |
| 197 | + </div> | |
| 198 | + )} | |
| 199 | + </div> | |
| 200 | + | |
| 201 | + {/* Info note */} | |
| 202 | + <p className="text-xs text-gray-400 dark:text-gray-500 mb-4 flex items-start gap-1.5"> | |
| 203 | + <span className="text-purple-400 mt-0.5">ℹ</span> | |
| 204 | + Vous serez automatiquement membre. Le dossier apparaîtra dans la sidebar de chaque membre. | |
| 205 | + </p> | |
| 206 | + | |
| 207 | + <div className="flex gap-2 justify-end"> | |
| 208 | + <Button variant="ghost" onClick={handleClose}> | |
| 209 | + Annuler | |
| 210 | + </Button> | |
| 211 | + <Button | |
| 212 | + onClick={handleCreate} | |
| 213 | + loading={loading} | |
| 214 | + disabled={!name.trim()} | |
| 215 | + > | |
| 216 | + <Users className="w-4 h-4" /> | |
| 217 | + Créer le dossier partagé | |
| 218 | + </Button> | |
| 219 | + </div> | |
| 220 | + </Modal> | |
| 221 | + ); | |
| 222 | +} | |
modified
components/folders/FolderCard.tsx
+8 −2
@@ -7,10 +7,12 @@ import { useState, useRef, useEffect } 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 | +import SharedBadge from "../ui/SharedBadge"; | |
| 10 | 11 | |
| 11 | 12 | interface FolderData { |
| 12 | 13 | id: string; |
| 13 | 14 | name: string; |
| 15 | + sharedSpaceId?: string | null; | |
| 14 | 16 | _count?: { files: number; children: number }; |
| 15 | 17 | } |
| 16 | 18 | |
@@ -90,6 +92,7 @@ export default function FolderCard({ folder, onRefresh, viewMode }: FolderCardPr | ||
| 90 | 92 | > |
| 91 | 93 | <Folder className="w-5 h-5 text-blue-400 flex-shrink-0" /> |
| 92 | 94 | <span className="text-sm text-gray-700 dark:text-gray-200 flex-1 truncate">{folder.name}</span> |
| 95 | + {folder.sharedSpaceId && <SharedBadge />} | |
| 93 | 96 | {folder._count && ( |
| 94 | 97 | <span className="text-xs text-gray-500"> |
| 95 | 98 | {folder._count.files} fichiers |
@@ -158,9 +161,12 @@ export default function FolderCard({ folder, onRefresh, viewMode }: FolderCardPr | ||
| 158 | 161 | onClick={() => router.push(`/dashboard/folder/${folder.id}`)} |
| 159 | 162 | > |
| 160 | 163 | <div className="flex items-center gap-3"> |
| 161 | − <Folder className="w-8 h-8 text-blue-400" /> | |
| 164 | + <Folder className="w-8 h-8 text-blue-400 flex-shrink-0" /> | |
| 162 | 165 | <div className="flex-1 min-w-0"> |
| 163 | − <p className="text-sm text-gray-700 dark:text-gray-200 truncate">{folder.name}</p> | |
| 166 | + <div className="flex items-center gap-1.5 flex-wrap"> | |
| 167 | + <p className="text-sm text-gray-700 dark:text-gray-200 truncate">{folder.name}</p> | |
| 168 | + {folder.sharedSpaceId && <SharedBadge />} | |
| 169 | + </div> | |
| 164 | 170 | {folder._count && ( |
| 165 | 171 | <p className="text-xs text-gray-500"> |
| 166 | 172 | {folder._count.files} fichiers, {folder._count.children} dossiers |
modified
components/layout/Sidebar.tsx
+114 −37
@@ -18,13 +18,21 @@ import { | ||
| 18 | 18 | HardDrive, |
| 19 | 19 | Key, |
| 20 | 20 | Settings, |
| 21 | + KeyRound, | |
| 22 | + Lock, | |
| 21 | 23 | } from "lucide-react"; |
| 22 | 24 | import ThemeToggle from "../theme/ThemeToggle"; |
| 23 | 25 | import NotificationBell from "./NotificationBell"; |
| 26 | +import SharedBadge from "../ui/SharedBadge"; | |
| 27 | +import ChangePasswordModal from "../auth/ChangePasswordModal"; | |
| 24 | 28 | |
| 25 | −const navItems = [ | |
| 29 | +const userNavItems = [ | |
| 26 | 30 | { href: "/dashboard", label: "Fichiers", icon: FolderOpen }, |
| 27 | 31 | { href: "/dashboard/favorites", label: "Favoris", icon: Star }, |
| 32 | + { href: "/dashboard/passwords", label: "Mots de passe", icon: Lock }, | |
| 33 | +]; | |
| 34 | + | |
| 35 | +const adminNavItems = [ | |
| 28 | 36 | { href: "/admin", label: "Dashboard", icon: LayoutDashboard }, |
| 29 | 37 | { href: "/admin/shares", label: "Liens partagés", icon: Share2 }, |
| 30 | 38 | { href: "/admin/activity", label: "Activité", icon: Activity }, |
@@ -32,6 +40,7 @@ const navItems = [ | ||
| 32 | 40 | |
| 33 | 41 | const adminItems = [ |
| 34 | 42 | { href: "/admin/users", label: "Utilisateurs", icon: Users }, |
| 43 | + { href: "/admin/shared-spaces", label: "Espaces partagés", icon: Users }, | |
| 35 | 44 | { href: "/admin/backup", label: "Backups", icon: HardDrive }, |
| 36 | 45 | { href: "/admin/api-keys", label: "Clés API", icon: Key }, |
| 37 | 46 | { href: "/admin/settings", label: "Paramètres", icon: Settings }, |
@@ -44,6 +53,12 @@ interface TagData { | ||
| 44 | 53 | _count?: { files: number }; |
| 45 | 54 | } |
| 46 | 55 | |
| 56 | +interface SharedSpaceData { | |
| 57 | + id: string; | |
| 58 | + name: string; | |
| 59 | + quotaBytes: number; | |
| 60 | +} | |
| 61 | + | |
| 47 | 62 | interface SidebarProps { |
| 48 | 63 | onNavigate?: () => void; |
| 49 | 64 | } |
@@ -52,14 +67,30 @@ export default function Sidebar({ onNavigate }: SidebarProps) { | ||
| 52 | 67 | const pathname = usePathname(); |
| 53 | 68 | const router = useRouter(); |
| 54 | 69 | const [tags, setTags] = useState<TagData[]>([]); |
| 70 | + const [sharedSpaces, setSharedSpaces] = useState<SharedSpaceData[]>([]); | |
| 55 | 71 | const [showTags, setShowTags] = useState(false); |
| 56 | 72 | const [showAdmin, setShowAdmin] = useState(false); |
| 73 | + const [showSpaces, setShowSpaces] = useState(true); | |
| 74 | + const [isAdmin, setIsAdmin] = useState(false); | |
| 75 | + const [showChangePassword, setShowChangePassword] = useState(false); | |
| 57 | 76 | |
| 58 | 77 | useEffect(() => { |
| 78 | + fetch("/api/auth/session") | |
| 79 | + .then((res) => res.json()) | |
| 80 | + .then((data) => { | |
| 81 | + if (data.role === "admin") setIsAdmin(true); | |
| 82 | + }) | |
| 83 | + .catch(() => {}); | |
| 84 | + | |
| 59 | 85 | fetch("/api/tags") |
| 60 | 86 | .then((res) => res.json()) |
| 61 | 87 | .then(setTags) |
| 62 | 88 | .catch(() => {}); |
| 89 | + | |
| 90 | + fetch("/api/shared-spaces/mine") | |
| 91 | + .then((res) => res.json()) | |
| 92 | + .then((data) => { if (Array.isArray(data)) setSharedSpaces(data); }) | |
| 93 | + .catch(() => {}); | |
| 63 | 94 | }, []); |
| 64 | 95 | |
| 65 | 96 | const handleLogout = async () => { |
@@ -73,6 +104,7 @@ export default function Sidebar({ onNavigate }: SidebarProps) { | ||
| 73 | 104 | }; |
| 74 | 105 | |
| 75 | 106 | const isAdminActive = adminItems.some((i) => pathname === i.href); |
| 107 | + const navItems = isAdmin ? [...userNavItems, ...adminNavItems] : userNavItems; | |
| 76 | 108 | |
| 77 | 109 | return ( |
| 78 | 110 | <div className="h-screen flex flex-col bg-white dark:bg-[#0a0a0a] border-r border-gray-200 dark:border-white/10 transition-colors"> |
@@ -86,12 +118,12 @@ export default function Sidebar({ onNavigate }: SidebarProps) { | ||
| 86 | 118 | <ThemeToggle /> |
| 87 | 119 | </div> |
| 88 | 120 | |
| 89 | − {/* Navigation - touch optimized */} | |
| 121 | + {/* Navigation */} | |
| 90 | 122 | <nav className="flex-1 px-2 py-1 space-y-0.5 overflow-auto overscroll-contain"> |
| 91 | 123 | {navItems.map((item) => { |
| 92 | 124 | const isActive = |
| 93 | 125 | pathname === item.href || |
| 94 | − (item.href === "/dashboard" && pathname.startsWith("/dashboard") && !pathname.includes("favorites")); | |
| 126 | + (item.href === "/dashboard" && pathname.startsWith("/dashboard") && !pathname.includes("favorites") && !pathname.includes("shared-space") && !pathname.includes("passwords")); | |
| 95 | 127 | return ( |
| 96 | 128 | <Link |
| 97 | 129 | key={item.href} |
@@ -109,40 +141,76 @@ export default function Sidebar({ onNavigate }: SidebarProps) { | ||
| 109 | 141 | ); |
| 110 | 142 | })} |
| 111 | 143 | |
| 112 | − {/* Admin Section - toggle does NOT close sidebar */} | |
| 113 | − <div className="pt-3"> | |
| 114 | − <button | |
| 115 | − onClick={() => setShowAdmin(!showAdmin)} | |
| 116 | − 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 ${ | |
| 117 | − isAdminActive ? "text-gray-700 dark:text-gray-300" : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300" | |
| 118 | − }`} | |
| 119 | − > | |
| 120 | − {showAdmin ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} | |
| 121 | − <Settings className="w-3.5 h-3.5" /> | |
| 122 | − <span className="uppercase tracking-wider font-medium">Administration</span> | |
| 123 | − </button> | |
| 124 | − {(showAdmin || isAdminActive) && ( | |
| 125 | − <div className="space-y-0.5 ml-1"> | |
| 126 | − {adminItems.map((item) => ( | |
| 127 | − <Link | |
| 128 | − key={item.href} | |
| 129 | − href={item.href} | |
| 130 | − onClick={handleLinkClick} | |
| 131 | − className={`flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl transition-colors min-h-[40px] ${ | |
| 132 | − pathname === item.href | |
| 133 | − ? "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white font-medium" | |
| 134 | − : "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" | |
| 135 | − }`} | |
| 136 | − > | |
| 137 | − <item.icon className="w-4 h-4 flex-shrink-0" /> | |
| 138 | − {item.label} | |
| 139 | − </Link> | |
| 140 | − ))} | |
| 141 | − </div> | |
| 142 | − )} | |
| 143 | − </div> | |
| 144 | + {/* Shared Spaces Section */} | |
| 145 | + {sharedSpaces.length > 0 && ( | |
| 146 | + <div className="pt-3"> | |
| 147 | + <button | |
| 148 | + onClick={() => setShowSpaces(!showSpaces)} | |
| 149 | + 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" | |
| 150 | + > | |
| 151 | + {showSpaces ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} | |
| 152 | + <Users className="w-3.5 h-3.5" /> | |
| 153 | + <span className="uppercase tracking-wider font-medium">Espaces partagés</span> | |
| 154 | + </button> | |
| 155 | + {showSpaces && ( | |
| 156 | + <div className="space-y-0.5 ml-1"> | |
| 157 | + {sharedSpaces.map((space) => ( | |
| 158 | + <Link | |
| 159 | + key={space.id} | |
| 160 | + href={`/dashboard/shared-space/${space.id}`} | |
| 161 | + onClick={handleLinkClick} | |
| 162 | + className={`flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl transition-colors min-h-[40px] ${ | |
| 163 | + pathname === `/dashboard/shared-space/${space.id}` | |
| 164 | + ? "bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium" | |
| 165 | + : "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" | |
| 166 | + }`} | |
| 167 | + > | |
| 168 | + <Users className="w-4 h-4 flex-shrink-0 text-purple-400" /> | |
| 169 | + <span className="truncate flex-1">{space.name}</span> | |
| 170 | + <SharedBadge /> | |
| 171 | + </Link> | |
| 172 | + ))} | |
| 173 | + </div> | |
| 174 | + )} | |
| 175 | + </div> | |
| 176 | + )} | |
| 177 | + | |
| 178 | + {/* Admin Section — admin only */} | |
| 179 | + {isAdmin && ( | |
| 180 | + <div className="pt-3"> | |
| 181 | + <button | |
| 182 | + onClick={() => setShowAdmin(!showAdmin)} | |
| 183 | + 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 ${ | |
| 184 | + isAdminActive ? "text-gray-700 dark:text-gray-300" : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300" | |
| 185 | + }`} | |
| 186 | + > | |
| 187 | + {showAdmin ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} | |
| 188 | + <Settings className="w-3.5 h-3.5" /> | |
| 189 | + <span className="uppercase tracking-wider font-medium">Administration</span> | |
| 190 | + </button> | |
| 191 | + {(showAdmin || isAdminActive) && ( | |
| 192 | + <div className="space-y-0.5 ml-1"> | |
| 193 | + {adminItems.map((item) => ( | |
| 194 | + <Link | |
| 195 | + key={item.href} | |
| 196 | + href={item.href} | |
| 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 === item.href | |
| 200 | + ? "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white 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 | + <item.icon className="w-4 h-4 flex-shrink-0" /> | |
| 205 | + {item.label} | |
| 206 | + </Link> | |
| 207 | + ))} | |
| 208 | + </div> | |
| 209 | + )} | |
| 210 | + </div> | |
| 211 | + )} | |
| 144 | 212 | |
| 145 | − {/* Tags Section - toggle does NOT close sidebar */} | |
| 213 | + {/* Tags Section */} | |
| 146 | 214 | {tags.length > 0 && ( |
| 147 | 215 | <div className="pt-3"> |
| 148 | 216 | <button |
@@ -174,7 +242,14 @@ export default function Sidebar({ onNavigate }: SidebarProps) { | ||
| 174 | 242 | </nav> |
| 175 | 243 | |
| 176 | 244 | {/* Footer */} |
| 177 | − <div className="px-2 py-3 border-t border-gray-200 dark:border-white/10"> | |
| 245 | + <div className="px-2 py-3 border-t border-gray-200 dark:border-white/10 space-y-0.5"> | |
| 246 | + <button | |
| 247 | + onClick={() => setShowChangePassword(true)} | |
| 248 | + 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]" | |
| 249 | + > | |
| 250 | + <KeyRound className="w-5 h-5" /> | |
| 251 | + Changer le mot de passe | |
| 252 | + </button> | |
| 178 | 253 | <button |
| 179 | 254 | onClick={handleLogout} |
| 180 | 255 | 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]" |
@@ -183,6 +258,8 @@ export default function Sidebar({ onNavigate }: SidebarProps) { | ||
| 183 | 258 | Déconnexion |
| 184 | 259 | </button> |
| 185 | 260 | </div> |
| 261 | + | |
| 262 | + <ChangePasswordModal isOpen={showChangePassword} onClose={() => setShowChangePassword(false)} /> | |
| 186 | 263 | </div> |
| 187 | 264 | ); |
| 188 | 265 | } |
added
components/preview/plugins/WordPreview.tsx
+128 −0
@@ -0,0 +1,128 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useState, useEffect } from "react"; | |
| 4 | +import { Loader2, Download, FileText } from "lucide-react"; | |
| 5 | +import type { PreviewPluginProps } from "./types"; | |
| 6 | + | |
| 7 | +export default function WordPreview({ file }: PreviewPluginProps) { | |
| 8 | + const [html, setHtml] = useState<string | null>(null); | |
| 9 | + const [error, setError] = useState(false); | |
| 10 | + const [loading, setLoading] = useState(true); | |
| 11 | + | |
| 12 | + useEffect(() => { | |
| 13 | + let cancelled = false; | |
| 14 | + | |
| 15 | + const convert = async () => { | |
| 16 | + setLoading(true); | |
| 17 | + setError(false); | |
| 18 | + try { | |
| 19 | + // Fetch the raw file bytes | |
| 20 | + const res = await fetch(`/api/files/${file.id}/download`); | |
| 21 | + if (!res.ok) throw new Error("Download failed"); | |
| 22 | + const arrayBuffer = await res.arrayBuffer(); | |
| 23 | + | |
| 24 | + // mammoth converts docx → clean HTML | |
| 25 | + const mammoth = await import("mammoth"); | |
| 26 | + const result = await mammoth.convertToHtml( | |
| 27 | + { arrayBuffer }, | |
| 28 | + { | |
| 29 | + styleMap: [ | |
| 30 | + "p[style-name='Heading 1'] => h1:fresh", | |
| 31 | + "p[style-name='Heading 2'] => h2:fresh", | |
| 32 | + "p[style-name='Heading 3'] => h3:fresh", | |
| 33 | + "p[style-name='Titre 1'] => h1:fresh", | |
| 34 | + "p[style-name='Titre 2'] => h2:fresh", | |
| 35 | + ], | |
| 36 | + } | |
| 37 | + ); | |
| 38 | + | |
| 39 | + if (!cancelled) { | |
| 40 | + setHtml(result.value || "<p>Document vide.</p>"); | |
| 41 | + } | |
| 42 | + } catch { | |
| 43 | + if (!cancelled) setError(true); | |
| 44 | + } finally { | |
| 45 | + if (!cancelled) setLoading(false); | |
| 46 | + } | |
| 47 | + }; | |
| 48 | + | |
| 49 | + convert(); | |
| 50 | + return () => { cancelled = true; }; | |
| 51 | + }, [file.id]); | |
| 52 | + | |
| 53 | + if (loading) { | |
| 54 | + return ( | |
| 55 | + <div className="flex flex-col items-center justify-center h-full gap-3 text-gray-400"> | |
| 56 | + <Loader2 className="w-8 h-8 animate-spin text-blue-400" /> | |
| 57 | + <p className="text-sm">Chargement du document Word...</p> | |
| 58 | + </div> | |
| 59 | + ); | |
| 60 | + } | |
| 61 | + | |
| 62 | + if (error) { | |
| 63 | + return ( | |
| 64 | + <div className="flex flex-col items-center justify-center h-full gap-4 text-gray-400 p-8"> | |
| 65 | + <FileText className="w-16 h-16 text-blue-400 opacity-50" /> | |
| 66 | + <p className="text-sm text-center">Impossible d'afficher ce document.</p> | |
| 67 | + <button | |
| 68 | + onClick={() => window.open(`/api/files/${file.id}/download`, "_blank")} | |
| 69 | + className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm text-white transition-colors" | |
| 70 | + > | |
| 71 | + <Download className="w-4 h-4" /> | |
| 72 | + Télécharger le fichier | |
| 73 | + </button> | |
| 74 | + </div> | |
| 75 | + ); | |
| 76 | + } | |
| 77 | + | |
| 78 | + return ( | |
| 79 | + <div className="h-full overflow-auto bg-gray-50 dark:bg-[#1a1a1a]"> | |
| 80 | + {/* Page-like container */} | |
| 81 | + <div className="max-w-3xl mx-auto my-6 px-4 sm:px-6"> | |
| 82 | + <div className="bg-white dark:bg-[#242424] shadow-md rounded-sm px-8 py-10 sm:px-14 sm:py-12 min-h-[500px]"> | |
| 83 | + {/* Download link */} | |
| 84 | + <div className="flex items-center justify-between mb-6 pb-4 border-b border-gray-100 dark:border-white/10"> | |
| 85 | + <div className="flex items-center gap-2"> | |
| 86 | + <FileText className="w-4 h-4 text-blue-500" /> | |
| 87 | + <span className="text-sm font-medium text-gray-600 dark:text-gray-400 truncate max-w-xs"> | |
| 88 | + {file.name} | |
| 89 | + </span> | |
| 90 | + </div> | |
| 91 | + <button | |
| 92 | + onClick={() => window.open(`/api/files/${file.id}/download`, "_blank")} | |
| 93 | + className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 border border-gray-200 dark:border-white/10 rounded-lg transition-colors" | |
| 94 | + > | |
| 95 | + <Download className="w-3.5 h-3.5" /> | |
| 96 | + Télécharger | |
| 97 | + </button> | |
| 98 | + </div> | |
| 99 | + | |
| 100 | + {/* Rendered Word content */} | |
| 101 | + <div | |
| 102 | + className="word-content prose prose-sm sm:prose dark:prose-invert max-w-none" | |
| 103 | + dangerouslySetInnerHTML={{ __html: html ?? "" }} | |
| 104 | + /> | |
| 105 | + </div> | |
| 106 | + </div> | |
| 107 | + | |
| 108 | + {/* Scoped styles for Word content */} | |
| 109 | + <style jsx global>{` | |
| 110 | + .word-content h1 { font-size: 1.6rem; font-weight: 700; margin-bottom: 0.5rem; margin-top: 1.5rem; } | |
| 111 | + .word-content h2 { font-size: 1.3rem; font-weight: 600; margin-bottom: 0.4rem; margin-top: 1.2rem; } | |
| 112 | + .word-content h3 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.3rem; margin-top: 1rem; } | |
| 113 | + .word-content p { margin-bottom: 0.6rem; line-height: 1.7; } | |
| 114 | + .word-content ul, .word-content ol { padding-left: 1.5rem; margin-bottom: 0.6rem; } | |
| 115 | + .word-content li { margin-bottom: 0.2rem; } | |
| 116 | + .word-content table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; } | |
| 117 | + .word-content td, .word-content th { border: 1px solid #d1d5db; padding: 0.4rem 0.6rem; text-align: left; } | |
| 118 | + .word-content th { background: #f3f4f6; font-weight: 600; } | |
| 119 | + .dark .word-content td, .dark .word-content th { border-color: rgba(255,255,255,0.15); } | |
| 120 | + .dark .word-content th { background: rgba(255,255,255,0.08); } | |
| 121 | + .word-content strong { font-weight: 700; } | |
| 122 | + .word-content em { font-style: italic; } | |
| 123 | + .word-content a { color: #3b82f6; text-decoration: underline; } | |
| 124 | + .word-content img { max-width: 100%; height: auto; margin: 0.5rem 0; } | |
| 125 | + `}</style> | |
| 126 | + </div> | |
| 127 | + ); | |
| 128 | +} | |
modified
components/preview/plugins/index.ts
+14 −0
@@ -11,8 +11,15 @@ const CodePreview = dynamic(() => import("./CodePreview"), { ssr: false }); | ||
| 11 | 11 | const CsvJsonPreview = dynamic(() => import("./CsvJsonPreview"), { ssr: false }); |
| 12 | 12 | const MarkdownPreview = dynamic(() => import("./MarkdownPreview"), { ssr: false }); |
| 13 | 13 | const ArchivePreview = dynamic(() => import("./ArchivePreview"), { ssr: false }); |
| 14 | +const WordPreview = dynamic(() => import("./WordPreview"), { ssr: false }); | |
| 14 | 15 | const FallbackPreview = dynamic(() => import("./FallbackPreview"), { ssr: false }); |
| 15 | 16 | |
| 17 | +const WORD_MIMES = [ | |
| 18 | + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx | |
| 19 | + "application/msword", // .doc | |
| 20 | + "application/vnd.oasis.opendocument.text", // .odt | |
| 21 | +]; | |
| 22 | + | |
| 16 | 23 | const plugins: PreviewPlugin[] = [ |
| 17 | 24 | { |
| 18 | 25 | id: "image", |
@@ -72,6 +79,13 @@ const plugins: PreviewPlugin[] = [ | ||
| 72 | 79 | component: CodePreview as any, |
| 73 | 80 | supportsEdit: true, |
| 74 | 81 | }, |
| 82 | + { | |
| 83 | + id: "word", | |
| 84 | + name: "Word", | |
| 85 | + canHandle: (mime) => WORD_MIMES.includes(mime), | |
| 86 | + priority: 15, | |
| 87 | + component: WordPreview as any, | |
| 88 | + }, | |
| 75 | 89 | { |
| 76 | 90 | id: "archive", |
| 77 | 91 | name: "Archive", |
added
components/ui/SharedBadge.tsx
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +export default function SharedBadge() { | |
| 2 | + return ( | |
| 3 | + <span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-400 leading-none whitespace-nowrap"> | |
| 4 | + Partagé | |
| 5 | + </span> | |
| 6 | + ); | |
| 7 | +} | |
modified
lib/ai.ts
+69 −23
@@ -7,12 +7,16 @@ const anthropic = new Anthropic({ | ||
| 7 | 7 | apiKey: process.env.ANTHROPIC_API_KEY, |
| 8 | 8 | }); |
| 9 | 9 | |
| 10 | +const MODEL = "claude-sonnet-4-6"; | |
| 11 | + | |
| 10 | 12 | export async function aiSearch(query: string, userId?: string): Promise<{ |
| 11 | − results: Array<{ fileId: string; name: string; relevance: string; snippet: string }>; | |
| 13 | + results: Array<{ fileId: string; name: string; relevance: string; snippet: string; folder?: string; mimeType?: string }>; | |
| 12 | 14 | answer: string; |
| 13 | 15 | }> { |
| 14 | − const where: Record<string, unknown> = {}; | |
| 15 | − if (userId) where.userId = userId; | |
| 16 | + // Include files owned by user + legacy files with no owner (userId=null) | |
| 17 | + const where: Record<string, unknown> = userId | |
| 18 | + ? { OR: [{ userId }, { userId: null }] } | |
| 19 | + : {}; | |
| 16 | 20 | |
| 17 | 21 | const files = await prisma.file.findMany({ |
| 18 | 22 | where, |
@@ -31,9 +35,9 @@ export async function aiSearch(query: string, userId?: string): Promise<{ | ||
| 31 | 35 | }).join("\n"); |
| 32 | 36 | |
| 33 | 37 | const response = await anthropic.messages.create({ |
| 34 | − model: "claude-sonnet-4-5-20250929", | |
| 38 | + model: MODEL, | |
| 35 | 39 | max_tokens: 1024, |
| 36 | − messages: [{ role: "user", content: `Tu es un assistant de recherche de fichiers. Voici la liste des fichiers:\n\n${fileList}\n\nRecherche: "${query}"\n\nRéponds en JSON:\n{"results": [{"fileId": "...", "name": "...", "relevance": "high|medium|low", "snippet": "courte explication"}], "answer": "résumé"}` }], | |
| 40 | + messages: [{ role: "user", content: `Tu es un assistant de recherche de fichiers. Voici la liste des fichiers:\n\n${fileList}\n\nRecherche: "${query}"\n\nRéponds en JSON:\n{"results": [{"fileId": "...", "name": "...", "relevance": "high|medium|low", "snippet": "courte explication", "folder": "nom du dossier ou null", "mimeType": "..."}], "answer": "résumé"}` }], | |
| 37 | 41 | }); |
| 38 | 42 | |
| 39 | 43 | try { |
@@ -59,7 +63,7 @@ export async function aiAutoTag(fileId: string): Promise<string[]> { | ||
| 59 | 63 | } |
| 60 | 64 | |
| 61 | 65 | const response = await anthropic.messages.create({ |
| 62 | − model: "claude-sonnet-4-5-20250929", | |
| 66 | + model: MODEL, | |
| 63 | 67 | max_tokens: 256, |
| 64 | 68 | messages: [{ role: "user", content: `Analyse ce fichier et suggère 3-5 tags pertinents en français.\n\n${contentHint}\n\nRéponds UNIQUEMENT avec un JSON array de strings. Ex: ["finance", "rapport", "2024"]` }], |
| 65 | 69 | }); |
@@ -87,9 +91,9 @@ export async function aiDescribe(fileId: string): Promise<string> { | ||
| 87 | 91 | const base64 = readFileSync(filePath).toString("base64"); |
| 88 | 92 | const mediaType = file.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp"; |
| 89 | 93 | const response = await anthropic.messages.create({ |
| 90 | − model: "claude-sonnet-4-5-20250929", | |
| 91 | − max_tokens: 256, | |
| 92 | − messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } }, { type: "text", text: "Décris cette image en une phrase concise en français." }] }], | |
| 94 | + model: MODEL, | |
| 95 | + max_tokens: 512, | |
| 96 | + messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } }, { type: "text", text: "Décris cette image en détail en français. Mentionne le sujet principal, les couleurs, le contexte et tout élément notable." }] }], | |
| 93 | 97 | }); |
| 94 | 98 | const desc = response.content[0].type === "text" ? response.content[0].text : ""; |
| 95 | 99 | await prisma.file.update({ where: { id: fileId }, data: { aiDescription: desc } }); |
@@ -108,9 +112,9 @@ export async function aiDescribe(fileId: string): Promise<string> { | ||
| 108 | 112 | if (file.ocrText) hint += `\nOCR: ${file.ocrText.substring(0, 500)}`; |
| 109 | 113 | |
| 110 | 114 | const response = await anthropic.messages.create({ |
| 111 | − model: "claude-sonnet-4-5-20250929", | |
| 112 | − max_tokens: 128, | |
| 113 | − messages: [{ role: "user", content: `Décris ce fichier en une phrase concise en français:\n\n${hint}` }], | |
| 115 | + model: MODEL, | |
| 116 | + max_tokens: 256, | |
| 117 | + messages: [{ role: "user", content: `Décris ce fichier de manière utile en français (2-3 phrases):\n\n${hint}` }], | |
| 114 | 118 | }); |
| 115 | 119 | const desc = response.content[0].type === "text" ? response.content[0].text : ""; |
| 116 | 120 | await prisma.file.update({ where: { id: fileId }, data: { aiDescription: desc } }); |
@@ -128,9 +132,9 @@ export async function aiOcr(fileId: string): Promise<string> { | ||
| 128 | 132 | const base64 = readFileSync(filePath).toString("base64"); |
| 129 | 133 | const mediaType = file.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp"; |
| 130 | 134 | const response = await anthropic.messages.create({ |
| 131 | − model: "claude-sonnet-4-5-20250929", | |
| 132 | − max_tokens: 2048, | |
| 133 | − messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } }, { type: "text", text: "Extrais tout le texte visible dans cette image. Retourne uniquement le texte extrait, sans commentaire." }] }], | |
| 135 | + model: MODEL, | |
| 136 | + max_tokens: 4096, | |
| 137 | + messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } }, { type: "text", text: "Extrais tout le texte visible dans cette image de manière précise. Préserve la structure (titres, listes, tableaux). Retourne uniquement le texte extrait, sans commentaire." }] }], | |
| 134 | 138 | }); |
| 135 | 139 | const text = response.content[0].type === "text" ? response.content[0].text : ""; |
| 136 | 140 | await prisma.file.update({ where: { id: fileId }, data: { ocrText: text } }); |
@@ -144,9 +148,9 @@ export async function aiOcr(fileId: string): Promise<string> { | ||
| 144 | 148 | if (!existsSync(filePath)) return ""; |
| 145 | 149 | const base64 = readFileSync(filePath).toString("base64"); |
| 146 | 150 | const response = await anthropic.messages.create({ |
| 147 | − model: "claude-sonnet-4-5-20250929", | |
| 148 | − max_tokens: 4096, | |
| 149 | − messages: [{ role: "user", content: [{ type: "document", source: { type: "base64", media_type: "application/pdf", data: base64 } }, { type: "text", text: "Extrais tout le texte de ce document PDF. Retourne uniquement le texte extrait." }] as Anthropic.ContentBlockParam[] }], | |
| 151 | + model: MODEL, | |
| 152 | + max_tokens: 8192, | |
| 153 | + messages: [{ role: "user", content: [{ type: "document", source: { type: "base64", media_type: "application/pdf", data: base64 } }, { type: "text", text: "Extrais tout le texte de ce document PDF en préservant la structure. Retourne uniquement le texte extrait." }] as Anthropic.ContentBlockParam[] }], | |
| 150 | 154 | }); |
| 151 | 155 | const text = response.content[0].type === "text" ? response.content[0].text : ""; |
| 152 | 156 | await prisma.file.update({ where: { id: fileId }, data: { ocrText: text } }); |
@@ -156,12 +160,54 @@ export async function aiOcr(fileId: string): Promise<string> { | ||
| 156 | 160 | return ""; |
| 157 | 161 | } |
| 158 | 162 | |
| 159 | −export async function aiChat(message: string, context?: string): Promise<string> { | |
| 163 | +export type ChatMessage = { role: "user" | "assistant"; content: string }; | |
| 164 | + | |
| 165 | +export async function aiChat(message: string, history: ChatMessage[] = [], userId?: string): Promise<string> { | |
| 166 | + let fileContext = ""; | |
| 167 | + if (userId) { | |
| 168 | + const where = { OR: [{ userId }, { userId: null }] }; | |
| 169 | + const recentFiles = await prisma.file.findMany({ | |
| 170 | + where, | |
| 171 | + select: { name: true, mimeType: true, aiTags: true, aiDescription: true, folder: { select: { name: true } } }, | |
| 172 | + take: 30, | |
| 173 | + orderBy: { createdAt: "desc" }, | |
| 174 | + }); | |
| 175 | + const totalCount = await prisma.file.count({ where }); | |
| 176 | + fileContext = `\nL'utilisateur possède ${totalCount} fichier(s) au total. Voici les 30 plus récents:\n` + | |
| 177 | + recentFiles.map(f => | |
| 178 | + `- ${f.name} (${f.mimeType})${f.folder ? ` [dossier: ${f.folder.name}]` : ""}${f.aiDescription ? ` — ${f.aiDescription}` : ""}${f.aiTags ? ` [tags: ${f.aiTags}]` : ""}` | |
| 179 | + ).join("\n"); | |
| 180 | + } | |
| 181 | + | |
| 160 | 182 | const response = await anthropic.messages.create({ |
| 161 | − model: "claude-sonnet-4-5-20250929", | |
| 162 | − max_tokens: 1024, | |
| 163 | − system: "Tu es l'assistant IA de SPB Cloud, un cloud personnel privé. Aide l'utilisateur avec ses fichiers et réponds en français.", | |
| 164 | − messages: [{ role: "user", content: context ? `Contexte:\n${context}\n\nQuestion: ${message}` : message }], | |
| 183 | + model: MODEL, | |
| 184 | + max_tokens: 2048, | |
| 185 | + system: `Tu es l'assistant IA de SPB Cloud, un cloud personnel privé. Tu aides l'utilisateur avec ses fichiers, réponds en français, et tu es concis et utile.${fileContext}`, | |
| 186 | + messages: [ | |
| 187 | + ...history, | |
| 188 | + { role: "user", content: message }, | |
| 189 | + ], | |
| 165 | 190 | }); |
| 166 | 191 | return response.content[0].type === "text" ? response.content[0].text : ""; |
| 167 | 192 | } |
| 193 | + | |
| 194 | +export async function buildFileContext(userId: string): Promise<string> { | |
| 195 | + // Include files owned by user + legacy files with no owner (userId=null) | |
| 196 | + const where = { OR: [{ userId }, { userId: null }] }; | |
| 197 | + const recentFiles = await prisma.file.findMany({ | |
| 198 | + where, | |
| 199 | + select: { name: true, mimeType: true, aiTags: true, aiDescription: true, ocrText: true, folder: { select: { name: true } } }, | |
| 200 | + take: 30, | |
| 201 | + orderBy: { createdAt: "desc" }, | |
| 202 | + }); | |
| 203 | + const totalCount = await prisma.file.count({ where }); | |
| 204 | + return `L'utilisateur possède ${totalCount} fichier(s) au total. Voici les 30 plus récents:\n` + | |
| 205 | + recentFiles.map(f => { | |
| 206 | + const parts = [`- ${f.name} (${f.mimeType})`]; | |
| 207 | + if (f.folder) parts.push(`[dossier: ${f.folder.name}]`); | |
| 208 | + if (f.aiDescription) parts.push(`— ${f.aiDescription}`); | |
| 209 | + if (f.aiTags) parts.push(`[tags: ${f.aiTags}]`); | |
| 210 | + if (f.ocrText) parts.push(`[OCR dispo: ${f.ocrText.substring(0, 150)}...]`); | |
| 211 | + return parts.join(" "); | |
| 212 | + }).join("\n"); | |
| 213 | +} | |
modified
lib/auth.ts
+6 −4
@@ -1,16 +1,18 @@ | ||
| 1 | 1 | import bcrypt from "bcryptjs"; |
| 2 | 2 | import { prisma } from "./prisma"; |
| 3 | 3 | |
| 4 | −export async function verifyPassword(password: string, email?: string): Promise<{ valid: boolean; user: { id: string; role: string; twoFactorEnabled: boolean } | null }> { | |
| 4 | +export async function verifyPassword(password: string, username?: string): Promise<{ valid: boolean; user: { id: string; role: string; twoFactorEnabled: boolean } | null }> { | |
| 5 | 5 | let user; |
| 6 | − if (email) { | |
| 7 | − user = await prisma.user.findUnique({ where: { email } }); | |
| 6 | + if (username) { | |
| 7 | + user = await prisma.user.findFirst({ | |
| 8 | + where: { OR: [{ username }, { email: username }] }, | |
| 9 | + }); | |
| 8 | 10 | if (!user) return { valid: false, user: null }; |
| 9 | 11 | const valid = await bcrypt.compare(password, user.passwordHash); |
| 10 | 12 | return { valid, user: valid ? { id: user.id, role: user.role, twoFactorEnabled: user.twoFactorEnabled } : null }; |
| 11 | 13 | } |
| 12 | 14 | |
| 13 | − // Legacy: try all users (for simple password-only login) | |
| 15 | + // Fallback: single user / password-only login | |
| 14 | 16 | const users = await prisma.user.findMany(); |
| 15 | 17 | for (const u of users) { |
| 16 | 18 | const match = await bcrypt.compare(password, u.passwordHash); |
added
lib/crypto.ts
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; | |
| 2 | + | |
| 3 | +const ALGORITHM = "aes-256-gcm"; | |
| 4 | +const KEY = Buffer.from( | |
| 5 | + (process.env.ENCRYPTION_KEY || "spb-private-cloud-encrypt-key32!!").padEnd(32, "0").slice(0, 32) | |
| 6 | +); | |
| 7 | + | |
| 8 | +export function encrypt(text: string): string { | |
| 9 | + const iv = randomBytes(12); | |
| 10 | + const cipher = createCipheriv(ALGORITHM, KEY, iv); | |
| 11 | + const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]); | |
| 12 | + const tag = cipher.getAuthTag(); | |
| 13 | + return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function decrypt(data: string): string { | |
| 17 | + const parts = data.split(":"); | |
| 18 | + if (parts.length !== 3) throw new Error("Invalid encrypted data"); | |
| 19 | + const [ivHex, tagHex, encryptedHex] = parts; | |
| 20 | + const iv = Buffer.from(ivHex, "hex"); | |
| 21 | + const tag = Buffer.from(tagHex, "hex"); | |
| 22 | + const encrypted = Buffer.from(encryptedHex, "hex"); | |
| 23 | + const decipher = createDecipheriv(ALGORITHM, KEY, iv); | |
| 24 | + decipher.setAuthTag(tag); | |
| 25 | + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8"); | |
| 26 | +} | |
modified
package-lock.json
+102 −1
@@ -38,6 +38,7 @@ | ||
| 38 | 38 | "iron-session": "^8.0.4", |
| 39 | 39 | "jszip": "^3.10.1", |
| 40 | 40 | "lucide-react": "^0.564.0", |
| 41 | + "mammoth": "^1.11.0", | |
| 41 | 42 | "mime-types": "^3.0.2", |
| 42 | 43 | "next": "14.2.35", |
| 43 | 44 | "otplib": "^13.3.0", |
@@ -3080,6 +3081,15 @@ | ||
| 3080 | 3081 | "win32" |
| 3081 | 3082 | ] |
| 3082 | 3083 | }, |
| 3084 | + "node_modules/@xmldom/xmldom": { | |
| 3085 | + "version": "0.8.11", | |
| 3086 | + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", | |
| 3087 | + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", | |
| 3088 | + "license": "MIT", | |
| 3089 | + "engines": { | |
| 3090 | + "node": ">=10.0.0" | |
| 3091 | + } | |
| 3092 | + }, | |
| 3083 | 3093 | "node_modules/abort-controller": { |
| 3084 | 3094 | "version": "3.0.0", |
| 3085 | 3095 | "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", |
@@ -3627,6 +3637,12 @@ | ||
| 3627 | 3637 | "url": "https://github.com/sponsors/sindresorhus" |
| 3628 | 3638 | } |
| 3629 | 3639 | }, |
| 3640 | + "node_modules/bluebird": { | |
| 3641 | + "version": "3.4.7", | |
| 3642 | + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", | |
| 3643 | + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", | |
| 3644 | + "license": "MIT" | |
| 3645 | + }, | |
| 3630 | 3646 | "node_modules/brace-expansion": { |
| 3631 | 3647 | "version": "1.1.12", |
| 3632 | 3648 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", |
@@ -4376,6 +4392,12 @@ | ||
| 4376 | 4392 | "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", |
| 4377 | 4393 | "license": "MIT" |
| 4378 | 4394 | }, |
| 4395 | + "node_modules/dingbat-to-unicode": { | |
| 4396 | + "version": "1.0.1", | |
| 4397 | + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", | |
| 4398 | + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", | |
| 4399 | + "license": "BSD-2-Clause" | |
| 4400 | + }, | |
| 4379 | 4401 | "node_modules/dlv": { |
| 4380 | 4402 | "version": "1.1.3", |
| 4381 | 4403 | "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", |
@@ -4396,6 +4418,15 @@ | ||
| 4396 | 4418 | "node": ">=6.0.0" |
| 4397 | 4419 | } |
| 4398 | 4420 | }, |
| 4421 | + "node_modules/duck": { | |
| 4422 | + "version": "0.1.12", | |
| 4423 | + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", | |
| 4424 | + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", | |
| 4425 | + "license": "BSD", | |
| 4426 | + "dependencies": { | |
| 4427 | + "underscore": "^1.13.1" | |
| 4428 | + } | |
| 4429 | + }, | |
| 4399 | 4430 | "node_modules/dunder-proto": { |
| 4400 | 4431 | "version": "1.0.1", |
| 4401 | 4432 | "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", |
@@ -6755,6 +6786,17 @@ | ||
| 6755 | 6786 | "loose-envify": "cli.js" |
| 6756 | 6787 | } |
| 6757 | 6788 | }, |
| 6789 | + "node_modules/lop": { | |
| 6790 | + "version": "0.4.2", | |
| 6791 | + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", | |
| 6792 | + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", | |
| 6793 | + "license": "BSD-2-Clause", | |
| 6794 | + "dependencies": { | |
| 6795 | + "duck": "^0.1.12", | |
| 6796 | + "option": "~0.2.1", | |
| 6797 | + "underscore": "^1.13.1" | |
| 6798 | + } | |
| 6799 | + }, | |
| 6758 | 6800 | "node_modules/lowlight": { |
| 6759 | 6801 | "version": "1.20.0", |
| 6760 | 6802 | "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", |
@@ -6784,6 +6826,39 @@ | ||
| 6784 | 6826 | "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" |
| 6785 | 6827 | } |
| 6786 | 6828 | }, |
| 6829 | + "node_modules/mammoth": { | |
| 6830 | + "version": "1.11.0", | |
| 6831 | + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz", | |
| 6832 | + "integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==", | |
| 6833 | + "license": "BSD-2-Clause", | |
| 6834 | + "dependencies": { | |
| 6835 | + "@xmldom/xmldom": "^0.8.6", | |
| 6836 | + "argparse": "~1.0.3", | |
| 6837 | + "base64-js": "^1.5.1", | |
| 6838 | + "bluebird": "~3.4.0", | |
| 6839 | + "dingbat-to-unicode": "^1.0.1", | |
| 6840 | + "jszip": "^3.7.1", | |
| 6841 | + "lop": "^0.4.2", | |
| 6842 | + "path-is-absolute": "^1.0.0", | |
| 6843 | + "underscore": "^1.13.1", | |
| 6844 | + "xmlbuilder": "^10.0.0" | |
| 6845 | + }, | |
| 6846 | + "bin": { | |
| 6847 | + "mammoth": "bin/mammoth" | |
| 6848 | + }, | |
| 6849 | + "engines": { | |
| 6850 | + "node": ">=12.0.0" | |
| 6851 | + } | |
| 6852 | + }, | |
| 6853 | + "node_modules/mammoth/node_modules/argparse": { | |
| 6854 | + "version": "1.0.10", | |
| 6855 | + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", | |
| 6856 | + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", | |
| 6857 | + "license": "MIT", | |
| 6858 | + "dependencies": { | |
| 6859 | + "sprintf-js": "~1.0.2" | |
| 6860 | + } | |
| 6861 | + }, | |
| 6787 | 6862 | "node_modules/math-intrinsics": { |
| 6788 | 6863 | "version": "1.1.0", |
| 6789 | 6864 | "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", |
@@ -7780,6 +7855,12 @@ | ||
| 7780 | 7855 | "wrappy": "1" |
| 7781 | 7856 | } |
| 7782 | 7857 | }, |
| 7858 | + "node_modules/option": { | |
| 7859 | + "version": "0.2.4", | |
| 7860 | + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", | |
| 7861 | + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", | |
| 7862 | + "license": "BSD-2-Clause" | |
| 7863 | + }, | |
| 7783 | 7864 | "node_modules/optionator": { |
| 7784 | 7865 | "version": "0.9.4", |
| 7785 | 7866 | "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", |
@@ -7934,7 +8015,6 @@ | ||
| 7934 | 8015 | "version": "1.0.1", |
| 7935 | 8016 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", |
| 7936 | 8017 | "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", |
| 7937 | − "dev": true, | |
| 7938 | 8018 | "license": "MIT", |
| 7939 | 8019 | "engines": { |
| 7940 | 8020 | "node": ">=0.10.0" |
@@ -9076,6 +9156,12 @@ | ||
| 9076 | 9156 | "url": "https://github.com/sponsors/wooorm" |
| 9077 | 9157 | } |
| 9078 | 9158 | }, |
| 9159 | + "node_modules/sprintf-js": { | |
| 9160 | + "version": "1.0.3", | |
| 9161 | + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", | |
| 9162 | + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", | |
| 9163 | + "license": "BSD-3-Clause" | |
| 9164 | + }, | |
| 9079 | 9165 | "node_modules/stable-hash": { |
| 9080 | 9166 | "version": "0.0.5", |
| 9081 | 9167 | "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", |
@@ -9866,6 +9952,12 @@ | ||
| 9866 | 9952 | "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", |
| 9867 | 9953 | "license": "MIT" |
| 9868 | 9954 | }, |
| 9955 | + "node_modules/underscore": { | |
| 9956 | + "version": "1.13.8", | |
| 9957 | + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", | |
| 9958 | + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", | |
| 9959 | + "license": "MIT" | |
| 9960 | + }, | |
| 9869 | 9961 | "node_modules/undici-types": { |
| 9870 | 9962 | "version": "6.21.0", |
| 9871 | 9963 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", |
@@ -10285,6 +10377,15 @@ | ||
| 10285 | 10377 | "dev": true, |
| 10286 | 10378 | "license": "ISC" |
| 10287 | 10379 | }, |
| 10380 | + "node_modules/xmlbuilder": { | |
| 10381 | + "version": "10.1.1", | |
| 10382 | + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", | |
| 10383 | + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", | |
| 10384 | + "license": "MIT", | |
| 10385 | + "engines": { | |
| 10386 | + "node": ">=4.0" | |
| 10387 | + } | |
| 10388 | + }, | |
| 10288 | 10389 | "node_modules/y18n": { |
| 10289 | 10390 | "version": "4.0.3", |
| 10290 | 10391 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", |
modified
package.json
+1 −0
@@ -47,6 +47,7 @@ | ||
| 47 | 47 | "iron-session": "^8.0.4", |
| 48 | 48 | "jszip": "^3.10.1", |
| 49 | 49 | "lucide-react": "^0.564.0", |
| 50 | + "mammoth": "^1.11.0", | |
| 50 | 51 | "mime-types": "^3.0.2", |
| 51 | 52 | "next": "14.2.35", |
| 52 | 53 | "otplib": "^13.3.0", |
modified
prisma/schema.prisma
+69 −16
@@ -11,6 +11,7 @@ datasource db { | ||
| 11 | 11 | |
| 12 | 12 | model User { |
| 13 | 13 | id String @id @default(cuid()) |
| 14 | + username String? @unique | |
| 14 | 15 | email String? @unique |
| 15 | 16 | name String? |
| 16 | 17 | passwordHash String |
@@ -20,12 +21,14 @@ model User { | ||
| 20 | 21 | createdAt DateTime @default(now()) |
| 21 | 22 | updatedAt DateTime @default(now()) |
| 22 | 23 | |
| 23 | − files File[] | |
| 24 | − folders Folder[] | |
| 25 | − permissions Permission[] | |
| 26 | − notifications Notification[] | |
| 27 | − apiKeys ApiKey[] | |
| 28 | − activityLogs ActivityLog[] | |
| 24 | + files File[] | |
| 25 | + folders Folder[] | |
| 26 | + permissions Permission[] | |
| 27 | + notifications Notification[] | |
| 28 | + apiKeys ApiKey[] | |
| 29 | + activityLogs ActivityLog[] | |
| 30 | + sharedSpaces SharedSpaceMember[] | |
| 31 | + passwordEntries PasswordEntry[] | |
| 29 | 32 | |
| 30 | 33 | @@index([email]) |
| 31 | 34 | @@index([role]) |
@@ -66,19 +69,22 @@ model File { | ||
| 66 | 69 | } |
| 67 | 70 | |
| 68 | 71 | model Folder { |
| 69 | − id String @id @default(cuid()) | |
| 70 | − name String | |
| 71 | − parentId String? | |
| 72 | − userId String? | |
| 73 | − parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade) | |
| 74 | − children Folder[] @relation("FolderTree") | |
| 75 | − user User? @relation(fields: [userId], references: [id], onDelete: SetNull) | |
| 76 | − files File[] | |
| 77 | − permissions Permission[] | |
| 78 | − createdAt DateTime @default(now()) | |
| 72 | + id String @id @default(cuid()) | |
| 73 | + name String | |
| 74 | + parentId String? | |
| 75 | + userId String? | |
| 76 | + sharedSpaceId String? | |
| 77 | + parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade) | |
| 78 | + children Folder[] @relation("FolderTree") | |
| 79 | + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) | |
| 80 | + sharedSpace SharedSpace? @relation(fields: [sharedSpaceId], references: [id], onDelete: SetNull) | |
| 81 | + files File[] | |
| 82 | + permissions Permission[] | |
| 83 | + createdAt DateTime @default(now()) | |
| 79 | 84 | |
| 80 | 85 | @@index([parentId]) |
| 81 | 86 | @@index([userId]) |
| 87 | + @@index([sharedSpaceId]) | |
| 82 | 88 | } |
| 83 | 89 | |
| 84 | 90 | // ==================== SHARING ==================== |
@@ -228,3 +234,50 @@ model BackupLog { | ||
| 228 | 234 | startedAt DateTime @default(now()) |
| 229 | 235 | completedAt DateTime? |
| 230 | 236 | } |
| 237 | + | |
| 238 | +// ==================== SHARED SPACES ==================== | |
| 239 | + | |
| 240 | +// ==================== PASSWORD MANAGER ==================== | |
| 241 | + | |
| 242 | +model PasswordEntry { | |
| 243 | + id String @id @default(cuid()) | |
| 244 | + userId String | |
| 245 | + user User @relation(fields: [userId], references: [id], onDelete: Cascade) | |
| 246 | + siteName String | |
| 247 | + siteUrl String? | |
| 248 | + username String? | |
| 249 | + password String // AES-256-GCM encrypted | |
| 250 | + notes String? | |
| 251 | + createdAt DateTime @default(now()) | |
| 252 | + updatedAt DateTime @updatedAt | |
| 253 | + | |
| 254 | + @@index([userId]) | |
| 255 | + @@index([siteName]) | |
| 256 | +} | |
| 257 | + | |
| 258 | +// ==================== SHARED SPACES ==================== | |
| 259 | + | |
| 260 | +model SharedSpace { | |
| 261 | + id String @id @default(cuid()) | |
| 262 | + name String | |
| 263 | + quotaBytes BigInt @default(536870912000) | |
| 264 | + createdAt DateTime @default(now()) | |
| 265 | + updatedAt DateTime @updatedAt | |
| 266 | + members SharedSpaceMember[] | |
| 267 | + folders Folder[] | |
| 268 | + | |
| 269 | + @@index([name]) | |
| 270 | +} | |
| 271 | + | |
| 272 | +model SharedSpaceMember { | |
| 273 | + id String @id @default(cuid()) | |
| 274 | + sharedSpaceId String | |
| 275 | + userId String | |
| 276 | + sharedSpace SharedSpace @relation(fields: [sharedSpaceId], references: [id], onDelete: Cascade) | |
| 277 | + user User @relation(fields: [userId], references: [id], onDelete: Cascade) | |
| 278 | + joinedAt DateTime @default(now()) | |
| 279 | + | |
| 280 | + @@unique([sharedSpaceId, userId]) | |
| 281 | + @@index([sharedSpaceId]) | |
| 282 | + @@index([userId]) | |
| 283 | +} | |
modified
prisma/seed.ts
+2 −2
@@ -8,13 +8,13 @@ async function main() { | ||
| 8 | 8 | |
| 9 | 9 | await prisma.user.upsert({ |
| 10 | 10 | where: { id: "admin" }, |
| 11 | − update: { passwordHash, role: "admin", name: "Admin", email: "admin@spbcloud.local" }, | |
| 11 | + update: { passwordHash, role: "admin", name: "Admin", username: "admin" }, | |
| 12 | 12 | create: { |
| 13 | 13 | id: "admin", |
| 14 | 14 | passwordHash, |
| 15 | 15 | role: "admin", |
| 16 | 16 | name: "Admin", |
| 17 | − email: "admin@spbcloud.local", | |
| 17 | + username: "admin", | |
| 18 | 18 | }, |
| 19 | 19 | }); |
| 20 | 20 | |
| 21 | 21 | |