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
@@ -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';