mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-07-29 15:43:47 +00:00
feat: full project sync - CI fixes, frontend, workspace API, and all changes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user