This commit is contained in:
Tomas Dvorak
2026-04-29 11:31:56 +02:00
parent 5fae9779ad
commit ef0b519058
24 changed files with 419 additions and 1162 deletions
+15 -38
View File
@@ -64,23 +64,12 @@ test.describe.serial('dashboard', () => {
await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
});
test('quick action: Library navigates to marketplace', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'Library' }).click();
await expect(page).toHaveURL(/\/library/);
await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
});
test('New Drawing opens template picker', async ({ page }) => {
test('New Drawing opens a blank fullscreen editor', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'New Drawing' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Choose a Template' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Blank Canvas' })).toBeVisible();
await expect(page.getByRole('button', { name: 'To-Do List' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Checklist' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Bullet List' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Flow Chart' })).toBeVisible();
await expect(page).toHaveURL(/\/drawing\//);
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeHidden();
});
});
@@ -97,11 +86,18 @@ test.describe.serial('projects', () => {
test('can create a drawing from file browser', async ({ page }) => {
await page.goto(BASE + '/files');
await page.getByRole('button', { name: 'Create new drawing' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Blank Canvas' }).click();
await expect(page).toHaveURL(/\/drawing\//);
await expect(page.getByText('Loading Excalidraw')).toBeVisible();
});
test('can create a project', async ({ page }) => {
await page.goto(BASE + '/files');
await page.getByRole('button', { name: 'Create new project' }).click();
await page.getByPlaceholder('Project name...').fill('Product sketches');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page).toHaveURL(/\/files\/folder\//);
await expect(page.getByText('Product sketches')).toBeVisible();
});
});
// Editor / Canvas
@@ -111,40 +107,21 @@ test.describe.serial('editor', () => {
test('creates drawing with To-Do template', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'New Drawing' }).click();
await page.getByRole('button', { name: 'To-Do List' }).click();
await expect(page).toHaveURL(/\/drawing\//);
await page.getByRole('button', { name: 'Toggle templates panel' }).click();
await page.getByText('To-Do List').click();
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
});
test('editor shows save controls and back button', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'New Drawing' }).click();
await page.getByRole('button', { name: 'Blank Canvas' }).click();
await expect(page).toHaveURL(/\/drawing\//);
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: /Back/i })).toBeVisible();
});
});
// Library Marketplace
test.describe.serial('library', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
test('loads marketplace with search and categories', async ({ page }) => {
await page.goto(BASE + '/library');
await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
await expect(page.getByPlaceholder('Search libraries...')).toBeVisible();
await expect(page.getByRole('button', { name: 'All' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Open External' })).toBeVisible();
});
test('search filters libraries', async ({ page }) => {
await page.goto(BASE + '/library');
await page.getByPlaceholder('Search libraries...').fill('zzzznonexistent');
await expect(page.getByText('No libraries found')).toBeVisible();
});
});
// Team / Invites
test.describe.serial('team', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
+19 -11
View File
@@ -47,16 +47,24 @@ export const App: React.FC = () => {
}
return (
<AppLayout>
<CommandPalette />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/files/*" element={<FileBrowser />} />
<Route path="/team" element={<TeamSettings />} />
<Route path="/settings" element={<UserSettings />} />
<Route path="/drawing/:id" element={<Editor />} />
<Route path="/folder/:folderId/drawing/:id" element={<Editor />} />
</Routes>
</AppLayout>
<Routes>
<Route path="/drawing/:id" element={<Editor />} />
<Route path="/folder/:folderId/drawing/:id" element={<Editor />} />
<Route
path="*"
element={(
<AppLayout>
<CommandPalette />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/files/folder/:folderId" element={<FileBrowser />} />
<Route path="/team" element={<TeamSettings />} />
<Route path="/settings" element={<UserSettings />} />
</Routes>
</AppLayout>
)}
/>
</Routes>
);
};
@@ -1,126 +0,0 @@
@use '../../styles/variables' as *;
.panel {
width: 340px;
border-left: 1px solid var(--color-gray-20);
background: var(--island-bg-color);
display: flex;
flex-direction: column;
height: 100%;
@media (max-width: 768px) {
position: fixed;
right: 0;
top: var(--header-height);
bottom: 0;
width: 100%;
z-index: 90;
border-left: none;
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
}
.title {
display: flex;
align-items: center;
gap: var(--space-2);
font-weight: 600;
font-size: var(--text-sm);
color: var(--color-gray-85);
}
.closeBtn {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: var(--space-1);
border-radius: var(--border-radius-md);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.messages {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.message {
display: flex;
gap: var(--space-2);
align-items: flex-start;
}
.user {
flex-direction: row-reverse;
.bubble {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
}
}
.assistant .bubble {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
.avatar {
width: 1.5rem;
height: 1.5rem;
border-radius: var(--border-radius-full);
display: flex;
align-items: center;
justify-content: center;
background: var(--color-gray-20);
color: var(--color-muted);
flex-shrink: 0;
}
.bubble {
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-lg);
font-size: var(--text-sm);
line-height: 1.5;
max-width: 260px;
word-wrap: break-word;
}
.inputRow {
display: flex;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-gray-20);
align-items: center;
}
.chatInput {
flex: 1;
input {
font-size: var(--text-sm);
}
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@ -1,116 +0,0 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Send, X, Bot, User, Loader2 } from 'lucide-react';
import { Button, Input } from '@/components';
import styles from './ChatPanel.module.scss';
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
interface ChatPanelProps {
onClose: () => void;
drawingContext?: string;
}
export const ChatPanel: React.FC<ChatPanelProps> = ({ onClose, drawingContext }) => {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'assistant', content: 'I can help you create or refine diagrams. What would you like to do?' },
]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages]);
const handleSend = useCallback(async () => {
if (!input.trim() || isLoading) return;
const userMsg = input.trim();
setInput('');
setMessages((prev) => [...prev, { role: 'user', content: userMsg }]);
setIsLoading(true);
try {
const systemPrompt = drawingContext
? `You are an AI assistant for Excalidraw. The user is working on a diagram. Context: ${drawingContext}. Help them create, refine, or explain their diagram. Respond with concise, actionable suggestions. When suggesting diagram structures, describe elements and their layout clearly.`
: 'You are an AI assistant for Excalidraw. Help users create, refine, or explain diagrams. Respond with concise, actionable suggestions.';
const res = await fetch('/api/v2/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
...messages.slice(-6).map((m) => ({ role: m.role, content: m.content })),
{ role: 'user', content: userMsg },
],
max_tokens: 800,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const assistantContent = data.choices?.[0]?.message?.content || 'Sorry, I could not generate a response.';
setMessages((prev) => [...prev, { role: 'assistant', content: assistantContent }]);
} catch (err) {
setMessages((prev) => [
...prev,
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again later.' },
]);
} finally {
setIsLoading(false);
}
}, [input, isLoading, messages, drawingContext]);
return (
<div className={styles.panel} role="complementary" aria-label="AI chat panel">
<div className={styles.header}>
<div className={styles.title}>
<Bot size={18} aria-hidden="true" />
<span>AI Assistant</span>
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close chat panel">
<X size={16} />
</button>
</div>
<div className={styles.messages} ref={scrollRef} role="log" aria-live="polite" aria-atomic="false">
{messages.map((msg, i) => (
<div
key={i}
className={`${styles.message} ${msg.role === 'user' ? styles.user : styles.assistant}`}
>
<div className={styles.avatar} aria-hidden="true">
{msg.role === 'user' ? <User size={14} /> : <Bot size={14} />}
</div>
<div className={styles.bubble}>{msg.content}</div>
</div>
))}
{isLoading && (
<div className={`${styles.message} ${styles.assistant}`}>
<div className={styles.avatar} aria-hidden="true"><Bot size={14} /></div>
<div className={styles.bubble}><Loader2 size={16} className={styles.spinner} /></div>
</div>
)}
</div>
<div className={styles.inputRow}>
<Input
className={styles.chatInput}
placeholder="Ask about your diagram..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
aria-label="Chat input"
/>
<Button size="sm" onClick={handleSend} disabled={isLoading || !input.trim()} aria-label="Send message">
<Send size={16} />
</Button>
</div>
</div>
);
};
+17 -1
View File
@@ -16,6 +16,7 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
const [query, setQuery] = useState('');
const [results, setResults] = useState<Drawing[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
@@ -56,6 +57,21 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
}
};
const handleCreateDrawing = async () => {
setIsCreating(true);
try {
const drawing = await api.drawings.create({
title: 'Untitled Drawing',
visibility: 'team',
});
navigate(`/drawing/${drawing.id}`);
} catch (err) {
console.error('Failed to create drawing:', err);
} finally {
setIsCreating(false);
}
};
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!searchRef.current?.contains(e.target as Node)) {
@@ -117,7 +133,7 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
<button className={styles.iconButton} aria-label="Notifications" title="Notifications">
<Bell size={20} aria-hidden="true" />
</button>
<Button>
<Button onClick={handleCreateDrawing} loading={isCreating}>
<Plus size={18} />
{t('dashboard.newDrawing')}
</Button>
@@ -65,20 +65,36 @@
}
.logo {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-primary);
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.logoImg {
width: 28px;
height: 28px;
.logoMark {
width: 32px;
height: 32px;
border: 2px solid var(--color-gray-85);
border-radius: 9px;
color: var(--color-gray-85);
background: var(--color-primary-light);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--text-lg);
font-weight: 800;
transform: rotate(-4deg);
flex-shrink: 0;
}
.logoText {
color: var(--color-gray-85);
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
}
.sidebarCloseBtn {
display: none;
background: none;
+2 -1
View File
@@ -37,7 +37,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ open, onClose }) => {
>
<div className={styles.sidebarHeader}>
<div className={styles.logo}>
<img src="https://plus.excalidraw.com/images/logo.svg" alt="Excalidraw" className={styles.logoImg} />
<span className={styles.logoMark} aria-hidden="true">E</span>
<span className={styles.logoText}>Excalidraw</span>
</div>
{onClose && (
<button
@@ -14,6 +14,7 @@
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
border: 1px solid var(--color-gray-20);
box-shadow: var(--modal-shadow);
width: 100%;
max-width: 420px;
+5 -4
View File
@@ -46,6 +46,7 @@ export const Modal: React.FC<ModalProps> = ({
}, [isOpen, onCancel, onClose]);
if (!isOpen) return null;
const close = () => onCancel?.() ?? onClose?.();
const iconMap = {
confirm: <AlertTriangle size={24} className={styles.iconWarning} />,
@@ -59,7 +60,7 @@ export const Modal: React.FC<ModalProps> = ({
className={styles.overlay}
onClick={(e) => {
if (e.target === overlayRef.current) {
onCancel?.() ?? onClose?.();
close();
}
}}
role="dialog"
@@ -72,7 +73,7 @@ export const Modal: React.FC<ModalProps> = ({
<h3 id="modal-title" className={styles.title}>{title}</h3>
<button
className={styles.closeBtn}
onClick={() => onCancel?.() ?? onClose?.()}
onClick={close}
aria-label="Close"
>
<X size={18} />
@@ -83,14 +84,14 @@ export const Modal: React.FC<ModalProps> = ({
{type === 'confirm' && (
<button
className={styles.btnSecondary}
onClick={() => onCancel?.() ?? onClose?.()}
onClick={close}
>
{cancelText}
</button>
)}
<button
className={type === 'alert' ? styles.btnDanger : styles.btnPrimary}
onClick={() => onConfirm?.() ?? onClose?.()}
onClick={() => onConfirm?.() ?? close()}
>
{confirmText}
</button>
@@ -1,9 +1,9 @@
import React from 'react';
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool } from 'lucide-react';
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool, KanbanSquare, MessageSquare, PanelsTopLeft, GitFork } from 'lucide-react';
import { Card } from '@/components';
import styles from './TemplatePicker.module.scss';
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow';
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow' | 'kanban' | 'meeting' | 'wireframe' | 'mindmap';
interface TemplatePickerProps {
isOpen: boolean;
@@ -85,6 +85,10 @@ function makeText(x: number, y: number, text: string, fontSize = 20) {
function makeCheckbox(x: number, y: number, checked = false) {
const box = makeHandDrawnRect(x, y, 20, 20);
(box as any).backgroundColor = checked ? '#a5eba8' : 'transparent';
(box as any).customData = {
templateRole: 'checkbox',
checked,
};
return box;
}
@@ -134,6 +138,58 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, any[]> = {
makeHandDrawnRect(200, 350, 200, 60),
makeText(230, 370, 'End', 20),
],
kanban: [
makeText(50, 40, 'Kanban Board', 30),
makeHandDrawnRect(50, 100, 180, 320),
makeHandDrawnRect(260, 100, 180, 320),
makeHandDrawnRect(470, 100, 180, 320),
makeText(75, 120, 'Backlog', 20),
makeText(285, 120, 'Doing', 20),
makeText(495, 120, 'Done', 20),
makeHandDrawnRect(70, 170, 140, 70),
makeText(85, 190, 'User research', 16),
makeHandDrawnRect(280, 170, 140, 70),
makeText(295, 190, 'Sketch flow', 16),
makeHandDrawnRect(490, 170, 140, 70),
makeText(505, 190, 'Project brief', 16),
],
meeting: [
makeText(50, 40, 'Meeting Notes', 30),
makeHandDrawnRect(50, 100, 560, 70),
makeText(70, 120, 'Agenda', 20),
makeText(70, 150, '- Topic one'),
makeHandDrawnRect(50, 200, 560, 100),
makeText(70, 220, 'Decisions', 20),
makeText(70, 250, '- Decision made'),
makeHandDrawnRect(50, 330, 560, 120),
makeText(70, 350, 'Action Items', 20),
makeCheckbox(70, 390, false),
makeText(105, 390, 'Owner and next step', 18),
],
wireframe: [
makeText(50, 35, 'Page Wireframe', 30),
makeHandDrawnRect(50, 90, 620, 60),
makeText(75, 110, 'Navigation', 18),
makeHandDrawnRect(50, 180, 280, 170),
makeText(75, 205, 'Hero copy', 22),
makeHandDrawnRect(360, 180, 310, 170),
makeText(385, 205, 'Preview area', 22),
makeHandDrawnRect(50, 380, 190, 110),
makeHandDrawnRect(265, 380, 190, 110),
makeHandDrawnRect(480, 380, 190, 110),
],
mindmap: [
makeHandDrawnRect(240, 200, 200, 70),
makeText(275, 220, 'Main idea', 22),
makeHandDrawnRect(50, 80, 150, 55),
makeText(75, 96, 'Research', 18),
makeHandDrawnRect(490, 80, 150, 55),
makeText(520, 96, 'Design', 18),
makeHandDrawnRect(50, 350, 150, 55),
makeText(80, 366, 'Build', 18),
makeHandDrawnRect(490, 350, 150, 55),
makeText(520, 366, 'Review', 18),
],
};
const OPTIONS: TemplateOption[] = [
@@ -142,6 +198,10 @@ const OPTIONS: TemplateOption[] = [
{ id: 'checklist', label: 'Checklist', description: 'Simple checklist with status', icon: CheckSquare, elements: [] },
{ id: 'list', label: 'Bullet List', description: 'Bulleted list with notes area', icon: List, elements: [] },
{ id: 'flow', label: 'Flow Chart', description: 'Simple process flow diagram', icon: ArrowRight, elements: [] },
{ id: 'kanban', label: 'Kanban Board', description: 'Three editable work columns', icon: KanbanSquare, elements: [] },
{ id: 'meeting', label: 'Meeting Notes', description: 'Agenda, decisions, actions', icon: MessageSquare, elements: [] },
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: PanelsTopLeft, elements: [] },
{ id: 'mindmap', label: 'Mind Map', description: 'Branching idea map', icon: GitFork, elements: [] },
];
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
-1
View File
@@ -4,7 +4,6 @@ export { Input } from './Input/Input';
export { AppLayout } from './Layout/AppLayout';
export { CommandPalette } from './CommandPalette/CommandPalette';
export { TemplatePicker } from './TemplatePicker/TemplatePicker';
export { ChatPanel } from './ChatPanel/ChatPanel';
export { Header } from './Layout/Header';
export { Sidebar } from './Layout/Sidebar';
export { Modal } from './Modal/Modal';
@@ -1,15 +1,21 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
max-width: 1280px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
margin-bottom: var(--space-6);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-6);
padding: var(--space-5) var(--space-6);
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
h1 {
font-size: var(--text-3xl);
@@ -61,12 +67,12 @@
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-6);
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-8);
@media (max-width: 1024px) {
grid-template-columns: repeat(2, 1fr);
@media (max-width: 1180px) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 640px) {
@@ -77,13 +83,21 @@
.statCard {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-4);
align-items: flex-start;
text-align: left;
padding: var(--space-5);
min-height: 150px;
}
.statIcon {
color: var(--color-primary);
width: 40px;
height: 40px;
border-radius: var(--border-radius-md);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-primary-darkest);
background: var(--color-primary-light);
margin-bottom: var(--space-3);
}
@@ -120,6 +134,7 @@
position: absolute;
inset: 0;
border-radius: var(--border-radius-full);
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-darkest));
transition: width 0.4s var(--ease-out);
}
@@ -261,11 +276,13 @@
}
.activityCard {
margin-top: var(--space-6);
margin-top: 0;
}
.activityList {
list-style: none;
max-height: 340px;
overflow: auto;
}
.activityItem {
+22 -39
View File
@@ -1,20 +1,20 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Clock, Star, Users, FileText, Plus, Loader2, FolderPlus, UserPlus, BookOpen, Activity } from 'lucide-react';
import { Button, Card, CardHeader, CardContent, TemplatePicker } from '@/components';
import { Clock, Database, Users, FileText, Plus, Loader2, FolderPlus, UserPlus, Activity } from 'lucide-react';
import { Button, Card, CardHeader, CardContent } from '@/components';
import { useDrawingStore, useAuthStore } from '@/stores';
import { api } from '@/services';
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import type { PickedTemplate } from '@/components/TemplatePicker/TemplatePicker';
import styles from './Dashboard.module.scss';
const StatChart: React.FC<{ value: number; max: number; color?: string }> = ({ value, max, color = '#6965db' }) => {
const pct = max > 0 ? (value / max) * 100 : 0;
const ACTIVITY_LIMIT = 5;
const StatChart: React.FC<{ value: number; max: number }> = ({ value, max }) => {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
return (
<div className={styles.chartBarWrap} aria-hidden="true">
<div className={styles.chartBarBg} />
<div className={styles.chartBar} style={{ width: `${pct}%`, background: color }} />
<div className={styles.chartBar} style={{ width: `${pct}%` }} />
</div>
);
};
@@ -25,7 +25,6 @@ export const Dashboard: React.FC = () => {
const { recentDrawings, setRecentDrawings, activity, setActivity } = useDrawingStore();
const { user } = useAuthStore();
const [isCreating, setIsCreating] = useState(false);
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [statsData, setStatsData] = useState({
teams: 0,
members: 0,
@@ -56,21 +55,14 @@ export const Dashboard: React.FC = () => {
loadData();
}, [setRecentDrawings, setActivity]);
const handleCreateDrawing = async (template: PickedTemplate = 'blank') => {
const handleCreateDrawing = async () => {
setIsCreating(true);
try {
const newDrawing = await api.drawings.create({
title: template === 'blank' ? 'Untitled Drawing' : `${template.charAt(0).toUpperCase() + template.slice(1)}`,
title: 'Untitled Drawing',
visibility: 'team',
});
setRecentDrawings([newDrawing, ...recentDrawings]);
if (template !== 'blank' && BUILTIN_TEMPLATES[template]) {
localStorage.setItem(`template_${newDrawing.id}`, JSON.stringify({
elements: BUILTIN_TEMPLATES[template],
appState: {},
files: {},
}));
}
navigate(`/drawing/${newDrawing.id}`);
} catch (err) {
console.error('Failed to create drawing:', err);
@@ -88,14 +80,18 @@ export const Dashboard: React.FC = () => {
};
const maxStat = Math.max(statsData.drawings, statsData.projects + statsData.folders, statsData.teams, statsData.revisions, 1);
const storageMax = Math.max(Number(statsData.storage_bytes), 1024 * 1024);
const stats = [
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, icon: FileText, color: '#6965db' },
{ label: t('dashboard.stats.projects'), value: statsData.projects + statsData.folders, icon: FolderPlus, color: '#4dabf7' },
{ label: t('dashboard.stats.teams'), value: statsData.teams, icon: Users, color: '#51cf66' },
{ label: t('dashboard.stats.revisions'), value: statsData.revisions, icon: Clock, color: '#fcc419' },
{ label: t('dashboard.stats.storage'), value: formatBytes(Number(statsData.storage_bytes)), raw: statsData.storage_bytes, icon: Star, color: '#ff6b6b' },
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, chartValue: statsData.drawings, max: maxStat, icon: FileText },
{ label: t('dashboard.stats.projects'), value: statsData.projects + statsData.folders, chartValue: statsData.projects + statsData.folders, max: maxStat, icon: FolderPlus },
{ label: t('dashboard.stats.teams'), value: statsData.teams, chartValue: statsData.teams, max: maxStat, icon: Users },
{ label: t('dashboard.stats.revisions'), value: statsData.revisions, chartValue: statsData.revisions, max: maxStat, icon: Clock },
{ label: t('dashboard.stats.storage'), value: formatBytes(Number(statsData.storage_bytes)), chartValue: Number(statsData.storage_bytes), max: storageMax, icon: Database },
];
const visibleActivity = activity
.filter((event) => event.event_type !== 'revision_created')
.slice(0, ACTIVITY_LIMIT);
return (
<div className={styles.container}>
@@ -105,11 +101,6 @@ export const Dashboard: React.FC = () => {
<p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
</div>
<div className={styles.quickActions}>
<TemplatePicker
isOpen={showTemplatePicker}
onClose={() => setShowTemplatePicker(false)}
onSelect={(t) => { setShowTemplatePicker(false); handleCreateDrawing(t); }}
/>
<Button
variant="secondary"
onClick={() => navigate('/files')}
@@ -127,15 +118,7 @@ export const Dashboard: React.FC = () => {
Invite
</Button>
<Button
variant="secondary"
onClick={() => navigate('/library')}
className={styles.actionBtn}
>
<BookOpen size={16} />
Library
</Button>
<Button
onClick={() => setShowTemplatePicker(true)}
onClick={handleCreateDrawing}
loading={isCreating}
className={styles.createButton}
>
@@ -158,7 +141,7 @@ export const Dashboard: React.FC = () => {
</div>
<div className={styles.statValue}>{stat.value}</div>
<div className={styles.statLabel}>{stat.label}</div>
<StatChart value={typeof stat.value === 'number' ? stat.value : 0} max={maxStat} color={stat.color} />
<StatChart value={stat.chartValue} max={stat.max} />
</CardContent>
</Card>
))}
@@ -235,13 +218,13 @@ export const Dashboard: React.FC = () => {
<h3><Activity size={16} style={{ display: 'inline', marginRight: 8, verticalAlign: 'middle' }} />Recent Activity</h3>
</CardHeader>
<CardContent>
{activity.length === 0 ? (
{visibleActivity.length === 0 ? (
<div className={styles.empty}>
<p className={styles.emptySub}>No recent activity</p>
</div>
) : (
<ul className={styles.activityList}>
{activity.slice(0, 8).map((event) => (
{visibleActivity.map((event) => (
<li key={event.id} className={styles.activityItem}>
<div className={styles.activityAvatar}>
{event.actor?.name?.[0] || '?'}
+75 -186
View File
@@ -1,8 +1,8 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, Bot, StickyNote, LayoutTemplate, BookOpen, Search } from 'lucide-react';
import { Button, ChatPanel } from '@/components';
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, StickyNote, LayoutTemplate } from 'lucide-react';
import { Button } from '@/components';
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import { useThemeStore } from '@/stores';
import { api } from '@/services';
@@ -56,6 +56,15 @@ function prepareElementsForImport(sourceElements: any[], offsetX: number, offset
});
}
function appStateWithoutGrid(appState: Record<string, unknown> = {}) {
return {
...appState,
gridModeEnabled: false,
gridSize: null,
gridStep: null,
};
}
export const Editor: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
@@ -68,7 +77,6 @@ export const Editor: React.FC = () => {
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved');
const [error, setError] = useState<string | null>(null);
const [showRevisions, setShowRevisions] = useState(false);
const [showChat, setShowChat] = useState(false);
const [showNotes, setShowNotes] = useState(false);
const [notes, setNotes] = useState('');
const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
@@ -76,16 +84,10 @@ export const Editor: React.FC = () => {
const currentStateRef = useRef<ExcalidrawState | null>(null);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedDataRef = useRef<string>('');
const lastToggledCheckboxRef = useRef<string | null>(null);
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null);
const [showTemplates, setShowTemplates] = useState(false);
const [showLibrary, setShowLibrary] = useState(false);
const [libraryItems, setLibraryItems] = useState<any[]>([]);
const [libraryFiltered, setLibraryFiltered] = useState<any[]>([]);
const [libraryLoading, setLibraryLoading] = useState(false);
const [libraryError, setLibraryError] = useState('');
const [librarySearch, setLibrarySearch] = useState('');
const [libraryCategory, setLibraryCategory] = useState('All');
// Load drawing data
useEffect(() => {
@@ -105,7 +107,7 @@ export const Editor: React.FC = () => {
const snapshot = JSON.parse(String(revisionsData[0].snapshot));
setInitialData({
elements: snapshot.elements || [],
appState: snapshot.appState || {},
appState: appStateWithoutGrid(snapshot.appState || {}),
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
@@ -116,7 +118,7 @@ export const Editor: React.FC = () => {
const tpl = JSON.parse(pendingTemplate);
setInitialData({
elements: tpl.elements || [],
appState: tpl.appState || {},
appState: appStateWithoutGrid(tpl.appState || {}),
files: tpl.files || {},
});
lastSavedDataRef.current = JSON.stringify(tpl);
@@ -125,7 +127,7 @@ export const Editor: React.FC = () => {
// Start with empty canvas
setInitialData({
elements: [],
appState: {},
appState: appStateWithoutGrid(),
files: {},
});
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
@@ -143,9 +145,48 @@ export const Editor: React.FC = () => {
// Handle changes from Excalidraw
const handleExcalidrawChange = useCallback((elements: readonly unknown[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
const selectedCheckbox = selectedIds.length === 1
? (elements as any[]).find((el) => (
el.id === selectedIds[0] &&
!el.isDeleted &&
el.customData?.templateRole === 'checkbox'
))
: null;
if (!selectedCheckbox) {
lastToggledCheckboxRef.current = null;
} else if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedCheckbox.id) {
lastToggledCheckboxRef.current = selectedCheckbox.id;
const nextChecked = !selectedCheckbox.customData?.checked;
const nextElements = (elements as any[]).map((el) => (
el.id === selectedCheckbox.id
? {
...el,
backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
customData: {
...(el.customData || {}),
checked: nextChecked,
},
version: (el.version || 1) + 1,
versionNonce: Math.floor(Math.random() * 1000000),
updated: Date.now(),
}
: el
));
excalidrawAPI.updateScene({ elements: nextElements });
currentStateRef.current = {
elements: nextElements,
appState: appStateWithoutGrid(appState),
files,
};
setSaveStatus('unsaved');
return;
}
currentStateRef.current = {
elements: elements as ExcalidrawElement[],
appState,
appState: appStateWithoutGrid(appState),
files,
};
setSaveStatus('unsaved');
@@ -155,7 +196,7 @@ export const Editor: React.FC = () => {
saveTimeoutRef.current = setTimeout(() => {
saveDrawing();
}, 2000);
}, []);
}, [excalidrawAPI]);
// Auto-save functionality
const saveDrawing = useCallback(async () => {
@@ -212,7 +253,7 @@ export const Editor: React.FC = () => {
const snapshot = JSON.parse(String(revision.snapshot));
setInitialData({
elements: snapshot.elements || [],
appState: snapshot.appState || {},
appState: appStateWithoutGrid(snapshot.appState || {}),
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
@@ -240,56 +281,6 @@ export const Editor: React.FC = () => {
};
}, []);
// Load library marketplace when panel opens
useEffect(() => {
if (!showLibrary || libraryItems.length > 0) return;
const load = async () => {
setLibraryLoading(true);
try {
const res = await fetch('https://libraries.excalidraw.com/libraries.json', {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error('Failed to load libraries');
const data = await res.json();
const items = Object.entries(data).map(([key, lib]: [string, any]) => ({
key,
name: lib.name || key,
description: lib.description || '',
authors: lib.authors || [{ name: 'Unknown' }],
source: `https://libraries.excalidraw.com/${key}.excalidrawlib`,
preview: lib.preview?.startsWith('http') ? lib.preview : `https://libraries.excalidraw.com/${key}.png`,
tags: lib.tags || [],
downloads: lib.downloads || 0,
}));
setLibraryItems(items);
setLibraryFiltered(items);
} catch (err) {
console.error(err);
setLibraryError('Could not load library marketplace.');
} finally {
setLibraryLoading(false);
}
};
load();
}, [showLibrary, libraryItems.length]);
// Filter library items
useEffect(() => {
let result = libraryItems;
if (librarySearch.trim()) {
const q = librarySearch.toLowerCase();
result = result.filter((l: any) =>
l.name.toLowerCase().includes(q) ||
l.description.toLowerCase().includes(q) ||
l.tags.some((t: string) => t.toLowerCase().includes(q))
);
}
if (libraryCategory !== 'All') {
result = result.filter((l: any) => l.tags.some((t: string) => t.toLowerCase() === libraryCategory.toLowerCase()));
}
setLibraryFiltered(result);
}, [librarySearch, libraryCategory, libraryItems]);
const handleLoadTemplate = (templateKey: string) => {
const templateElements = BUILTIN_TEMPLATES[templateKey as keyof typeof BUILTIN_TEMPLATES];
if (!templateElements || !excalidrawAPI) return;
@@ -307,51 +298,29 @@ export const Editor: React.FC = () => {
setSaveStatus('unsaved');
};
const handleLoadLibraryItem = async (item: any) => {
if (!excalidrawAPI || !item.source) return;
try {
const res = await fetch(item.source);
if (!res.ok) throw new Error('Failed to load library');
const libData = await res.json();
let sourceElements: any[] = [];
if (libData.libraryItems && Array.isArray(libData.libraryItems)) {
sourceElements = libData.libraryItems[0]?.elements || [];
} else if (Array.isArray(libData)) {
sourceElements = libData;
} else if (libData.elements && Array.isArray(libData.elements)) {
sourceElements = libData.elements;
}
if (!sourceElements.length) {
alert('This library appears to be empty');
return;
}
const currentElements = excalidrawAPI.getSceneElements?.() || [];
let offsetX = 100;
let offsetY = 100;
if (currentElements.length > 0) {
const maxX = Math.max(...currentElements.map((el: any) => (el.x || 0) + (el.width || 0)));
offsetX = maxX + 100;
}
const newElements = prepareElementsForImport(sourceElements, offsetX, offsetY);
const mergedElements = [...currentElements, ...newElements];
excalidrawAPI.updateScene({ elements: mergedElements });
setShowLibrary(false);
setSaveStatus('unsaved');
} catch (err) {
console.error('Failed to load library item:', err);
alert('Failed to load library item');
}
};
const templateOptions = [
{ id: 'blank', label: 'Blank', description: 'Empty canvas start', icon: null },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks', icon: null },
{ id: 'checklist', label: 'Checklist', description: 'Status checklist', icon: null },
{ id: 'list', label: 'Bullet List', description: 'Bulleted notes', icon: null },
{ id: 'flow', label: 'Flow Chart', description: 'Process diagram', icon: null },
{ id: 'kanban', label: 'Kanban Board', description: 'Backlog, doing, done columns', icon: null },
{ id: 'meeting', label: 'Meeting Notes', description: 'Agenda, decisions, actions', icon: null },
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: null },
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with branches', icon: null },
];
const libraryCategories = ['All', 'Arrows', 'Charts', 'Cloud', 'Devops', 'Diagrams', 'Education', 'Food', 'Frames', 'Gaming', 'Icons', 'Illustrations', 'Machines', 'Misc', 'People', 'Software', 'Systems', 'Tech', 'Workflow'];
useEffect(() => {
if (!excalidrawAPI?.onPointerUp) return undefined;
return excalidrawAPI.onPointerUp((activeTool: { type?: string; locked?: boolean }) => {
if ((activeTool.type === 'line' || activeTool.type === 'arrow') && !activeTool.locked) {
window.setTimeout(() => {
excalidrawAPI.setActiveTool?.({ type: 'selection' });
}, 0);
}
});
}, [excalidrawAPI]);
if (isLoading) {
return (
@@ -391,16 +360,6 @@ export const Editor: React.FC = () => {
</span>
</div>
<div className={styles.right}>
<Button
variant="ghost"
size="sm"
onClick={() => setShowChat(!showChat)}
title="AI Assistant"
aria-pressed={showChat}
aria-label="Toggle AI chat panel"
>
<Bot size={16} />
</Button>
<Button
variant="ghost"
size="sm"
@@ -434,27 +393,17 @@ export const Editor: React.FC = () => {
<Button
variant="ghost"
size="sm"
onClick={() => { setShowTemplates(!showTemplates); setShowLibrary(false); }}
onClick={() => setShowTemplates(!showTemplates)}
title="Templates"
aria-pressed={showTemplates}
aria-label="Toggle templates panel"
>
<LayoutTemplate size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setShowLibrary(!showLibrary); setShowTemplates(false); }}
title="Library Marketplace"
aria-pressed={showLibrary}
aria-label="Toggle library panel"
>
<BookOpen size={16} />
</Button>
</div>
</div>
<div className={styles.canvasWrapper}>
<div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates || showLibrary) ? styles.canvasNarrow : ''}`}>
<div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates) ? styles.canvasNarrow : ''}`}>
{initialData && (
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
<Excalidraw
@@ -462,7 +411,7 @@ export const Editor: React.FC = () => {
initialData={initialData}
onChange={handleExcalidrawChange}
theme={appTheme === 'dark' ? 'dark' : 'light'}
gridModeEnabled={true}
gridModeEnabled={false}
UIOptions={{
canvasActions: {
saveToActiveFile: false,
@@ -550,66 +499,6 @@ export const Editor: React.FC = () => {
</div>
)}
{showLibrary && (
<div className={styles.sidePanel}>
<div className={styles.sidePanelHeader}>
<h3>Library Marketplace</h3>
<Button variant="ghost" size="sm" onClick={() => setShowLibrary(false)} aria-label="Close">
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.sidePanelContent}>
<div className={styles.sidePanelSearch}>
<Search size={14} />
<input
type="text"
placeholder="Search libraries..."
value={librarySearch}
onChange={(e) => setLibrarySearch(e.target.value)}
className={styles.sidePanelInput}
/>
</div>
<select
className={styles.sidePanelSelect}
value={libraryCategory}
onChange={(e) => setLibraryCategory(e.target.value)}
>
{libraryCategories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{libraryLoading && (
<div className={styles.sidePanelLoading}>
<Loader2 size={20} className={styles.spinner} />
<span>Loading...</span>
</div>
)}
{libraryError && (
<div className={styles.sidePanelError}>{libraryError}</div>
)}
{!libraryLoading && !libraryError && libraryFiltered.length === 0 && (
<div className={styles.sidePanelEmpty}>No libraries found</div>
)}
{!libraryLoading && libraryFiltered.map((item: any) => (
<button
key={item.key}
className={styles.sidePanelItem}
onClick={() => handleLoadLibraryItem(item)}
>
<span className={styles.sidePanelItemTitle}>{item.name}</span>
<span className={styles.sidePanelItemDesc}>{item.description || item.tags.slice(0, 3).join(', ')}</span>
</button>
))}
</div>
</div>
)}
{showChat && (
<ChatPanel
onClose={() => setShowChat(false)}
drawingContext={drawing?.title}
/>
)}
</div>
</div>
);
@@ -4,6 +4,9 @@
height: 100%;
display: flex;
flex-direction: column;
max-width: 1320px;
margin: 0 auto;
width: 100%;
}
.header {
@@ -13,6 +16,11 @@
margin-bottom: var(--space-6);
gap: var(--space-4);
flex-wrap: wrap;
padding: var(--space-5);
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
@media (max-width: 640px) {
flex-direction: column;
@@ -94,6 +102,7 @@
display: flex;
gap: var(--space-6);
flex: 1;
min-height: 0;
@media (max-width: 768px) {
flex-direction: column;
@@ -102,8 +111,13 @@
}
.sidebar {
width: 200px;
width: 240px;
flex-shrink: 0;
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
padding: var(--space-3);
align-self: flex-start;
@media (max-width: 768px) {
width: 100%;
@@ -157,6 +171,7 @@
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-4);
align-content: start;
min-width: 0;
@media (max-width: 640px) {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
@@ -175,6 +190,9 @@
text-align: center;
padding: var(--space-16);
color: var(--color-muted);
border: 1px dashed var(--color-gray-30);
border-radius: var(--border-radius-lg);
background: var(--island-bg-color);
}
.emptySub {
@@ -334,6 +352,10 @@
gap: var(--space-2);
margin-bottom: var(--space-3);
flex-wrap: wrap;
padding: var(--space-3);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
}
.newProjectInput {
@@ -382,6 +404,19 @@
}
}
.inlineError {
flex-basis: 100%;
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-danger-text);
background: var(--color-danger-background);
border: 1px solid var(--color-danger-icon-background);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
}
.renameInput {
width: 100%;
background: var(--input-bg-color);
+15 -5
View File
@@ -1,7 +1,7 @@
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 { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle } from 'lucide-react';
import { Card, Button, Modal } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
@@ -28,6 +28,7 @@ export const FileBrowser: React.FC = () => {
// New project (folder) state
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [projectError, setProjectError] = useState('');
// Rename state
const [renamingId, setRenamingId] = useState<string | null>(null);
@@ -142,14 +143,16 @@ export const FileBrowser: React.FC = () => {
const handleCreateFolder = async () => {
const name = newProjectName.trim();
if (!name) return;
setProjectError('');
try {
const newFolder = await api.folders.create({ name });
const newFolder = await api.folders.create({ name, visibility: 'team' });
setFolders([...folders, newFolder]);
setShowNewProject(false);
setNewProjectName('');
navigate(`/files/folder/${newFolder.id}`);
} catch (err) {
console.error('Failed to create project:', err);
setProjectError('We could not create that project. Check the name and try again.');
showModal('alert', 'Error', 'Failed to create project. Please try again.');
}
};
@@ -244,6 +247,7 @@ export const FileBrowser: React.FC = () => {
message={modal.message}
onConfirm={modal.onConfirm}
onCancel={modal.onCancel}
onClose={() => setModal(m => ({ ...m, open: false }))}
confirmText={modal.type === 'confirm' ? 'Delete' : 'OK'}
/>
<div className={styles.container} role="region" aria-label={t('fileBrowser.title')}>
@@ -316,7 +320,7 @@ export const FileBrowser: React.FC = () => {
<Plus size={16} />
New Drawing
</Button>
<Button variant="secondary" onClick={() => { setShowNewProject(true); setNewProjectName(''); }} aria-label="Create new project">
<Button variant="secondary" onClick={() => { setShowNewProject(true); setNewProjectName(''); setProjectError(''); }} aria-label="Create new project">
<Folder size={16} />
New Project
</Button>
@@ -335,12 +339,18 @@ export const FileBrowser: React.FC = () => {
onChange={(e) => setNewProjectName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder();
if (e.key === 'Escape') { setShowNewProject(false); setNewProjectName(''); }
if (e.key === 'Escape') { setShowNewProject(false); setNewProjectName(''); setProjectError(''); }
}}
className={styles.newProjectInput}
/>
<button className={styles.newProjectBtn} onClick={handleCreateFolder}>Create</button>
<button className={styles.newProjectBtnCancel} onClick={() => { setShowNewProject(false); setNewProjectName(''); }}>Cancel</button>
<button className={styles.newProjectBtnCancel} onClick={() => { setShowNewProject(false); setNewProjectName(''); setProjectError(''); }}>Cancel</button>
{projectError && (
<div className={styles.inlineError} role="alert">
<AlertCircle size={14} />
{projectError}
</div>
)}
</div>
)}
<ul className={styles.folderTree} role="tree">
@@ -1,186 +0,0 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-8);
h1 {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-2xl);
font-weight: 700;
margin: 0;
}
}
.subtitle {
color: var(--color-gray-60);
margin-top: var(--space-2);
}
.errorBanner {
background: var(--color-danger-background);
color: var(--color-danger-text);
padding: var(--space-4);
border-radius: var(--border-radius-lg);
margin-bottom: var(--space-6);
}
.filters {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-8);
}
.searchBox {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
padding: var(--space-2) var(--space-4);
input {
border: none;
background: transparent;
outline: none;
flex: 1;
font-size: var(--text-base);
}
}
.categories {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.categoryChip {
padding: var(--space-1) var(--space-3);
border-radius: var(--border-radius-full);
border: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--duration-fast);
&.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
&:hover:not(.active) {
background: var(--color-surface-low);
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}
.libraryCard {
overflow: hidden;
}
.preview {
height: 160px;
background: var(--color-gray-10);
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
color: var(--color-gray-50);
}
.info {
padding: var(--space-4);
}
.name {
font-weight: 600;
margin: 0 0 var(--space-2);
}
.description {
font-size: var(--text-sm);
color: var(--color-gray-60);
margin: 0 0 var(--space-3);
}
.meta {
display: flex;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--color-gray-50);
margin-bottom: var(--space-3);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.tag {
font-size: 11px;
padding: 2px 8px;
border-radius: var(--border-radius-full);
background: var(--color-primary-light);
color: var(--color-primary-darkest);
}
.importBtn {
width: 100%;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: var(--space-4);
}
.spinner {
animation: spin 1s linear infinite;
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-12);
color: var(--color-gray-50);
}
.emptySub {
font-size: var(--text-sm);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -1,183 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Download, Loader2, BookOpen, ExternalLink, Heart, Filter } from 'lucide-react';
import { Button, Card, CardContent, Input } from '@/components';
import { api } from '@/services';
import styles from './LibraryMarketplace.module.scss';
interface LibraryItem {
name: string;
description: string;
authors: { name: string; github?: string }[];
source: string;
preview?: string;
tags: string[];
downloads: number;
}
const CATEGORIES = ['All', 'Arrows', 'Charts', 'Cloud', 'Devops', 'Diagrams', 'Education', 'Food', 'Frames', 'Gaming', 'Icons', 'Illustrations', 'Machines', 'Misc', 'People', 'Software', 'Systems', 'Tech', 'Workflow'];
export const LibraryMarketplace: React.FC = () => {
const navigate = useNavigate();
const [libraries, setLibraries] = useState<LibraryItem[]>([]);
const [filtered, setFiltered] = useState<LibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState('All');
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setLoading(true);
// Try to fetch from excalidraw libraries
const res = await fetch('https://libraries.excalidraw.com/libraries.json', {
headers: { Accept: 'application/json' }
});
if (!res.ok) throw new Error('Failed to load libraries');
const data = await res.json();
const items: LibraryItem[] = Object.entries(data).map(([key, lib]: [string, any]) => ({
name: lib.name || key,
description: lib.description || '',
authors: lib.authors || [{ name: 'Unknown' }],
source: `https://libraries.excalidraw.com/${key}.excalidrawlib`,
preview: lib.preview?.startsWith('http') ? lib.preview : `https://libraries.excalidraw.com/${key}.png`,
tags: lib.tags || [],
downloads: lib.downloads || 0,
}));
setLibraries(items);
setFiltered(items);
} catch (err) {
console.error(err);
setError('Could not load library marketplace. You can still browse libraries at libraries.excalidraw.com');
// Fallback: show some popular libraries as placeholders
setLibraries([
{ name: 'Software Architecture', description: 'Common architecture diagrams and icons', authors: [{ name: 'Excalidraw Community' }], source: '', preview: '', tags: ['Software', 'Architecture'], downloads: 0 },
{ name: 'AWS Icons', description: 'Amazon Web Services icons', authors: [{ name: 'AWS' }], source: '', preview: '', tags: ['Cloud', 'AWS'], downloads: 0 },
{ name: 'Kubernetes', description: 'K8s components and diagrams', authors: [{ name: 'K8s Community' }], source: '', preview: '', tags: ['Devops', 'Cloud'], downloads: 0 },
]);
} finally {
setLoading(false);
}
};
load();
}, []);
useEffect(() => {
let result = libraries;
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter(l => l.name.toLowerCase().includes(q) || l.description.toLowerCase().includes(q) || l.tags.some(t => t.toLowerCase().includes(q)));
}
if (activeCategory !== 'All') {
result = result.filter(l => l.tags.some(t => t.toLowerCase() === activeCategory.toLowerCase()));
}
setFiltered(result);
}, [search, activeCategory, libraries]);
const handleImport = useCallback(async (lib: LibraryItem) => {
if (!lib.source) {
window.open('https://libraries.excalidraw.com', '_blank');
return;
}
try {
// Create a new drawing and navigate to it, the library will be loaded client-side
const drawing = await api.drawings.create({
title: lib.name,
visibility: 'team',
});
// Store selected library in localStorage for the editor to pick up
localStorage.setItem('pending_library', JSON.stringify({ drawingId: drawing.id, source: lib.source }));
navigate(`/drawing/${drawing.id}`);
} catch (err) {
console.error('Failed to create drawing from library:', err);
}
}, [navigate]);
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>Loading library marketplace...</p>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<div>
<h1><BookOpen size={24} /> Library Marketplace</h1>
<p className={styles.subtitle}>Browse and import templates from the Excalidraw community library</p>
</div>
<Button variant="secondary" onClick={() => window.open('https://libraries.excalidraw.com', '_blank')}>
<ExternalLink size={16} /> Open External
</Button>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
<div className={styles.filters}>
<div className={styles.searchBox}>
<Search size={16} />
<Input
placeholder="Search libraries..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className={styles.searchInput}
/>
</div>
<div className={styles.categories}>
<Filter size={16} />
{CATEGORIES.map(cat => (
<button
key={cat}
className={`${styles.categoryChip} ${activeCategory === cat ? styles.active : ''}`}
onClick={() => setActiveCategory(cat)}
>
{cat}
</button>
))}
</div>
</div>
<div className={styles.grid}>
{filtered.length === 0 ? (
<div className={styles.empty}>
<BookOpen size={48} />
<p>No libraries found</p>
<p className={styles.emptySub}>Try a different search or category</p>
</div>
) : filtered.map((lib, idx) => (
<Card key={idx} className={styles.libraryCard} hover>
<div className={styles.preview}>
{lib.preview ? (
<img src={lib.preview} alt={lib.name} loading="lazy" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
) : (
<div className={styles.placeholder}><BookOpen size={32} /></div>
)}
</div>
<CardContent className={styles.info}>
<h4 className={styles.name}>{lib.name}</h4>
<p className={styles.description}>{lib.description || 'No description'}</p>
<div className={styles.meta}>
<span className={styles.authors}>{lib.authors.map(a => a.name).join(', ')}</span>
{lib.downloads > 0 && <span className={styles.downloads}><Download size={12} /> {lib.downloads}</span>}
</div>
<div className={styles.tags}>
{lib.tags.slice(0, 4).map(tag => (
<span key={tag} className={styles.tag}>{tag}</span>
))}
</div>
<Button size="sm" className={styles.importBtn} onClick={() => handleImport(lib)}>
<Heart size={14} /> Import
</Button>
</CardContent>
</Card>
))}
</div>
</div>
);
};