From 519bb45b89591b78b3ef65e4b937c53482552887 Mon Sep 17 00:00:00 2001 From: diogo464 Date: Tue, 12 Aug 2025 16:16:11 +0100 Subject: Implement /v2 prototype UI with page-based navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /v2 route structure with dynamic nested directory pages - Create V2DirectoryView component with breadcrumb navigation - Add V2MoveDialog with directory search and flat list display - Implement single upload button for current directory context - Add /api/directories endpoint for move dialog directory picker - Fix breadcrumb decoding to show readable names instead of URL encoding - Add file sorting: directories first, then files, all alphabetically - Improve performance by loading only current directory contents - Add ScrollArea component and @radix-ui/react-scroll-area dependency - Ensure proper URL encoding/decoding flow to prevent malformed paths 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/app/api/directories/route.ts | 16 + frontend/app/v2/[...path]/page.tsx | 12 + frontend/app/v2/page.tsx | 5 + frontend/components/ui/scroll-area.tsx | 48 ++ frontend/components/v2/V2DirectoryView.tsx | 694 +++++++++++++++++++++++++++++ frontend/components/v2/V2MoveDialog.tsx | 169 +++++++ frontend/lib/drive_server.ts | 7 + frontend/package-lock.json | 38 ++ frontend/package.json | 1 + 9 files changed, 990 insertions(+) create mode 100644 frontend/app/api/directories/route.ts create mode 100644 frontend/app/v2/[...path]/page.tsx create mode 100644 frontend/app/v2/page.tsx create mode 100644 frontend/components/ui/scroll-area.tsx create mode 100644 frontend/components/v2/V2DirectoryView.tsx create mode 100644 frontend/components/v2/V2MoveDialog.tsx diff --git a/frontend/app/api/directories/route.ts b/frontend/app/api/directories/route.ts new file mode 100644 index 0000000..b3515bb --- /dev/null +++ b/frontend/app/api/directories/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server' +import { Drive_ls_directories } from '@/lib/drive_server' + +export async function GET() { + try { + const directories = await Drive_ls_directories() + + return NextResponse.json(directories) + } catch (error) { + console.error('Error fetching directories:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/frontend/app/v2/[...path]/page.tsx b/frontend/app/v2/[...path]/page.tsx new file mode 100644 index 0000000..4af0167 --- /dev/null +++ b/frontend/app/v2/[...path]/page.tsx @@ -0,0 +1,12 @@ +import { V2DirectoryView } from "@/components/v2/V2DirectoryView" + +export default async function V2DirectoryPage({ + params, +}: { + params: Promise<{ path: string[] }> +}) { + const { path: pathSegments } = await params + const currentPath = '/' + (pathSegments?.join('/') || '') + + return +} \ No newline at end of file diff --git a/frontend/app/v2/page.tsx b/frontend/app/v2/page.tsx new file mode 100644 index 0000000..e693c77 --- /dev/null +++ b/frontend/app/v2/page.tsx @@ -0,0 +1,5 @@ +import { V2DirectoryView } from "@/components/v2/V2DirectoryView" + +export default function V2RootPage() { + return +} \ No newline at end of file diff --git a/frontend/components/ui/scroll-area.tsx b/frontend/components/ui/scroll-area.tsx new file mode 100644 index 0000000..63b95e3 --- /dev/null +++ b/frontend/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } \ No newline at end of file diff --git a/frontend/components/v2/V2DirectoryView.tsx b/frontend/components/v2/V2DirectoryView.tsx new file mode 100644 index 0000000..03532e8 --- /dev/null +++ b/frontend/components/v2/V2DirectoryView.tsx @@ -0,0 +1,694 @@ +"use client" + +import type React from "react" +import { useState, useRef, useEffect } from "react" +import Link from "next/link" +import { + ChevronRight, + File, + Folder, + Upload, + Trash2, + Move, + MoreHorizontal, + HardDrive, + Edit, + Link as LinkIcon, + Info, + LogIn, + LogOut, + Home, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Progress } from "@/components/ui/progress" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { toast } from "@/hooks/use-toast" +import { DriveLsEntry } from "@/lib/drive_types" +import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from "@/lib/constants" +import { V2MoveDialog } from "./V2MoveDialog" + +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 Bytes" + const k = 1024 + const sizes = ["Bytes", "KB", "MB", "GB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i] +} + +function formatDate(timestamp: number): string { + return new Date(timestamp * 1000).toISOString().split('T')[0] +} + +function formatDateTime(timestamp: number): string { + const date = new Date(timestamp * 1000) + const dateStr = date.toISOString().split('T')[0] + const timeStr = date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + return `${dateStr} at ${timeStr}` +} + +interface V2DirectoryViewProps { + path: string +} + +export function V2DirectoryView({ path }: V2DirectoryViewProps) { + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selectedFiles, setSelectedFiles] = useState>(new Set()) + const [renameDialogOpen, setRenameDialogOpen] = useState(false) + const [infoDialogOpen, setInfoDialogOpen] = useState(false) + const [moveDialogOpen, setMoveDialogOpen] = useState(false) + const [currentItem, setCurrentItem] = useState(null) + const [newName, setNewName] = useState("") + const fileInputRef = useRef(null) + const [uploading, setUploading] = useState(false) + + const [isLoggedIn, setIsLoggedIn] = useState(true) // Mock logged in state + + const maxStorage = 1073741824 // 1GB + const usedStorage = 0 // TODO: Calculate from files if needed + const storagePercentage = (usedStorage / maxStorage) * 100 + + // Generate breadcrumbs from path + const generateBreadcrumbs = (currentPath: string) => { + if (currentPath === '/') { + return [{ name: 'Root', path: '/v2' }] + } + + const parts = currentPath.split('/').filter(Boolean) + const breadcrumbs = [{ name: 'Root', path: '/v2' }] + + let accumulatedPath = '' + parts.forEach((part, index) => { + accumulatedPath += '/' + part + breadcrumbs.push({ + name: decodeURIComponent(part), // Decode URL encoded characters + path: '/v2' + accumulatedPath + }) + }) + + return breadcrumbs + } + + const breadcrumbs = generateBreadcrumbs(path) + + // Sort files: directories first, then files, all alphabetically + const sortFiles = (files: DriveLsEntry[]): DriveLsEntry[] => { + return [...files].sort((a, b) => { + // Directories first, then files + if (a.type === "dir" && b.type === "file") return -1; + if (a.type === "file" && b.type === "dir") return 1; + + // Both same type, sort alphabetically by name (case-insensitive) + const aName = a.path.split('/').pop() || a.path; + const bName = b.path.split('/').pop() || b.path; + return aName.toLowerCase().localeCompare(bName.toLowerCase()); + }); + }; + + // Function to refresh directory contents + const refreshDirectory = async () => { + try { + const encodedPath = path === '/' ? '' : path.split('/').filter(Boolean).map(encodeURIComponent).join('/') + const response = await fetch(`/api/fs/${encodedPath}`) + if (!response.ok) { + throw new Error(`Failed to fetch directory: ${response.statusText}`) + } + const entries = await response.json() + setFiles(sortFiles(entries)) // Sort the files before setting state + } catch (err) { + console.error('Error refreshing directory:', err) + toast({ + title: "Failed to refresh", + description: "Could not refresh directory contents", + variant: "destructive" + }) + } + } + + // Load directory contents + useEffect(() => { + async function loadDirectoryData() { + try { + setLoading(true) + setError(null) + await refreshDirectory() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load directory data') + console.error('Error loading directory data:', err) + } finally { + setLoading(false) + } + } + + loadDirectoryData() + }, [path]) + + const toggleFileSelection = (filePath: string) => { + const newSelected = new Set(selectedFiles) + if (newSelected.has(filePath)) { + newSelected.delete(filePath) + } else { + newSelected.add(filePath) + } + setSelectedFiles(newSelected) + } + + const selectAll = () => { + setSelectedFiles(new Set(files.map(file => file.path))) + } + + const deselectAll = () => { + setSelectedFiles(new Set()) + } + + const openRenameDialog = (item: DriveLsEntry) => { + setCurrentItem(item) + setNewName(item.path.split('/').pop() || '') + setRenameDialogOpen(true) + } + + const openInfoDialog = (item: DriveLsEntry) => { + setCurrentItem(item) + setInfoDialogOpen(true) + } + + const openMoveDialog = () => { + setMoveDialogOpen(true) + } + + const copyPermalink = (item: DriveLsEntry) => { + const permalink = `${window.location.origin}/drive/file/${item.path}` + navigator.clipboard.writeText(permalink).then(() => { + toast({ + title: "Link copied!", + description: "Permalink has been copied to clipboard", + }) + }) + } + + const handleRename = () => { + if (currentItem && newName.trim()) { + // TODO: Implement actual rename API call + const updatedFiles = files.map((file) => { + if (file.path === currentItem.path) { + return { ...file, path: file.path.replace(/[^/]+$/, newName.trim()) } + } + return file + }) + setFiles(sortFiles(updatedFiles)) // Sort after rename + setRenameDialogOpen(false) + setCurrentItem(null) + setNewName("") + toast({ + title: "Renamed successfully", + description: `Item renamed to "${newName.trim()}"`, + }) + } + } + + const handleFileUpload = async (event: React.ChangeEvent) => { + const uploadedFiles = event.target.files + if (!uploadedFiles || uploadedFiles.length === 0) return + + // Validate file count + if (uploadedFiles.length > UPLOAD_MAX_FILES) { + toast({ + title: "Too many files", + description: `You can only upload up to ${UPLOAD_MAX_FILES} files at once`, + variant: "destructive" + }) + return + } + + // Validate file sizes + const oversizedFiles = Array.from(uploadedFiles).filter(file => file.size > UPLOAD_MAX_FILE_SIZE) + if (oversizedFiles.length > 0) { + toast({ + title: "Files too large", + description: `Maximum file size is ${formatFileSize(UPLOAD_MAX_FILE_SIZE)}. Found ${oversizedFiles.length} oversized file(s)`, + variant: "destructive" + }) + return + } + + setUploading(true) + let successCount = 0 + let errorCount = 0 + + try { + // Upload files sequentially to the current directory + for (const file of Array.from(uploadedFiles)) { + try { + const formData = new FormData() + formData.append('file', file) + + // Construct the upload path (current path + filename) + // The path should already be properly decoded by Next.js page params + const uploadPath = path === '/' ? file.name : `${path.slice(1)}/${file.name}` + // Encode each path segment for the URL - Next.js will decode it back for the API + const encodedPath = uploadPath.split('/').map(encodeURIComponent).join('/') + + const response = await fetch(`/api/fs/${encodedPath}`, { + method: 'PUT', + headers: { + 'AUTH': '1' // Development auth header + }, + body: formData + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || `Upload failed with status ${response.status}`) + } + + successCount++ + } catch (error) { + console.error(`Failed to upload ${file.name}:`, error) + errorCount++ + } + } + + // Show results + if (successCount > 0) { + toast({ + title: "Upload successful", + description: `${successCount} file(s) uploaded successfully${errorCount > 0 ? `, ${errorCount} failed` : ''}` + }) + + // Refresh the directory + await refreshDirectory() + } + + if (errorCount > 0 && successCount === 0) { + toast({ + title: "Upload failed", + description: `All ${errorCount} file(s) failed to upload`, + variant: "destructive" + }) + } + + } catch (error) { + console.error('Upload error:', error) + toast({ + title: "Upload failed", + description: error instanceof Error ? error.message : 'Unknown error occurred', + variant: "destructive" + }) + } finally { + setUploading(false) + // Reset the input + event.target.value = '' + } + } + + const handleDelete = async (itemPaths: string[]) => { + // TODO: Implement actual delete API calls + const updatedFiles = files.filter(file => !itemPaths.includes(file.path)) + setFiles(sortFiles(updatedFiles)) // Sort after delete (though order shouldn't change for deletion) + // Remove deleted items from selection + const newSelected = new Set(selectedFiles) + itemPaths.forEach((path) => newSelected.delete(path)) + setSelectedFiles(newSelected) + + toast({ + title: "Deleted successfully", + description: `${itemPaths.length} item(s) deleted`, + }) + } + + const handleLogin = () => { + // Redirect to external auth page (configured via env var) + const authUrl = process.env.NEXT_PUBLIC_AUTH_URL || "/auth/login" + window.location.href = authUrl + } + + const handleLogout = () => { + // Handle logout (would typically clear tokens, etc.) + setIsLoggedIn(false) + // Could also redirect to logout endpoint + } + + const handleMove = async (destinationPath: string) => { + // TODO: Implement actual move API calls + console.log('Moving files:', Array.from(selectedFiles), 'to:', destinationPath) + setSelectedFiles(new Set()) + setMoveDialogOpen(false) + + toast({ + title: "Moved successfully", + description: `${selectedFiles.size} item(s) moved to ${destinationPath}`, + }) + + // Refresh directory after move + await refreshDirectory() + } + + return ( +
+ {/* Header with Breadcrumbs */} +
+
+
+ +

