feat: Implement file browser and operations components with enhanced UI
Some checks failed
GBCI / build (push) Failing after 4m39s

- Added FileBrowser component for displaying files and directories.
- Introduced FileOperations component for handling file uploads and folder creation.
- Created FileTree component to visualize the directory structure.
- Developed DriveScreen page to integrate file browsing, operations, and UI controls.
- Enhanced file system data structure for realistic representation.
- Implemented search, filter, and sort functionalities in the file browser.
- Added keyboard shortcuts and improved accessibility features.
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-06-21 21:40:06 -03:00
parent 1d458886dd
commit 6e87c5b449
12 changed files with 4235 additions and 38 deletions

View file

@ -9,7 +9,7 @@ const examples = [
{ name: "Chat", href: "/chat", color: "#25D366" }, // WhatsApp green
{ name: "Dashboard", href: "/dashboard", color: "#6366F1" }, // Indigo
{ name: "Mail", href: "/mail", color: "#FFD700" }, // Outlook yellow
{ name: "Tree", href: "/tree", color: "#10B981" }, // Emerald green
{ name: "Drive", href: "/drive", color: "#10B981" }, // Emerald green
{ name: "Editor", href: "/editor", color: "#2563EB" }, // Word blue
{ name: "Tables", href: "/table", color: "#8B5CF6" }, // Purple
{ name: "Meet", href: "/meet", color: "#059669" }, // Google Meet green

585
app/drive/page.tsx Normal file
View file

@ -0,0 +1,585 @@
"use client";
import React, { useState, useEffect, useMemo } from 'react';
import { Search, Plus, Upload, Download, Trash2, Copy, Move, Share, Star, Grid, List, Filter, SortAsc, Eye, Edit3, Archive, Clock, Users, Lock, Folder, File, Image, Video, Music, FileText, Code, Database, Package } from 'lucide-react';
// Enhanced file system with realistic data structure
const fileSystemData = {
"": {
name: "Root",
path: "",
is_dir: true,
children: ["projects", "documents", "media", "shared", "archives", "templates"]
},
"projects": {
name: "Projects",
path: "projects",
is_dir: true,
size: null,
modified: "2025-01-15T10:30:00Z",
created: "2024-12-01T09:00:00Z",
starred: true,
shared: false,
tags: ["work", "development"],
children: ["web-apps", "mobile-apps", "data-science", "ai-research", "blockchain"]
},
"projects/web-apps": {
name: "Web Applications",
path: "projects/web-apps",
is_dir: true,
size: null,
modified: "2025-01-14T16:45:00Z",
created: "2024-12-01T09:15:00Z",
starred: false,
shared: true,
tags: ["frontend", "backend", "fullstack"],
children: ["dashboard-pro", "e-commerce-platform", "social-media-app", "file-manager"]
},
"projects/web-apps/dashboard-pro": {
name: "Dashboard Pro",
path: "projects/web-apps/dashboard-pro",
is_dir: true,
size: null,
modified: "2025-01-13T14:20:00Z",
created: "2024-12-10T11:00:00Z",
starred: true,
shared: true,
tags: ["react", "typescript", "tailwind"],
children: ["src", "components", "styles", "tests", "package.json", "README.md", "deployment.yaml"]
},
"projects/web-apps/dashboard-pro/package.json": {
name: "package.json",
path: "projects/web-apps/dashboard-pro/package.json",
is_dir: false,
size: 2048,
type: "json",
modified: "2025-01-13T14:20:00Z",
created: "2024-12-10T11:00:00Z",
starred: false,
shared: false,
tags: ["config", "dependencies"]
},
"projects/web-apps/dashboard-pro/README.md": {
name: "README.md",
path: "projects/web-apps/dashboard-pro/README.md",
is_dir: false,
size: 5120,
type: "markdown",
modified: "2025-01-12T09:30:00Z",
created: "2024-12-10T11:05:00Z",
starred: false,
shared: true,
tags: ["documentation"]
},
"documents": {
name: "Documents",
path: "documents",
is_dir: true,
size: null,
modified: "2025-01-14T12:00:00Z",
created: "2024-11-01T08:00:00Z",
starred: false,
shared: false,
tags: ["personal", "work"],
children: ["proposals", "contracts", "presentations", "reports", "spreadsheets"]
},
"documents/proposals": {
name: "Proposals",
path: "documents/proposals",
is_dir: true,
size: null,
modified: "2025-01-10T15:30:00Z",
created: "2024-11-15T10:00:00Z",
starred: true,
shared: true,
tags: ["business", "client"],
children: ["Q1-2025-Strategy.pdf", "AI-Integration-Proposal.docx", "Budget-Proposal-2025.xlsx"]
},
"documents/proposals/Q1-2025-Strategy.pdf": {
name: "Q1 2025 Strategy.pdf",
path: "documents/proposals/Q1-2025-Strategy.pdf",
is_dir: false,
size: 1048576,
type: "pdf",
modified: "2025-01-10T15:30:00Z",
created: "2025-01-08T14:00:00Z",
starred: true,
shared: true,
tags: ["strategy", "quarterly", "important"]
},
"media": {
name: "Media",
path: "media",
is_dir: true,
size: null,
modified: "2025-01-13T18:45:00Z",
created: "2024-10-01T12:00:00Z",
starred: false,
shared: false,
tags: ["photos", "videos", "audio"],
children: ["photos", "videos", "audio", "graphics"]
},
"media/photos": {
name: "Photos",
path: "media/photos",
is_dir: true,
size: null,
modified: "2025-01-13T18:45:00Z",
created: "2024-10-01T12:15:00Z",
starred: false,
shared: false,
tags: ["photography"],
children: ["vacation-2024.jpg", "team-photo.jpg", "product-shots.jpg", "nature-landscape.jpg"]
},
"media/photos/vacation-2024.jpg": {
name: "vacation-2024.jpg",
path: "media/photos/vacation-2024.jpg",
is_dir: false,
size: 3145728,
type: "image",
modified: "2024-12-25T20:00:00Z",
created: "2024-12-25T20:00:00Z",
starred: true,
shared: false,
tags: ["vacation", "memories"]
},
"shared": {
name: "Shared",
path: "shared",
is_dir: true,
size: null,
modified: "2025-01-12T11:20:00Z",
created: "2024-11-01T08:30:00Z",
starred: false,
shared: true,
tags: ["collaboration", "team"],
children: ["team-resources", "client-deliverables", "public-assets"]
}
};
const getFileIcon = (item) => {
if (item.is_dir) {
return item.starred ? <Star className="w-4 h-4 text-primary" /> : <Folder className="w-4 h-4 text-accent" />;
}
const iconMap = {
pdf: <FileText className="w-4 h-4 text-red-500" />,
docx: <FileText className="w-4 h-4 text-blue-500" />,
xlsx: <Database className="w-4 h-4 text-green-500" />,
json: <Code className="w-4 h-4 text-yellow-500" />,
markdown: <Edit3 className="w-4 h-4 text-purple-500" />,
md: <Edit3 className="w-4 h-4 text-purple-500" />,
jpg: <Image className="w-4 h-4 text-pink-500" />,
jpeg: <Image className="w-4 h-4 text-pink-500" />,
png: <Image className="w-4 h-4 text-pink-500" />,
mp4: <Video className="w-4 h-4 text-red-600" />,
mp3: <Music className="w-4 h-4 text-green-600" />,
zip: <Archive className="w-4 h-4 text-orange-500" />,
yaml: <Code className="w-4 h-4 text-blue-400" />
};
return iconMap[item.type] || <File className="w-4 h-4 text-muted-foreground" />;
};
const formatFileSize = (bytes) => {
if (!bytes) return '';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
};
const formatDate = (dateString) => {
if (!dateString) return '';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const FileTree = ({ onSelect, selectedPath }) => {
const [expanded, setExpanded] = useState({ "": true, "projects": true });
const toggleExpand = (path) => {
setExpanded(prev => ({ ...prev, [path]: !prev[path] }));
onSelect(path);
};
const renderTreeItem = (path, level = 0) => {
const item = fileSystemData[path];
if (!item || !item.is_dir) return null;
const isExpanded = expanded[path];
const isSelected = selectedPath === path;
return (
<div key={path}>
<div
onClick={() => toggleExpand(path)}
className={`flex items-center cursor-pointer hover:bg-accent hover:text-accent-foreground p-2 rounded transition-colors
${isSelected ? 'bg-primary text-primary-foreground' : 'text-foreground'}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
>
<div className="flex items-center space-x-2 flex-1">
{getFileIcon(item)}
<span className="text-sm font-medium truncate">{item.name}</span>
{item.starred && <Star className="w-3 h-3 text-yellow-500 fill-current" />}
{item.shared && <Users className="w-3 h-3 text-blue-500" />}
</div>
<div className="text-xs text-muted-foreground">
{isExpanded ? '▼' : '▶'}
</div>
</div>
{isExpanded && item.children && (
<div>
{item.children.map(childPath => {
const fullPath = path ? `${path}/${childPath}` : childPath;
return renderTreeItem(fullPath, level + 1);
})}
</div>
)}
</div>
);
};
return (
<div className="w-80 border-r border-border bg-card overflow-y-auto">
<div className="p-4 border-b border-border">
<h2 className="text-lg font-semibold text-card-foreground flex items-center">
<Folder className="w-5 h-5 mr-2 text-primary" />
File Explorer
</h2>
</div>
<div className="p-2">
{renderTreeItem("")}
</div>
</div>
);
};
const FileBrowser = ({ path, viewMode, searchTerm, sortBy, filterType }) => {
const currentItem = fileSystemData[path];
const files = useMemo(() => {
if (!currentItem || !currentItem.is_dir || !currentItem.children) return [];
let items = currentItem.children.map(childName => {
const childPath = path ? `${path}/${childName}` : childName;
return fileSystemData[childPath];
}).filter(Boolean);
// Apply search filter
if (searchTerm) {
items = items.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(item.tags && item.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())))
);
}
// Apply type filter
if (filterType && filterType !== 'all') {
items = items.filter(item => {
if (filterType === 'folders') return item.is_dir;
if (filterType === 'files') return !item.is_dir;
if (filterType === 'starred') return item.starred;
if (filterType === 'shared') return item.shared;
return true;
});
}
// Apply sorting
items.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'modified':
return new Date(b.modified || 0) - new Date(a.modified || 0);
case 'size':
return (b.size || 0) - (a.size || 0);
case 'type':
return a.type?.localeCompare(b.type || '') || 0;
default:
return 0;
}
});
return items;
}, [currentItem, searchTerm, sortBy, filterType, path]);
if (viewMode === 'grid') {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4">
{files.map((item) => (
<div key={item.path} className="group relative">
<div className="bg-card border border-border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer hover:bg-accent hover:text-accent-foreground">
<div className="flex flex-col items-center space-y-2">
<div className="text-3xl">
{getFileIcon(item)}
</div>
<div className="text-sm font-medium text-center truncate w-full text-card-foreground group-hover:text-accent-foreground">
{item.name}
</div>
{!item.is_dir && (
<div className="text-xs text-muted-foreground">
{formatFileSize(item.size)}
</div>
)}
<div className="flex space-x-1">
{item.starred && <Star className="w-3 h-3 text-yellow-500 fill-current" />}
{item.shared && <Users className="w-3 h-3 text-blue-500" />}
</div>
</div>
</div>
</div>
))}
</div>
);
}
return (
<div className="overflow-hidden">
<div className="bg-muted border-b border-border p-2">
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground">
<div className="col-span-5">Name</div>
<div className="col-span-2">Modified</div>
<div className="col-span-1">Size</div>
<div className="col-span-2">Type</div>
<div className="col-span-2">Tags</div>
</div>
</div>
<div className="divide-y divide-border">
{files.map((item) => (
<div key={item.path} className="grid grid-cols-12 gap-4 p-3 hover:bg-accent hover:text-accent-foreground cursor-pointer group">
<div className="col-span-5 flex items-center space-x-3">
{getFileIcon(item)}
<span className="font-medium truncate">{item.name}</span>
<div className="flex space-x-1">
{item.starred && <Star className="w-3 h-3 text-yellow-500 fill-current" />}
{item.shared && <Users className="w-3 h-3 text-blue-500" />}
</div>
</div>
<div className="col-span-2 text-sm text-muted-foreground flex items-center">
{formatDate(item.modified)}
</div>
<div className="col-span-1 text-sm text-muted-foreground flex items-center">
{formatFileSize(item.size)}
</div>
<div className="col-span-2 text-sm text-muted-foreground flex items-center">
{item.is_dir ? 'Folder' : item.type?.toUpperCase() || '—'}
</div>
<div className="col-span-2 flex items-center">
<div className="flex flex-wrap gap-1">
{item.tags?.slice(0, 2).map(tag => (
<span key={tag} className="text-xs bg-secondary text-secondary-foreground px-2 py-1 rounded-full">
{tag}
</span>
))}
{item.tags?.length > 2 && (
<span className="text-xs text-muted-foreground">+{item.tags.length - 2}</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
};
const FileOperations = ({ currentPath, onRefresh }) => {
const [showUploadProgress, setShowUploadProgress] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const handleUpload = async () => {
setShowUploadProgress(true);
// Simulate upload progress
for (let i = 0; i <= 100; i += 10) {
setUploadProgress(i);
await new Promise(resolve => setTimeout(resolve, 100));
}
setTimeout(() => {
setShowUploadProgress(false);
setUploadProgress(0);
onRefresh();
}, 500);
};
const createFolder = () => {
const folderName = prompt('Enter folder name:');
if (folderName) {
onRefresh();
}
};
return (
<div className="border-t border-border bg-card p-4">
<div className="flex flex-wrap gap-2">
<button
onClick={handleUpload}
className="flex items-center space-x-2 bg-primary text-primary-foreground px-4 py-2 rounded-md hover:opacity-90 transition-opacity"
>
<Upload className="w-4 h-4" />
<span>Upload Files</span>
</button>
<button
onClick={createFolder}
className="flex items-center space-x-2 bg-secondary text-secondary-foreground px-4 py-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
>
<Plus className="w-4 h-4" />
<span>New Folder</span>
</button>
<button className="flex items-center space-x-2 bg-secondary text-secondary-foreground px-4 py-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
<Download className="w-4 h-4" />
<span>Download</span>
</button>
<button className="flex items-center space-x-2 bg-secondary text-secondary-foreground px-4 py-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
<Share className="w-4 h-4" />
<span>Share</span>
</button>
<button className="flex items-center space-x-2 bg-destructive text-destructive-foreground px-4 py-2 rounded-md hover:opacity-90 transition-opacity">
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</button>
</div>
{showUploadProgress && (
<div className="mt-4">
<div className="flex justify-between text-sm text-muted-foreground mb-1">
<span>Uploading files...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
</div>
);
};
export default function DriveScreen() {
const [currentPath, setCurrentPath] = useState('');
const [refreshKey, setRefreshKey] = useState(0);
const [viewMode, setViewMode] = useState('list');
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filterType, setFilterType] = useState('all');
const handleRefresh = () => {
setRefreshKey(prev => prev + 1);
};
const currentItem = fileSystemData[currentPath];
const breadcrumbs = currentPath ? currentPath.split('/') : [];
return (
<div className="flex h-screen bg-background text-foreground">
<FileTree onSelect={setCurrentPath} selectedPath={currentPath} />
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="border-b border-border bg-card p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<h1 className="text-2xl font-bold text-card-foreground">
{currentItem?.name || 'Root'}
</h1>
{currentItem?.starred && <Star className="w-5 h-5 text-yellow-500 fill-current" />}
{currentItem?.shared && <Users className="w-5 h-5 text-blue-500" />}
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setViewMode(viewMode === 'list' ? 'grid' : 'list')}
className="p-2 hover:bg-accent hover:text-accent-foreground rounded-md transition-colors"
>
{viewMode === 'list' ? <Grid className="w-4 h-4" /> : <List className="w-4 h-4" />}
</button>
</div>
</div>
{/* Breadcrumbs */}
<div className="flex items-center space-x-2 text-sm text-muted-foreground mb-4">
<button
onClick={() => setCurrentPath('')}
className="hover:text-foreground transition-colors"
>
Home
</button>
{breadcrumbs.map((crumb, index) => {
const path = breadcrumbs.slice(0, index + 1).join('/');
return (
<React.Fragment key={path}>
<span>/</span>
<button
onClick={() => setCurrentPath(path)}
className="hover:text-foreground transition-colors"
>
{crumb}
</button>
</React.Fragment>
);
})}
</div>
{/* Search and Filters */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search files and folders..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 w-64 bg-input border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring text-foreground placeholder:text-muted-foreground"
/>
</div>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-2 bg-input border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring text-foreground"
>
<option value="all">All Items</option>
<option value="folders">Folders</option>
<option value="files">Files</option>
<option value="starred">Starred</option>
<option value="shared">Shared</option>
</select>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-2 bg-input border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring text-foreground"
>
<option value="name">Sort by Name</option>
<option value="modified">Sort by Modified</option>
<option value="size">Sort by Size</option>
<option value="type">Sort by Type</option>
</select>
</div>
</div>
</div>
{/* File Browser */}
<div className="flex-1 overflow-y-auto bg-background">
<FileBrowser
key={`${currentPath}-${refreshKey}`}
path={currentPath}
viewMode={viewMode}
searchTerm={searchTerm}
sortBy={sortBy}
filterType={filterType}
/>
</div>
{/* File Operations */}
<FileOperations currentPath={currentPath} onRefresh={handleRefresh} />
</div>
</div>
);
}

View file

@ -1,8 +1,917 @@
'use client'
"use client";
export default function MainPage() {
return (
<div className="flex flex-col h-screen bg-gray-50">
</div>
)
import { useState, useRef, useEffect } from 'react';
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import TextStyle from '@tiptap/extension-text-style';
import FontFamily from '@tiptap/extension-font-family';
import Color from '@tiptap/extension-color';
import Highlight from '@tiptap/extension-highlight';
import TextAlign from '@tiptap/extension-text-align';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Underline from '@tiptap/extension-underline';
import Table from '@tiptap/extension-table';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
import TableRow from '@tiptap/extension-table-row';
import {
Bold, Italic, Underline as UnderlineIcon, AlignLeft, AlignCenter, AlignRight, AlignJustify,
Link as LinkIcon, Image as ImageIcon, Save, FileText, Printer, Table as TableIcon,
Plus, Minus, Trash2, Type, Palette, Highlighter, FileImage, File, Home,
View, ChevronDown, Search,
Undo, Redo, Copy, MoreVertical
} from 'lucide-react';
const RibbonTab = ({ label, isActive, onClick, children }) => (
<div className="ribbon-tab">
<button
onClick={onClick}
className={`ribbon-tab-button ${isActive ? 'active' : ''}`}
>
{label}
</button>
{isActive && (
<div className="ribbon-content">
{children}
</div>
)}
</div>
);
const RibbonGroup = ({ title, children }) => (
<div className="ribbon-group">
<div className="ribbon-group-content">
{children}
</div>
<div className="ribbon-group-title">{title}</div>
</div>
);
const RibbonButton = ({ icon: Icon, label, onClick, isActive, size = 'medium', dropdown = false }) => (
<button
onClick={onClick}
className={`ribbon-button ${size} ${isActive ? 'active' : ''}`}
title={label}
>
<div className="ribbon-button-content">
<Icon size={size === 'large' ? 24 : 16} />
{size === 'large' && <span className="ribbon-button-label">{label}</span>}
{dropdown && <ChevronDown size={12} className="dropdown-arrow" />}
</div>
</button>
);
export default function RibbonWordClone() {
const [fileName, setFileName] = useState('Document 1');
const [fontSize, setFontSize] = useState('12');
const [fontFamily, setFontFamily] = useState('Calibri');
const [textColor, setTextColor] = useState('#000000');
const [highlightColor, setHighlightColor] = useState('#ffff00');
const [activeTab, setActiveTab] = useState('home');
const [zoom, setZoom] = useState(100);
const [pages, setPages] = useState([1]);
const fileInputRef = useRef(null);
const editor = useEditor({
extensions: [
StarterKit,
TextStyle,
FontFamily,
Color,
Highlight.configure({ multicolor: true }),
TextAlign.configure({ types: ['heading', 'paragraph', 'tableCell'] }),
Link.configure({ openOnClick: false }),
Image,
Underline,
Table.configure({
resizable: true,
HTMLAttributes: {
class: 'editor-table',
},
}),
TableRow,
TableHeader,
TableCell,
],
content: `
<h1 style="text-align: center; font-size: 24px; margin-bottom: 20px;">${fileName}</h1>
<p><br></p>
<p>Start typing your document here...</p>
<p><br></p>
`,
onUpdate: ({ editor }) => {
// Simulate multiple pages based on content height
const element = document.querySelector('.ProseMirror');
if (element) {
const contentHeight = element.scrollHeight;
const pageHeight = 1123; // A4 height in pixels at 96 DPI
const neededPages = Math.max(1, Math.ceil(contentHeight / pageHeight));
if (neededPages !== pages.length) {
setPages(Array.from({ length: neededPages }, (_, i) => i + 1));
}
}
},
});
const addImage = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleImageUpload = (e) => {
const file = e.target.files?.[0];
if (file && editor) {
const reader = new FileReader();
reader.onload = (event) => {
const imageUrl = event.target?.result;
editor.chain().focus().setImage({ src: imageUrl }).run();
};
reader.readAsDataURL(file);
}
};
const saveDocument = () => {
if (editor) {
const content = editor.getHTML();
const blob = new Blob([content], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName}.html`;
a.click();
URL.revokeObjectURL(url);
}
};
if (!editor) {
return null;
}
return (
<div className="word-clone">
<style jsx global>{`
:root {
/* 3DBevel Theme */
--background: 0 0% 80%;
--foreground: 0 0% 10%;
--card: 0 0% 75%;
--card-foreground: 0 0% 10%;
--popover: 0 0% 80%;
--popover-foreground: 0 0% 10%;
--primary: 210 80% 40%;
--primary-foreground: 0 0% 80%;
--secondary: 0 0% 70%;
--secondary-foreground: 0 0% 10%;
--muted: 0 0% 65%;
--muted-foreground: 0 0% 30%;
--accent: 30 80% 40%;
--accent-foreground: 0 0% 80%;
--destructive: 0 85% 60%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 70%;
--input: 0 0% 70%;
--ring: 210 80% 40%;
--radius: 0.5rem;
}
* {
box-sizing: border-box;
}
.word-clone {
min-height: 100vh;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* Title Bar */
.title-bar {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid hsl(var(--border));
}
.title-bar h1 {
font-size: 14px;
font-weight: 600;
margin: 0;
}
.title-controls {
display: flex;
gap: 8px;
}
.title-input {
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
padding: 4px 8px;
font-size: 12px;
color: hsl(var(--foreground));
}
/* Quick Access Toolbar */
.quick-access {
background: hsl(var(--card));
border-bottom: 1px solid hsl(var(--border));
padding: 4px 8px;
display: flex;
align-items: center;
gap: 2px;
}
.quick-access-btn {
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
padding: 4px;
cursor: pointer;
color: hsl(var(--foreground));
transition: all 0.2s;
}
.quick-access-btn:hover {
background: hsl(var(--muted));
border-color: hsl(var(--border));
}
/* Ribbon */
.ribbon {
background: hsl(var(--card));
border-bottom: 2px solid hsl(var(--border));
}
.ribbon-tabs {
display: flex;
background: hsl(var(--muted));
border-bottom: 1px solid hsl(var(--border));
}
.ribbon-tab-button {
background: transparent;
border: none;
padding: 8px 16px;
cursor: pointer;
font-size: 12px;
color: hsl(var(--muted-foreground));
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.ribbon-tab-button:hover {
background: hsl(var(--secondary));
color: hsl(var(--foreground));
}
.ribbon-tab-button.active {
background: hsl(var(--card));
color: hsl(var(--foreground));
border-bottom-color: hsl(var(--primary));
font-weight: 600;
}
.ribbon-content {
display: flex;
padding: 8px;
gap: 2px;
min-height: 80px;
align-items: stretch;
}
.ribbon-group {
display: flex;
flex-direction: column;
border-right: 1px solid hsl(var(--border));
padding-right: 8px;
margin-right: 8px;
}
.ribbon-group:last-child {
border-right: none;
}
.ribbon-group-content {
display: flex;
flex-wrap: wrap;
gap: 2px;
flex: 1;
align-items: flex-start;
padding: 4px 0;
}
.ribbon-group-title {
font-size: 10px;
color: hsl(var(--muted-foreground));
text-align: center;
margin-top: 4px;
border-top: 1px solid hsl(var(--border));
padding-top: 2px;
}
.ribbon-button {
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
color: hsl(var(--foreground));
transition: all 0.2s;
position: relative;
}
.ribbon-button:hover {
background: hsl(var(--muted));
border-color: hsl(var(--border));
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.ribbon-button.active {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
.ribbon-button.medium {
padding: 6px;
min-width: 32px;
min-height: 32px;
}
.ribbon-button.large {
padding: 8px;
min-width: 48px;
min-height: 48px;
flex-direction: column;
}
.ribbon-button-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.ribbon-button-label {
font-size: 10px;
text-align: center;
line-height: 1.1;
}
.dropdown-arrow {
position: absolute;
bottom: 2px;
right: 2px;
}
/* Format Controls */
.format-select {
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 3px;
padding: 4px 6px;
font-size: 11px;
color: hsl(var(--foreground));
margin: 2px;
}
.color-picker-wrapper {
position: relative;
display: inline-block;
}
.color-picker {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.color-indicator {
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 3px;
border-radius: 1px;
}
/* Editor Area */
.editor-container {
display: flex;
flex: 1;
background: hsl(var(--muted));
}
.editor-sidebar {
width: 200px;
background: hsl(var(--card));
border-right: 1px solid hsl(var(--border));
padding: 16px;
}
.editor-main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
overflow-y: auto;
max-height: calc(100vh - 200px);
}
.pages-container {
display: flex;
flex-direction: column;
gap: 20px;
transform: scale(${zoom / 100});
transform-origin: top center;
}
.page {
width: 210mm;
min-height: 297mm;
background: white;
box-shadow:
0 0 0 1px hsl(var(--border)),
0 4px 8px rgba(0,0,0,0.1),
0 8px 16px rgba(0,0,0,0.05);
position: relative;
margin: 0 auto;
}
.page-number {
position: absolute;
top: -30px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: hsl(var(--muted-foreground));
background: hsl(var(--background));
padding: 2px 8px;
border-radius: 10px;
}
.page-content {
padding: 25mm;
min-height: 247mm;
}
.ProseMirror {
outline: none;
min-height: 100%;
}
.ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.ProseMirror a {
color: hsl(var(--primary));
text-decoration: underline;
}
/* Table styles */
.editor-table {
border-collapse: collapse;
margin: 16px 0;
width: 100%;
border: 1px solid hsl(var(--border));
}
.editor-table td,
.editor-table th {
border: 1px solid hsl(var(--border));
padding: 8px 12px;
min-width: 50px;
position: relative;
}
.editor-table th {
background: hsl(var(--muted));
font-weight: 600;
}
/* Bubble Menu */
.bubble-menu {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
padding: 4px;
gap: 2px;
}
.bubble-menu .ribbon-button {
min-width: 28px;
min-height: 28px;
padding: 4px;
}
/* Status Bar */
.status-bar {
background: hsl(var(--card));
border-top: 1px solid hsl(var(--border));
padding: 4px 12px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.zoom-controls {
display: flex;
align-items: center;
gap: 8px;
}
.zoom-slider {
width: 100px;
}
@media print {
.title-bar,
.quick-access,
.ribbon,
.editor-sidebar,
.status-bar {
display: none !important;
}
.editor-main {
padding: 0;
max-height: none;
}
.pages-container {
transform: none;
gap: 0;
}
.page {
box-shadow: none;
margin: 0;
break-after: page;
}
.page-number {
display: none;
}
}
`}</style>
{/* Quick Access Toolbar */}
<div className="quick-access">
<button className="quick-access-btn" onClick={() => editor.chain().focus().undo().run()}>
<Undo size={14} />
</button>
<button className="quick-access-btn" onClick={() => editor.chain().focus().redo().run()}>
<Redo size={14} />
</button>
<button className="quick-access-btn" onClick={saveDocument}>
<Save size={14} />
</button>
<div className="title-controls">
<input
type="text"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
className="title-input"
placeholder="Document name"
/>
<button className="quick-access-btn" onClick={saveDocument}>
<Save size={14} />
</button>
</div>
</div>
{/* Ribbon */}
<div className="ribbon">
<div className="ribbon-tabs">
<button
className={`ribbon-tab-button ${activeTab === 'home' ? 'active' : ''}`}
onClick={() => setActiveTab('home')}
>
Home
</button>
<button
className={`ribbon-tab-button ${activeTab === 'insert' ? 'active' : ''}`}
onClick={() => setActiveTab('insert')}
>
Insert
</button>
<button
className={`ribbon-tab-button ${activeTab === 'layout' ? 'active' : ''}`}
onClick={() => setActiveTab('layout')}
>
Layout
</button>
<button
className={`ribbon-tab-button ${activeTab === 'view' ? 'active' : ''}`}
onClick={() => setActiveTab('view')}
>
View
</button>
</div>
{activeTab === 'home' && (
<div className="ribbon-content">
<RibbonGroup title="Clipboard">
<RibbonButton
icon={Copy}
label="Copy"
size="large"
onClick={() => document.execCommand('copy')} isActive={undefined} />
<RibbonButton
icon={Copy}
label="Paste"
size="large"
onClick={() => document.execCommand('paste')} isActive={undefined} />
<RibbonButton
icon={Copy}
label="Cut"
size="medium"
onClick={() => document.execCommand('cut')} isActive={undefined} />
</RibbonGroup>
<RibbonGroup title="Font">
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', gap: '4px' }}>
<select
value={fontFamily}
onChange={(e) => {
setFontFamily(e.target.value);
editor.chain().focus().setFontFamily(e.target.value).run();
}}
className="format-select"
style={{ width: '120px' }}
>
<option value="Calibri">Calibri</option>
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Georgia">Georgia</option>
<option value="Verdana">Verdana</option>
</select>
<select
value={fontSize}
onChange={(e) => {
setFontSize(e.target.value);
editor.chain().focus().setFontSize(e.target.value + 'pt').run();
}}
className="format-select"
style={{ width: '60px' }}
>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
<option value="14">14</option>
<option value="16">16</option>
<option value="18">18</option>
<option value="20">20</option>
<option value="24">24</option>
<option value="28">28</option>
<option value="36">36</option>
</select>
</div>
<div style={{ display: 'flex', gap: '2px' }}>
<RibbonButton
icon={Bold}
label="Bold"
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
/>
<RibbonButton
icon={Italic}
label="Italic"
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
/>
<RibbonButton
icon={UnderlineIcon}
label="Underline"
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
/>
<div className="color-picker-wrapper">
<RibbonButton icon={Type} label="Text Color" onClick={undefined} isActive={undefined} />
<input
type="color"
value={textColor}
onChange={(e) => {
setTextColor(e.target.value);
editor.chain().focus().setColor(e.target.value).run();
}}
className="color-picker"
/>
<div
className="color-indicator"
style={{ backgroundColor: textColor }}
/>
</div>
<div className="color-picker-wrapper">
<RibbonButton icon={Highlighter} label="Highlight" onClick={undefined} isActive={undefined} />
<input
type="color"
value={highlightColor}
onChange={(e) => {
setHighlightColor(e.target.value);
editor.chain().focus().setHighlight({ color: e.target.value }).run();
}}
className="color-picker"
/>
<div
className="color-indicator"
style={{ backgroundColor: highlightColor }}
/>
</div>
</div>
</div>
</RibbonGroup>
<RibbonGroup title="Paragraph">
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', gap: '2px' }}>
<RibbonButton
icon={AlignLeft}
label="Align Left"
onClick={() => editor.chain().focus().setTextAlign('left').run()}
isActive={editor.isActive({ textAlign: 'left' })}
/>
<RibbonButton
icon={AlignCenter}
label="Center"
onClick={() => editor.chain().focus().setTextAlign('center').run()}
isActive={editor.isActive({ textAlign: 'center' })}
/>
<RibbonButton
icon={AlignRight}
label="Align Right"
onClick={() => editor.chain().focus().setTextAlign('right').run()}
isActive={editor.isActive({ textAlign: 'right' })}
/>
<RibbonButton
icon={AlignJustify}
label="Justify"
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
isActive={editor.isActive({ textAlign: 'justify' })}
/>
</div>
</div>
</RibbonGroup>
</div>
)}
{activeTab === 'insert' && (
<div className="ribbon-content">
<RibbonGroup title="Illustrations">
<RibbonButton
icon={ImageIcon}
label="Picture"
size="large"
onClick={addImage} isActive={undefined} />
</RibbonGroup>
<RibbonGroup title="Tables">
<RibbonButton
icon={TableIcon}
label="Table"
size="large"
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} isActive={undefined} />
</RibbonGroup>
<RibbonGroup title="Links">
<RibbonButton
icon={LinkIcon}
label="Link"
size="large"
onClick={() => {
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('URL', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}}
isActive={editor.isActive('link')}
/>
</RibbonGroup>
</div>
)}
{activeTab === 'view' && (
<div className="ribbon-content">
<RibbonGroup title="Zoom">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<div style={{ fontSize: '14px', fontWeight: '600' }}>{zoom}%</div>
<input
type="range"
min="50"
max="200"
value={zoom}
onChange={(e) => setZoom(parseInt(e.target.value))}
className="zoom-slider"
/>
</div>
</RibbonGroup>
</div>
)}
</div>
{/* Editor Area */}
<div className="editor-container">
<div className="editor-main">
<div className="pages-container">
{pages.map((pageNum, index) => (
<div key={pageNum} className="page">
<div className="page-number">Page {pageNum}</div>
<div className="page-content">
{index === 0 && <EditorContent editor={editor} />}
</div>
</div>
))}
</div>
</div>
</div>
{/* Bubble Menu */}
{editor && (
<BubbleMenu editor={editor}>
<div className="bubble-menu">
<RibbonButton
icon={Bold}
label="Bold"
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
/>
<RibbonButton
icon={Italic}
label="Italic"
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
/>
<RibbonButton
icon={UnderlineIcon}
label="Underline"
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
/>
<RibbonButton
icon={LinkIcon}
label="Link"
onClick={() => {
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('URL', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}}
isActive={editor.isActive('link')}
/>
</div>
</BubbleMenu>
)}
{/* Status Bar */}
<div className="status-bar">
<div>
Page {pages.length} of {pages.length} | Words: {editor.storage.characterCount?.words() || 0}
</div>
<div className="zoom-controls">
<button onClick={() => setZoom(Math.max(50, zoom - 10))}>-</button>
<span>{zoom}%</span>
<button onClick={() => setZoom(Math.min(200, zoom + 10))}>+</button>
</div>
</div>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept="image/*"
style={{ display: 'none' }}
/>
</div>
);
}

View file

@ -1,6 +1,5 @@
"use client";
import React, { useState } from 'react';
import { UserAuthForm } from '../components/user-auth-form';
const AuthenticationScreen = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
@ -53,7 +52,6 @@ const AuthenticationScreen = () => {
<p>Enter your email below to create your account</p>
</div>
<UserAuthForm />
<p className="auth-terms">
By clicking continue, you agree to our Terms of Service and Privacy Policy.

View file

@ -1,3 +0,0 @@
Lotus 123.
https://docs.sheetjs.com/docs/demos/desktop/tauri

View file

@ -1,26 +0,0 @@
"use client";
import { useState } from 'react';
import { FileTree } from './components/FileTree';
import { FileBrowser } from './components/FileBrowser';
import { FileOperations } from './components/FileOperations';
// TODO: XTREE like keyword.
export default function DriveScreen() {
const [currentPath, setCurrentPath] = useState('');
const [refreshKey, setRefreshKey] = useState(0);
const handleRefresh = () => {
setRefreshKey(prev => prev + 1);
};
return (
<div className="flex h-screen bg-white">
<FileTree onSelect={setCurrentPath} />
<div className="flex-1 flex flex-col overflow-hidden">
<FileBrowser key={`${currentPath}-${refreshKey}`} path={currentPath} />
<FileOperations currentPath={currentPath} onRefresh={handleRefresh} />
</div>
</div>
);
}

View file

@ -42,6 +42,22 @@
"@tailwindcss/vite": "4.0.17",
"@tauri-apps/api": "2.4.0",
"@tauri-apps/plugin-opener": "2",
"@tiptap/extension-color": "^2.22.3",
"@tiptap/extension-font-family": "^2.22.3",
"@tiptap/extension-highlight": "^2.22.3",
"@tiptap/extension-image": "^2.22.3",
"@tiptap/extension-link": "^2.22.3",
"@tiptap/extension-table": "^2.22.3",
"@tiptap/extension-table-cell": "^2.22.3",
"@tiptap/extension-table-header": "^2.22.3",
"@tiptap/extension-table-row": "^2.22.3",
"@tiptap/extension-text-align": "^2.22.3",
"@tiptap/extension-text-style": "^2.22.3",
"@tiptap/extension-underline": "^2.22.3",
"@tiptap/pm": "^2.22.3",
"@tiptap/react": "^2.22.3",
"@tiptap/starter-kit": "^2.22.3",
"@umoteam/editor": "^7.0.1",
"@zitadel/react": "1.0.5",
"autoprefixer": "10.4.17",
"botframework-directlinejs": "0.15.1",
@ -64,6 +80,7 @@
"react-day-picker": "^9.7.0",
"react-dom": "18.3.1",
"react-hook-form": "^7.58.1",
"react-icons": "^5.5.0",
"react-markdown": "10.1.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.4",

2717
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff