mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-05 06:32:56 +00:00
feat: full project sync - CI fixes, frontend, workspace API, and all changes
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2 } from 'lucide-react';
|
||||
import { Card, Button, Modal } from '@/components';
|
||||
import { useDrawingStore } from '@/stores';
|
||||
import { api } from '@/services';
|
||||
import type { Drawing } from '@/types';
|
||||
import styles from './FileBrowser.module.scss';
|
||||
|
||||
export const FileBrowser: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const urlParams = useParams<{ folderId?: string }>();
|
||||
const { drawings, folders, setDrawings, setFolders } = useDrawingStore();
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [sortBy, setSortBy] = useState<'name' | 'updated' | 'created'>('updated');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [visibilityFilter, setVisibilityFilter] = useState<'all' | 'private' | 'team' | 'public-link'>('all');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [activeFolderId, setActiveFolderId] = useState<string | null>(urlParams.folderId || null);
|
||||
|
||||
// Dropdown menu state
|
||||
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// New project (folder) state
|
||||
const [showNewProject, setShowNewProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
|
||||
// Rename state
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
// Move state
|
||||
const [movingId, setMovingId] = useState<string | null>(null);
|
||||
|
||||
// Modal state
|
||||
const [modal, setModal] = useState<{
|
||||
open: boolean;
|
||||
type: 'confirm' | 'alert' | 'info';
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
}>({ open: false, type: 'info', title: '', message: '' });
|
||||
|
||||
const showModal = (type: 'confirm' | 'alert' | 'info', title: string, message: string, onConfirm?: () => void) => {
|
||||
setModal({ open: true, type, title, message, onConfirm, onCancel: () => setModal(m => ({ ...m, open: false })) });
|
||||
};
|
||||
|
||||
// Load real data on mount
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [drawingsData, foldersData] = await Promise.all([
|
||||
api.drawings.list(),
|
||||
api.folders.list(),
|
||||
]);
|
||||
setDrawings(drawingsData);
|
||||
setFolders(foldersData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load file browser data:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, [setDrawings, setFolders]);
|
||||
|
||||
// Update active folder when URL changes
|
||||
useEffect(() => {
|
||||
setActiveFolderId(urlParams.folderId || null);
|
||||
}, [urlParams.folderId]);
|
||||
|
||||
const activeFolder = folders.find((f) => f.id === activeFolderId);
|
||||
|
||||
// Filter drawings by active folder + visibility, then sort
|
||||
let visibleDrawings = activeFolderId
|
||||
? drawings.filter((d) => d.folder_id === activeFolderId)
|
||||
: drawings;
|
||||
|
||||
if (visibilityFilter !== 'all') {
|
||||
visibleDrawings = visibleDrawings.filter((d) => d.visibility === visibilityFilter);
|
||||
}
|
||||
|
||||
visibleDrawings = [...visibleDrawings].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortBy === 'name') cmp = a.title.localeCompare(b.title);
|
||||
else if (sortBy === 'updated') cmp = new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime();
|
||||
else if (sortBy === 'created') cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
return sortOrder === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const handleFolderClick = useCallback(
|
||||
(folderId: string | null) => {
|
||||
setActiveFolderId(folderId);
|
||||
if (folderId) {
|
||||
navigate(`/files/folder/${folderId}`);
|
||||
} else {
|
||||
navigate('/files');
|
||||
}
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleDrawingClick = useCallback(
|
||||
(drawing: Drawing) => {
|
||||
if (drawing.folder_id) {
|
||||
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
|
||||
} else {
|
||||
navigate(`/drawing/${drawing.id}`);
|
||||
}
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleCreateDrawing = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const newDrawing = await api.drawings.create({
|
||||
title: 'Untitled Drawing',
|
||||
visibility: 'team',
|
||||
folder_id: activeFolderId || null,
|
||||
});
|
||||
setDrawings([newDrawing, ...drawings]);
|
||||
if (newDrawing.folder_id) {
|
||||
navigate(`/folder/${newDrawing.folder_id}/drawing/${newDrawing.id}`);
|
||||
} else {
|
||||
navigate(`/drawing/${newDrawing.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create drawing:', err);
|
||||
showModal('alert', 'Error', 'Failed to create drawing. Please try again.');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const newFolder = await api.folders.create({ name });
|
||||
setFolders([...folders, newFolder]);
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
navigate(`/files/folder/${newFolder.id}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to create project:', err);
|
||||
showModal('alert', 'Error', 'Failed to create project. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDrawing = (drawing: Drawing) => {
|
||||
showModal('confirm', 'Delete Drawing', `Delete "${drawing.title}"? This cannot be undone.`, async () => {
|
||||
try {
|
||||
await api.drawings.delete(drawing.id);
|
||||
setDrawings(drawings.filter(d => d.id !== drawing.id));
|
||||
setActiveMenu(null);
|
||||
setModal(m => ({ ...m, open: false }));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete drawing:', err);
|
||||
setModal(m => ({ ...m, open: false }));
|
||||
setTimeout(() => showModal('alert', 'Error', 'Failed to delete drawing.'), 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateDrawing = async (drawing: Drawing) => {
|
||||
try {
|
||||
const newDrawing = await api.drawings.create({
|
||||
title: `Copy of ${drawing.title}`,
|
||||
visibility: drawing.visibility,
|
||||
folder_id: drawing.folder_id || null,
|
||||
});
|
||||
setDrawings([newDrawing, ...drawings]);
|
||||
setActiveMenu(null);
|
||||
navigate(`/drawing/${newDrawing.id}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to duplicate drawing:', err);
|
||||
showModal('alert', 'Error', 'Failed to duplicate drawing. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameDrawing = async (drawing: Drawing) => {
|
||||
const title = renameValue.trim();
|
||||
if (!title || title === drawing.title) {
|
||||
setRenamingId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.drawings.update(drawing.id, { title });
|
||||
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, title } : d));
|
||||
setRenamingId(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to rename drawing:', err);
|
||||
showModal('alert', 'Error', 'Failed to rename drawing. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveDrawing = async (drawing: Drawing, folderId: string | null) => {
|
||||
try {
|
||||
await api.drawings.update(drawing.id, { folder_id: folderId });
|
||||
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
|
||||
setMovingId(null);
|
||||
setActiveMenu(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to move drawing:', err);
|
||||
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// Close menu on outside click
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setActiveMenu(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onClick);
|
||||
return () => document.removeEventListener('mousedown', onClick);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.loading}>
|
||||
<Loader2 size={32} className={styles.spinner} />
|
||||
<p>{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={modal.open}
|
||||
type={modal.type}
|
||||
title={modal.title}
|
||||
message={modal.message}
|
||||
onConfirm={modal.onConfirm}
|
||||
onCancel={modal.onCancel}
|
||||
confirmText={modal.type === 'confirm' ? 'Delete' : 'OK'}
|
||||
/>
|
||||
<div className={styles.container} role="region" aria-label={t('fileBrowser.title')}>
|
||||
<div className={styles.header}>
|
||||
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
|
||||
<button
|
||||
className={styles.breadcrumbLink}
|
||||
onClick={() => handleFolderClick(null)}
|
||||
aria-current={!activeFolderId ? 'page' : undefined}
|
||||
>
|
||||
All Projects
|
||||
</button>
|
||||
{activeFolder && (
|
||||
<>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
<span className={styles.breadcrumbCurrent} aria-current="page">
|
||||
{activeFolder.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className={styles.actions}>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={visibilityFilter}
|
||||
onChange={(e) => setVisibilityFilter(e.target.value as any)}
|
||||
aria-label="Filter by visibility"
|
||||
title="Filter by visibility"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="private">Private</option>
|
||||
<option value="team">Team</option>
|
||||
<option value="public-link">Public</option>
|
||||
</select>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [sb, so] = e.target.value.split('-');
|
||||
setSortBy(sb as any);
|
||||
setSortOrder(so as any);
|
||||
}}
|
||||
aria-label="Sort drawings"
|
||||
title="Sort drawings"
|
||||
>
|
||||
<option value="updated-desc">Recently updated</option>
|
||||
<option value="updated-asc">Oldest updated</option>
|
||||
<option value="created-desc">Recently created</option>
|
||||
<option value="created-asc">Oldest created</option>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
</select>
|
||||
<button
|
||||
className={`${styles.viewToggle} ${viewMode === 'grid' ? styles.active : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
aria-label="Grid view"
|
||||
aria-pressed={viewMode === 'grid'}
|
||||
>
|
||||
<Grid size={18} />
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.viewToggle} ${viewMode === 'list' ? styles.active : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
aria-label="List view"
|
||||
aria-pressed={viewMode === 'list'}
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
<Button onClick={handleCreateDrawing} loading={isCreating} aria-label="Create new drawing">
|
||||
<Plus size={16} />
|
||||
New Drawing
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => { setShowNewProject(true); setNewProjectName(''); }} aria-label="Create new project">
|
||||
<Folder size={16} />
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
|
||||
{showNewProject && (
|
||||
<div className={styles.newProjectForm}>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="Project name..."
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateFolder();
|
||||
if (e.key === 'Escape') { setShowNewProject(false); setNewProjectName(''); }
|
||||
}}
|
||||
className={styles.newProjectInput}
|
||||
/>
|
||||
<button className={styles.newProjectBtn} onClick={handleCreateFolder}>Create</button>
|
||||
<button className={styles.newProjectBtnCancel} onClick={() => { setShowNewProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
<ul className={styles.folderTree} role="tree">
|
||||
<li>
|
||||
<button
|
||||
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
|
||||
onClick={() => handleFolderClick(null)}
|
||||
aria-current={!activeFolderId ? 'true' : undefined}
|
||||
role="treeitem"
|
||||
>
|
||||
<Folder size={18} aria-hidden="true" />
|
||||
<span>All Projects</span>
|
||||
</button>
|
||||
</li>
|
||||
{folders.map((folder) => (
|
||||
<li key={folder.id}>
|
||||
<button
|
||||
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''}`}
|
||||
onClick={() => handleFolderClick(folder.id)}
|
||||
aria-current={activeFolderId === folder.id ? 'true' : undefined}
|
||||
role="treeitem"
|
||||
>
|
||||
<Folder size={18} aria-hidden="true" />
|
||||
<span>{folder.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main className={viewMode === 'grid' ? styles.grid : styles.list} role="list" aria-label="Drawing list">
|
||||
{visibleDrawings.length === 0 ? (
|
||||
<div className={styles.empty} role="status">
|
||||
<p>No drawings yet</p>
|
||||
<p className={styles.emptySub}>
|
||||
{activeFolder ? 'Create a new drawing in this project' : 'Create a new drawing or import existing files'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
visibleDrawings.map((drawing) => (
|
||||
<Card
|
||||
key={drawing.id}
|
||||
className={styles.drawingCard}
|
||||
hover
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
onClick={() => handleDrawingClick(drawing)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleDrawingClick(drawing);
|
||||
}
|
||||
}}
|
||||
aria-label={`Open drawing ${drawing.title}`}
|
||||
>
|
||||
<div className={styles.thumbnail}>
|
||||
{drawing.thumbnail_url ? (
|
||||
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
|
||||
) : (
|
||||
<img
|
||||
src={`/api/drawings/${drawing.id}/thumbnail`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
{renamingId === drawing.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
className={styles.renameInput}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameDrawing(drawing);
|
||||
if (e.key === 'Escape') setRenamingId(null);
|
||||
}}
|
||||
onBlur={() => handleRenameDrawing(drawing)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h4 className={styles.title}>{drawing.title}</h4>
|
||||
<p className={styles.meta}>
|
||||
Edited {new Date(drawing.updated_at).toLocaleDateString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
|
||||
<button
|
||||
className={styles.more}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
|
||||
setRenamingId(null);
|
||||
}}
|
||||
aria-label={`More options for ${drawing.title}`}
|
||||
aria-expanded={activeMenu === drawing.id}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{activeMenu === drawing.id && (
|
||||
<div className={styles.dropdown}>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
|
||||
{movingId === drawing.id ? (
|
||||
<div className={styles.dropdownSubmenu}>
|
||||
<button className={styles.dropdownSubheader}>Move to:</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
|
||||
{folders.map(f => (
|
||||
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
|
||||
))}
|
||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
|
||||
)}
|
||||
<div className={styles.dropdownDivider} />
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user