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