From cab1118f43d272cbf82ead356a6b34961bac21d0 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Mon, 10 Nov 2025 12:32:33 +0000 Subject: [PATCH] feat: basic frontend ui --- README.md | 156 ++++++++++++++--- app/components/Breadcrumb.tsx | 87 ++++++++++ app/components/FileItem.tsx | 206 ++++++++++++++++++++++ app/components/FileList.tsx | 125 ++++++++++++++ app/components/Header.tsx | 127 ++++++++++++++ app/components/PermissionsDialog.tsx | 220 ++++++++++++++++++++++++ app/components/Sidebar.tsx | 109 ++++++++++++ app/globals.css | 25 ++- app/layout.tsx | 4 +- app/page.tsx | 246 ++++++++++++++++++++------- 10 files changed, 1217 insertions(+), 88 deletions(-) create mode 100644 app/components/Breadcrumb.tsx create mode 100644 app/components/FileItem.tsx create mode 100644 app/components/FileList.tsx create mode 100644 app/components/Header.tsx create mode 100644 app/components/PermissionsDialog.tsx create mode 100644 app/components/Sidebar.tsx diff --git a/README.md b/README.md index e215bc4..062f86f 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,154 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Solid File Manager + +A Google Drive-like file manager for Solid Pods, built with Next.js, React, and TypeScript. + +## Overview + +This application provides a user-friendly interface for managing files and folders in Solid Pods, with features similar to Google Drive: + +- **File Management**: Browse, view, and organize files and folders +- **Permission Management**: Share files with others using ACP (Access Control Policies) with a Google Drive-like interface +- **Multiple Drives**: View and manage multiple storage roots/drives +- **Grid and List Views**: Toggle between grid and list views for file browsing +- **Search**: Search functionality for finding files quickly + +## Features + +### Current UI Features (Phase 1) + +- ✅ Google Drive-like interface layout +- ✅ Left sidebar with drives list +- ✅ Main content area with file list +- ✅ Grid and list view toggle +- ✅ Breadcrumb navigation +- ✅ File item display with icons and metadata +- ✅ Permissions/sharing dialog (UI only) +- ✅ Minimal black, white, and light purple color scheme +- ✅ Semantic HTML for accessibility + +### Planned Features (Phase 2 - Integration) + +- [ ] Solid authentication (OIDC) +- [ ] File operations (create, read, update, delete) +- [ ] Folder navigation +- [ ] ACP permission management integration +- [ ] Storage root discovery from WebID +- [ ] File upload/download +- [ ] Real-time file updates + +## Tech Stack + +- **Framework**: Next.js 16 +- **UI Library**: React 19 +- **Styling**: Tailwind CSS 4 +- **Language**: TypeScript +- **Solid SDK**: [@inrupt/solid-client-js](https://github.com/inrupt/solid-client-js) (to be integrated) ## Getting Started -First, run the development server: +### Prerequisites + +- Node.js 18+ +- npm, yarn, pnpm, or bun + +### Installation + +1. Clone the repository: +```bash +git clone +cd solid-file-manager +``` + +2. Install dependencies: +```bash +npm install +``` +3. Run the development server: ```bash npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +4. Open [http://localhost:3000](http://localhost:3000) in your browser. + +## Development Setup + +### Local CSS (Community Solid Server) + +For development, you'll need to run a local Community Solid Server (CSS). The app is configured to work with a CSS instance running on `http://localhost:3001/`. + +### Environment Variables + +Create a `.env.local` file in the root directory: + +```env +# The URI of the Solid container used by the demo Community Solid Server +# Default for local dev (Community Solid Server started by `npm run start:css`) +NEXT_PUBLIC_BASE_URI="http://localhost:3001/" + +# The manifest resource file used by the app (relative to the container root) +NEXT_PUBLIC_MANIFEST_RESOURCE_URI="resource.ttl" + +# Admin WebID used for booting the demo (replace with your WebID) +NEXT_PUBLIC_ADMIN_WEBID="https://id.inrupt.com/your-webid" + +NEXT_PUBLIC_OIDC_ISSUER="https://login.inrupt.com" +``` + +## Project Structure + +``` +solid-file-manager/ +├── app/ +│ ├── components/ # React components +│ │ ├── Header.tsx # Top header with search and actions +│ │ ├── Sidebar.tsx # Left sidebar with drives list +│ │ ├── Breadcrumb.tsx # Navigation breadcrumb +│ │ ├── FileList.tsx # Main file list component +│ │ ├── FileItem.tsx # Individual file/folder item +│ │ └── PermissionsDialog.tsx # Sharing/permissions dialog +│ ├── page.tsx # Main page component +│ ├── layout.tsx # Root layout +│ └── globals.css # Global styles +├── public/ # Static assets +└── README.md +``` + +## Solid Protocol Integration + +This application will integrate with Solid using: + +- **Solid Protocol**: [https://solidproject.org/TR/protocol#resources](https://solidproject.org/TR/protocol#resources) +- **ACP (Access Control Policies)**: [https://solid.github.io/authorization-panel/acp-specification/](https://solid.github.io/authorization-panel/acp-specification/) +- **Storage Root Discovery**: Using `pim:storage` predicate from WebID +- **SDK**: [@inrupt/solid-client-js](https://github.com/inrupt/solid-client-js) + +## Design Principles -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +- **Minimal Design**: Black, white, and light purple color scheme with no gradients +- **Accessibility**: Semantic HTML and ARIA labels throughout +- **Code Splitting**: Components are split into reusable, focused modules +- **Type Safety**: Full TypeScript support for type safety -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Contributing -## Learn More +This project is currently in active development. The UI phase is complete, and Solid integration is the next step. -To learn more about Next.js, take a look at the following resources: +## License -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +[Add your license here] -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## References -## Deploy on Vercel +- [Solid Project](https://solidproject.org/) +- [Solid Protocol Specification](https://solidproject.org/TR/protocol) +- [ACP Specification](https://solid.github.io/authorization-panel/acp-specification/) +- [Inrupt Solid Client JS](https://github.com/inrupt/solid-client-js) +- [Community Solid Server](https://github.com/CommunitySolidServer/CommunitySolidServer) -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Related Projects -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- [nextfm](https://github.com/inrupt/nextfm) +- [Penny](https://penny.vincenttunru.com/) +- [PodPro](https://podpro.dev/) +- [solid-filemanager](https://otto-aa.github.io/solid-filemanager/) diff --git a/app/components/Breadcrumb.tsx b/app/components/Breadcrumb.tsx new file mode 100644 index 0000000..c3d2e18 --- /dev/null +++ b/app/components/Breadcrumb.tsx @@ -0,0 +1,87 @@ +"use client"; + +interface BreadcrumbItem { + name: string; + path: string; +} + +interface BreadcrumbProps { + items: BreadcrumbItem[]; + onNavigate: (path: string) => void; +} + +export default function Breadcrumb({ items, onNavigate }: BreadcrumbProps) { + // On mobile, show only the last item or truncate + const displayItems = items.length > 2 ? [items[0], ...items.slice(-2)] : items; + + return ( + + ); +} + diff --git a/app/components/FileItem.tsx b/app/components/FileItem.tsx new file mode 100644 index 0000000..d9aba49 --- /dev/null +++ b/app/components/FileItem.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState } from "react"; + +export type FileType = "folder" | "file" | "image" | "document" | "other"; + +export interface FileItemData { + id: string; + name: string; + type: FileType; + url: string; + lastModified?: Date; + size?: number; + mimeType?: string; +} + +interface FileItemProps { + file: FileItemData; + view: "grid" | "list"; + onSelect: (file: FileItemData) => void; + onDoubleClick: (file: FileItemData) => void; + isSelected?: boolean; +} + +function getFileIcon(type: FileType, mimeType?: string) { + switch (type) { + case "folder": + return ( + + ); + case "image": + return ( + + ); + case "document": + return ( + + ); + default: + return ( + + ); + } +} + +function formatFileSize(bytes?: number): string { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function formatDate(date?: Date): string { + if (!date) return ""; + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) return "Today"; + if (days === 1) return "Yesterday"; + if (days < 7) return `${days} days ago`; + if (days < 30) return `${Math.floor(days / 7)} weeks ago`; + + return date.toLocaleDateString(); +} + +export default function FileItem({ + file, + view, + onSelect, + onDoubleClick, + isSelected = false, +}: FileItemProps) { + const [isHovered, setIsHovered] = useState(false); + + if (view === "grid") { + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={() => onSelect(file)} + onDoubleClick={() => onDoubleClick(file)} + role="button" + tabIndex={0} + aria-label={`${file.type === "folder" ? "Folder" : "File"}: ${file.name}`} + > +
+ {getFileIcon(file.type, file.mimeType)} +
+

