827 lines
30 KiB
TypeScript
827 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, useCallback } from 'react';
|
|
import { DashboardLayout } from '@/components/layout/sidebar';
|
|
import { PageHeader } from '@/components/layout/page-header';
|
|
// SupabaseTest removed - using API route instead
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
FileText,
|
|
Folder,
|
|
Search,
|
|
Plus,
|
|
FileCode,
|
|
FileType,
|
|
File as FileIcon,
|
|
Clock,
|
|
Tag,
|
|
Edit3,
|
|
Trash2,
|
|
Eye,
|
|
X,
|
|
Filter,
|
|
Save,
|
|
FolderPlus,
|
|
Clock3,
|
|
Hash,
|
|
LayoutGrid,
|
|
List,
|
|
} from 'lucide-react';
|
|
import { MarkdownPreviewDialog } from '@/components/MarkdownPreviewDialog';
|
|
import { cn } from '@/lib/utils';
|
|
import { useDocuments } from '@/hooks/useDocuments';
|
|
import { Document, DocumentType, Folder as FolderType, DOCUMENT_TYPE_COLORS } from '@/types/documents';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
|
|
const typeIcons: Record<DocumentType, typeof FileText> = {
|
|
markdown: FileType,
|
|
text: FileText,
|
|
pdf: FileIcon,
|
|
code: FileCode,
|
|
};
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function formatDate(dateString: string): string {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
|
|
|
|
if (diffInHours < 1) return 'Just now';
|
|
if (diffInHours < 24) return `${Math.floor(diffInHours)}h ago`;
|
|
if (diffInHours < 48) return 'Yesterday';
|
|
if (diffInHours < 168) return date.toLocaleDateString('en-US', { weekday: 'short' });
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
// Document Editor Component
|
|
function DocumentEditor({
|
|
document,
|
|
onSave,
|
|
onCancel,
|
|
folders,
|
|
}: {
|
|
document: Document | null;
|
|
onSave: (doc: Partial<Document>) => void;
|
|
onCancel: () => void;
|
|
folders: FolderType[];
|
|
}) {
|
|
const [title, setTitle] = useState(document?.title || '');
|
|
const [content, setContent] = useState(document?.content || '');
|
|
const [folder, setFolder] = useState(document?.folder || 'General');
|
|
const [tags, setTags] = useState<string[]>(document?.tags || []);
|
|
const [tagInput, setTagInput] = useState('');
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
|
|
const handleSave = () => {
|
|
if (!title.trim()) return;
|
|
onSave({
|
|
title: title.trim(),
|
|
content: content.trim(),
|
|
folder,
|
|
tags,
|
|
type: 'markdown',
|
|
});
|
|
};
|
|
|
|
const addTag = () => {
|
|
const trimmed = tagInput.trim().toLowerCase();
|
|
if (trimmed && !tags.includes(trimmed)) {
|
|
setTags([...tags, trimmed]);
|
|
setTagInput('');
|
|
}
|
|
};
|
|
|
|
const removeTag = (tagToRemove: string) => {
|
|
setTags(tags.filter((t) => t !== tagToRemove));
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
|
|
<div className="flex items-center gap-3 flex-1">
|
|
<Input
|
|
placeholder="Document title..."
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
className="text-base sm:text-lg font-semibold bg-transparent border-0 border-b rounded-none px-0 focus-visible:ring-0 focus-visible:border-primary"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
>
|
|
<Eye className="w-4 h-4 mr-2" />
|
|
{showPreview ? 'Edit' : 'Preview'}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={onCancel}>
|
|
<X className="w-4 h-4 mr-2" />
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" onClick={handleSave} disabled={!title.trim()}>
|
|
<Save className="w-4 h-4 mr-2" />
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 mb-4">
|
|
<Select value={folder} onValueChange={setFolder}>
|
|
<SelectTrigger className="w-full sm:w-[180px]">
|
|
<Folder className="w-4 h-4 mr-2" />
|
|
<SelectValue placeholder="Select folder" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{folders.map((f) => (
|
|
<SelectItem key={f.id} value={f.name}>
|
|
{f.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<div className="flex items-center gap-2 flex-1">
|
|
<Hash className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
<div className="flex items-center gap-2 flex-wrap flex-1">
|
|
{tags.map((tag) => (
|
|
<Badge key={tag} variant="secondary" className="gap-1">
|
|
{tag}
|
|
<button
|
|
onClick={() => removeTag(tag)}
|
|
className="hover:text-destructive"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
<div className="flex items-center">
|
|
<Input
|
|
placeholder="Add tag..."
|
|
value={tagInput}
|
|
onChange={(e) => setTagInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
addTag();
|
|
}
|
|
}}
|
|
className="w-24 h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-[300px] sm:min-h-[400px]">
|
|
{showPreview ? (
|
|
<div className="h-full p-3 sm:p-4 border rounded-lg bg-card overflow-auto prose prose-sm dark:prose-invert max-w-none">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content || '*No content*'}</ReactMarkdown>
|
|
</div>
|
|
) : (
|
|
<Textarea
|
|
placeholder="Write your document in markdown..."
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
className="h-full min-h-[300px] sm:min-h-[400px] resize-none font-mono text-sm"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Document List Item Component
|
|
function DocumentListItem({
|
|
document,
|
|
onClick,
|
|
onEdit,
|
|
onDelete,
|
|
}: {
|
|
document: Document;
|
|
onClick: () => void;
|
|
onEdit: (e: React.MouseEvent) => void;
|
|
onDelete: (e: React.MouseEvent) => void;
|
|
}) {
|
|
const Icon = typeIcons[document.type];
|
|
const colorClass = DOCUMENT_TYPE_COLORS[document.type];
|
|
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className="flex items-start gap-3 p-3 sm:p-4 hover:bg-accent/50 transition-colors cursor-pointer group"
|
|
>
|
|
<div className={cn('p-2 rounded-lg shrink-0', colorClass)}>
|
|
<Icon className="w-4 h-4 sm:w-5 sm:h-5" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h4 className="font-medium text-sm sm:text-base truncate">{document.title}</h4>
|
|
<Badge variant="outline" className="text-xs shrink-0">
|
|
{document.folder}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs sm:text-sm text-muted-foreground truncate">
|
|
{document.description || document.content.slice(0, 80).replace(/#/g, '').trim() || 'No description'}
|
|
</p>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
{document.tags.slice(0, 3).map((tag) => (
|
|
<span key={tag} className="text-xs text-muted-foreground">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
{document.tags.length > 3 && (
|
|
<span className="text-xs text-muted-foreground">
|
|
+{document.tags.length - 3}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="hidden sm:block text-right text-xs sm:text-sm text-muted-foreground shrink-0">
|
|
<p>{formatFileSize(document.size)}</p>
|
|
<p className="text-xs">{formatDate(document.updatedAt)}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onEdit}>
|
|
<Edit3 className="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={onDelete}>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Document Viewer Component
|
|
function DocumentViewer({
|
|
document,
|
|
onEdit,
|
|
onClose,
|
|
}: {
|
|
document: Document;
|
|
onEdit: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const Icon = typeIcons[document.type];
|
|
const colorClass = DOCUMENT_TYPE_COLORS[document.type];
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b">
|
|
<div className="flex items-center gap-3">
|
|
<div className={cn('p-2 rounded-lg shrink-0', colorClass)}>
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h2 className="text-base sm:text-lg font-semibold truncate">{document.title}</h2>
|
|
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-muted-foreground">
|
|
<span>{document.folder}</span>
|
|
<span>•</span>
|
|
<span>{formatFileSize(document.size)}</span>
|
|
<span>•</span>
|
|
<span>Updated {formatDate(document.updatedAt)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Button variant="outline" size="sm" onClick={onEdit}>
|
|
<Edit3 className="w-4 h-4 mr-2" />
|
|
Edit
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 py-2">
|
|
{document.tags.map((tag) => (
|
|
<Badge key={tag} variant="secondary" className="text-xs">
|
|
#{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto py-4 px-2 prose prose-base dark:prose-invert max-w-none prose-headings:mt-4 prose-headings:mb-2 prose-p:my-2 prose-ul:my-2 prose-ol:my-2">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{document.content}</ReactMarkdown>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Main Documents Page
|
|
export default function DocumentsPage() {
|
|
const {
|
|
documents,
|
|
folders,
|
|
isLoaded,
|
|
createDocument,
|
|
updateDocument,
|
|
deleteDocument,
|
|
createFolder,
|
|
getRecentDocuments,
|
|
getAllTags,
|
|
refreshDocuments,
|
|
error,
|
|
} = useDocuments();
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
|
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
|
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');
|
|
const [editingDocument, setEditingDocument] = useState<Document | null>(null);
|
|
const [viewingDocument, setViewingDocument] = useState<Document | null>(null);
|
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
const [isNewFolderDialogOpen, setIsNewFolderDialogOpen] = useState(false);
|
|
const [newFolderName, setNewFolderName] = useState('');
|
|
const [activeTab, setActiveTab] = useState('all');
|
|
|
|
// Filter documents
|
|
const filteredDocuments = useMemo(() => {
|
|
let filtered = documents;
|
|
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
filtered = filtered.filter(
|
|
(doc) =>
|
|
doc.title.toLowerCase().includes(query) ||
|
|
doc.content.toLowerCase().includes(query) ||
|
|
doc.tags.some((tag) => tag.toLowerCase().includes(query))
|
|
);
|
|
}
|
|
|
|
if (selectedFolder) {
|
|
filtered = filtered.filter((doc) => doc.folder === selectedFolder);
|
|
}
|
|
|
|
if (selectedTag) {
|
|
filtered = filtered.filter((doc) => doc.tags.includes(selectedTag));
|
|
}
|
|
|
|
if (activeTab === 'recent') {
|
|
filtered = [...filtered].sort(
|
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
);
|
|
}
|
|
|
|
return filtered;
|
|
}, [documents, searchQuery, selectedFolder, selectedTag, activeTab]);
|
|
|
|
const recentDocuments = useMemo(() => getRecentDocuments(5), [getRecentDocuments]);
|
|
const allTags = useMemo(() => getAllTags(), [getAllTags]);
|
|
|
|
const handleCreateDocument = async (docData: Partial<Document>) => {
|
|
const result = await createDocument({
|
|
title: docData.title!,
|
|
content: docData.content || '',
|
|
type: 'markdown',
|
|
folder: docData.folder || 'General',
|
|
tags: docData.tags || [],
|
|
});
|
|
if (result) {
|
|
setIsCreateDialogOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleUpdateDocument = async (docData: Partial<Document>) => {
|
|
if (editingDocument) {
|
|
const result = await updateDocument(editingDocument.id, docData);
|
|
if (result) {
|
|
setEditingDocument(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDeleteDocument = async (id: string) => {
|
|
if (confirm('Are you sure you want to delete this document?')) {
|
|
const success = await deleteDocument(id);
|
|
if (success) {
|
|
if (viewingDocument?.id === id) setViewingDocument(null);
|
|
if (editingDocument?.id === id) setEditingDocument(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleCreateFolder = () => {
|
|
if (newFolderName.trim()) {
|
|
createFolder(newFolderName.trim());
|
|
setNewFolderName('');
|
|
setIsNewFolderDialogOpen(false);
|
|
}
|
|
};
|
|
|
|
if (!isLoaded) {
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
|
<div className="animate-pulse text-muted-foreground">Loading documents...</div>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
// Show error state with retry option
|
|
if (error) {
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="flex flex-col items-center justify-center h-96 gap-4 px-4">
|
|
<FileText className="w-12 h-12 text-destructive/50" />
|
|
<div className="text-center">
|
|
<h3 className="font-medium text-lg">Failed to load documents</h3>
|
|
<p className="text-muted-foreground text-sm max-w-md mt-2">
|
|
{error}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button onClick={() => window.location.reload()} variant="outline">
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="space-y-4 sm:space-y-6">
|
|
{/* Header */}
|
|
<PageHeader
|
|
title="Documents"
|
|
description="Manage your notes, docs, and files across projects."
|
|
>
|
|
<Button onClick={() => setIsCreateDialogOpen(true)} size="sm">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
New Document
|
|
</Button>
|
|
</PageHeader>
|
|
|
|
{/* Recent Documents Carousel */}
|
|
{recentDocuments.length > 0 && !searchQuery && !selectedFolder && !selectedTag && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
|
|
<Clock3 className="w-4 h-4" />
|
|
<span>Recent Documents</span>
|
|
</div>
|
|
<div className="flex gap-3 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0">
|
|
{recentDocuments.map((doc) => {
|
|
const Icon = typeIcons[doc.type];
|
|
return (
|
|
<button
|
|
key={doc.id}
|
|
onClick={() => setViewingDocument(doc)}
|
|
className="flex items-center gap-3 px-4 py-3 bg-card border rounded-lg hover:border-primary/50 transition-colors text-left shrink-0 min-w-[180px] sm:min-w-[200px]"
|
|
>
|
|
<Icon className="w-5 h-5 text-muted-foreground shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="font-medium text-sm truncate">{doc.title}</p>
|
|
<p className="text-xs text-muted-foreground">{formatDate(doc.updatedAt)}</p>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 sm:gap-6">
|
|
{/* Sidebar */}
|
|
<div className="lg:col-span-1 space-y-4">
|
|
{/* Folders */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm sm:text-base">Folders</CardTitle>
|
|
<Dialog open={isNewFolderDialogOpen} onOpenChange={setIsNewFolderDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6">
|
|
<FolderPlus className="w-4 h-4" />
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>New Folder</DialogTitle>
|
|
<DialogDescription>
|
|
Create a new folder to organize your documents.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<Input
|
|
placeholder="Folder name..."
|
|
value={newFolderName}
|
|
onChange={(e) => setNewFolderName(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
|
/>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsNewFolderDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
|
|
Create
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-1">
|
|
<button
|
|
onClick={() => setSelectedFolder(null)}
|
|
className={cn(
|
|
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors',
|
|
selectedFolder === null
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'hover:bg-accent'
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Folder className="w-4 h-4" />
|
|
All Documents
|
|
</span>
|
|
<span className="text-xs opacity-70">{documents.length}</span>
|
|
</button>
|
|
{folders.map((folder) => (
|
|
<button
|
|
key={folder.id}
|
|
onClick={() => setSelectedFolder(folder.name)}
|
|
className={cn(
|
|
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors',
|
|
selectedFolder === folder.name
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'hover:bg-accent'
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Folder className="w-4 h-4" />
|
|
{folder.name}
|
|
</span>
|
|
<span className="text-xs opacity-70">{folder.count}</span>
|
|
</button>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Tags */}
|
|
{allTags.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm sm:text-base flex items-center gap-2">
|
|
<Tag className="w-4 h-4" />
|
|
Tags
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-wrap gap-2">
|
|
{allTags.map((tag) => (
|
|
<button
|
|
key={tag}
|
|
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
|
className={cn(
|
|
'px-2 py-1 rounded-md text-xs transition-colors',
|
|
selectedTag === tag
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-secondary hover:bg-secondary/80'
|
|
)}
|
|
>
|
|
#{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Storage */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm sm:text-base">Storage</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-xs sm:text-sm">
|
|
<span className="text-muted-foreground">
|
|
{formatFileSize(documents.reduce((acc, d) => acc + d.size, 0))} used
|
|
</span>
|
|
<span>{documents.length} docs</span>
|
|
</div>
|
|
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
|
<div className="h-full w-[25%] bg-gradient-to-r from-blue-500 to-purple-500 rounded-full" />
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Stored locally in your browser
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="lg:col-span-3 space-y-4">
|
|
{/* Toolbar */}
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search documents..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{(selectedFolder || selectedTag) && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedFolder(null);
|
|
setSelectedTag(null);
|
|
}}
|
|
>
|
|
<Filter className="w-4 h-4 mr-2" />
|
|
Clear
|
|
<X className="w-3 h-3 ml-2" />
|
|
</Button>
|
|
)}
|
|
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'list' | 'grid')}>
|
|
<TabsList className="h-9">
|
|
<TabsTrigger value="list" className="px-3">
|
|
<List className="w-4 h-4" />
|
|
</TabsTrigger>
|
|
<TabsTrigger value="grid" className="px-3">
|
|
<LayoutGrid className="w-4 h-4" />
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList>
|
|
<TabsTrigger value="all">All</TabsTrigger>
|
|
<TabsTrigger value="recent">Recent</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{/* Document List */}
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
{filteredDocuments.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<FileText className="w-10 h-10 sm:w-12 sm:h-12 text-muted-foreground/50 mb-4" />
|
|
<h3 className="font-medium text-base sm:text-lg">No documents found</h3>
|
|
<p className="text-muted-foreground text-xs sm:text-sm max-w-xs mt-1">
|
|
{searchQuery || selectedFolder || selectedTag
|
|
? 'Try adjusting your filters or search query'
|
|
: 'Create your first document to get started'}
|
|
</p>
|
|
{!searchQuery && !selectedFolder && !selectedTag && (
|
|
<Button className="mt-4" onClick={() => setIsCreateDialogOpen(true)} size="sm">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Create Document
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : viewMode === 'list' ? (
|
|
<div className="divide-y divide-border">
|
|
{filteredDocuments.map((doc) => (
|
|
<DocumentListItem
|
|
key={doc.id}
|
|
document={doc}
|
|
onClick={() => setViewingDocument(doc)}
|
|
onEdit={(e) => {
|
|
e.stopPropagation();
|
|
setEditingDocument(doc);
|
|
}}
|
|
onDelete={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteDocument(doc.id);
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 p-3 sm:p-4">
|
|
{filteredDocuments.map((doc) => {
|
|
const Icon = typeIcons[doc.type];
|
|
const colorClass = DOCUMENT_TYPE_COLORS[doc.type];
|
|
return (
|
|
<button
|
|
key={doc.id}
|
|
onClick={() => setViewingDocument(doc)}
|
|
className="flex flex-col p-3 sm:p-4 border rounded-lg hover:border-primary/50 transition-colors text-left"
|
|
>
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className={cn('p-2 rounded-lg', colorClass)}>
|
|
<Icon className="w-4 h-4 sm:w-5 sm:h-5" />
|
|
</div>
|
|
<Badge variant="outline" className="text-xs">
|
|
{doc.folder}
|
|
</Badge>
|
|
</div>
|
|
<h4 className="font-medium text-sm truncate mb-1">{doc.title}</h4>
|
|
<p className="text-xs sm:text-sm text-muted-foreground line-clamp-2 mb-3">
|
|
{doc.description || doc.content.slice(0, 80).replace(/#/g, '').trim() || 'No description'}
|
|
</p>
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground mt-auto">
|
|
<span>{formatDate(doc.updatedAt)}</span>
|
|
<span>{formatFileSize(doc.size)}</span>
|
|
</div>
|
|
{doc.tags.length > 0 && (
|
|
<div className="flex gap-1 mt-2 flex-wrap">
|
|
{doc.tags.slice(0, 2).map((tag) => (
|
|
<span key={tag} className="text-xs text-muted-foreground">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
{doc.tags.length > 2 && (
|
|
<span className="text-xs text-muted-foreground">
|
|
+{doc.tags.length - 2}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create Document Dialog */}
|
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
|
<DialogContent className="max-w-4xl h-[90vh] sm:h-[80vh] p-0">
|
|
<div className="p-4 sm:p-6 h-full overflow-hidden">
|
|
<DocumentEditor
|
|
document={null}
|
|
onSave={handleCreateDocument}
|
|
onCancel={() => setIsCreateDialogOpen(false)}
|
|
folders={folders}
|
|
/>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Edit Document Dialog */}
|
|
<Dialog open={!!editingDocument} onOpenChange={() => setEditingDocument(null)}>
|
|
<DialogContent className="max-w-4xl h-[90vh] sm:h-[80vh] p-0">
|
|
<div className="p-4 sm:p-6 h-full overflow-hidden">
|
|
{editingDocument && (
|
|
<DocumentEditor
|
|
document={editingDocument}
|
|
onSave={handleUpdateDocument}
|
|
onCancel={() => setEditingDocument(null)}
|
|
folders={folders}
|
|
/>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* View Document Dialog */}
|
|
<MarkdownPreviewDialog
|
|
isOpen={!!viewingDocument}
|
|
onClose={() => setViewingDocument(null)}
|
|
title={viewingDocument?.title || ''}
|
|
content={viewingDocument?.content || ''}
|
|
folder={viewingDocument?.folder}
|
|
tags={viewingDocument?.tags}
|
|
/>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|