mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 22:02:57 +00:00
update
This commit is contained in:
+15
-38
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] || '?'}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user