+ {file.name} +

+
+ ); + } + + // List view + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={() => onSelect(file)} + onDoubleClick={() => onDoubleClick(file)} + role="button" + tabIndex={0} + aria-label={`${file.type === "folder" ? "Folder" : "File"}: ${file.name}`} + > +
+ {getFileIcon(file.type, file.mimeType)} +
+
+

{file.name}

+
+
+ {file.lastModified && formatDate(file.lastModified)} +
+
+ {file.size && formatFileSize(file.size)} +
+ {isHovered && ( +
+ +
+ )} +
+ ); +} + diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx new file mode 100644 index 0000000..cabbcea --- /dev/null +++ b/app/components/FileList.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useState } from "react"; +import FileItem, { FileItemData } from "./FileItem"; + +interface FileListProps { + files: FileItemData[]; + currentPath: string; + onFileSelect: (file: FileItemData) => void; + onFileDoubleClick: (file: FileItemData) => void; + selectedFileIds: string[]; +} + +export default function FileList({ + files, + currentPath, + onFileSelect, + onFileDoubleClick, + selectedFileIds, +}: FileListProps) { + const [view, setView] = useState<"grid" | "list">("list"); + + return ( +
+ {/* Toolbar */} +
+
+ + +
+
+ {files.length} {files.length === 1 ? "item" : "items"} +
+
+ + {/* File List/Grid */} +
+ {files.length === 0 ? ( +
+
+

No files or folders

+
+
+ ) : view === "grid" ? ( +
+ {files.map((file) => ( + + ))} +
+ ) : ( +
+ {files.map((file) => ( + + ))} +
+ )} +
+
+ ); +} + diff --git a/app/components/Header.tsx b/app/components/Header.tsx new file mode 100644 index 0000000..b2adbec --- /dev/null +++ b/app/components/Header.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useState } from "react"; + +interface HeaderProps { + selectedFileCount?: number; + onShareClick?: () => void; + onMenuClick?: () => void; +} + +export default function Header({ selectedFileCount = 0, onShareClick, onMenuClick }: HeaderProps) { + const [searchQuery, setSearchQuery] = useState(""); + + return ( +
+
+ {/* Menu Button (Mobile) */} + {onMenuClick && ( + + )} + + {/* Logo/App Name */} +
+

Solid File Manager

+
+ + {/* Search Bar */} +
+
+ setSearchQuery(e.target.value)} + className="h-9 w-full rounded-md border border-gray-300 bg-white px-3 pl-9 text-sm text-black placeholder:text-gray-500 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500" + aria-label="Search files" + /> + +
+
+ + {/* Action Buttons */} +
+ {selectedFileCount > 0 && onShareClick && ( + + )} + +
+
+
+ ); +} + diff --git a/app/components/PermissionsDialog.tsx b/app/components/PermissionsDialog.tsx new file mode 100644 index 0000000..b8ca80e --- /dev/null +++ b/app/components/PermissionsDialog.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState } from "react"; + +export interface Permission { + id: string; + type: "user" | "group"; + webId: string; + name: string; + email?: string; + role: "viewer" | "editor" | "owner"; +} + +interface PermissionsDialogProps { + isOpen: boolean; + onClose: () => void; + fileName: string; + permissions: Permission[]; + onAddPermission: (webId: string, role: "viewer" | "editor") => void; + onRemovePermission: (permissionId: string) => void; + onUpdatePermission: (permissionId: string, role: "viewer" | "editor") => void; +} + +export default function PermissionsDialog({ + isOpen, + onClose, + fileName, + permissions, + onAddPermission, + onRemovePermission, + onUpdatePermission, +}: PermissionsDialogProps) { + const [shareInput, setShareInput] = useState(""); + const [selectedRole, setSelectedRole] = useState<"viewer" | "editor">("viewer"); + const [isAdding, setIsAdding] = useState(false); + + if (!isOpen) return null; + + const handleAddPermission = async () => { + if (!shareInput.trim()) return; + setIsAdding(true); + try { + await onAddPermission(shareInput.trim(), selectedRole); + setShareInput(""); + } finally { + setIsAdding(false); + } + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

+ Share "{fileName}" +

+ +
+
+ + {/* Content */} +
+ {/* Add People Section */} +
+ +
+ setShareInput(e.target.value)} + placeholder="Enter WebID or email" + className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-black placeholder:text-gray-500 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleAddPermission(); + } + }} + /> +
+ + +
+
+
+ + {/* Permissions List */} +
+