Drive V2

+
+ + {/* Breadcrumbs */} + +
+ +
+ + {isLoggedIn ? ( + + ) : ( + + )} +
+
+ + {/* Loading State */} + {loading && ( +
+
+
+

Loading directory contents...

+
+
+ )} + + {/* Error State */} + {error && ( +
+
+ Error loading directory: {error} +
+
+ )} + + {/* Storage Info */} + {!loading && !error && ( +
+
+ Storage Usage + + {formatFileSize(usedStorage)} of {formatFileSize(maxStorage)} used + +
+ +
+ {storagePercentage.toFixed(1)}% used + {formatFileSize(maxStorage - usedStorage)} available +
+
+ )} + + {/* Bulk Actions */} + {!loading && !error && selectedFiles.size > 0 && ( +
+
+ + {selectedFiles.size} item{selectedFiles.size !== 1 ? "s" : ""} selected + + +
+
+ + +
+
+ )} + + {/* File Table */} + {!loading && !error && ( +
+ + + + + Name + Size + Modified + Actions + + + + {files.length === 0 ? ( + + + This directory is empty + + + ) : ( + files.map((file) => { + const isSelected = selectedFiles.has(file.path) + const fileName = file.path.split('/').pop() || file.path + + return ( + + e.stopPropagation()}> + toggleFileSelection(file.path)} + /> + + +
+ {file.type === "dir" ? ( + <> + + + {fileName} + + + ) : ( + <> + + {file.blob ? ( + + {fileName} + + ) : ( + {fileName} + )} + + )} +
+
+ {formatFileSize(file.size || 0)} + {formatDate(file.lastmod)} + e.stopPropagation()}> + + + + + + openRenameDialog(file)}> + + Rename + + copyPermalink(file)}> + + Copy Permalink + + openInfoDialog(file)}> + + Info + + + handleDelete([file.path])} + className="text-red-600" + > + + Delete + + + + +
+ ) + }) + )} +
+
+
+ )} + + {/* Rename Dialog */} + + + + Rename {currentItem?.type === "dir" ? "Folder" : "File"} + +
+
+ + setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleRename() + } + }} + placeholder="Enter new name" + /> +
+
+ + +
+
+
+
+ + {/* Info Dialog */} + + + + + {currentItem?.type === "dir" ? ( + + ) : ( + + )} + {currentItem?.type === "dir" ? "Folder" : "File"} Information + + + {currentItem && ( +
+
+
+ +

{currentItem.path.split('/').pop()}

+
+
+ +

{formatFileSize(currentItem.size || 0)}

+
+
+ +

{formatDateTime(currentItem.lastmod)}

+
+
+ +

{currentItem.author}

+
+
+ +

{currentItem.type}

+
+
+ +

{currentItem.path}

+
+
+
+ +
+
+ )} +
+
+ + {/* Move Dialog */} + + + +
+ ) +} \ No newline at end of file diff --git a/frontend/components/v2/V2MoveDialog.tsx b/frontend/components/v2/V2MoveDialog.tsx new file mode 100644 index 0000000..7cedde0 --- /dev/null +++ b/frontend/components/v2/V2MoveDialog.tsx @@ -0,0 +1,169 @@ +"use client" + +import React, { useState, useEffect } from "react" +import { Search, Folder, Move } from "lucide-react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { toast } from "@/hooks/use-toast" +import { DriveLsEntry } from "@/lib/drive_types" + +interface V2MoveDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedCount: number + onMove: (destinationPath: string) => void +} + +export function V2MoveDialog({ open, onOpenChange, selectedCount, onMove }: V2MoveDialogProps) { + const [directories, setDirectories] = useState([]) + const [filteredDirectories, setFilteredDirectories] = useState([]) + const [searchQuery, setSearchQuery] = useState("") + const [selectedDirectory, setSelectedDirectory] = useState(null) + const [loading, setLoading] = useState(false) + + // Load all directories when dialog opens + useEffect(() => { + if (open) { + loadDirectories() + } + }, [open]) + + // Filter directories based on search query + useEffect(() => { + if (!searchQuery.trim()) { + setFilteredDirectories(directories) + } else { + const query = searchQuery.toLowerCase() + const filtered = directories.filter(dir => + dir.path.toLowerCase().includes(query) + ) + setFilteredDirectories(filtered) + } + }, [searchQuery, directories]) + + const loadDirectories = async () => { + setLoading(true) + try { + const response = await fetch('/api/directories') + if (!response.ok) { + throw new Error(`Failed to fetch directories: ${response.statusText}`) + } + const dirs = await response.json() + setDirectories(dirs) + setFilteredDirectories(dirs) + } catch (error) { + console.error('Error loading directories:', error) + toast({ + title: "Failed to load directories", + description: error instanceof Error ? error.message : 'Unknown error occurred', + variant: "destructive" + }) + } finally { + setLoading(false) + } + } + + const handleMove = () => { + if (selectedDirectory) { + onMove(selectedDirectory) + // Reset dialog state + setSelectedDirectory(null) + setSearchQuery("") + } + } + + const handleClose = () => { + onOpenChange(false) + // Reset dialog state + setSelectedDirectory(null) + setSearchQuery("") + } + + return ( + + + + + + Move {selectedCount} item{selectedCount !== 1 ? "s" : ""} + + + +
+ {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + {/* Directory List */} + +
+ {loading ? ( +
+
+
+ ) : filteredDirectories.length === 0 ? ( +
+ {searchQuery ? 'No directories found matching your search' : 'No directories available'} +
+ ) : ( +
+ {filteredDirectories.map((directory) => ( +
setSelectedDirectory(directory.path)} + > + + + {directory.path} + +
+ ))} +
+ )} +
+
+ + {/* Selected Directory Display */} + {selectedDirectory && ( +
+
+ Moving to: +
+
+ {selectedDirectory} +
+
+ )} + + {/* Action Buttons */} +
+ + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/lib/drive_server.ts b/frontend/lib/drive_server.ts index 81e9321..992a287 100644 --- a/frontend/lib/drive_server.ts +++ b/frontend/lib/drive_server.ts @@ -174,6 +174,13 @@ export async function Drive_tree(): Promise { return { root: calculateSizesAndSort(rootNodes) }; } +/// lists only directories (recursively) from the given path +export async function Drive_ls_directories(path: string = '/'): Promise { + // Get all entries recursively and filter for directories + const allEntries = await Drive_ls(path, true) + return allEntries.filter(entry => entry.type === 'dir') +} + /// returns the log entries from the drive export async function Drive_log(): Promise { const result = spawnSync('fctdrive', ['log'], { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2a93c83..6680145 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toast": "^1.2.14", "class-variance-authority": "^0.7.1", @@ -1016,6 +1017,12 @@ "node": ">=12.4.0" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", @@ -1517,6 +1524,37 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz", + "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index d7922cf..d3762a2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toast": "^1.2.14", "class-variance-authority": "^0.7.1", -- cgit