From f82aafda40c826fcc1dd7fbff99a75bc87154a93 Mon Sep 17 00:00:00 2001 From: ajinkya22-dev Date: Mon, 5 Jan 2026 18:25:24 +0530 Subject: [PATCH 1/3] Database Schema & API --- client/.env.example | 1 + server/index.js | 2 + server/package-lock.json | 3 + server/prisma/schema.prisma | 61 ++++++++ server/routes/auth.js | 3 +- server/routes/vault.js | 296 ++++++++++++++++++++++++++++++++++++ 6 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 server/routes/vault.js diff --git a/client/.env.example b/client/.env.example index 3afc3b8..a19fa93 100644 --- a/client/.env.example +++ b/client/.env.example @@ -8,3 +8,4 @@ VITE_SOCKET_URL=http://localhost:3001 # Python backend API VITE_PYTHON_API_URL=http://localhost:8000 + \ No newline at end of file diff --git a/server/index.js b/server/index.js index 073c5c5..07481e9 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,7 @@ import roadmapRoutes from "./routes/roadmaps.js"; import feedbackRoutes from "./routes/feedback.js"; import pdfChatRoutes from "./routes/pdfChat.js"; import sitemapRoutes from "./routes/sitemap.js"; +import vaultRoutes from "./routes/vault.js"; import { setupSocketHandlers } from "./socket/socketHandlers.js"; import initRedis from "./utils/redis.js"; @@ -102,6 +103,7 @@ app.use("/api/courses", courseRoutes); app.use("/api/roadmaps", roadmapRoutes); app.use("/api/feedback", feedbackRoutes); app.use("/api/pdf-chat", pdfChatRoutes); +app.use("/api/vault", vaultRoutes); app.use("/", sitemapRoutes); // Setup Socket.IO handlers diff --git a/server/package-lock.json b/server/package-lock.json index e97d1f1..3a8d753 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1423,6 +1423,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz", "integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -3105,6 +3106,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3877,6 +3879,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index ac165f1..f3f2977 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -50,6 +50,7 @@ model User { featureSuggestions FeatureSuggestion[] bugReports BugReport[] pdfChatSessions PdfChatSession[] + vault Vault? @@map("users") } @@ -450,4 +451,64 @@ model CourseTest { @@unique([courseId, userId, createdAt]) @@map("course_tests") +} + +model Vault { + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @map("user_id") @db.ObjectId @unique + storageUsed Int @default(0) @map("storage_used") + storageLimit Int @default(524288000) @map("storage_limit") // 500 MB in bytes + createdAt DateTime @default(now()) @map("created_at") + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + folders VaultFolder[] + + @@map("vaults") +} + +model VaultFolder { + id String @id @default(auto()) @map("_id") @db.ObjectId + vaultId String @map("vault_id") @db.ObjectId + parentId String? @map("parent_id") @db.ObjectId + name String + isSystemFolder Boolean @default(false) @map("is_system_folder") + createdAt DateTime @default(now()) @map("created_at") + + // Relations + vault Vault @relation(fields: [vaultId], references: [id], onDelete: Cascade) + parent VaultFolder? @relation("FolderChildren", fields: [parentId], references: [id], onDelete: NoAction, onUpdate: NoAction) + children VaultFolder[] @relation("FolderChildren") + files VaultFile[] + + @@map("vault_folders") +} + +model VaultFile { + id String @id @default(auto()) @map("_id") @db.ObjectId + folderId String @map("folder_id") @db.ObjectId + name String + type String + size Int + r2Key String @map("r2_key") + createdAt DateTime @default(now()) @map("created_at") + + // Relations + folder VaultFolder @relation(fields: [folderId], references: [id], onDelete: Cascade) + outputs VaultOutput[] + + @@map("vault_files") +} + +model VaultOutput { + id String @id @default(auto()) @map("_id") @db.ObjectId + fileId String @map("file_id") @db.ObjectId + type String + content String + createdAt DateTime @default(now()) @map("created_at") + + // Relations + file VaultFile @relation(fields: [fileId], references: [id], onDelete: Cascade) + + @@map("vault_outputs") } \ No newline at end of file diff --git a/server/routes/auth.js b/server/routes/auth.js index 6eb2a55..31d8e0f 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -9,6 +9,7 @@ const router = express.Router(); // Generate JWT token const generateToken = (user) => { + const secret = process.env.JWT_SECRET || "dev-secret"; return jwt.sign( { userId: user.id, @@ -16,7 +17,7 @@ const generateToken = (user) => { email: user.email, needsUsername: user.needsUsername || false, }, - process.env.JWT_SECRET, + secret, { expiresIn: "7d" } ); }; diff --git a/server/routes/vault.js b/server/routes/vault.js new file mode 100644 index 0000000..8755325 --- /dev/null +++ b/server/routes/vault.js @@ -0,0 +1,296 @@ +import express from "express"; +import prisma from "../db.js"; +import { authenticateToken } from "../middleware/auth.js"; + +const router = express.Router(); + +const STORAGE_LIMIT_BYTES = 524288000; // 500 MB + +// Helper: get or create vault + AI Outputs system folder +const getOrCreateVaultForUser = async (userId) => { + let vault = await prisma.vault.findUnique({ + where: { userId }, + }); + + if (!vault) { + vault = await prisma.vault.create({ + data: { + userId, + storageUsed: 0, + storageLimit: STORAGE_LIMIT_BYTES, + }, + }); + } + + // Ensure AI Outputs system folder exists + const existingSystemFolder = await prisma.vaultFolder.findFirst({ + where: { + vaultId: vault.id, + isSystemFolder: true, + name: "AI Outputs", + parentId: null, + }, + }); + + if (!existingSystemFolder) { + await prisma.vaultFolder.create({ + data: { + vaultId: vault.id, + name: "AI Outputs", + isSystemFolder: true, + parentId: null, + }, + }); + } + + return vault; +}; + +// GET /api/vault - Get user's vault with root folders +router.get("/", authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + + const vault = await getOrCreateVaultForUser(userId); + + const rootFolders = await prisma.vaultFolder.findMany({ + where: { + vaultId: vault.id, + parentId: null, + }, + orderBy: { createdAt: "asc" }, + }); + + res.json({ + vault: { + id: vault.id, + storageUsed: vault.storageUsed, + storageLimit: vault.storageLimit, + createdAt: vault.createdAt, + }, + rootFolders, + }); + } catch (error) { + console.error("Error fetching vault:", error); + res.status(500).json({ error: "Failed to fetch vault" }); + } +}); + +// POST /api/vault/folders - Create folder +router.post("/folders", authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const { name, parentId } = req.body; + + if (!name || typeof name !== "string" || !name.trim()) { + return res.status(400).json({ error: "Folder name is required" }); + } + + const vault = await getOrCreateVaultForUser(userId); + + let parentFolder = null; + if (parentId) { + parentFolder = await prisma.vaultFolder.findFirst({ + where: { + id: parentId, + vaultId: vault.id, + }, + }); + + if (!parentFolder) { + return res.status(404).json({ error: "Parent folder not found" }); + } + } + + const folder = await prisma.vaultFolder.create({ + data: { + vaultId: vault.id, + parentId: parentFolder ? parentFolder.id : null, + name: name.trim(), + isSystemFolder: false, + }, + }); + + res.status(201).json(folder); + } catch (error) { + console.error("Error creating folder:", error); + res.status(500).json({ error: "Failed to create folder" }); + } +}); + +// PATCH /api/vault/folders/:id - Rename folder +router.patch("/folders/:id", authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const { id } = req.params; + const { name } = req.body; + + if (!name || typeof name !== "string" || !name.trim()) { + return res.status(400).json({ error: "Folder name is required" }); + } + + const vault = await getOrCreateVaultForUser(userId); + + const folder = await prisma.vaultFolder.findFirst({ + where: { + id, + vaultId: vault.id, + }, + }); + + if (!folder) { + return res.status(404).json({ error: "Folder not found" }); + } + + if (folder.isSystemFolder) { + return res + .status(400) + .json({ error: "System folders cannot be renamed" }); + } + + const updated = await prisma.vaultFolder.update({ + where: { id: folder.id }, + data: { name: name.trim() }, + }); + + res.json(updated); + } catch (error) { + console.error("Error renaming folder:", error); + res.status(500).json({ error: "Failed to rename folder" }); + } +}); + +// DELETE /api/vault/folders/:id - Delete folder (cascade files) +router.delete("/folders/:id", authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const { id } = req.params; + + const vault = await getOrCreateVaultForUser(userId); + + const folder = await prisma.vaultFolder.findFirst({ + where: { + id, + vaultId: vault.id, + }, + }); + + if (!folder) { + return res.status(404).json({ error: "Folder not found" }); + } + + if (folder.isSystemFolder) { + return res + .status(400) + .json({ error: "System folders cannot be deleted" }); + } + + await prisma.vaultFolder.delete({ + where: { id: folder.id }, + }); + + res.json({ message: "Folder deleted successfully" }); + } catch (error) { + console.error("Error deleting folder:", error); + res.status(500).json({ error: "Failed to delete folder" }); + } +}); + +// GET /api/vault/folders/:id - Get folder contents +router.get("/folders/:id", authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const { id } = req.params; + + const vault = await getOrCreateVaultForUser(userId); + + const folder = await prisma.vaultFolder.findFirst({ + where: { + id, + vaultId: vault.id, + }, + }); + + if (!folder) { + return res.status(404).json({ error: "Folder not found" }); + } + + const [subfolders, files] = await Promise.all([ + prisma.vaultFolder.findMany({ + where: { parentId: folder.id }, + orderBy: { createdAt: "asc" }, + }), + prisma.vaultFile.findMany({ + where: { folderId: folder.id }, + orderBy: { createdAt: "asc" }, + }), + ]); + + res.json({ folder, subfolders, files }); + } catch (error) { + console.error("Error fetching folder contents:", error); + res.status(500).json({ error: "Failed to fetch folder contents" }); + } +}); + +// DELETE /api/vault/files/:id - Delete file +router.delete("/files/:id", authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const { id } = req.params; + + const vault = await getOrCreateVaultForUser(userId); + + const file = await prisma.vaultFile.findUnique({ + where: { id }, + include: { + folder: true, + }, + }); + + if (!file || file.folder.vaultId !== vault.id) { + return res.status(404).json({ error: "File not found" }); + } + + await prisma.$transaction([ + prisma.vaultFile.delete({ + where: { id: file.id }, + }), + prisma.vault.update({ + where: { id: vault.id }, + data: { + storageUsed: { + decrement: file.size, + }, + }, + }), + ]); + + // NOTE: Actual R2 object deletion can be added here in a future issue + + res.json({ message: "File deleted successfully" }); + } catch (error) { + console.error("Error deleting file:", error); + res.status(500).json({ error: "Failed to delete file" }); + } +}); + +// GET /api/vault/storage - Get storage usage +router.get("/storage", authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + + const vault = await getOrCreateVaultForUser(userId); + + res.json({ + storageUsed: vault.storageUsed, + storageLimit: vault.storageLimit, + remaining: Math.max(vault.storageLimit - vault.storageUsed, 0), + }); + } catch (error) { + console.error("Error fetching storage usage:", error); + res.status(500).json({ error: "Failed to fetch storage usage" }); + } +}); + +export default router; From bc051ab3ff0cc3cdcf84d3ecb70f5f00565431ee Mon Sep 17 00:00:00 2001 From: ajinkya22-dev Date: Mon, 12 Jan 2026 18:33:06 +0530 Subject: [PATCH 2/3] fix: revert JWT_SECRET fallback and clean lockfile --- server/package-lock.json | 3 --- server/routes/auth.js | 3 +-- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 3a8d753..e97d1f1 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1423,7 +1423,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz", "integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -3106,7 +3105,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3879,7 +3877,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, diff --git a/server/routes/auth.js b/server/routes/auth.js index 31d8e0f..6eb2a55 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -9,7 +9,6 @@ const router = express.Router(); // Generate JWT token const generateToken = (user) => { - const secret = process.env.JWT_SECRET || "dev-secret"; return jwt.sign( { userId: user.id, @@ -17,7 +16,7 @@ const generateToken = (user) => { email: user.email, needsUsername: user.needsUsername || false, }, - secret, + process.env.JWT_SECRET, { expiresIn: "7d" } ); }; From 302ae9cbebc1606bdb725b2ae86158bebd9ce182 Mon Sep 17 00:00:00 2001 From: ajinkya pathak Date: Fri, 23 Jan 2026 06:39:18 -0500 Subject: [PATCH 3/3] feat(vault): add Vault file browser UI, upload modals and mock service; remove demo-login test code --- client/src/App.tsx | 9 + client/src/components/vault/DropZone.tsx | 40 ++++ client/src/components/vault/FileGrid.tsx | 40 ++++ client/src/components/vault/FileList.tsx | 33 +++ .../src/components/vault/VaultBreadcrumb.tsx | 27 +++ client/src/components/vault/VaultPage.tsx | 150 ++++++++++++++ client/src/components/vault/VaultSidebar.tsx | 43 ++++ .../vault/modals/ConfirmDeleteModal.tsx | 19 ++ .../vault/modals/CreateFolderModal.tsx | 20 ++ .../vault/modals/UploadFileModal.tsx | 27 +++ .../vault/modals/UploadFolderModal.tsx | 39 ++++ client/src/services/vaultService.ts | 188 ++++++++++++++++++ client/src/utils/auth.ts | 1 + 13 files changed, 636 insertions(+) create mode 100644 client/src/components/vault/DropZone.tsx create mode 100644 client/src/components/vault/FileGrid.tsx create mode 100644 client/src/components/vault/FileList.tsx create mode 100644 client/src/components/vault/VaultBreadcrumb.tsx create mode 100644 client/src/components/vault/VaultPage.tsx create mode 100644 client/src/components/vault/VaultSidebar.tsx create mode 100644 client/src/components/vault/modals/ConfirmDeleteModal.tsx create mode 100644 client/src/components/vault/modals/CreateFolderModal.tsx create mode 100644 client/src/components/vault/modals/UploadFileModal.tsx create mode 100644 client/src/components/vault/modals/UploadFolderModal.tsx create mode 100644 client/src/services/vaultService.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 556900e..c98ef58 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -38,6 +38,7 @@ import BugReportPage from "./components/feedback/BugReportPage"; import AdminPanel from "./components/admin/AdminPanel"; import AdminRoute from "./components/admin/AdminRoute"; import PdfChatbotPage from "./components/ai/PdfChatbotPage"; +import VaultPage from "./components/vault/VaultPage"; import CertificateVerificationPage from "./pages/CertificateVerificationPage"; import Footer from "./components/layout/Footer"; import ScrollToTop from "./components/ui/ScrollToTop"; @@ -161,6 +162,14 @@ const App: React.FC = () => { } /> } /> } /> + + + + } + /> } diff --git a/client/src/components/vault/DropZone.tsx b/client/src/components/vault/DropZone.tsx new file mode 100644 index 0000000..b70015a --- /dev/null +++ b/client/src/components/vault/DropZone.tsx @@ -0,0 +1,40 @@ +import React, { useCallback, useState } from "react"; + +const DropZone: React.FC<{ onFiles?: (files: FileList | null) => void; onDropFiles?: (files: FileList | null) => void }> = ({ onFiles, onDropFiles }) => { + const [isOver, setIsOver] = useState(false); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsOver(false); + const dt = e.dataTransfer; + // Give parent a chance to show actions instead of auto-uploading + if (onDropFiles) onDropFiles(dt.files); + else if (onFiles) onFiles(dt.files); + }, + [onFiles, onDropFiles] + ); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsOver(true); + }; + + const handleDragLeave = () => setIsOver(false); + + return ( +
+
+
Drag & drop files here
+
or click Upload to select files
+
+
+ ); +}; + +export default DropZone; diff --git a/client/src/components/vault/FileGrid.tsx b/client/src/components/vault/FileGrid.tsx new file mode 100644 index 0000000..2863f90 --- /dev/null +++ b/client/src/components/vault/FileGrid.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { FolderItem, FileItem } from "../../services/vaultService"; + +const ItemCard: React.FC<{ + item: FolderItem | FileItem; + onOpenFolder: (id: string) => void; + onRequestDelete: (id: string) => void; +}> = ({ item, onOpenFolder, onRequestDelete }) => { + const isFolder = item.type === "folder"; + return ( +
isFolder && onOpenFolder(item.id)}> +
{isFolder ? "📁" : "📄"}
+
{item.name}
+
+ {isFolder ? "Folder" : `${(item as FileItem).size} bytes`} + +
+
+ ); +}; + +const FileGrid: React.FC<{ + folders: FolderItem[]; + files: FileItem[]; + onOpenFolder: (id: string) => void; + onRequestDelete: (id: string) => void; +}> = ({ folders, files, onOpenFolder, onRequestDelete }) => { + return ( +
+ {folders.map((f) => ( + + ))} + {files.map((fi) => ( + + ))} +
+ ); +}; + +export default FileGrid; diff --git a/client/src/components/vault/FileList.tsx b/client/src/components/vault/FileList.tsx new file mode 100644 index 0000000..0ec657a --- /dev/null +++ b/client/src/components/vault/FileList.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { FolderItem, FileItem } from "../../services/vaultService"; + +const FileRow: React.FC<{ item: FolderItem | FileItem; onOpenFolder: (id: string) => void; onRequestDelete: (id: string) => void }> = ({ item, onOpenFolder, onRequestDelete }) => { + const isFolder = item.type === "folder"; + return ( +
+
+
{isFolder ? "📁" : "📄"}
+
isFolder && onOpenFolder(item.id)}>{item.name}
+
+
+
{isFolder ? "Folder" : `${(item as FileItem).size} bytes`}
+ +
+
+ ); +}; + +const FileList: React.FC<{ folders: FolderItem[]; files: FileItem[]; onOpenFolder: (id: string) => void; onRequestDelete: (id: string) => void }> = ({ folders, files, onOpenFolder, onRequestDelete }) => { + return ( +
+ {folders.map((f) => ( + + ))} + {files.map((fi) => ( + + ))} +
+ ); +}; + +export default FileList; diff --git a/client/src/components/vault/VaultBreadcrumb.tsx b/client/src/components/vault/VaultBreadcrumb.tsx new file mode 100644 index 0000000..887cd32 --- /dev/null +++ b/client/src/components/vault/VaultBreadcrumb.tsx @@ -0,0 +1,27 @@ +import React, { useEffect, useState } from "react"; +import vaultService, { FolderItem } from "../../services/vaultService"; + +const VaultBreadcrumb: React.FC<{ currentFolderId: string | null; onNavigate: (id: string) => void }> = ({ currentFolderId, onNavigate }) => { + const [path, setPath] = useState([]); + + useEffect(() => { + if (!currentFolderId) return; + (async () => { + const p = await vaultService.getPath(currentFolderId); + setPath(p); + })(); + }, [currentFolderId]); + + return ( +
+ {path.map((p, i) => ( + onNavigate(p.id)}> + {i > 0 && /} + {p.name} + + ))} +
+ ); +}; + +export default VaultBreadcrumb; diff --git a/client/src/components/vault/VaultPage.tsx b/client/src/components/vault/VaultPage.tsx new file mode 100644 index 0000000..aa82d15 --- /dev/null +++ b/client/src/components/vault/VaultPage.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useState, useRef } from "react"; +import VaultSidebar from "./VaultSidebar"; +import VaultBreadcrumb from "./VaultBreadcrumb"; +import FileGrid from "./FileGrid"; +import FileList from "./FileList"; +import CreateFolderModal from "./modals/CreateFolderModal"; +import ConfirmDeleteModal from "./modals/ConfirmDeleteModal"; +import UploadFileModal from "./modals/UploadFileModal"; +import UploadFolderModal from "./modals/UploadFolderModal"; +import vaultService, { FolderItem, FileItem } from "../../services/vaultService"; + +const VaultPage: React.FC = () => { + const [currentFolderId, setCurrentFolderId] = useState(null); + const [folders, setFolders] = useState([]); + const [files, setFiles] = useState([]); + const [view, setView] = useState<"grid" | "list">("grid"); + const [showCreate, setShowCreate] = useState(false); + const [showDelete, setShowDelete] = useState(false); + const [toDeleteId, setToDeleteId] = useState(null); + const [showUploadFileModal, setShowUploadFileModal] = useState(false); + const [showUploadFolderModal, setShowUploadFolderModal] = useState(false); + const [showNewMenu, setShowNewMenu] = useState(false); + const newMenuRef = useRef(null); + + useEffect(() => { + (async () => { + const root = await vaultService.getRootId(); + setCurrentFolderId((id) => id ?? root); + })(); + }, []); + + useEffect(() => { + if (!currentFolderId) return; + (async () => { + const { folders: f, files: fi } = await vaultService.getChildren(currentFolderId); + setFolders(f); + setFiles(fi); + })(); + }, [currentFolderId]); + + async function handleCreate(name: string) { + if (!currentFolderId) return; + await vaultService.createFolder(currentFolderId, name); + const { folders: f } = await vaultService.getChildren(currentFolderId); + setFolders(f); + setShowCreate(false); + } + + async function handleFiles(files: File[] | FileList | null) { + if (!files || (Array.isArray(files) ? files.length === 0 : files.length === 0) || !currentFolderId) return; + const list: File[] = Array.isArray(files) ? files : Array.from(files); + for (let i = 0; i < list.length; i++) { + const f = list[i]; + await vaultService.uploadFile(currentFolderId, f.name, f.size, f.type || "application/octet-stream"); + } + const { folders: fldr, files: fi } = await vaultService.getChildren(currentFolderId); + setFolders(fldr); + setFiles(fi); + setShowUploadFileModal(false); + setShowUploadFolderModal(false); + } + + useEffect(() => { + function onDocClick(e: MouseEvent) { + if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) { + setShowNewMenu(false); + } + } + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, []); + + async function handleDelete(id: string) { + await vaultService.deleteItem(id); + const { folders: f, files: fi } = await vaultService.getChildren(currentFolderId); + setFolders(f); + setFiles(fi); + setShowDelete(false); + setToDeleteId(null); + } + + return ( +
+
+
+ setCurrentFolderId(id)} /> +
+
+
+ +
+
Storage: 120MB / 2GB
+
+ + {showNewMenu && ( +
+ + + +
+ )} +
+ +
+
+ +
+ {view === "grid" ? ( + setCurrentFolderId(id)} + onRequestDelete={(id) => { + setToDeleteId(id); + setShowDelete(true); + }} + /> + ) : ( + setCurrentFolderId(id)} + onRequestDelete={(id) => { + setToDeleteId(id); + setShowDelete(true); + }} + /> + )} +
+
+
+ + setShowCreate(false)} onCreate={handleCreate} /> + setShowUploadFileModal(false)} onUpload={handleFiles} /> + setShowUploadFolderModal(false)} onUpload={handleFiles} /> + setShowDelete(false)} + onConfirm={() => toDeleteId && handleDelete(toDeleteId)} + /> +
+ ); +}; + +export default VaultPage; diff --git a/client/src/components/vault/VaultSidebar.tsx b/client/src/components/vault/VaultSidebar.tsx new file mode 100644 index 0000000..b3d1061 --- /dev/null +++ b/client/src/components/vault/VaultSidebar.tsx @@ -0,0 +1,43 @@ +import React, { useEffect, useState } from "react"; +import vaultService from "../../services/vaultService"; + +type Node = { id: string; name: string; children?: Node[] }; + +const TreeNode: React.FC<{ node: Node; onNavigate: (id: string) => void }> = ({ node, onNavigate }) => { + const [open, setOpen] = useState(false); + return ( +
+
{ setOpen(!open); onNavigate(node.id); }}> +
F
+
{node.name}
+
+ {open && node.children && ( +
+ {node.children.map((c) => ( + + ))} +
+ )} +
+ ); +}; + +const VaultSidebar: React.FC<{ onNavigate: (id: string) => void }> = ({ onNavigate }) => { + const [tree, setTree] = useState(null); + + useEffect(() => { + (async () => { + const t = await vaultService.getFolderTree(); + setTree(t as Node); + })(); + }, []); + + return ( +
+
My Vault
+ {tree ? :
Loading...
} +
+ ); +}; + +export default VaultSidebar; diff --git a/client/src/components/vault/modals/ConfirmDeleteModal.tsx b/client/src/components/vault/modals/ConfirmDeleteModal.tsx new file mode 100644 index 0000000..d98f716 --- /dev/null +++ b/client/src/components/vault/modals/ConfirmDeleteModal.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +const ConfirmDeleteModal: React.FC<{ open: boolean; onClose: () => void; onConfirm: () => void }> = ({ open, onClose, onConfirm }) => { + if (!open) return null; + return ( +
+
+
Confirm Delete
+
Are you sure you want to delete this item? This action cannot be undone.
+
+ + +
+
+
+ ); +}; + +export default ConfirmDeleteModal; diff --git a/client/src/components/vault/modals/CreateFolderModal.tsx b/client/src/components/vault/modals/CreateFolderModal.tsx new file mode 100644 index 0000000..1862374 --- /dev/null +++ b/client/src/components/vault/modals/CreateFolderModal.tsx @@ -0,0 +1,20 @@ +import React, { useState } from "react"; + +const CreateFolderModal: React.FC<{ open: boolean; onClose: () => void; onCreate: (name: string) => void }> = ({ open, onClose, onCreate }) => { + const [name, setName] = useState(""); + if (!open) return null; + return ( +
+
+
Create Folder
+ setName(e.target.value)} className="w-full p-2 rounded bg-[#0b1220] border border-slate-700 text-slate-200" placeholder="Folder name" /> +
+ + +
+
+
+ ); +}; + +export default CreateFolderModal; diff --git a/client/src/components/vault/modals/UploadFileModal.tsx b/client/src/components/vault/modals/UploadFileModal.tsx new file mode 100644 index 0000000..43cf72b --- /dev/null +++ b/client/src/components/vault/modals/UploadFileModal.tsx @@ -0,0 +1,27 @@ +import React, { useState } from "react"; +import DropZone from "../DropZone"; + +const UploadFileModal: React.FC<{ open: boolean; onClose: () => void; onUpload: (files: File[] | FileList | null) => void }> = ({ open, onClose, onUpload }) => { + const [files, setFiles] = useState(null); + + if (!open) return null; + + return ( +
+
+
+
Upload Files
+ +
+ setFiles(fl ? Array.from(fl) : null)} /> +
+ setFiles(e.target.files ? Array.from(e.target.files) : null)} /> + + +
+
+
+ ); +}; + +export default UploadFileModal; diff --git a/client/src/components/vault/modals/UploadFolderModal.tsx b/client/src/components/vault/modals/UploadFolderModal.tsx new file mode 100644 index 0000000..c4568f5 --- /dev/null +++ b/client/src/components/vault/modals/UploadFolderModal.tsx @@ -0,0 +1,39 @@ +import React, { useState } from "react"; + +const UploadFolderModal: React.FC<{ open: boolean; onClose: () => void; onUpload: (files: File[] | FileList | null) => void }> = ({ open, onClose, onUpload }) => { + const [files, setFiles] = useState(null); + + if (!open) return null; + + return ( +
+
+
+
Upload Folder
+ +
+ +
+

Select a folder from your computer. Browser support may vary.

+ setFiles(e.target.files ? Array.from(e.target.files) : null)} + /> + +
+ +
+ +
+
+
+ ); +}; + +export default UploadFolderModal; diff --git a/client/src/services/vaultService.ts b/client/src/services/vaultService.ts new file mode 100644 index 0000000..9a567a1 --- /dev/null +++ b/client/src/services/vaultService.ts @@ -0,0 +1,188 @@ +export type VaultItemBase = { + id: string; + name: string; + createdAt: string; + updatedAt: string; +}; + +export type FolderItem = VaultItemBase & { + type: "folder"; + parentId: string | null; + children: string[]; +}; + +export type FileItem = VaultItemBase & { + type: "file"; + parentId: string | null; + size: number; + mime: string; +}; + +type Item = FolderItem | FileItem; + +const makeId = () => Math.random().toString(36).slice(2, 9); + +// Simple in-memory mock store +const store: { items: Record; rootId: string } = { items: {}, rootId: "" }; + +function nowISO() { + return new Date().toISOString(); +} + +function init() { + if (store.rootId) return; + const rootId = makeId(); + store.rootId = rootId; + store.items[rootId] = { + id: rootId, + name: "My Drive", + type: "folder", + parentId: null, + children: [], + createdAt: nowISO(), + updatedAt: nowISO(), + }; + + // sample folders/files + const f1 = makeId(); + const f2 = makeId(); + store.items[f1] = { + id: f1, + name: "Personal", + type: "folder", + parentId: rootId, + children: [], + createdAt: nowISO(), + updatedAt: nowISO(), + } as FolderItem; + store.items[f2] = { + id: f2, + name: "CourseNotes.pdf", + type: "file", + parentId: rootId, + size: 234567, + mime: "application/pdf", + createdAt: nowISO(), + updatedAt: nowISO(), + } as FileItem; + (store.items[rootId] as FolderItem).children.push(f1, f2); +} + +init(); + +export async function getRootId(): Promise { + return store.rootId; +} + +export async function getChildren(parentId: string | null) { + const pid = parentId ?? store.rootId; + const parent = store.items[pid] as FolderItem; + if (!parent || parent.type !== "folder") return { folders: [], files: [] }; + const folders: FolderItem[] = []; + const files: FileItem[] = []; + for (const cid of parent.children) { + const it = store.items[cid]; + if (!it) continue; + if (it.type === "folder") folders.push(it as FolderItem); + else files.push(it as FileItem); + } + return { folders, files }; +} + +export async function getPath(folderId: string) { + const path: FolderItem[] = []; + let cur: any = store.items[folderId]; + while (cur) { + if (cur.type === "folder") path.unshift(cur as FolderItem); + cur = cur.parentId ? store.items[cur.parentId] : null; + } + return path; +} + +export async function createFolder(parentId: string | null, name: string) { + const pid = parentId ?? store.rootId; + const id = makeId(); + const folder: FolderItem = { + id, + name, + type: "folder", + parentId: pid, + children: [], + createdAt: nowISO(), + updatedAt: nowISO(), + }; + store.items[id] = folder; + (store.items[pid] as FolderItem).children.push(id); + return folder; +} + +export async function uploadFile(parentId: string | null, name: string, size: number, mime: string) { + const pid = parentId ?? store.rootId; + const id = makeId(); + const file: FileItem = { + id, + name, + type: "file", + parentId: pid, + size, + mime, + createdAt: nowISO(), + updatedAt: nowISO(), + }; + store.items[id] = file; + (store.items[pid] as FolderItem).children.push(id); + return file; +} + +export async function renameItem(id: string, name: string) { + const it = store.items[id]; + if (!it) throw new Error("Not found"); + it.name = name; + it.updatedAt = nowISO(); + return it; +} + +export async function deleteItem(id: string) { + const it = store.items[id]; + if (!it) throw new Error("Not found"); + // remove from parent's children + if (it.parentId) { + const parent = store.items[it.parentId] as FolderItem; + parent.children = parent.children.filter((c) => c !== id); + } + // recursively delete if folder + if (it.type === "folder") { + for (const child of (it as FolderItem).children.slice()) { + await deleteItem(child); + } + } + delete store.items[id]; + return true; +} + +export async function getFolderTree() { + // return a shallow tree from root for sidebar + const root = store.items[store.rootId] as FolderItem; + function make(node: FolderItem) { + return { + id: node.id, + name: node.name, + children: node.children + .map((c) => store.items[c]) + .filter(Boolean) + .filter((i) => i.type === "folder") + .map((i: any) => make(i as FolderItem)), + }; + } + return make(root); +} + +export default { + getRootId, + getChildren, + getPath, + createFolder, + renameItem, + deleteItem, + getFolderTree, +}; diff --git a/client/src/utils/auth.ts b/client/src/utils/auth.ts index 44e4e8f..b6f6917 100644 --- a/client/src/utils/auth.ts +++ b/client/src/utils/auth.ts @@ -45,6 +45,7 @@ export const isAuthenticated = async (): Promise => { return false; } + const API_BASE_URL = import.meta.env.VITE_API_URL || "/api"; const profileUrl = `${API_BASE_URL}/auth/profile`;