People with access

+
+ {permissions.map((permission) => ( +
+
+
+ {permission.name.charAt(0).toUpperCase()} +
+
+

{permission.name}

+ {permission.email && ( +

{permission.email}

+ )} +
+
+
+ {permission.role === "owner" ? ( + Owner + ) : ( + <> + + + + )} +
+
+ ))} +
+
+
+ + {/* Footer */} +
+
+ +
+
+
+
+ ); +} + diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx new file mode 100644 index 0000000..54513aa --- /dev/null +++ b/app/components/Sidebar.tsx @@ -0,0 +1,109 @@ +"use client"; + +interface Drive { + id: string; + name: string; + url: string; +} + +interface SidebarProps { + drives: Drive[]; + selectedDriveId?: string; + onDriveSelect: (driveId: string) => void; + isOpen?: boolean; + onClose?: () => void; +} + +export default function Sidebar({ + drives, + selectedDriveId, + onDriveSelect, + isOpen = true, + onClose, +}: SidebarProps) { + // On desktop (lg+), sidebar is always visible, so isOpen doesn't matter + // On mobile, use isOpen state + const isMobileOpen = isOpen; + + return ( + <> + {/* Sidebar */} + + + ); +} + diff --git a/app/globals.css b/app/globals.css index a2dc41e..b563a3b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,25 +2,32 @@ :root { --background: #ffffff; - --foreground: #171717; + --foreground: #000000; + --purple-light: #e8d5ff; + --purple-accent: #9c6ade; + --border: #e0e0e0; + --hover: #f5f5f5; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-purple-light: var(--purple-light); + --color-purple-accent: var(--purple-accent); + --color-border: var(--border); + --color-hover: var(--hover); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + margin: 0; + padding: 0; +} + +* { + box-sizing: border-box; } diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..1a5e8fc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Solid File Manager", + description: "A Google Drive-like file manager for Solid Pods", }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..7795b99 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,195 @@ -import Image from "next/image"; +"use client"; + +import { useState } from "react"; +import Header from "./components/Header"; +import Sidebar from "./components/Sidebar"; +import Breadcrumb from "./components/Breadcrumb"; +import FileList from "./components/FileList"; +import PermissionsDialog, { Permission } from "./components/PermissionsDialog"; +import { FileItemData } from "./components/FileItem"; + +// Mock data for development +const mockDrives = [ + { id: "1", name: "My Drive", url: "https://storage.example.com/" }, + { id: "2", name: "Shared with me", url: "https://storage.example.com/shared/" }, +]; + +const mockFiles: FileItemData[] = [ + { + id: "1", + name: "Documents", + type: "folder", + url: "https://storage.example.com/documents/", + lastModified: new Date(Date.now() - 86400000), + }, + { + id: "2", + name: "example.pdf", + type: "document", + url: "https://storage.example.com/example.pdf", + lastModified: new Date(Date.now() - 3600000), + size: 1024000, + mimeType: "application/pdf", + }, + { + id: "3", + name: "photo.jpg", + type: "image", + url: "https://storage.example.com/photo.jpg", + lastModified: new Date(Date.now() - 7200000), + size: 2048000, + mimeType: "image/jpeg", + }, + { + id: "4", + name: "Notes", + type: "folder", + url: "https://storage.example.com/notes/", + lastModified: new Date(Date.now() - 172800000), + }, +]; export default function Home() { + const [selectedDriveId, setSelectedDriveId] = useState("1"); + const [currentPath, setCurrentPath] = useState("/"); + const [selectedFileIds, setSelectedFileIds] = useState([]); + const [files, setFiles] = useState(mockFiles); + const [permissionsDialogOpen, setPermissionsDialogOpen] = useState(false); + const [selectedFileForPermissions, setSelectedFileForPermissions] = + useState(null); + const [permissions, setPermissions] = useState([]); + // Sidebar is open by default on desktop, closed on mobile + const [sidebarOpen, setSidebarOpen] = useState(false); + + const breadcrumbItems = [ + { name: "My Drive", path: "/" }, + ...(currentPath !== "/" ? [{ name: currentPath.split("/").pop() || "", path: currentPath }] : []), + ]; + + const handleDriveSelect = (driveId: string) => { + setSelectedDriveId(driveId); + setCurrentPath("/"); + setSelectedFileIds([]); + // In real implementation, fetch files for the selected drive + }; + + const handleFileSelect = (file: FileItemData) => { + setSelectedFileIds((prev) => { + if (prev.includes(file.id)) { + return prev.filter((id) => id !== file.id); + } + return [...prev, file.id]; + }); + }; + + const handleFileDoubleClick = (file: FileItemData) => { + if (file.type === "folder") { + setCurrentPath(file.url); + setSelectedFileIds([]); + // In real implementation, navigate into folder and fetch its contents + } else { + // In real implementation, open/preview the file + console.log("Open file:", file); + } + }; + + const handleBreadcrumbNavigate = (path: string) => { + setCurrentPath(path); + setSelectedFileIds([]); + // In real implementation, navigate to the path and fetch files + }; + + const handleShareClickForFile = (file: FileItemData) => { + setSelectedFileForPermissions(file); + setPermissionsDialogOpen(true); + // In real implementation, fetch permissions for the file + setPermissions([ + { + id: "1", + type: "user", + webId: "https://id.inrupt.com/user", + name: "You", + role: "owner", + }, + ]); + }; + + const handleAddPermission = async (webId: string, role: "viewer" | "editor") => { + // In real implementation, add permission via ACP + const newPermission: Permission = { + id: Date.now().toString(), + type: "user", + webId, + name: webId.split("/").pop() || webId, + role, + }; + setPermissions((prev) => [...prev, newPermission]); + }; + + const handleRemovePermission = (permissionId: string) => { + // In real implementation, remove permission via ACP + setPermissions((prev) => prev.filter((p) => p.id !== permissionId)); + }; + + const handleUpdatePermission = (permissionId: string, role: "viewer" | "editor") => { + // In real implementation, update permission via ACP + setPermissions((prev) => + prev.map((p) => (p.id === permissionId ? { ...p, role } : p)) + ); + }; + + const handleShareClick = () => { + if (selectedFileIds.length === 1) { + const file = files.find((f) => f.id === selectedFileIds[0]); + if (file) { + handleShareClickForFile(file); + } + } else if (selectedFileIds.length > 1) { + // Handle multiple file sharing (could show a different dialog) + console.log("Share multiple files:", selectedFileIds); + } + }; + return ( -
-
- Next.js logo +
0 ? handleShareClick : undefined} + onMenuClick={() => setSidebarOpen(true)} + /> +
+ setSidebarOpen(false)} + /> +
+ + +
+
+ {selectedFileForPermissions && ( + { + setPermissionsDialogOpen(false); + setSelectedFileForPermissions(null); + }} + fileName={selectedFileForPermissions.name} + permissions={permissions} + onAddPermission={handleAddPermission} + onRemovePermission={handleRemovePermission} + onUpdatePermission={handleUpdatePermission} /> -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
+ )}
); }