feat: full project sync - CI fixes, frontend, workspace API, and all changes

This commit is contained in:
Tomas Dvorak
2026-04-27 09:08:07 +02:00
parent a07fca997e
commit 89b9390c14
109 changed files with 21120 additions and 545 deletions
+8
View File
@@ -0,0 +1,8 @@
.loading-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: var(--text-lg);
color: var(--color-muted);
}
+62
View File
@@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { AppLayout } from './components/Layout/AppLayout';
import { Dashboard } from './pages/Dashboard/Dashboard';
import { Login } from './pages/Auth/Login';
import { Signup } from './pages/Auth/Signup';
import { FileBrowser } from './pages/FileBrowser/FileBrowser';
import { TeamSettings } from './pages/Team/TeamSettings';
import { UserSettings } from './pages/Settings/UserSettings';
import { Editor } from './pages/Editor/Editor';
import { useAuthStore } from './stores';
import { useAuth } from './hooks';
import { CommandPalette } from './components';
import { api } from './services';
import './App.scss';
export const App: React.FC = () => {
useAuth(); // Initialize auth check
const { isAuthenticated, isLoading } = useAuthStore();
const [setupStatus, setSetupStatus] = useState<{ has_users: boolean } | null>(null);
const [setupLoading, setSetupLoading] = useState(true);
useEffect(() => {
if (!isAuthenticated && !isLoading) {
api.auth.setupStatus()
.then(setSetupStatus)
.catch(() => setSetupStatus({ has_users: true }))
.finally(() => setSetupLoading(false));
} else {
setSetupLoading(false);
}
}, [isAuthenticated, isLoading]);
if (isLoading || setupLoading) {
return <div className="loading-screen">Loading...</div>;
}
if (!isAuthenticated) {
const hasUsers = setupStatus?.has_users ?? true;
return (
<Routes>
<Route path="/login" element={hasUsers ? <Login hasUsers={hasUsers} /> : <Navigate to="/signup" replace />} />
<Route path="/signup" element={hasUsers ? <Navigate to="/login" replace /> : <Signup hasUsers={hasUsers} />} />
<Route path="*" element={<Navigate to={hasUsers ? "/login" : "/signup"} replace />} />
</Routes>
);
}
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>
);
};
@@ -0,0 +1,157 @@
@use '../../styles/variables' as *;
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 0.625rem 1rem;
font-family: var(--ui-font);
font-size: var(--text-sm);
font-weight: 500;
line-height: 1.5;
border-radius: var(--border-radius-lg);
border: 1px solid transparent;
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
text-decoration: none;
white-space: nowrap;
user-select: none;
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--color-primary-light);
}
&:active {
transform: scale(0.98);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
// Size variants
&.size-sm {
padding: 0.375rem 0.75rem;
font-size: var(--text-xs);
}
&.size-lg {
padding: 0.75rem 1.5rem;
font-size: var(--text-base);
}
// Full width
&.fullWidth {
width: 100%;
}
}
// Primary variant
.variant-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
&:hover:not(:disabled) {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
&:active {
background: var(--color-primary-darkest);
border-color: var(--color-primary-darkest);
}
}
// Secondary variant
.variant-secondary {
background: var(--color-surface-low);
color: var(--color-on-surface);
border-color: var(--color-surface-high);
&:hover:not(:disabled) {
background: var(--color-surface-high);
border-color: var(--color-gray-30);
}
&:active {
background: var(--color-gray-20);
}
}
// Ghost variant
.variant-ghost {
background: transparent;
color: var(--color-on-surface);
border-color: transparent;
&:hover:not(:disabled) {
background: var(--color-surface-low);
}
&:active {
background: var(--color-surface-high);
}
}
// Danger variant
.variant-danger {
background: var(--color-danger);
color: white;
border-color: var(--color-danger);
&:hover:not(:disabled) {
background: var(--color-danger-dark);
border-color: var(--color-danger-dark);
}
&:active {
background: var(--color-danger-darker);
border-color: var(--color-danger-darker);
}
}
// Icon only
.iconOnly {
padding: 0.5rem;
&.size-sm {
padding: 0.375rem;
}
&.size-lg {
padding: 0.625rem;
}
}
// Loading state
.loading {
position: relative;
color: transparent !important;
&::after {
content: '';
position: absolute;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { clsx } from 'clsx';
import styles from './Button.module.scss';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
fullWidth?: boolean;
children: React.ReactNode;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({
variant = 'primary',
size = 'md',
loading = false,
fullWidth = false,
children,
className,
disabled,
...props
}, ref) => {
const isIconOnly = React.Children.count(children) === 1 &&
React.isValidElement(children) &&
(children.type === 'svg' || String(children.type).includes('Icon'));
return (
<button
ref={ref}
className={clsx(
styles.button,
styles[`variant-${variant}`],
styles[`size-${size}`],
{
[styles.loading]: loading,
[styles.fullWidth]: fullWidth,
[styles.iconOnly]: isIconOnly,
},
className
)}
disabled={disabled || loading}
{...props}
>
{children}
</button>
);
}
);
Button.displayName = 'Button';
+2
View File
@@ -0,0 +1,2 @@
export { Button } from './Button';
export type { ButtonProps } from './Button';
@@ -0,0 +1,34 @@
@use '../../styles/variables' as *;
.card {
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
overflow: hidden;
transition: transform var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.hover {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-island-stronger);
}
}
.header {
padding: var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
}
.content {
padding: var(--space-4);
}
.footer {
padding: var(--space-4);
border-top: 1px solid var(--color-gray-20);
background: var(--color-surface-low);
}
@@ -0,0 +1,22 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Card } from './Card';
describe('Card', () => {
it('renders children', () => {
render(<Card>Hello World</Card>);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('handles click events', () => {
const onClick = vi.fn();
render(<Card onClick={onClick}>Click me</Card>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
});
it('forwards aria-label', () => {
render(<Card aria-label="Test card">Content</Card>);
expect(screen.getByLabelText('Test card')).toBeInTheDocument();
});
});
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import styles from './Card.module.scss';
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
className?: string;
onClick?: () => void;
hover?: boolean;
}
export const Card: React.FC<CardProps> = ({ children, className, onClick, hover = true, ...rest }) => {
return (
<div
className={`${styles.card} ${hover ? styles.hover : ''} ${className || ''}`}
onClick={onClick}
role={onClick ? 'button' : rest.role}
{...rest}
>
{children}
</div>
);
};
export const CardHeader: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => (
<div className={`${styles.header} ${className || ''}`}>{children}</div>
);
export const CardContent: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => (
<div className={`${styles.content} ${className || ''}`}>{children}</div>
);
export const CardFooter: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => (
<div className={`${styles.footer} ${className || ''}`}>{children}</div>
);
@@ -0,0 +1,126 @@
@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); }
}
@@ -0,0 +1,116 @@
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>
);
};
@@ -0,0 +1,115 @@
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
}
.dialog {
width: 100%;
max-width: 560px;
background: var(--color-surface-lowest);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
border: 1px solid var(--color-gray-20);
}
.inputRow {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
}
.inputIcon {
color: var(--color-gray-50);
flex-shrink: 0;
}
.input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: var(--text-base);
color: var(--color-gray-95);
padding: 0;
&::placeholder {
color: var(--color-gray-50);
}
}
.kbd {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-gray-50);
background: var(--color-gray-10);
border: 1px solid var(--color-gray-20);
border-radius: var(--radius-sm);
padding: 2px 6px;
flex-shrink: 0;
}
.list {
max-height: 320px;
overflow-y: auto;
padding: var(--space-2);
}
.item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.1s ease;
&:hover,
&.selected {
background: var(--color-gray-10);
}
}
.itemIcon {
color: var(--color-gray-60);
flex-shrink: 0;
}
.itemLabel {
flex: 1;
font-size: var(--text-sm);
font-weight: 500;
}
.itemShortcut {
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-gray-50);
background: var(--color-gray-10);
border: 1px solid var(--color-gray-20);
border-radius: var(--radius-sm);
padding: 1px 5px;
}
.empty {
padding: var(--space-6);
text-align: center;
font-size: var(--text-sm);
color: var(--color-gray-50);
}
@@ -0,0 +1,179 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, Command, FileText, FolderOpen, Users, Settings, FileCode, LayoutDashboard } from 'lucide-react';
import styles from './CommandPalette.module.scss';
interface CommandItem {
id: string;
label: string;
shortcut?: string;
icon?: React.ElementType;
action: () => void;
}
export const CommandPalette: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const commands: CommandItem[] = [
{
id: 'dashboard',
label: t('sidebar.dashboard'),
icon: LayoutDashboard,
action: () => navigate('/'),
},
{
id: 'files',
label: t('sidebar.files'),
icon: FolderOpen,
action: () => navigate('/files'),
},
{
id: 'templates',
label: t('sidebar.templates'),
icon: FileCode,
action: () => navigate('/templates'),
},
{
id: 'team',
label: t('sidebar.team'),
icon: Users,
action: () => navigate('/team'),
},
{
id: 'settings',
label: t('sidebar.settings'),
icon: Settings,
action: () => navigate('/settings'),
},
{
id: 'new-drawing',
label: t('dashboard.newDrawing'),
icon: FileText,
action: () => navigate('/drawing/new'),
},
];
const filtered = query.trim()
? commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase()))
: commands;
const openPalette = useCallback(() => {
setIsOpen(true);
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 0);
}, []);
const closePalette = useCallback(() => {
setIsOpen(false);
setQuery('');
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
openPalette();
}
if (e.key === 'Escape') {
closePalette();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [openPalette, closePalette]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const cmd = filtered[selectedIndex];
if (cmd) {
cmd.action();
closePalette();
}
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen, filtered, selectedIndex, closePalette]);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
if (!isOpen) return null;
return (
<div
className={styles.overlay}
onClick={closePalette}
role="dialog"
aria-modal="true"
aria-label="Command palette"
>
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
<div className={styles.inputRow}>
<Search size={18} className={styles.inputIcon} aria-hidden="true" />
<input
ref={inputRef}
type="text"
className={styles.input}
placeholder={t('commandPalette.placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
aria-label="Search commands"
aria-autocomplete="list"
aria-controls="command-list"
aria-activedescendant={filtered[selectedIndex] ? `cmd-${filtered[selectedIndex].id}` : undefined}
/>
<span className={styles.kbd} aria-label="Keyboard shortcut">
<Command size={12} aria-hidden="true" /> K
</span>
</div>
<div ref={listRef} className={styles.list} id="command-list" role="listbox">
{filtered.length === 0 ? (
<div className={styles.empty}>{t('commandPalette.noResults')}</div>
) : (
filtered.map((cmd, index) => {
const Icon = cmd.icon;
return (
<button
key={cmd.id}
id={`cmd-${cmd.id}`}
className={`${styles.item} ${index === selectedIndex ? styles.selected : ''}`}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => {
cmd.action();
closePalette();
}}
role="option"
aria-selected={index === selectedIndex}
>
{Icon && <Icon size={16} className={styles.itemIcon} aria-hidden="true" />}
<span className={styles.itemLabel}>{cmd.label}</span>
{cmd.shortcut && <span className={styles.itemShortcut}>{cmd.shortcut}</span>}
</button>
);
})
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,64 @@
@use '../../styles/variables' as *;
.wrapper {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--input-label-color);
}
.input {
padding: 0.5rem 0.75rem;
font-size: var(--text-base);
font-family: var(--ui-font);
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
color: var(--color-on-surface);
transition: border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
&:hover:not(:focus):not(:disabled) {
background: var(--input-hover-bg-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&::placeholder {
color: var(--color-gray-50);
}
}
.error {
border-color: var(--color-danger);
&:focus {
border-color: var(--color-danger);
box-shadow: 0 0 0 3px var(--color-danger-background);
}
}
.errorText {
font-size: var(--text-xs);
color: var(--color-danger);
}
.helperText {
font-size: var(--text-xs);
color: var(--color-muted);
}
+45
View File
@@ -0,0 +1,45 @@
import React, { forwardRef } from 'react';
import styles from './Input.module.scss';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, className, id, ...props }, ref) => {
const inputId = id || React.useId();
return (
<div className={styles.wrapper}>
{label && (
<label htmlFor={inputId} className={styles.label}>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={`${styles.input} ${error ? styles.error : ''} ${className || ''}`}
aria-invalid={error ? 'true' : undefined}
aria-describedby={
error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined
}
{...props}
/>
{error && (
<span id={`${inputId}-error`} className={styles.errorText} role="alert">
{error}
</span>
)}
{helperText && !error && (
<span id={`${inputId}-helper`} className={styles.helperText}>
{helperText}
</span>
)}
</div>
);
}
);
Input.displayName = 'Input';
@@ -0,0 +1,39 @@
import React, { useState, useCallback } from 'react';
import { Menu } from 'lucide-react';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import styles from './Layout.module.scss';
export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const openSidebar = useCallback(() => setSidebarOpen(true), []);
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
return (
<div className={styles.layout}>
<Sidebar open={sidebarOpen} onClose={closeSidebar} />
{sidebarOpen && (
<div
className={styles.sidebarOverlay}
onClick={closeSidebar}
role="presentation"
aria-hidden="true"
/>
)}
<div className={styles.main}>
<Header>
<button
className={styles.mobileMenuToggle}
onClick={openSidebar}
aria-label="Open menu"
aria-expanded={sidebarOpen}
aria-controls="app-sidebar"
>
<Menu size={20} />
</button>
</Header>
<div className={styles.content}>{children}</div>
</div>
</div>
);
};
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, Bell, Plus, FileText, Loader2, Sun, Moon } from 'lucide-react';
import { Button } from '@/components';
import { useThemeStore } from '@/stores';
import { api } from '@/services';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { Drawing } from '@/types';
import styles from './Layout.module.scss';
export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { theme, toggleTheme } = useThemeStore();
const [query, setQuery] = useState('');
const [results, setResults] = useState<Drawing[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const performSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
return;
}
setIsSearching(true);
try {
const res = await api.search.get(q);
setResults(res);
} catch (err) {
console.error('Search failed:', err);
setResults([]);
} finally {
setIsSearching(false);
}
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
setShowResults(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => performSearch(val), 250);
};
const handleSelect = (drawing: Drawing) => {
setQuery('');
setResults([]);
setShowResults(false);
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
};
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!searchRef.current?.contains(e.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
return (
<header className={styles.header}>
{children}
<div className={styles.search} ref={searchRef} role="search" aria-label="Search drawings">
<Search size={18} />
<input
type="text"
placeholder={t('common.search') + '...'}
value={query}
onChange={handleChange}
onFocus={() => query && setShowResults(true)}
aria-label="Search drawings"
aria-autocomplete="list"
aria-controls="search-results"
aria-expanded={showResults}
/>
{isSearching && <Loader2 size={14} className={styles.searchSpinner} />}
{showResults && (query.trim() || results.length > 0) && (
<div id="search-results" className={styles.searchDropdown} role="listbox">
{results.length === 0 ? (
<div className={styles.searchEmpty}>
{isSearching ? t('common.loading') : t('search.noResults')}
</div>
) : (
results.map((drawing) => (
<button
key={drawing.id}
className={styles.searchResult}
onClick={() => handleSelect(drawing)}
role="option"
aria-label={`Open drawing ${drawing.title}`}
>
<FileText size={14} aria-hidden="true" />
<span className={styles.searchResultTitle}>{drawing.title}</span>
{drawing.owner?.name && (
<span className={styles.searchResultMeta}>{drawing.owner.name}</span>
)}
</button>
))
)}
</div>
)}
</div>
<div className={styles.actions}>
<button className={styles.iconButton} onClick={toggleTheme} title={t('userSettings.theme')} aria-label={t('userSettings.theme')}>
{theme === 'light' ? <Sun size={20} aria-hidden="true" /> : <Moon size={20} aria-hidden="true" />}
</button>
<button className={styles.iconButton} aria-label="Notifications" title="Notifications">
<Bell size={20} aria-hidden="true" />
</button>
<Button>
<Plus size={18} />
{t('dashboard.newDrawing')}
</Button>
</div>
</header>
);
};
@@ -0,0 +1,340 @@
@use '../../styles/variables' as *;
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-width);
background: var(--island-bg-color);
border-right: 1px solid var(--color-gray-20);
display: flex;
flex-direction: column;
padding: var(--space-4);
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
transition: transform var(--duration-normal) var(--ease-out);
@media (max-width: 768px) {
transform: translateX(-100%);
&.open {
transform: translateX(0);
}
}
}
.sidebarOverlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 99;
@media (max-width: 768px) {
display: block;
}
}
.mobileMenuToggle {
display: none;
background: none;
border: none;
color: var(--color-gray-70);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
@media (max-width: 768px) {
display: flex;
align-items: center;
justify-content: center;
}
}
.sidebarHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-8);
padding: var(--space-2) 0;
}
.logo {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.logoImg {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.sidebarCloseBtn {
display: none;
background: none;
border: none;
color: var(--color-gray-70);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
@media (max-width: 768px) {
display: flex;
align-items: center;
justify-content: center;
}
}
.nav {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex: 1;
}
.navItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
color: var(--color-gray-70);
text-decoration: none;
border-radius: var(--border-radius-md);
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
}
}
.footer {
border-top: 1px solid var(--color-gray-20);
padding-top: var(--space-4);
margin-top: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
}
.user {
display: flex;
align-items: center;
gap: var(--space-3);
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--text-sm);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.userName {
font-size: var(--text-sm);
color: var(--color-gray-70);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logout {
background: none;
border: none;
color: var(--color-gray-50);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-danger);
}
}
.main {
flex: 1;
margin-left: var(--sidebar-width);
display: flex;
flex-direction: column;
@media (max-width: 768px) {
margin-left: 0;
}
}
.header {
height: var(--header-height);
background: var(--island-bg-color);
border-bottom: 1px solid var(--color-gray-20);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-6);
position: sticky;
top: 0;
z-index: 50;
@media (max-width: 768px) {
padding: 0 var(--space-4);
}
}
.search {
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);
width: 400px;
transition: all var(--duration-fast) var(--ease-out);
@media (max-width: 768px) {
width: auto;
flex: 1;
min-width: 0;
}
input {
border: none;
background: transparent;
outline: none;
font-size: var(--text-sm);
color: var(--color-on-surface);
width: 100%;
&::placeholder {
color: var(--color-gray-50);
}
}
&:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.iconButton {
background: none;
border: none;
color: var(--color-gray-60);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.content {
flex: 1;
padding: var(--space-6);
overflow: auto;
}
.searchSpinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.searchDropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-gray-20);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
max-height: 300px;
overflow-y: auto;
z-index: 100;
}
.searchResult {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.1s ease;
&:hover {
background: var(--color-gray-10);
}
}
.searchResultTitle {
flex: 1;
font-size: var(--text-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.searchResultMeta {
font-size: 11px;
color: var(--color-gray-50);
}
.searchEmpty {
padding: var(--space-4);
text-align: center;
font-size: var(--text-sm);
color: var(--color-gray-50);
}
@@ -0,0 +1,92 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
LayoutDashboard,
FolderOpen,
Users,
Settings,
LogOut,
X,
} from 'lucide-react';
import { useAuthStore } from '@/stores';
import styles from './Layout.module.scss';
interface SidebarProps {
open?: boolean;
onClose?: () => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ open, onClose }) => {
const { t } = useTranslation();
const { user, logout } = useAuthStore();
const navItems = [
{ to: '/', icon: LayoutDashboard, label: t('sidebar.dashboard') },
{ to: '/files', icon: FolderOpen, label: t('sidebar.projects') },
{ to: '/team', icon: Users, label: t('sidebar.team') },
{ to: '/settings', icon: Settings, label: t('sidebar.settings') },
];
return (
<aside
id="app-sidebar"
className={`${styles.sidebar} ${open ? styles.open : ''}`}
role="navigation"
aria-label="Main navigation"
>
<div className={styles.sidebarHeader}>
<div className={styles.logo}>
<img src="https://plus.excalidraw.com/images/logo.svg" alt="Excalidraw" className={styles.logoImg} />
</div>
{onClose && (
<button
className={styles.sidebarCloseBtn}
onClick={onClose}
aria-label="Close menu"
>
<X size={20} />
</button>
)}
</div>
<nav className={styles.nav}>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`${styles.navItem} ${isActive ? styles.active : ''}`
}
onClick={onClose}
aria-label={item.label}
>
<item.icon size={20} aria-hidden="true" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className={styles.footer}>
<div className={styles.user}>
<div className={styles.avatar}>
{user?.avatar_url ? (
<img src={user.avatar_url} alt={user.name} />
) : (
user?.name?.[0] || '?'
)}
</div>
<span className={styles.userName}>{user?.name}</span>
</div>
<button
className={styles.logout}
onClick={logout}
aria-label="Log out"
title="Log out"
>
<LogOut size={18} aria-hidden="true" />
</button>
</div>
</aside>
);
};
@@ -0,0 +1,135 @@
@use '../../styles/variables' as *;
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.15s ease;
}
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--modal-shadow);
width: 100%;
max-width: 420px;
padding: var(--space-6);
animation: slideUp 0.2s ease;
}
.header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
}
.iconWarning {
color: var(--color-warning-darker);
}
.iconDanger {
color: var(--color-danger);
}
.iconInfo {
color: var(--color-primary);
}
.title {
flex: 1;
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-on-surface);
margin: 0;
}
.closeBtn {
background: none;
border: none;
color: var(--color-gray-60);
cursor: pointer;
padding: var(--space-1);
border-radius: var(--border-radius-md);
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.message {
font-size: var(--text-sm);
color: var(--color-gray-70);
line-height: 1.5;
margin-bottom: var(--space-6);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
.btnPrimary,
.btnSecondary,
.btnDanger {
padding: var(--space-2) var(--space-4);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
border: none;
transition: all var(--duration-fast) var(--ease-out);
}
.btnPrimary {
background: var(--color-primary);
color: white;
&:hover {
background: var(--color-primary-hover);
}
}
.btnSecondary {
background: var(--color-surface-low);
color: var(--color-on-surface);
border: 1px solid var(--color-gray-20);
&:hover {
background: var(--color-gray-10);
}
}
.btnDanger {
background: var(--color-danger);
color: white;
&:hover {
background: var(--color-danger-dark);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
+101
View File
@@ -0,0 +1,101 @@
import React, { useEffect, useRef } from 'react';
import { X, AlertTriangle, Info } from 'lucide-react';
import styles from './Modal.module.scss';
export type ModalType = 'confirm' | 'alert' | 'info';
interface ModalProps {
isOpen: boolean;
title: string;
message: string;
type?: ModalType;
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
onCancel?: () => void;
onClose?: () => void;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
title,
message,
type = 'info',
confirmText = 'OK',
cancelText = 'Cancel',
onConfirm,
onCancel,
onClose,
}) => {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel?.() ?? onClose?.();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKey);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKey);
document.body.style.overflow = '';
};
}, [isOpen, onCancel, onClose]);
if (!isOpen) return null;
const iconMap = {
confirm: <AlertTriangle size={24} className={styles.iconWarning} />,
alert: <AlertTriangle size={24} className={styles.iconDanger} />,
info: <Info size={24} className={styles.iconInfo} />,
};
return (
<div
ref={overlayRef}
className={styles.overlay}
onClick={(e) => {
if (e.target === overlayRef.current) {
onCancel?.() ?? onClose?.();
}
}}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className={styles.modal}>
<div className={styles.header}>
<div className={styles.icon}>{iconMap[type]}</div>
<h3 id="modal-title" className={styles.title}>{title}</h3>
<button
className={styles.closeBtn}
onClick={() => onCancel?.() ?? onClose?.()}
aria-label="Close"
>
<X size={18} />
</button>
</div>
<p className={styles.message}>{message}</p>
<div className={styles.actions}>
{type === 'confirm' && (
<button
className={styles.btnSecondary}
onClick={() => onCancel?.() ?? onClose?.()}
>
{cancelText}
</button>
)}
<button
className={type === 'alert' ? styles.btnDanger : styles.btnPrimary}
onClick={() => onConfirm?.() ?? onClose?.()}
>
{confirmText}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,92 @@
@use '../../styles/variables' as *;
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
}
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--modal-shadow);
width: 100%;
max-width: 720px;
max-height: 80vh;
overflow-y: auto;
padding: var(--space-6);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
h2 {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-xl);
font-weight: 600;
margin: 0;
}
}
.closeBtn {
background: none;
border: none;
cursor: pointer;
color: var(--color-gray-60);
padding: var(--space-2);
border-radius: var(--border-radius-md);
&:hover {
background: var(--color-gray-10);
color: var(--color-gray-90);
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-4);
}
.card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-6) var(--space-4);
cursor: pointer;
border: 2px solid transparent;
transition: all var(--duration-fast);
&:hover {
border-color: var(--color-primary);
transform: translateY(-2px);
}
}
.iconWrap {
color: var(--color-primary);
margin-bottom: var(--space-3);
}
.title {
font-weight: 600;
margin: 0 0 var(--space-1);
font-size: var(--text-base);
}
.desc {
font-size: var(--text-sm);
color: var(--color-gray-60);
margin: 0;
}
@@ -0,0 +1,173 @@
import React from 'react';
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool } from 'lucide-react';
import { Card } from '@/components';
import styles from './TemplatePicker.module.scss';
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow';
interface TemplatePickerProps {
isOpen: boolean;
onClose: () => void;
onSelect: (template: PickedTemplate) => void;
}
interface TemplateOption {
id: PickedTemplate;
label: string;
description: string;
icon: React.ElementType;
elements: any[];
}
function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string) {
return {
id: `el-${Math.random().toString(36).slice(2)}`,
type: 'rectangle',
x, y, width: w, height: h,
angle: 0,
strokeColor: '#1e1e1e',
backgroundColor: 'transparent',
fillStyle: 'hachure',
strokeWidth: 1,
strokeStyle: 'solid',
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3, value: 32 },
seed: Math.floor(Math.random() * 10000),
version: 2,
versionNonce: Math.floor(Math.random() * 100000),
isDeleted: false,
boundElements: text ? [{ id: `txt-${Math.random().toString(36).slice(2)}`, type: 'text' }] : [],
updated: Date.now(),
link: null,
locked: false,
};
}
function makeText(x: number, y: number, text: string, fontSize = 20) {
return {
id: `txt-${Math.random().toString(36).slice(2)}`,
type: 'text',
x, y, width: text.length * (fontSize * 0.55), height: fontSize * 1.4,
angle: 0,
strokeColor: '#1e1e1e',
backgroundColor: 'transparent',
fillStyle: 'hachure',
strokeWidth: 1,
strokeStyle: 'solid',
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: null,
seed: Math.floor(Math.random() * 10000),
version: 2,
versionNonce: Math.floor(Math.random() * 100000),
isDeleted: false,
boundElements: [],
updated: Date.now(),
link: null,
locked: false,
text,
fontSize,
fontFamily: 1,
textAlign: 'left',
verticalAlign: 'top',
baseline: 18,
containerId: null,
originalText: text,
lineHeight: 1.25,
};
}
function makeCheckbox(x: number, y: number, checked = false) {
const box = makeHandDrawnRect(x, y, 20, 20);
(box as any).backgroundColor = checked ? '#a5eba8' : 'transparent';
return box;
}
export const BUILTIN_TEMPLATES: Record<PickedTemplate, any[]> = {
blank: [],
todo: [
makeHandDrawnRect(50, 50, 500, 50),
makeText(70, 65, 'To-Do List', 28),
makeCheckbox(60, 130, false),
makeText(90, 130, 'First task'),
makeCheckbox(60, 170, false),
makeText(90, 170, 'Second task'),
makeCheckbox(60, 210, false),
makeText(90, 210, 'Third task'),
makeHandDrawnRect(50, 280, 500, 2),
makeText(60, 300, 'Notes:', 18),
],
checklist: [
makeHandDrawnRect(50, 50, 500, 50),
makeText(70, 65, 'Checklist', 28),
makeCheckbox(60, 130, true),
makeText(90, 130, 'Completed item', 18),
makeCheckbox(60, 170, false),
makeText(90, 170, 'Pending item', 18),
makeCheckbox(60, 210, false),
makeText(90, 210, 'Another task', 18),
makeHandDrawnRect(60, 250, 480, 1),
makeText(70, 265, 'Add more items below', 14),
],
list: [
makeHandDrawnRect(50, 50, 500, 50),
makeText(70, 65, 'Bullet List', 28),
makeText(60, 130, '- First bullet point'),
makeText(60, 170, '- Second bullet point'),
makeText(60, 210, '- Third bullet point'),
makeText(60, 250, '- Fourth item with details'),
makeHandDrawnRect(50, 300, 500, 2),
makeText(60, 320, 'Add your own items...', 14),
],
flow: [
makeHandDrawnRect(200, 50, 200, 60),
makeText(230, 70, 'Start', 20),
makeHandDrawnRect(200, 150, 200, 60),
makeText(220, 170, 'Process A', 20),
makeHandDrawnRect(200, 250, 200, 60),
makeText(220, 270, 'Process B', 20),
makeHandDrawnRect(200, 350, 200, 60),
makeText(230, 370, 'End', 20),
],
};
const OPTIONS: TemplateOption[] = [
{ id: 'blank', label: 'Blank Canvas', description: 'Start with an empty canvas', icon: PenTool, elements: [] },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks with a title', icon: ListTodo, elements: [] },
{ 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: [] },
];
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
if (!isOpen) return null;
return (
<div className={styles.overlay} role="dialog" aria-modal="true" aria-labelledby="template-title" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className={styles.modal}>
<div className={styles.header}>
<h2 id="template-title"><LayoutTemplate size={20} /> Choose a Template</h2>
<button onClick={onClose} className={styles.closeBtn} aria-label="Close"><X size={18} /></button>
</div>
<div className={styles.grid}>
{OPTIONS.map((opt) => {
const Icon = opt.icon;
return (
<Card key={opt.id} className={styles.card} hover onClick={() => onSelect(opt.id)} role="button" tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(opt.id); }}>
<div className={styles.iconWrap}><Icon size={32} /></div>
<h3 className={styles.title}>{opt.label}</h3>
<p className={styles.desc}>{opt.description}</p>
</Card>
);
})}
</div>
</div>
</div>
);
};
+11
View File
@@ -0,0 +1,11 @@
export { Button } from './Button/Button';
export { Card, CardHeader, CardContent } from './Card/Card';
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';
export type { PickedTemplate } from './TemplatePicker/TemplatePicker';
+3
View File
@@ -0,0 +1,3 @@
export { useAuth } from './useAuth';
export { useDrawings } from './useDrawings';
export { useTeams } from './useTeams';
+42
View File
@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useAuthStore } from '@/stores';
import { api } from '@/services';
export function useAuth() {
const { setUser, setSession, setLoading, logout, isAuthenticated } = useAuthStore();
useEffect(() => {
const init = async () => {
try {
const user = await api.auth.me();
setUser(user);
} catch {
// Not logged in
} finally {
setLoading(false);
}
};
init();
}, [setUser, setLoading]);
const login = async (email: string, password: string) => {
const { user, session } = await api.auth.login(email, password);
setUser(user);
setSession(session);
return user;
};
const signup = async (name: string, email: string, password: string) => {
const { user, session } = await api.auth.signup(name, email, password);
setUser(user);
setSession(session);
return user;
};
const doLogout = async () => {
await api.auth.logout();
logout();
};
return { login, signup, logout: doLogout, isAuthenticated };
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useCallback } from 'react';
import { useDrawingStore, useTeamStore } from '@/stores';
import { api } from '@/services';
export function useDrawings() {
const { drawings, recentDrawings, setDrawings, setRecentDrawings, setLoading, addDrawing, updateDrawing } = useDrawingStore();
const { currentTeam } = useTeamStore();
const fetchDrawings = useCallback(async () => {
setLoading(true);
try {
const data = await api.drawings.list(currentTeam?.id);
setDrawings(data);
setRecentDrawings(data.slice(0, 10));
} finally {
setLoading(false);
}
}, [currentTeam?.id, setDrawings, setRecentDrawings, setLoading]);
useEffect(() => {
fetchDrawings();
}, [fetchDrawings]);
const createDrawing = async (title: string, folderId?: string) => {
const drawing = await api.drawings.create({
title,
folder_id: folderId,
team_id: currentTeam?.id,
});
addDrawing(drawing);
return drawing;
};
return { drawings, recentDrawings, fetchDrawings, createDrawing, updateDrawing };
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useCallback } from 'react';
import { useTeamStore } from '@/stores';
import { api } from '@/services';
export function useTeams() {
const { teams, members, setTeams, setMembers, setLoading, setCurrentTeam } = useTeamStore();
const fetchTeams = useCallback(async () => {
setLoading(true);
try {
const data = await api.teams.list();
setTeams(data);
if (data.length > 0 && !useTeamStore.getState().currentTeam) {
setCurrentTeam(data[0]);
}
} finally {
setLoading(false);
}
}, [setTeams, setCurrentTeam, setLoading]);
const fetchMembers = useCallback(async (teamId: string) => {
const data = await api.teams.members(teamId);
setMembers(data);
}, [setMembers]);
useEffect(() => {
fetchTeams();
}, [fetchTeams]);
return { teams, members, fetchTeams, fetchMembers, setCurrentTeam };
}
+23
View File
@@ -0,0 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
},
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
});
export default i18n;
+139
View File
@@ -0,0 +1,139 @@
{
"app": {
"name": "Excalidraw FULL"
},
"common": {
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"create": "Create",
"edit": "Edit",
"back": "Back",
"search": "Search",
"submit": "Submit",
"close": "Close",
"confirm": "Confirm",
"error": "Error",
"success": "Success",
"or": "or",
"continueWith": "or continue with"
},
"auth": {
"login": {
"title": "Welcome back",
"subtitle": "Sign in to your Excalidraw FULL account",
"emailLabel": "Email",
"emailPlaceholder": "[email protected]",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter your password",
"signIn": "Sign In",
"noAccount": "Don't have an account?",
"signUpLink": "Sign up",
"errorInvalid": "Invalid email or password"
},
"signup": {
"title": "Create account",
"subtitle": "Start your visual workspace journey",
"nameLabel": "Full Name",
"namePlaceholder": "John Doe",
"emailLabel": "Email",
"emailPlaceholder": "[email protected]",
"passwordLabel": "Password",
"passwordPlaceholder": "Create a strong password",
"createAccount": "Create Account",
"hasAccount": "Already have an account?",
"signInLink": "Sign in",
"errorCreate": "Could not create account"
},
"oauth": {
"github": "GitHub"
}
},
"sidebar": {
"dashboard": "Dashboard",
"files": "Files",
"projects": "Projects",
"templates": "Templates",
"library": "Library",
"team": "Team",
"settings": "Settings"
},
"dashboard": {
"welcome": "Welcome back, {{name}}",
"subtitle": "Here's what's happening in your workspace",
"newDrawing": "New Drawing",
"creating": "Creating...",
"stats": {
"drawings": "Drawings",
"projects": "Projects",
"folders": "Folders",
"teams": "Teams",
"revisions": "Revisions",
"storage": "Storage"
},
"recentDrawings": "Recent Drawings",
"noDrawings": "No recent drawings",
"noDrawingsSub": "Create your first drawing to get started"
},
"editor": {
"back": "Back",
"saveNow": "Save Now",
"loadingCanvas": "Loading Excalidraw...",
"errorLoad": "Failed to load drawing",
"errorSave": "Failed to save:",
"saving": "Saving...",
"saved": "Saved",
"unsaved": "Unsaved changes",
"revisions": "revisions",
"revision": "Revision",
"revisionBrowser": "Revision Browser",
"noRevisions": "No revisions yet",
"goToDashboard": "Go to Dashboard",
"notFound": "Drawing not found",
"presenterNotes": "Presenter Notes",
"notesPlaceholder": "Add notes for your presentation..."
},
"fileBrowser": {
"title": "Projects"
},
"templates": {
"title": "Templates"
},
"teamSettings": {
"title": "Team Settings"
},
"userSettings": {
"title": "Settings",
"subtitle": "Manage your account preferences",
"language": "Language",
"theme": "Theme",
"tabProfile": "Profile",
"tabAccount": "Account",
"tabNotifications": "Notifications",
"tabAppearance": "Appearance",
"profileInfo": "Profile Information",
"changeAvatar": "Change Avatar",
"username": "Username",
"saveChanges": "Save Changes",
"accountSecurity": "Account Security",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm New Password",
"updatePassword": "Update Password",
"notificationPrefs": "Notification Preferences",
"emailMentions": "Email notifications for mentions",
"emailInvites": "Email notifications for team invites",
"weeklySummary": "Weekly activity summary",
"appearance": "Appearance",
"light": "Light",
"dark": "Dark"
},
"commandPalette": {
"placeholder": "Search commands...",
"noResults": "No matching commands"
},
"search": {
"noResults": "No results"
}
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './i18n';
import { App } from './App';
import './styles/global.scss';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+81
View File
@@ -0,0 +1,81 @@
@use '../../styles/variables' as *;
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
background:
radial-gradient(ellipse at top left, var(--color-surface-high), transparent 60%),
radial-gradient(ellipse at bottom right, var(--color-gray-10), transparent 60%),
var(--color-surface-low);
}
.card {
width: 100%;
max-width: 400px;
padding: var(--space-8);
}
.header {
text-align: center;
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-2xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
p {
color: var(--color-muted);
}
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.divider {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-6);
color: var(--color-muted);
font-size: var(--text-sm);
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-gray-20);
}
}
.footer {
text-align: center;
margin-top: var(--space-6);
font-size: var(--text-sm);
color: var(--color-muted);
a {
color: var(--color-primary);
font-weight: 500;
}
}
.error {
background: var(--color-danger-background);
color: var(--color-danger-text);
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
text-align: center;
}
+96
View File
@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Github } from 'lucide-react';
import { Button, Input, Card } from '@/components';
import { useAuth } from '@/hooks';
import styles from './Auth.module.scss';
const loginStrings = {
title: 'auth.login.title',
subtitle: 'auth.login.subtitle',
emailLabel: 'auth.login.emailLabel',
emailPlaceholder: 'auth.login.emailPlaceholder',
passwordLabel: 'auth.login.passwordLabel',
passwordPlaceholder: 'auth.login.passwordPlaceholder',
signIn: 'auth.login.signIn',
noAccount: 'auth.login.noAccount',
signUpLink: 'auth.login.signUpLink',
};
const commonStrings = {
continueWith: 'common.continueWith',
};
export const Login: React.FC<{ hasUsers: boolean }> = ({ hasUsers }) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await login(email, password);
navigate('/');
} catch {
setError('Invalid email or password');
} finally {
setIsLoading(false);
}
};
return (
<div className={styles.container}>
<Card className={styles.card}>
<div className={styles.header}>
<h1>{t(loginStrings.title)}</h1>
<p>{t(loginStrings.subtitle)}</p>
</div>
{error && <div className={styles.error}>{error}</div>}
<form onSubmit={handleSubmit} className={styles.form}>
<Input
label={t(loginStrings.emailLabel)}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t(loginStrings.emailPlaceholder)}
required
/>
<Input
label={t(loginStrings.passwordLabel)}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t(loginStrings.passwordPlaceholder)}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{t(loginStrings.signIn)}
</Button>
</form>
<div className={styles.divider}>
<span>{t(commonStrings.continueWith)}</span>
</div>
<Button variant="secondary" fullWidth>
<Github size={18} />
GitHub
</Button>
{!hasUsers && (
<p className={styles.footer}>
{t(loginStrings.noAccount)} <Link to="/signup">{t(loginStrings.signUpLink)}</Link>
</p>
)}
</Card>
</div>
);
};
+89
View File
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Github } from 'lucide-react';
import { Button, Input, Card } from '@/components';
import { useAuth } from '@/hooks';
import styles from './Auth.module.scss';
export const Signup: React.FC<{ hasUsers: boolean }> = ({ hasUsers }) => {
const { t } = useTranslation();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { signup } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await signup(name, email, password);
navigate('/');
} catch {
setError('Could not create account');
} finally {
setIsLoading(false);
}
};
return (
<div className={styles.container}>
<Card className={styles.card}>
<div className={styles.header}>
<h1>{t('auth.signup.title')}</h1>
<p>{t('auth.signup.subtitle')}</p>
</div>
{error && <div className={styles.error}>{error}</div>}
<form onSubmit={handleSubmit} className={styles.form}>
<Input
label={t('auth.signup.nameLabel')}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('auth.signup.namePlaceholder')}
required
/>
<Input
label={t('auth.signup.emailLabel')}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('auth.signup.emailPlaceholder')}
required
/>
<Input
label={t('auth.signup.passwordLabel')}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('auth.signup.passwordPlaceholder')}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{t('auth.signup.createAccount')}
</Button>
</form>
<div className={styles.divider}>
<span>{t('common.continueWith')}</span>
</div>
<Button variant="secondary" fullWidth>
<Github size={18} />
GitHub
</Button>
{hasUsers && (
<p className={styles.footer}>
{t('auth.signup.hasAccount')} <Link to="/login">{t('auth.signup.signInLink')}</Link>
</p>
)}
</Card>
</div>
);
};
@@ -0,0 +1,310 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
display: flex;
align-items: center;
justify-content: space-between;
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.quickActions {
display: flex;
align-items: center;
gap: var(--space-3);
@media (max-width: 768px) {
flex-wrap: wrap;
}
}
.actionBtn {
display: flex;
align-items: center;
gap: var(--space-2);
}
.createButton {
display: flex;
align-items: center;
gap: var(--space-2);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-6);
margin-bottom: var(--space-8);
@media (max-width: 1024px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
.statCard {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-4);
}
.statIcon {
color: var(--color-primary);
margin-bottom: var(--space-3);
}
.statValue {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--color-gray-85);
line-height: 1;
}
.statLabel {
font-size: var(--text-sm);
color: var(--color-muted);
margin-top: var(--space-1);
}
.chartBarWrap {
position: relative;
width: 100%;
height: 6px;
margin-top: var(--space-3);
border-radius: var(--border-radius-full);
overflow: hidden;
}
.chartBarBg {
position: absolute;
inset: 0;
background: var(--color-gray-20);
border-radius: var(--border-radius-full);
}
.chartBar {
position: absolute;
inset: 0;
border-radius: var(--border-radius-full);
transition: width 0.4s var(--ease-out);
}
.activityResource {
display: inline-block;
padding: 1px 6px;
border-radius: var(--border-radius-sm);
background: var(--color-surface-low);
color: var(--color-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-left: var(--space-1);
}
.twoColumn {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
.column {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.empty {
text-align: center;
padding: var(--space-8);
}
.emptySub {
color: var(--color-muted);
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.drawingList {
list-style: none;
}
.drawingItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.drawingThumb {
width: 48px;
height: 48px;
border-radius: var(--border-radius-md);
overflow: hidden;
background: var(--color-surface-low);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.drawingInfo {
flex: 1;
min-width: 0;
}
.drawingTitle {
font-weight: 500;
color: var(--color-gray-85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawingMeta {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
.templateGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-4);
}
.templateCard {
cursor: pointer;
transition: transform var(--duration-fast) var(--ease-out);
&:hover {
transform: translateY(-2px);
}
}
.templatePreview {
aspect-ratio: 16 / 10;
border-radius: var(--border-radius-md);
overflow: hidden;
background: var(--color-surface-low);
margin-bottom: var(--space-2);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.templatePlaceholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.templateName {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-70);
text-align: center;
}
.activityCard {
margin-top: var(--space-6);
}
.activityList {
list-style: none;
}
.activityItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.activityAvatar {
width: 32px;
height: 32px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
font-weight: 600;
flex-shrink: 0;
}
.activityInfo {
flex: 1;
}
.activityText {
font-size: var(--text-sm);
color: var(--color-gray-80);
}
.activityTime {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
+269
View File
@@ -0,0 +1,269 @@
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 { 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;
return (
<div className={styles.chartBarWrap} aria-hidden="true">
<div className={styles.chartBarBg} />
<div className={styles.chartBar} style={{ width: `${pct}%`, background: color }} />
</div>
);
};
export const Dashboard: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
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,
projects: 0,
folders: 0,
drawings: 0,
templates: 0,
revisions: 0,
assets: 0,
storage_bytes: 0,
});
useEffect(() => {
const loadData = async () => {
try {
const [drawings, stats, activityData] = await Promise.all([
api.drawings.list(),
api.stats.get(),
api.activity.list(),
]);
setRecentDrawings(drawings);
setStatsData(stats);
setActivity(activityData);
} catch (err) {
console.error('Failed to load dashboard data:', err);
}
};
loadData();
}, [setRecentDrawings, setActivity]);
const handleCreateDrawing = async (template: PickedTemplate = 'blank') => {
setIsCreating(true);
try {
const newDrawing = await api.drawings.create({
title: template === 'blank' ? 'Untitled Drawing' : `${template.charAt(0).toUpperCase() + template.slice(1)}`,
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);
} finally {
setIsCreating(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const maxStat = Math.max(statsData.drawings, statsData.projects + statsData.folders, statsData.teams, statsData.revisions, 1);
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' },
];
return (
<div className={styles.container}>
<div className={styles.header}>
<div>
<h1>{t('dashboard.welcome', { name: user?.name || t('common.user') })}</h1>
<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')}
className={styles.actionBtn}
>
<FolderPlus size={16} />
New Project
</Button>
<Button
variant="secondary"
onClick={() => navigate('/team')}
className={styles.actionBtn}
>
<UserPlus size={16} />
Invite
</Button>
<Button
variant="secondary"
onClick={() => navigate('/library')}
className={styles.actionBtn}
>
<BookOpen size={16} />
Library
</Button>
<Button
onClick={() => setShowTemplatePicker(true)}
loading={isCreating}
className={styles.createButton}
>
{isCreating ? (
<Loader2 size={18} className={styles.spinner} />
) : (
<Plus size={18} />
)}
{t('dashboard.newDrawing')}
</Button>
</div>
</div>
<div className={styles.statsGrid}>
{stats.map((stat) => (
<Card key={stat.label}>
<CardContent className={styles.statCard}>
<div className={styles.statIcon}>
<stat.icon size={24} />
</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} />
</CardContent>
</Card>
))}
</div>
<div className={styles.twoColumn}>
<div className={styles.column}>
<Card>
<CardHeader>
<h3>{t('dashboard.recentDrawings')}</h3>
</CardHeader>
<CardContent>
{recentDrawings.length === 0 ? (
<div className={styles.empty}>
<p>{t('dashboard.noDrawings')}</p>
<p className={styles.emptySub}>{t('dashboard.noDrawingsSub')}</p>
</div>
) : (
<ul className={styles.drawingList} role="list" aria-label="Recent drawings">
{recentDrawings.slice(0, 5).map((drawing) => (
<li
key={drawing.id}
className={styles.drawingItem}
role="listitem"
tabIndex={0}
onClick={() => {
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<div className={styles.drawingThumb}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.drawingInfo}>
<p className={styles.drawingTitle}>{drawing.title}</p>
<p className={styles.drawingMeta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<div className={styles.column}>
<Card className={styles.activityCard}>
<CardHeader>
<h3><Activity size={16} style={{ display: 'inline', marginRight: 8, verticalAlign: 'middle' }} />Recent Activity</h3>
</CardHeader>
<CardContent>
{activity.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) => (
<li key={event.id} className={styles.activityItem}>
<div className={styles.activityAvatar}>
{event.actor?.name?.[0] || '?'}
</div>
<div className={styles.activityInfo}>
<p className={styles.activityText}>
<strong>{event.actor?.name || 'Unknown'}</strong>{' '}
{event.event_type.replace(/_/g, ' ')}{' '}
<span className={styles.activityResource}>{event.resource_type}</span>
</p>
<p className={styles.activityTime}>
{new Date(event.created_at).toLocaleString()}
</p>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
};
@@ -0,0 +1,418 @@
@use '../../styles/variables' as *;
.container {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--color-surface-lowest);
}
.toolbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-4);
background: var(--island-bg-color);
border-bottom: 1px solid var(--color-gray-20);
}
.left {
display: flex;
align-items: center;
gap: var(--space-4);
}
.right {
display: flex;
align-items: center;
gap: var(--space-2);
}
.title {
font-weight: 500;
color: var(--color-gray-85);
font-size: var(--text-md);
}
.saveStatus {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--color-gray-60);
}
.unsaved {
color: var(--color-warning);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.canvas {
flex: 1;
position: relative;
overflow: hidden;
:global(.excalidraw) {
width: 100%;
height: 100%;
}
}
.loadingCanvas {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface-lowest);
color: var(--color-gray-60);
font-size: var(--text-md);
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--color-muted);
background: repeating-linear-gradient(
45deg,
var(--color-gray-10),
var(--color-gray-10) 10px,
transparent 10px,
transparent 20px
);
p {
margin: 0;
}
}
.loading,
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-4);
color: var(--color-gray-60);
}
.sub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.revisionBadge {
background: var(--color-primary);
color: white;
font-size: 10px;
border-radius: 999px;
padding: 1px 6px;
margin-left: 4px;
}
.canvasWrapper {
display: flex;
flex: 1;
overflow: hidden;
}
.canvasNarrow {
flex: 0 0 calc(100% - 280px);
}
.revisionPanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.revisionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.revisionList {
flex: 1;
overflow-y: auto;
padding: var(--space-2);
}
.revisionItem {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.15s ease;
&:hover {
background: var(--color-gray-10);
}
}
.revisionActive {
background: var(--color-primary-10);
color: var(--color-primary);
&:hover {
background: var(--color-primary-20);
}
}
.revisionMeta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.revisionLabel {
font-size: var(--text-sm);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.revisionDate {
font-size: 11px;
color: var(--color-gray-60);
}
.revisionEditor {
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-gray-50);
flex-shrink: 0;
}
.notesPanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.notesHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.notesTextarea {
flex: 1;
resize: none;
border: none;
padding: var(--space-3) var(--space-4);
font-family: inherit;
font-size: var(--text-sm);
line-height: 1.5;
background: var(--color-surface-lowest);
color: var(--color-on-surface);
outline: none;
&::placeholder {
color: var(--color-gray-50);
}
}
.revisionEmpty {
text-align: center;
color: var(--color-gray-50);
font-size: var(--text-sm);
padding: var(--space-4);
}
.sidePanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.sidePanelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.sidePanelContent {
flex: 1;
overflow-y: auto;
padding: var(--space-2);
}
.sidePanelItem {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.15s ease;
margin-bottom: var(--space-1);
&:hover {
background: var(--color-gray-10);
}
}
.sidePanelItemTitle {
font-size: var(--text-sm);
font-weight: 500;
}
.sidePanelItemDesc {
font-size: 11px;
color: var(--color-gray-50);
}
.sidePanelSearch {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
background: var(--color-surface-low);
border-radius: var(--border-radius-md);
border: 1px solid var(--color-gray-20);
svg {
color: var(--color-gray-50);
flex-shrink: 0;
}
}
.sidePanelInput {
background: transparent;
border: none;
outline: none;
color: var(--color-on-surface);
font-size: var(--text-sm);
width: 100%;
}
.sidePanelSelect {
width: 100%;
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
color: var(--color-on-surface);
font-size: var(--text-sm);
cursor: pointer;
}
.sidePanelLoading,
.sidePanelEmpty,
.sidePanelError {
text-align: center;
padding: var(--space-4);
font-size: var(--text-sm);
color: var(--color-gray-50);
}
.sidePanelError {
color: var(--color-danger);
}
@media (max-width: 768px) {
.toolbar {
height: auto;
padding: var(--space-2) var(--space-3);
gap: var(--space-2);
flex-wrap: wrap;
}
.left {
flex: 1;
min-width: 0;
gap: var(--space-2);
}
.title {
font-size: var(--text-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.canvasNarrow {
flex: 1 !important;
}
.revisionPanel,
.notesPanel,
.sidePanel {
position: fixed;
right: 0;
top: 48px;
bottom: 0;
width: 100%;
max-width: 340px;
z-index: 80;
}
}
+616
View File
@@ -0,0 +1,616 @@
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 { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import { useThemeStore } from '@/stores';
import { api } from '@/services';
import type { Drawing, DrawingRevision } from '@/types';
import styles from './Editor.module.scss';
// Dynamic import for Excalidraw to avoid SSR issues
const Excalidraw = React.lazy(() => import('@excalidraw/excalidraw').then(mod => ({ default: mod.Excalidraw })));
interface ExcalidrawElement {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
[key: string]: unknown;
}
interface ExcalidrawState {
elements: ExcalidrawElement[];
appState: Record<string, unknown>;
files: Record<string, { dataURL: string; mimeType: string }>;
}
function prepareElementsForImport(sourceElements: any[], offsetX: number, offsetY: number): any[] {
if (!sourceElements || !sourceElements.length) return [];
const idMap = new Map<string, string>();
sourceElements.forEach((el: any) => {
idMap.set(el.id, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
});
return sourceElements.map((el: any) => {
const newEl = { ...el };
newEl.id = idMap.get(el.id) || el.id;
newEl.x = (el.x || 0) + offsetX;
newEl.y = (el.y || 0) + offsetY;
newEl.version = (el.version || 1) + 1;
newEl.versionNonce = Math.floor(Math.random() * 1000000);
newEl.updated = Date.now();
newEl.seed = Math.floor(Math.random() * 100000);
if (newEl.boundElements) {
newEl.boundElements = newEl.boundElements.map((be: any) => ({
...be,
id: idMap.get(be.id) || be.id,
}));
}
if (newEl.containerId && idMap.has(newEl.containerId)) {
newEl.containerId = idMap.get(newEl.containerId);
}
return newEl;
});
}
export const Editor: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [drawing, setDrawing] = useState<Drawing | null>(null);
const [revisions, setRevisions] = useState<DrawingRevision[]>([]);
const [initialData, setInitialData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
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);
const { theme: appTheme } = useThemeStore();
const currentStateRef = useRef<ExcalidrawState | null>(null);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedDataRef = useRef<string>('');
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(() => {
const loadDrawing = async () => {
if (!id) return;
try {
setIsLoading(true);
const [drawingData, revisionsData] = await Promise.all([
api.drawings.get(id),
api.revisions.list(id),
]);
setDrawing(drawingData);
setRevisions(revisionsData);
// Load latest revision data if available
if (revisionsData.length > 0 && revisionsData[0].snapshot) {
const snapshot = JSON.parse(String(revisionsData[0].snapshot));
setInitialData({
elements: snapshot.elements || [],
appState: snapshot.appState || {},
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
} else {
// Check for pending template from dashboard
const pendingTemplate = localStorage.getItem(`template_${id}`);
if (pendingTemplate) {
const tpl = JSON.parse(pendingTemplate);
setInitialData({
elements: tpl.elements || [],
appState: tpl.appState || {},
files: tpl.files || {},
});
lastSavedDataRef.current = JSON.stringify(tpl);
localStorage.removeItem(`template_${id}`);
} else {
// Start with empty canvas
setInitialData({
elements: [],
appState: {},
files: {},
});
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
}
}
} catch (err) {
setError('Failed to load drawing');
console.error(err);
} finally {
setIsLoading(false);
}
};
loadDrawing();
}, [id]);
// Handle changes from Excalidraw
const handleExcalidrawChange = useCallback((elements: readonly unknown[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
currentStateRef.current = {
elements: elements as ExcalidrawElement[],
appState,
files,
};
setSaveStatus('unsaved');
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
saveDrawing();
}, 2000);
}, []);
// Auto-save functionality
const saveDrawing = useCallback(async () => {
if (!id || !currentStateRef.current || isSaving) return;
const { elements, appState, files } = currentStateRef.current;
const snapshot = {
type: 'excalidraw',
version: 2,
source: window.location.hostname,
elements,
appState: {
viewBackgroundColor: appState.viewBackgroundColor,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
theme: appState.theme,
zenModeEnabled: appState.zenModeEnabled,
viewModeEnabled: appState.viewModeEnabled,
editingGroup: appState.editingGroup,
selectedElementIds: appState.selectedElementIds,
},
files,
};
const snapshotJson = JSON.stringify(snapshot);
if (snapshotJson === lastSavedDataRef.current) {
setSaveStatus('saved');
return;
}
try {
setIsSaving(true);
setSaveStatus('saving');
await api.revisions.create(id, snapshot, 'Auto-save');
lastSavedDataRef.current = snapshotJson;
setSaveStatus('saved');
} catch (err) {
console.error('Failed to save:', err);
setSaveStatus('unsaved');
} finally {
setIsSaving(false);
}
}, [id, isSaving]);
// Remove unused revisions warning by displaying count in UI
const revisionCount = revisions.length;
// Restore a specific revision
const handleRestoreRevision = (revision: DrawingRevision) => {
if (!revision.snapshot) return;
try {
const snapshot = JSON.parse(String(revision.snapshot));
setInitialData({
elements: snapshot.elements || [],
appState: snapshot.appState || {},
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
setSelectedRevision(revision.id);
setSaveStatus('saved');
} catch (err) {
console.error('Failed to restore revision:', err);
}
};
// Manual save
const handleManualSave = async () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
await saveDrawing();
};
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
// 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;
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(templateElements, offsetX, offsetY);
const mergedElements = [...currentElements, ...newElements];
excalidrawAPI.updateScene({ elements: mergedElements });
setShowTemplates(false);
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 },
];
const libraryCategories = ['All', 'Arrows', 'Charts', 'Cloud', 'Devops', 'Diagrams', 'Education', 'Food', 'Frames', 'Gaming', 'Icons', 'Illustrations', 'Machines', 'Misc', 'People', 'Software', 'Systems', 'Tech', 'Workflow'];
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>{t('common.loading')}</p>
</div>
</div>
);
}
if (error || !drawing) {
return (
<div className={styles.container}>
<div className={styles.error}>
<p>{error || t('editor.notFound')}</p>
<Button onClick={() => navigate('/')}>{t('editor.goToDashboard')}</Button>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.toolbar}>
<div className={styles.left}>
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft size={18} />
{t('editor.back')}
</Button>
<span className={styles.title}>{drawing.title}</span>
<span className={styles.saveStatus}>
{saveStatus === 'saving' && <><Loader2 size={14} className={styles.spinner} /> {t('editor.saving')}</>}
{saveStatus === 'saved' && <><Check size={14} /> {t('editor.saved')} {revisionCount > 0 && `(${revisionCount} ${t('editor.revisions')})`}</>}
{saveStatus === 'unsaved' && <span className={styles.unsaved}>{t('editor.unsaved')}</span>}
</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"
onClick={() => setShowNotes(!showNotes)}
title="Presenter notes"
aria-pressed={showNotes}
aria-label="Toggle presenter notes"
>
<StickyNote size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowRevisions(!showRevisions)}
title={t('editor.revisionBrowser')}
aria-pressed={showRevisions}
aria-label="Toggle revision browser"
>
<History size={16} />
{revisionCount > 0 && <span className={styles.revisionBadge}>{revisionCount}</span>}
</Button>
<Button
size="sm"
onClick={handleManualSave}
loading={isSaving}
disabled={saveStatus === 'saved'}
>
<Save size={16} />
{t('editor.saveNow')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setShowTemplates(!showTemplates); setShowLibrary(false); }}
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 : ''}`}>
{initialData && (
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
<Excalidraw
excalidrawAPI={(api: any) => setExcalidrawAPI(api)}
initialData={initialData}
onChange={handleExcalidrawChange}
theme={appTheme === 'dark' ? 'dark' : 'light'}
gridModeEnabled={true}
UIOptions={{
canvasActions: {
saveToActiveFile: false,
loadScene: false,
export: { saveFileToDisk: false },
},
}}
/>
</React.Suspense>
)}
</div>
{showRevisions && (
<div className={styles.revisionPanel}>
<div className={styles.revisionHeader}>
<h3>{t('editor.revisionBrowser')}</h3>
<Button variant="ghost" size="sm" onClick={() => setShowRevisions(false)}>
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.revisionList}>
{revisions.length === 0 ? (
<p className={styles.revisionEmpty}>{t('editor.noRevisions')}</p>
) : (
revisions.map((rev) => (
<button
key={rev.id}
className={`${styles.revisionItem} ${selectedRevision === rev.id ? styles.revisionActive : ''}`}
onClick={() => handleRestoreRevision(rev)}
>
<div className={styles.revisionMeta}>
<span className={styles.revisionLabel}>{rev.change_summary || t('editor.revision')}</span>
<span className={styles.revisionDate}>
{new Date(rev.created_at).toLocaleString()}
</span>
</div>
{rev.created_by && (
<span className={styles.revisionEditor}>{rev.created_by.slice(0, 8)}</span>
)}
</button>
))
)}
</div>
</div>
)}
{showNotes && (
<div className={styles.notesPanel} role="complementary" aria-label={t('editor.presenterNotes')}>
<div className={styles.notesHeader}>
<h3>{t('editor.presenterNotes')}</h3>
<Button variant="ghost" size="sm" onClick={() => setShowNotes(false)} aria-label={t('common.close')}>
<ChevronRight size={16} />
</Button>
</div>
<textarea
className={styles.notesTextarea}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('editor.notesPlaceholder')}
aria-label={t('editor.presenterNotes')}
/>
</div>
)}
{showTemplates && (
<div className={styles.sidePanel}>
<div className={styles.sidePanelHeader}>
<h3>Templates</h3>
<Button variant="ghost" size="sm" onClick={() => setShowTemplates(false)} aria-label="Close">
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.sidePanelContent}>
{templateOptions.map((opt) => (
<button
key={opt.id}
className={styles.sidePanelItem}
onClick={() => handleLoadTemplate(opt.id)}
>
<span className={styles.sidePanelItemTitle}>{opt.label}</span>
<span className={styles.sidePanelItemDesc}>{opt.description}</span>
</button>
))}
</div>
</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>
);
};
@@ -0,0 +1,398 @@
@use '../../styles/variables' as *;
.container {
height: 100%;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
flex-wrap: wrap;
@media (max-width: 640px) {
flex-direction: column;
align-items: flex-start;
}
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-lg);
font-weight: 500;
color: var(--color-gray-85);
svg {
color: var(--color-muted);
}
}
.breadcrumbLink {
background: none;
border: none;
color: var(--color-primary);
font-size: var(--text-lg);
font-weight: 500;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
.breadcrumbCurrent {
color: var(--color-gray-85);
}
.actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.filterSelect {
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.viewToggle {
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
padding: var(--space-2);
color: var(--color-muted);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
&:hover, &.active {
background: var(--color-surface-primary-container);
color: var(--color-primary);
border-color: var(--color-primary-light);
}
}
.content {
display: flex;
gap: var(--space-6);
flex: 1;
@media (max-width: 768px) {
flex-direction: column;
gap: var(--space-4);
}
}
.sidebar {
width: 200px;
flex-shrink: 0;
@media (max-width: 768px) {
width: 100%;
}
}
.folderTree {
display: flex;
flex-direction: column;
gap: var(--space-1);
list-style: none;
padding: 0;
margin: 0;
}
.folderItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
color: var(--color-gray-70);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
background: none;
border: none;
width: 100%;
text-align: left;
font-size: var(--text-sm);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.folderActive {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
}
svg {
color: var(--color-primary);
flex-shrink: 0;
}
}
.grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-4);
align-content: start;
@media (max-width: 640px) {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
}
.list {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-16);
color: var(--color-muted);
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
height: 100%;
color: var(--color-muted);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.drawingCard {
position: relative;
}
.thumbnail {
aspect-ratio: 4 / 3;
background: var(--color-surface-low);
border-radius: var(--border-radius-md);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.info {
padding: var(--space-3);
}
.title {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
.more {
position: absolute;
top: var(--space-2);
right: var(--space-2);
background: var(--island-bg-color);
border: none;
border-radius: var(--border-radius-md);
padding: var(--space-1);
color: var(--color-muted);
cursor: pointer;
opacity: 0;
transition: all var(--duration-fast) var(--ease-out);
.drawingCard:hover & {
opacity: 1;
}
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.moreWrap {
position: absolute;
top: var(--space-2);
right: var(--space-2);
}
.dropdown {
position: absolute;
top: calc(100% + var(--space-1));
right: 0;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-island);
min-width: 160px;
z-index: 10;
display: flex;
flex-direction: column;
padding: var(--space-1);
}
.dropdownItem {
background: none;
border: none;
text-align: left;
padding: var(--space-2) var(--space-3);
cursor: pointer;
border-radius: var(--border-radius-sm);
color: var(--color-on-surface);
font-size: var(--text-sm);
&:hover {
background: var(--color-surface-low);
}
}
.dropdownDanger {
color: #e03131;
&:hover {
background: rgba(224, 49, 49, 0.08);
}
}
.dropdownDivider {
height: 1px;
background: var(--default-border-color);
margin: var(--space-1) 0;
}
.dropdownSubmenu {
display: flex;
flex-direction: column;
}
.dropdownSubheader {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.newProjectForm {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.newProjectInput {
flex: 1;
min-width: 120px;
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
color: var(--text-primary-color);
font-size: var(--text-sm);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.newProjectBtn {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
font-weight: 500;
&:hover {
background: var(--color-primary-darkest);
}
}
.newProjectBtnCancel {
background: none;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-on-surface);
&:hover {
background: var(--color-surface-low);
}
}
.renameInput {
width: 100%;
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-1) var(--space-2);
color: var(--text-primary-color);
font-size: var(--text-sm);
&:focus {
outline: none;
border-color: var(--color-primary);
}
}
@@ -0,0 +1,476 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2 } from 'lucide-react';
import { Card, Button, Modal } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
import type { Drawing } from '@/types';
import styles from './FileBrowser.module.scss';
export const FileBrowser: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const urlParams = useParams<{ folderId?: string }>();
const { drawings, folders, setDrawings, setFolders } = useDrawingStore();
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [sortBy, setSortBy] = useState<'name' | 'updated' | 'created'>('updated');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [visibilityFilter, setVisibilityFilter] = useState<'all' | 'private' | 'team' | 'public-link'>('all');
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [activeFolderId, setActiveFolderId] = useState<string | null>(urlParams.folderId || null);
// Dropdown menu state
const [activeMenu, setActiveMenu] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
// New project (folder) state
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
// Rename state
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// Move state
const [movingId, setMovingId] = useState<string | null>(null);
// Modal state
const [modal, setModal] = useState<{
open: boolean;
type: 'confirm' | 'alert' | 'info';
title: string;
message: string;
onConfirm?: () => void;
onCancel?: () => void;
}>({ open: false, type: 'info', title: '', message: '' });
const showModal = (type: 'confirm' | 'alert' | 'info', title: string, message: string, onConfirm?: () => void) => {
setModal({ open: true, type, title, message, onConfirm, onCancel: () => setModal(m => ({ ...m, open: false })) });
};
// Load real data on mount
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
const [drawingsData, foldersData] = await Promise.all([
api.drawings.list(),
api.folders.list(),
]);
setDrawings(drawingsData);
setFolders(foldersData);
} catch (err) {
console.error('Failed to load file browser data:', err);
} finally {
setIsLoading(false);
}
};
loadData();
}, [setDrawings, setFolders]);
// Update active folder when URL changes
useEffect(() => {
setActiveFolderId(urlParams.folderId || null);
}, [urlParams.folderId]);
const activeFolder = folders.find((f) => f.id === activeFolderId);
// Filter drawings by active folder + visibility, then sort
let visibleDrawings = activeFolderId
? drawings.filter((d) => d.folder_id === activeFolderId)
: drawings;
if (visibilityFilter !== 'all') {
visibleDrawings = visibleDrawings.filter((d) => d.visibility === visibilityFilter);
}
visibleDrawings = [...visibleDrawings].sort((a, b) => {
let cmp = 0;
if (sortBy === 'name') cmp = a.title.localeCompare(b.title);
else if (sortBy === 'updated') cmp = new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime();
else if (sortBy === 'created') cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
return sortOrder === 'asc' ? cmp : -cmp;
});
const handleFolderClick = useCallback(
(folderId: string | null) => {
setActiveFolderId(folderId);
if (folderId) {
navigate(`/files/folder/${folderId}`);
} else {
navigate('/files');
}
},
[navigate]
);
const handleDrawingClick = useCallback(
(drawing: Drawing) => {
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
},
[navigate]
);
const handleCreateDrawing = async () => {
setIsCreating(true);
try {
const newDrawing = await api.drawings.create({
title: 'Untitled Drawing',
visibility: 'team',
folder_id: activeFolderId || null,
});
setDrawings([newDrawing, ...drawings]);
if (newDrawing.folder_id) {
navigate(`/folder/${newDrawing.folder_id}/drawing/${newDrawing.id}`);
} else {
navigate(`/drawing/${newDrawing.id}`);
}
} catch (err) {
console.error('Failed to create drawing:', err);
showModal('alert', 'Error', 'Failed to create drawing. Please try again.');
} finally {
setIsCreating(false);
}
};
const handleCreateFolder = async () => {
const name = newProjectName.trim();
if (!name) return;
try {
const newFolder = await api.folders.create({ name });
setFolders([...folders, newFolder]);
setShowNewProject(false);
setNewProjectName('');
navigate(`/files/folder/${newFolder.id}`);
} catch (err) {
console.error('Failed to create project:', err);
showModal('alert', 'Error', 'Failed to create project. Please try again.');
}
};
const handleDeleteDrawing = (drawing: Drawing) => {
showModal('confirm', 'Delete Drawing', `Delete "${drawing.title}"? This cannot be undone.`, async () => {
try {
await api.drawings.delete(drawing.id);
setDrawings(drawings.filter(d => d.id !== drawing.id));
setActiveMenu(null);
setModal(m => ({ ...m, open: false }));
} catch (err) {
console.error('Failed to delete drawing:', err);
setModal(m => ({ ...m, open: false }));
setTimeout(() => showModal('alert', 'Error', 'Failed to delete drawing.'), 100);
}
});
};
const handleDuplicateDrawing = async (drawing: Drawing) => {
try {
const newDrawing = await api.drawings.create({
title: `Copy of ${drawing.title}`,
visibility: drawing.visibility,
folder_id: drawing.folder_id || null,
});
setDrawings([newDrawing, ...drawings]);
setActiveMenu(null);
navigate(`/drawing/${newDrawing.id}`);
} catch (err) {
console.error('Failed to duplicate drawing:', err);
showModal('alert', 'Error', 'Failed to duplicate drawing. Please try again.');
}
};
const handleRenameDrawing = async (drawing: Drawing) => {
const title = renameValue.trim();
if (!title || title === drawing.title) {
setRenamingId(null);
return;
}
try {
await api.drawings.update(drawing.id, { title });
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, title } : d));
setRenamingId(null);
} catch (err) {
console.error('Failed to rename drawing:', err);
showModal('alert', 'Error', 'Failed to rename drawing. Please try again.');
}
};
const handleMoveDrawing = async (drawing: Drawing, folderId: string | null) => {
try {
await api.drawings.update(drawing.id, { folder_id: folderId });
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
setMovingId(null);
setActiveMenu(null);
} catch (err) {
console.error('Failed to move drawing:', err);
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
}
};
// Close menu on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setActiveMenu(null);
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>{t('common.loading')}</p>
</div>
</div>
);
}
return (
<>
<Modal
isOpen={modal.open}
type={modal.type}
title={modal.title}
message={modal.message}
onConfirm={modal.onConfirm}
onCancel={modal.onCancel}
confirmText={modal.type === 'confirm' ? 'Delete' : 'OK'}
/>
<div className={styles.container} role="region" aria-label={t('fileBrowser.title')}>
<div className={styles.header}>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<button
className={styles.breadcrumbLink}
onClick={() => handleFolderClick(null)}
aria-current={!activeFolderId ? 'page' : undefined}
>
All Projects
</button>
{activeFolder && (
<>
<ChevronRight size={16} aria-hidden="true" />
<span className={styles.breadcrumbCurrent} aria-current="page">
{activeFolder.name}
</span>
</>
)}
</nav>
<div className={styles.actions}>
<select
className={styles.filterSelect}
value={visibilityFilter}
onChange={(e) => setVisibilityFilter(e.target.value as any)}
aria-label="Filter by visibility"
title="Filter by visibility"
>
<option value="all">All</option>
<option value="private">Private</option>
<option value="team">Team</option>
<option value="public-link">Public</option>
</select>
<select
className={styles.filterSelect}
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [sb, so] = e.target.value.split('-');
setSortBy(sb as any);
setSortOrder(so as any);
}}
aria-label="Sort drawings"
title="Sort drawings"
>
<option value="updated-desc">Recently updated</option>
<option value="updated-asc">Oldest updated</option>
<option value="created-desc">Recently created</option>
<option value="created-asc">Oldest created</option>
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
</select>
<button
className={`${styles.viewToggle} ${viewMode === 'grid' ? styles.active : ''}`}
onClick={() => setViewMode('grid')}
aria-label="Grid view"
aria-pressed={viewMode === 'grid'}
>
<Grid size={18} />
</button>
<button
className={`${styles.viewToggle} ${viewMode === 'list' ? styles.active : ''}`}
onClick={() => setViewMode('list')}
aria-label="List view"
aria-pressed={viewMode === 'list'}
>
<List size={18} />
</button>
<Button onClick={handleCreateDrawing} loading={isCreating} aria-label="Create new drawing">
<Plus size={16} />
New Drawing
</Button>
<Button variant="secondary" onClick={() => { setShowNewProject(true); setNewProjectName(''); }} aria-label="Create new project">
<Folder size={16} />
New Project
</Button>
</div>
</div>
<div className={styles.content}>
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
{showNewProject && (
<div className={styles.newProjectForm}>
<input
type="text"
autoFocus
placeholder="Project name..."
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder();
if (e.key === 'Escape') { setShowNewProject(false); setNewProjectName(''); }
}}
className={styles.newProjectInput}
/>
<button className={styles.newProjectBtn} onClick={handleCreateFolder}>Create</button>
<button className={styles.newProjectBtnCancel} onClick={() => { setShowNewProject(false); setNewProjectName(''); }}>Cancel</button>
</div>
)}
<ul className={styles.folderTree} role="tree">
<li>
<button
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(null)}
aria-current={!activeFolderId ? 'true' : undefined}
role="treeitem"
>
<Folder size={18} aria-hidden="true" />
<span>All Projects</span>
</button>
</li>
{folders.map((folder) => (
<li key={folder.id}>
<button
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(folder.id)}
aria-current={activeFolderId === folder.id ? 'true' : undefined}
role="treeitem"
>
<Folder size={18} aria-hidden="true" />
<span>{folder.name}</span>
</button>
</li>
))}
</ul>
</aside>
<main className={viewMode === 'grid' ? styles.grid : styles.list} role="list" aria-label="Drawing list">
{visibleDrawings.length === 0 ? (
<div className={styles.empty} role="status">
<p>No drawings yet</p>
<p className={styles.emptySub}>
{activeFolder ? 'Create a new drawing in this project' : 'Create a new drawing or import existing files'}
</p>
</div>
) : (
visibleDrawings.map((drawing) => (
<Card
key={drawing.id}
className={styles.drawingCard}
hover
role="listitem"
tabIndex={0}
onClick={() => handleDrawingClick(drawing)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDrawingClick(drawing);
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<div className={styles.thumbnail}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.info}>
{renamingId === drawing.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameDrawing(drawing);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameDrawing(drawing)}
/>
) : (
<>
<h4 className={styles.title}>{drawing.title}</h4>
<p className={styles.meta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</>
)}
</div>
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
<button
className={styles.more}
onClick={(e) => {
e.stopPropagation();
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
setRenamingId(null);
}}
aria-label={`More options for ${drawing.title}`}
aria-expanded={activeMenu === drawing.id}
>
<MoreVertical size={16} />
</button>
{activeMenu === drawing.id && (
<div className={styles.dropdown}>
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
{movingId === drawing.id ? (
<div className={styles.dropdownSubmenu}>
<button className={styles.dropdownSubheader}>Move to:</button>
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
{folders.map(f => (
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
))}
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
</div>
) : (
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
)}
<div className={styles.dropdownDivider} />
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
</div>
)}
</div>
</Card>
))
)}
</main>
</div>
</div>
</>
);
};
@@ -0,0 +1,186 @@
@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); }
}
@@ -0,0 +1,183 @@
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>
);
};
@@ -0,0 +1,158 @@
@use '../../styles/variables' as *;
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--space-8);
}
.sidebar {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.tab {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-md);
color: var(--color-gray-70);
background: none;
border: none;
cursor: pointer;
font-size: var(--text-sm);
transition: all var(--duration-fast) var(--ease-out);
text-align: left;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
}
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.avatarSection {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.avatar {
width: 64px;
height: 64px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-2xl);
font-weight: 600;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: var(--space-4);
}
.toggleList {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.toggle {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-gray-70);
input {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
}
}
.themeSelect {
margin-bottom: var(--space-4);
}
.label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--input-label-color);
margin-bottom: var(--space-3);
}
.themeOptions {
display: flex;
gap: var(--space-3);
}
.themeOption {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-gray-30);
border-radius: var(--border-radius-md);
background: var(--island-bg-color);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
&.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
}
@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { User, Key, Bell, Palette, Sun, Moon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardContent, Button, Input } from '@/components';
import { useAuthStore, useThemeStore } from '@/stores';
import styles from './Settings.module.scss';
export const UserSettings: React.FC = () => {
const { t } = useTranslation();
const { user } = useAuthStore();
const { theme, setTheme } = useThemeStore();
const [activeTab, setActiveTab] = useState('profile');
const tabs = [
{ id: 'profile', label: t('userSettings.tabProfile'), icon: User },
{ id: 'account', label: t('userSettings.tabAccount'), icon: Key },
{ id: 'notifications', label: t('userSettings.tabNotifications'), icon: Bell },
{ id: 'appearance', label: t('userSettings.tabAppearance'), icon: Palette },
];
return (
<div className={styles.container}>
<div className={styles.header}>
<h1>{t('userSettings.title')}</h1>
<p className={styles.subtitle}>{t('userSettings.subtitle')}</p>
</div>
<div className={styles.layout}>
<div className={styles.sidebar} role="tablist" aria-label="Settings tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={`${styles.tab} ${activeTab === tab.id ? styles.active : ''}`}
onClick={() => setActiveTab(tab.id)}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
aria-label={tab.label}
>
<tab.icon size={18} aria-hidden="true" />
<span>{tab.label}</span>
</button>
))}
</div>
<div className={styles.content}>
{activeTab === 'profile' && (
<Card role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
<CardHeader>
<h3>{t('userSettings.profileInfo')}</h3>
</CardHeader>
<CardContent>
<div className={styles.form}>
<div className={styles.avatarSection}>
<div className={styles.avatar}>
{user?.avatar_url ? (
<img src={user.avatar_url} alt={user.name} />
) : (
user?.name?.[0] || '?'
)}
</div>
<Button variant="secondary" size="sm">{t('userSettings.changeAvatar')}</Button>
</div>
<Input label={t('auth.signup.nameLabel')} defaultValue={user?.name} />
<Input label={t('userSettings.username')} defaultValue={user?.username} />
<Input label={t('auth.login.emailLabel')} type="email" defaultValue={user?.email} />
<div className={styles.actions}>
<Button>{t('userSettings.saveChanges')}</Button>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === 'account' && (
<Card role="tabpanel" id="panel-account" aria-labelledby="tab-account">
<CardHeader>
<h3>{t('userSettings.accountSecurity')}</h3>
</CardHeader>
<CardContent>
<div className={styles.form}>
<Input label={t('userSettings.currentPassword')} type="password" />
<Input label={t('userSettings.newPassword')} type="password" />
<Input label={t('userSettings.confirmPassword')} type="password" />
<div className={styles.actions}>
<Button>{t('userSettings.updatePassword')}</Button>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === 'notifications' && (
<Card role="tabpanel" id="panel-notifications" aria-labelledby="tab-notifications">
<CardHeader>
<h3>{t('userSettings.notificationPrefs')}</h3>
</CardHeader>
<CardContent>
<div className={styles.toggleList}>
<label className={styles.toggle}>
<input type="checkbox" defaultChecked />
<span>{t('userSettings.emailMentions')}</span>
</label>
<label className={styles.toggle}>
<input type="checkbox" defaultChecked />
<span>{t('userSettings.emailInvites')}</span>
</label>
<label className={styles.toggle}>
<input type="checkbox" />
<span>{t('userSettings.weeklySummary')}</span>
</label>
</div>
</CardContent>
</Card>
)}
{activeTab === 'appearance' && (
<Card role="tabpanel" id="panel-appearance" aria-labelledby="tab-appearance">
<CardHeader>
<h3>{t('userSettings.appearance')}</h3>
</CardHeader>
<CardContent>
<div className={styles.themeSelect}>
<p className={styles.label}>{t('userSettings.theme')}</p>
<div className={styles.themeOptions}>
<button
className={`${styles.themeOption} ${theme === 'light' ? styles.active : ''}`}
onClick={() => setTheme('light')}
>
<Sun size={16} />
{t('userSettings.light')}
</button>
<button
className={`${styles.themeOption} ${theme === 'dark' ? styles.active : ''}`}
onClick={() => setTheme('dark')}
>
<Moon size={16} />
{t('userSettings.dark')}
</button>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
};
+207
View File
@@ -0,0 +1,207 @@
@use '../../styles/variables' as *;
.container {
max-width: 900px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-6);
}
.sidePanel {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.membersList {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.empty {
text-align: center;
padding: var(--space-8);
color: var(--color-muted);
svg {
margin-bottom: var(--space-4);
opacity: 0.5;
}
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.memberItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.memberAvatar {
width: 40px;
height: 40px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
}
.memberInfo {
flex: 1;
}
.memberName {
font-weight: 500;
color: var(--color-gray-85);
}
.memberEmail {
font-size: var(--text-sm);
color: var(--color-muted);
}
.memberRole {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--color-surface-low);
border-radius: var(--border-radius-full);
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-gray-70);
text-transform: capitalize;
}
.inviteForm {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.inviteInput {
padding: var(--space-3);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
background: var(--input-bg-color);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.pendingCard {
margin-top: var(--space-2);
}
.inviteItem {
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.inviteEmail {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-80);
}
.inviteRole {
font-size: var(--text-xs);
color: var(--color-muted);
text-transform: capitalize;
margin-top: var(--space-1);
}
.roleLabel {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--color-gray-70);
}
.roleSelect {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
background: var(--input-bg-color);
cursor: pointer;
}
.error {
color: var(--color-danger-text);
font-size: var(--text-sm);
background: var(--color-danger-background);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
}
.success {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-success-text);
font-size: var(--text-sm);
background: var(--color-success);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
}
.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;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
+185
View File
@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { Users, Crown, Shield, User, Loader2, Check, UserPlus } from 'lucide-react';
import { Card, CardHeader, CardContent, Button, Input } from '@/components';
import { useTeamStore } from '@/stores';
import { api } from '@/services';
import styles from './Team.module.scss';
const roleIcons: Record<string, React.ElementType> = {
owner: Crown,
admin: Shield,
editor: User,
viewer: User,
};
const ROLES = ['viewer', 'editor', 'admin'];
export const TeamSettings: React.FC = () => {
const { currentTeam, members, setMembers, setCurrentTeam } = useTeamStore();
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('editor');
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setLoading(true);
const [teamsData, membersData] = await Promise.all([
api.teams.list(),
currentTeam ? api.teams.members(currentTeam.id) : Promise.resolve([]),
]);
if (teamsData.length > 0) {
setCurrentTeam(teamsData[0]);
}
setMembers(membersData);
} catch (err) {
console.error('Failed to load team data:', err);
} finally {
setLoading(false);
}
};
load();
}, [currentTeam?.id, setMembers, setCurrentTeam]);
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim() || !newEmail.trim() || !newPassword.trim() || !currentTeam) return;
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setSending(true);
setError('');
setSent(false);
try {
await api.teams.createUser(currentTeam.id, {
name: newName.trim(),
email: newEmail.trim(),
password: newPassword,
role: newRole,
});
const membersData = await api.teams.members(currentTeam.id);
setMembers(membersData);
setSent(true);
setNewName('');
setNewEmail('');
setNewPassword('');
setNewRole('editor');
} catch (err: any) {
setError(err?.message || 'Failed to create user');
} finally {
setSending(false);
}
};
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}><Loader2 size={32} className={styles.spinner} /><p>Loading team...</p></div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h1>Team Settings</h1>
<p className={styles.subtitle} aria-label="Current team">{currentTeam?.name || 'My Team'}</p>
</div>
<div className={styles.grid}>
<Card role="region" aria-label="Team members">
<CardHeader>
<h3>Members ({members.length})</h3>
</CardHeader>
<CardContent>
<div className={styles.membersList} role="list" aria-label="Team members list">
{members.length === 0 ? (
<div className={styles.empty}>
<Users size={32} />
<p>No team members yet</p>
<p className={styles.emptySub}>Add members to collaborate</p>
</div>
) : (
members.map((member) => {
const RoleIcon = roleIcons[member.role] || User;
return (
<div key={member.id} className={styles.memberItem} role="listitem" aria-label={`Member ${member.user?.name || 'Unknown'}`}>
<div className={styles.memberAvatar} aria-hidden="true">
{member.user?.name?.[0] || '?'}
</div>
<div className={styles.memberInfo}>
<p className={styles.memberName}>{member.user?.name || 'Unknown'}</p>
<p className={styles.memberEmail}>{member.user?.email}</p>
</div>
<div className={styles.memberRole} aria-label={`Role: ${member.role}`}>
<RoleIcon size={14} aria-hidden="true" />
<span>{member.role}</span>
</div>
</div>
);
})
)}
</div>
</CardContent>
</Card>
<div className={styles.sidePanel}>
<Card role="region" aria-label="Add team member">
<CardHeader>
<h3>Add Member</h3>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateUser} className={styles.inviteForm}>
<Input
type="text"
label="Full name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Jane Doe"
required
className={styles.inviteInput}
/>
<Input
type="email"
label="Email address"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="[email protected]"
required
className={styles.inviteInput}
/>
<Input
type="password"
label="Initial password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min 8 characters"
required
className={styles.inviteInput}
/>
<label className={styles.roleLabel}>
Role
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className={styles.roleSelect}>
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</label>
{error && <p className={styles.error}>{error}</p>}
{sent && <p className={styles.success}><Check size={14} /> User created!</p>}
<Button fullWidth type="submit" loading={sending} disabled={!newName.trim() || !newEmail.trim() || !newPassword.trim()}>
<UserPlus size={16} />
Create User
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
</div>
);
};
@@ -0,0 +1,228 @@
@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-6);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
}
.categories {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-6);
border-bottom: 1px solid var(--color-gray-20);
padding-bottom: var(--space-4);
}
.category {
padding: var(--space-2) var(--space-4);
border: none;
background: none;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
border-radius: var(--border-radius-md);
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary);
font-weight: 500;
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-16);
color: var(--color-muted);
svg {
margin-bottom: var(--space-4);
opacity: 0.5;
}
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.templateCard {
overflow: hidden;
}
.preview {
aspect-ratio: 16 / 10;
background: var(--color-surface-low);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
color: var(--color-gray-50);
}
.info {
padding: var(--space-4);
}
.name {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-1);
}
.description {
font-size: var(--text-sm);
color: var(--color-muted);
margin-bottom: var(--space-3);
}
.meta {
display: flex;
gap: var(--space-2);
}
.scope, .type {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
border-radius: var(--border-radius-full);
text-transform: capitalize;
font-weight: 500;
}
.scope {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
}
.type {
background: var(--color-gray-20);
color: var(--color-gray-70);
}
.useBtn {
margin-top: var(--space-3);
width: 100%;
}
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--space-4);
}
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--modal-shadow);
width: 100%;
max-width: 420px;
padding: var(--space-6);
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
h2 {
font-size: var(--text-xl);
font-weight: 600;
color: var(--color-gray-85);
}
button {
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);
}
}
}
.modalBody {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.spinner {
animation: spin 1s linear infinite;
align-self: center;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
}
.categories {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.grid {
grid-template-columns: 1fr;
}
}
+114
View File
@@ -0,0 +1,114 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Sparkles, X, Loader2, FilePlus } from 'lucide-react';
import { Card, CardContent, Button, Input } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
import type { Template, TemplateScope } from '@/types';
import styles from './Templates.module.scss';
const categories: { id: TemplateScope | 'all'; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'system', label: 'System' },
{ id: 'team', label: 'Team' },
{ id: 'personal', label: 'Personal' },
];
export const Templates: React.FC = () => {
const navigate = useNavigate();
const { templates, setTemplates, addDrawing } = useDrawingStore();
const [active, setActive] = useState<TemplateScope | 'all'>('all');
const [showModal, setShowModal] = useState(false);
const [creating, setCreating] = useState(false);
const [applyingId, setApplyingId] = useState<string | null>(null);
const [name, setName] = useState('');
const [error, setError] = useState('');
useEffect(() => {
api.templates.list().then(setTemplates).catch(console.error);
}, [setTemplates]);
const filtered = active === 'all' ? templates : templates.filter((t) => t.scope === active);
const handleCreate = async () => {
if (!name.trim()) { setError('Name required'); return; }
setCreating(true); setError('');
try {
const t = await api.templates.create({ name: name.trim(), type: 'empty', scope: 'personal' });
setTemplates([t, ...templates]); setShowModal(false); setName('');
} catch (err) { setError('Create failed'); }
finally { setCreating(false); }
};
const handleUseTemplate = async (template: Template) => {
setApplyingId(template.id);
try {
const drawing = await api.drawings.create({
title: template.name,
visibility: 'team',
});
addDrawing(drawing);
navigate(`/drawing/${drawing.id}`);
} catch (err) {
console.error('Failed to create drawing from template:', err);
} finally {
setApplyingId(null);
}
};
return (
<div className={styles.container}>
<div className={styles.header}>
<div><h1>Templates</h1><p className={styles.subtitle}>Start from a template or create your own</p></div>
<Button onClick={() => setShowModal(true)}><Plus size={18} />Create</Button>
</div>
<div className={styles.categories} role="tablist">
{categories.map((c) => (
<button key={c.id} className={`${styles.category} ${active === c.id ? styles.active : ''}`}
onClick={() => setActive(c.id)} role="tab" aria-selected={active === c.id}>{c.label}</button>
))}
</div>
<div className={styles.grid} role="tabpanel">
{filtered.length === 0 ? (
<div className={styles.empty} role="status"><Sparkles size={48} aria-hidden="true" />
<p>No templates</p><p className={styles.emptySub}>Create your first template</p></div>
) : filtered.map((t) => (
<Card key={t.id} className={styles.templateCard} hover>
<div className={styles.preview}>
{t.preview_url ? <img src={t.preview_url} alt="" loading="lazy" /> : <div className={styles.placeholder} role="img" aria-label="No preview"><Sparkles size={32} aria-hidden="true" /></div>}
</div>
<CardContent className={styles.info}>
<h4 className={styles.name}>{t.name}</h4>
<p className={styles.description}>{t.description || 'No description'}</p>
<div className={styles.meta}>
<span className={styles.scope}>{t.scope}</span>
<span className={styles.type}>{t.type}</span>
</div>
<Button
size="sm"
className={styles.useBtn}
onClick={() => handleUseTemplate(t)}
loading={applyingId === t.id}
aria-label={`Use template ${t.name}`}
>
<FilePlus size={14} />
Use Template
</Button>
</CardContent>
</Card>
))}
</div>
{showModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="tm-title" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className={styles.modal}>
<div className={styles.modalHeader}><h2 id="tm-title">Create Template</h2><button onClick={() => setShowModal(false)} aria-label="Close"><X size={18} /></button></div>
<div className={styles.modalBody}>
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} error={error} />
{creating ? <Loader2 className={styles.spinner} size={20} /> : <Button onClick={handleCreate}>Create</Button>}
</div>
</div>
</div>
)}
</div>
);
};
+7
View File
@@ -0,0 +1,7 @@
export { Dashboard } from './Dashboard/Dashboard';
export { Login } from './Auth/Login';
export { Signup } from './Auth/Signup';
export { FileBrowser } from './FileBrowser/FileBrowser';
export { TeamSettings } from './Team/TeamSettings';
export { UserSettings } from './Settings/UserSettings';
export { Templates } from './Templates/Templates';
+91
View File
@@ -0,0 +1,91 @@
import type { User, Session, Drawing, DrawingRevision, Team, TeamMembership, TeamInvite, Template, Folder, ActivityEvent } from '@/types';
const API_BASE = '/api';
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json();
}
export const api = {
auth: {
me: (): Promise<User> => fetchApi('/auth/me'),
setupStatus: (): Promise<{ has_users: boolean }> => fetchApi('/auth/setup-status'),
login: (email: string, password: string): Promise<{ user: User; session: Session }> =>
fetchApi('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
signup: (name: string, email: string, password: string): Promise<{ user: User; session: Session }> =>
fetchApi('/auth/signup', { method: 'POST', body: JSON.stringify({ name, email, password }) }),
logout: (): Promise<void> => fetchApi('/auth/logout', { method: 'POST' }),
},
drawings: {
list: (teamId?: string): Promise<Drawing[]> =>
fetchApi(`/drawings${teamId ? `?team_id=${teamId}` : ''}`),
get: (id: string): Promise<Drawing> => fetchApi(`/drawings/${id}`),
create: (data: object): Promise<Drawing> =>
fetchApi('/drawings', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: object): Promise<Drawing> =>
fetchApi(`/drawings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string): Promise<void> =>
fetchApi(`/drawings/${id}`, { method: 'DELETE' }),
},
revisions: {
list: (drawingId: string): Promise<DrawingRevision[]> =>
fetchApi(`/drawings/${drawingId}/revisions`),
create: (drawingId: string, snapshot: object, changeSummary?: string): Promise<DrawingRevision> =>
fetchApi(`/drawings/${drawingId}/revisions`, {
method: 'POST',
body: JSON.stringify({ snapshot, change_summary: changeSummary }),
}),
},
folders: {
list: (): Promise<Folder[]> => fetchApi('/folders'),
create: (data: object): Promise<Folder> =>
fetchApi('/folders', { method: 'POST', body: JSON.stringify(data) }),
},
teams: {
list: (): Promise<Team[]> => fetchApi('/teams'),
create: (data: { name: string; slug: string }): Promise<Team> => fetchApi('/teams', { method: 'POST', body: JSON.stringify(data) }),
members: (teamId: string): Promise<TeamMembership[]> => fetchApi(`/teams/${teamId}/members`),
invites: (teamId: string): Promise<TeamInvite[]> => fetchApi(`/teams/${teamId}/invites`),
createInvite: (teamId: string, data: { email: string; role: string }): Promise<TeamInvite> => fetchApi(`/teams/${teamId}/invites`, { method: 'POST', body: JSON.stringify(data) }),
acceptInvite: (token: string): Promise<void> => fetchApi('/invites/accept', { method: 'POST', body: JSON.stringify({ token }) }),
createUser: (teamId: string, data: { name: string; email: string; password: string; role: string }): Promise<User> => fetchApi(`/teams/${teamId}/users`, { method: 'POST', body: JSON.stringify(data) }),
},
templates: {
list: (): Promise<Template[]> => fetchApi('/templates'),
create: (data: { name: string; type: string; scope: string }): Promise<Template> =>
fetchApi('/templates', { method: 'POST', body: JSON.stringify(data) }),
},
stats: {
get: (teamId?: string): Promise<{
teams: number;
members: number;
projects: number;
folders: number;
drawings: number;
templates: number;
revisions: number;
assets: number;
storage_bytes: number;
}> => fetchApi(`/stats${teamId ? `?team_id=${teamId}` : ''}`),
},
activity: {
list: (): Promise<ActivityEvent[]> => fetchApi('/activity'),
},
search: {
get: (q: string): Promise<Drawing[]> => fetchApi(`/search?q=${encodeURIComponent(q)}`),
},
};
+1
View File
@@ -0,0 +1 @@
export { api } from './api';
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand';
import type { User, Session } from '@/types';
interface AuthState {
user: User | null;
session: Session | null;
isLoading: boolean;
isAuthenticated: boolean;
setUser: (user: User | null) => void;
setSession: (session: Session | null) => void;
setLoading: (loading: boolean) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
session: null,
isLoading: true,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
setSession: (session) => set({ session }),
setLoading: (isLoading) => set({ isLoading }),
logout: () => set({ user: null, session: null, isAuthenticated: false }),
}));
+48
View File
@@ -0,0 +1,48 @@
import { create } from 'zustand';
import type { Drawing, Folder, Project, Template, ActivityEvent } from '@/types';
interface DrawingState {
drawings: Drawing[];
folders: Folder[];
projects: Project[];
templates: Template[];
recentDrawings: Drawing[];
activity: ActivityEvent[];
isLoading: boolean;
setDrawings: (drawings: Drawing[]) => void;
setFolders: (folders: Folder[]) => void;
setProjects: (projects: Project[]) => void;
setTemplates: (templates: Template[]) => void;
setRecentDrawings: (drawings: Drawing[]) => void;
setActivity: (activity: ActivityEvent[]) => void;
addDrawing: (drawing: Drawing) => void;
updateDrawing: (id: string, updates: Partial<Drawing>) => void;
removeDrawing: (id: string) => void;
setLoading: (loading: boolean) => void;
}
export const useDrawingStore = create<DrawingState>((set) => ({
drawings: [],
folders: [],
projects: [],
templates: [],
recentDrawings: [],
activity: [],
isLoading: false,
setDrawings: (drawings) => set({ drawings }),
setFolders: (folders) => set({ folders }),
setProjects: (projects) => set({ projects }),
setTemplates: (templates) => set({ templates }),
setRecentDrawings: (recentDrawings) => set({ recentDrawings }),
setActivity: (activity) => set({ activity }),
addDrawing: (drawing) => set((state) => ({ drawings: [drawing, ...state.drawings] })),
updateDrawing: (id, updates) =>
set((state) => ({
drawings: state.drawings.map((d) => (d.id === id ? { ...d, ...updates } : d)),
})),
removeDrawing: (id) =>
set((state) => ({
drawings: state.drawings.filter((d) => d.id !== id),
})),
setLoading: (isLoading) => set({ isLoading }),
}));
+4
View File
@@ -0,0 +1,4 @@
export { useAuthStore } from './authStore';
export { useTeamStore } from './teamStore';
export { useDrawingStore } from './drawingStore';
export { useThemeStore } from './themeStore';
+32
View File
@@ -0,0 +1,32 @@
import { create } from 'zustand';
import type { Team, TeamMembership, TeamInvite } from '@/types';
interface TeamState {
currentTeam: Team | null;
teams: Team[];
members: TeamMembership[];
invites: TeamInvite[];
isLoading: boolean;
setCurrentTeam: (team: Team | null) => void;
setTeams: (teams: Team[]) => void;
addTeam: (team: Team) => void;
removeTeam: (teamId: string) => void;
setMembers: (members: TeamMembership[]) => void;
setInvites: (invites: TeamInvite[]) => void;
setLoading: (loading: boolean) => void;
}
export const useTeamStore = create<TeamState>((set) => ({
currentTeam: null,
teams: [],
members: [],
invites: [],
isLoading: false,
setCurrentTeam: (team) => set({ currentTeam: team }),
setTeams: (teams) => set({ teams }),
addTeam: (team) => set((state) => ({ teams: [...state.teams, team] })),
removeTeam: (teamId) => set((state) => ({ teams: state.teams.filter((t) => t.id !== teamId) })),
setMembers: (members) => set({ members }),
setInvites: (invites) => set({ invites }),
setLoading: (isLoading) => set({ isLoading }),
}));
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useThemeStore } from './themeStore';
describe('themeStore', () => {
beforeEach(() => {
useThemeStore.setState({ theme: 'light' });
});
it('defaults to light', () => {
expect(useThemeStore.getState().theme).toBe('light');
});
it('toggles to dark', () => {
useThemeStore.getState().toggleTheme();
expect(useThemeStore.getState().theme).toBe('dark');
});
it('toggles back to light', () => {
useThemeStore.getState().setTheme('dark');
useThemeStore.getState().toggleTheme();
expect(useThemeStore.getState().theme).toBe('light');
});
});
+55
View File
@@ -0,0 +1,55 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark';
const getInitialTheme = (): Theme => {
if (typeof document !== 'undefined') {
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'dark' || attr === 'light') return attr;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
};
const applyTheme = (theme: Theme) => {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', theme);
}
};
interface ThemeState {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
_hasHydrated: boolean;
setHasHydrated: (hasHydrated: boolean) => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: getInitialTheme(),
_hasHydrated: false,
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
setTheme: (theme) => {
applyTheme(theme);
set({ theme });
},
toggleTheme: () => {
const next = get().theme === 'light' ? 'dark' : 'light';
applyTheme(next);
set({ theme: next });
},
}),
{
name: 'excalidraw-theme',
onRehydrateStorage: () => (state) => {
if (state) {
applyTheme(state.theme);
state.setHasHydrated(true);
}
},
}
)
);
+118
View File
@@ -0,0 +1,118 @@
@use './variables' as *;
// ============================================
// Reset & Base
// ============================================
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--ui-font);
font-size: var(--text-base);
line-height: 1.5;
color: var(--color-on-surface);
background-color: var(--color-surface-low);
min-height: 100vh;
}
// ============================================
// Typography
// ============================================
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.25;
color: var(--color-gray-85);
}
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
h3 { font-size: var(--text-2xl); }
h4 { font-size: var(--text-xl); }
h5 { font-size: var(--text-lg); }
h6 { font-size: var(--text-base); }
p {
margin-bottom: var(--space-4);
}
a {
color: var(--link-color);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
&:hover {
color: var(--link-color-hover);
text-decoration: underline;
}
}
// ============================================
// Utilities
// ============================================
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
// ============================================
// Scrollbar
// ============================================
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: var(--border-radius-full);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
border: 2px solid transparent;
background-clip: padding-box;
}
// ============================================
// Focus Styles
// ============================================
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
// ============================================
// Selection
// ============================================
::selection {
background: var(--color-primary-light);
color: var(--color-primary-darkest);
}
+293
View File
@@ -0,0 +1,293 @@
// Excalidraw FULL - Design Tokens
// Based on Excalidraw's hand-drawn aesthetic
// ============================================
// Color Palette
// ============================================
// Primary Purple
$color-primary: #6965db;
$color-primary-darker: #5b57d1;
$color-primary-darkest: #4a47b1;
$color-primary-light: #e3e2fe;
$color-primary-light-darker: #d7d5ff;
$color-primary-hover: #5753d0;
// Grays
$color-gray-10: #f5f5f5;
$color-gray-20: #ebebeb;
$color-gray-30: #d6d6d6;
$color-gray-40: #b8b8b8;
$color-gray-50: #999999;
$color-gray-60: #7a7a7a;
$color-gray-70: #5c5c5c;
$color-gray-80: #3d3d3d;
$color-gray-85: #242424;
$color-gray-90: #1e1e1e;
$color-gray-100: #121212;
// Semantic Colors
$color-success: #cafccc;
$color-success-darker: #bafabc;
$color-success-darkest: #a5eba8;
$color-success-text: #268029;
$color-success-contrast: #65bb6a;
$color-warning: #fceeca;
$color-warning-dark: #f5c354;
$color-warning-darker: #f3ab2c;
$color-warning-darkest: #ec8b14;
$color-danger: #db6965;
$color-danger-dark: #d65550;
$color-danger-darker: #d1413c;
$color-danger-text: #700000;
$color-danger-background: #fff0f0;
$color-danger-icon-background: #ffdad6;
$color-danger-icon-color: #700000;
// ============================================
// CSS Variables
// ============================================
:root {
// Primary
--color-primary: #{$color-primary};
--color-primary-darker: #{$color-primary-darker};
--color-primary-darkest: #{$color-primary-darkest};
--color-primary-light: #{$color-primary-light};
--color-primary-light-darker: #{$color-primary-light-darker};
--color-primary-hover: #{$color-primary-hover};
--color-brand-active: var(--color-primary-darkest);
// Grays
--color-gray-10: #{$color-gray-10};
--color-gray-20: #{$color-gray-20};
--color-gray-30: #{$color-gray-30};
--color-gray-40: #{$color-gray-40};
--color-gray-50: #{$color-gray-50};
--color-gray-60: #{$color-gray-60};
--color-gray-70: #{$color-gray-70};
--color-gray-80: #{$color-gray-80};
--color-gray-85: #{$color-gray-85};
--color-gray-90: #{$color-gray-90};
--color-gray-100: #{$color-gray-100};
// Surfaces
--island-bg-color: #ffffff;
--island-bg-color-alt: #fff;
--color-surface-lowest: #ffffff;
--color-surface-low: #f8f9fa;
--color-surface-high: #e9ecef;
--color-surface-primary-container: #{$color-primary-light};
--color-on-surface: #{$color-gray-90};
--color-on-primary-container: #{$color-primary-darkest};
// Semantic
--color-success: #{$color-success};
--color-success-darker: #{$color-success-darker};
--color-success-darkest: #{$color-success-darkest};
--color-success-text: #{$color-success-text};
--color-success-contrast: #{$color-success-contrast};
--color-warning: #{$color-warning};
--color-warning-dark: #{$color-warning-dark};
--color-warning-darker: #{$color-warning-darker};
--color-danger: #{$color-danger};
--color-danger-dark: #{$color-danger-dark};
--color-danger-darker: #{$color-danger-darker};
--color-danger-text: #{$color-danger-text};
--color-danger-background: #{$color-danger-background};
--color-disabled: var(--color-gray-40);
--color-muted: var(--color-gray-50);
--color-muted-darker: var(--color-gray-60);
--color-selection: #6965db;
--color-icon-white: #fff;
--color-logo-icon: var(--color-primary);
--color-logo-text: #190064;
// Text
--text-primary-color: var(--color-on-surface);
--link-color: #1c7ed6;
--link-color-hover: #1971c2;
// Inputs
--input-bg-color: #fff;
--input-border-color: #{$color-gray-30};
--input-hover-bg-color: #{$color-gray-10};
--input-label-color: #{$color-gray-70};
// Borders
--default-border-color: var(--color-gray-30);
--dialog-border-color: var(--color-gray-20);
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
--border-radius-xl: 0.75rem;
--border-radius-full: 9999px;
// Shadows (Island Pattern)
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgb(0 0 0 / 18%);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035),
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
// Spacing
--space-factor: 0.25rem;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
// Typography
--ui-font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
// Sizing
--default-button-size: 2rem;
--default-icon-size: 1rem;
--lg-button-size: 2.25rem;
--lg-icon-size: 1rem;
--avatar-size: 2rem;
--sidebar-width: 260px;
--header-height: 64px;
// Timing
--duration-fast: 0.15s;
--duration-normal: 0.2s;
--duration-slow: 0.3s;
--ease-out: cubic-bezier(0.4, 0, 0.2, 1);
// Scrollbar
--scrollbar-thumb: var(--color-gray-30);
--scrollbar-thumb-hover: var(--color-gray-40);
}
// Dark Mode
@media (prefers-color-scheme: dark) {
:root {
// Grays (inverted)
--color-gray-10: #1a1a1a;
--color-gray-20: #2d2d2d;
--color-gray-30: #3d3d3d;
--color-gray-40: #5c5c5c;
--color-gray-50: #7a7a7a;
--color-gray-60: #999999;
--color-gray-70: #b8b8b8;
--color-gray-80: #d6d6d6;
--color-gray-85: #ebebeb;
--color-gray-90: #f5f5f5;
--color-gray-100: #ffffff;
--island-bg-color: #252525;
--island-bg-color-alt: #2d2d2d;
--color-surface-lowest: #121212;
--color-surface-low: #1a1a1a;
--color-surface-high: #2d2d2d;
--color-on-surface: #f5f5f5;
--color-on-primary-container: #e3e2fe;
--color-muted: var(--color-gray-50);
--color-muted-darker: var(--color-gray-60);
--input-bg-color: #2d2d2d;
--input-border-color: #3d3d3d;
--input-hover-bg-color: #363636;
--input-label-color: var(--color-gray-70);
--default-border-color: #3d3d3d;
--dialog-border-color: #2d2d2d;
--text-primary-color: #f5f5f5;
--link-color: #74c0fc;
--link-color-hover: #a5d8ff;
--scrollbar-thumb: var(--color-gray-40);
--scrollbar-thumb-hover: var(--color-gray-60);
// Shadows need less opacity in dark mode
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.15);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.35);
}
}
// Manual dark mode override
[data-theme="dark"] {
// Grays (inverted)
--color-gray-10: #1a1a1a;
--color-gray-20: #2d2d2d;
--color-gray-30: #3d3d3d;
--color-gray-40: #5c5c5c;
--color-gray-50: #7a7a7a;
--color-gray-60: #999999;
--color-gray-70: #b8b8b8;
--color-gray-80: #d6d6d6;
--color-gray-85: #ebebeb;
--color-gray-90: #f5f5f5;
--color-gray-100: #ffffff;
--island-bg-color: #252525;
--island-bg-color-alt: #2d2d2d;
--color-surface-lowest: #121212;
--color-surface-low: #1a1a1a;
--color-surface-high: #2d2d2d;
--color-on-surface: #f5f5f5;
--color-on-primary-container: #e3e2fe;
--color-muted: var(--color-gray-50);
--color-muted-darker: var(--color-gray-60);
--input-bg-color: #2d2d2d;
--input-border-color: #3d3d3d;
--input-hover-bg-color: #363636;
--input-label-color: var(--color-gray-70);
--default-border-color: #3d3d3d;
--dialog-border-color: #2d2d2d;
--text-primary-color: #f5f5f5;
--link-color: #74c0fc;
--link-color-hover: #a5d8ff;
--scrollbar-thumb: var(--color-gray-40);
--scrollbar-thumb-hover: var(--color-gray-60);
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.15);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.35);
}
+283
View File
@@ -0,0 +1,283 @@
// ============================================
// User & Auth Types
// ============================================
export interface User {
id: string;
name: string;
username: string;
email: string;
avatar_url: string | null;
locale: string;
timezone: string;
created_at: string;
updated_at: string;
}
export interface AuthIdentity {
id: string;
user_id: string;
provider: 'github' | 'password' | 'google';
provider_user_id: string;
email_verified_at: string | null;
created_at: string;
}
export interface Session {
id: string;
user_id: string;
expires_at: string;
created_at: string;
}
// ============================================
// Team Types
// ============================================
export type TeamRole = 'owner' | 'admin' | 'editor' | 'viewer';
export interface Team {
id: string;
name: string;
slug: string;
owner_user_id: string;
plan_type: 'free' | 'pro';
created_at: string;
updated_at: string;
}
export interface TeamMembership {
id: string;
team_id: string;
user_id: string;
role: TeamRole;
joined_at: string;
user?: User;
}
export interface TeamInvite {
id: string;
team_id: string;
email: string;
role: TeamRole;
invited_by: string;
expires_at: string;
created_at: string;
}
// ============================================
// Project & Folder Types
// ============================================
export interface Project {
id: string;
team_id: string;
name: string;
slug: string;
description: string | null;
created_by: string;
created_at: string;
updated_at: string;
}
export interface Folder {
id: string;
team_id: string;
project_id: string | null;
parent_folder_id: string | null;
name: string;
slug: string;
path_cache: string;
visibility: 'private' | 'team';
created_by: string;
created_at: string;
updated_at: string;
}
// ============================================
// Drawing Types
// ============================================
export type DrawingVisibility = 'private' | 'team' | 'restricted' | 'public-link';
export interface Drawing {
id: string;
team_id: string;
folder_id: string | null;
project_id: string | null;
slug: string | null;
title: string;
description: string | null;
owner_user_id: string;
latest_revision_id: string | null;
visibility: DrawingVisibility;
is_archived: boolean;
thumbnail_asset_id: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
// Joined fields
owner?: User;
folder?: Folder;
project?: Project;
thumbnail_url?: string;
}
export interface DrawingRevision {
id: string;
drawing_id: string;
revision_number: number;
snapshot_path: string;
snapshot_size: number;
content_hash: string;
created_by: string;
created_at: string;
change_summary: string | null;
snapshot?: string | Record<string, unknown>;
created_by_user?: User;
}
export interface DrawingAsset {
id: string;
drawing_id: string;
kind: 'image' | 'export' | 'attachment' | 'thumbnail';
path: string;
mime_type: string;
size: number;
width: number | null;
height: number | null;
uploaded_by: string;
created_at: string;
url?: string;
}
// ============================================
// Template Types
// ============================================
export type TemplateScope = 'system' | 'team' | 'personal';
export type TemplateType =
| 'todo'
| 'kanban'
| 'brainstorm'
| 'flowchart'
| 'meeting-notes'
| 'architecture'
| 'mindmap'
| 'wireframe'
| 'empty';
export interface Template {
id: string;
team_id: string | null;
scope: TemplateScope;
type: TemplateType;
name: string;
description: string | null;
snapshot_path: string;
metadata_json: Record<string, unknown>;
created_by: string;
created_at: string;
updated_at: string;
preview_url?: string;
}
// ============================================
// Share & Permission Types
// ============================================
export type Permission = 'view' | 'comment' | 'edit' | 'manage' | 'share' | 'invite';
export interface ShareLink {
id: string;
resource_type: 'drawing' | 'folder' | 'project';
resource_id: string;
token_hash: string;
permission: Permission;
expires_at: string | null;
password_hash: string | null;
created_by: string;
revoked_at: string | null;
created_at: string;
}
export interface PermissionGrant {
id: string;
resource_type: string;
resource_id: string;
subject_type: 'user' | 'team' | 'link';
subject_id: string;
permission: Permission;
inherited_from: string | null;
created_at: string;
}
// ============================================
// Activity Types
// ============================================
export type ActivityEventType =
| 'drawing_created'
| 'drawing_updated'
| 'drawing_deleted'
| 'drawing_moved'
| 'drawing_renamed'
| 'drawing_shared'
| 'folder_created'
| 'folder_updated'
| 'folder_deleted'
| 'folder_shared'
| 'member_joined'
| 'member_left'
| 'member_invited'
| 'member_role_changed'
| 'revision_created'
| 'revision_restored'
| 'template_applied';
export interface ActivityEvent {
id: string;
actor_user_id: string | null;
team_id: string | null;
resource_type: string;
resource_id: string;
event_type: ActivityEventType;
metadata_json: Record<string, unknown>;
created_at: string;
actor?: User;
}
// ============================================
// UI Types
// ============================================
export type Theme = 'light' | 'dark' | 'system';
export interface BreadcrumbItem {
label: string;
href?: string;
}
export interface FilterOptions {
sortBy: 'name' | 'updated' | 'created';
sortOrder: 'asc' | 'desc';
view: 'grid' | 'list';
}
// ============================================
// API Response Types
// ============================================
export interface ApiResponse<T> {
data: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
per_page: number;
has_more: boolean;
}
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.scss' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.sass' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.css' {
const classes: { [key: string]: string };
export default classes;
}