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

This commit is contained in:
Tomas Dvorak
2026-04-27 09:08:07 +02:00
parent a07fca997e
commit 89b9390c14
109 changed files with 21120 additions and 545 deletions
+81
View File
@@ -0,0 +1,81 @@
@use '../../styles/variables' as *;
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
background:
radial-gradient(ellipse at top left, var(--color-surface-high), transparent 60%),
radial-gradient(ellipse at bottom right, var(--color-gray-10), transparent 60%),
var(--color-surface-low);
}
.card {
width: 100%;
max-width: 400px;
padding: var(--space-8);
}
.header {
text-align: center;
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-2xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
p {
color: var(--color-muted);
}
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.divider {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-6);
color: var(--color-muted);
font-size: var(--text-sm);
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-gray-20);
}
}
.footer {
text-align: center;
margin-top: var(--space-6);
font-size: var(--text-sm);
color: var(--color-muted);
a {
color: var(--color-primary);
font-weight: 500;
}
}
.error {
background: var(--color-danger-background);
color: var(--color-danger-text);
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
text-align: center;
}
+96
View File
@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Github } from 'lucide-react';
import { Button, Input, Card } from '@/components';
import { useAuth } from '@/hooks';
import styles from './Auth.module.scss';
const loginStrings = {
title: 'auth.login.title',
subtitle: 'auth.login.subtitle',
emailLabel: 'auth.login.emailLabel',
emailPlaceholder: 'auth.login.emailPlaceholder',
passwordLabel: 'auth.login.passwordLabel',
passwordPlaceholder: 'auth.login.passwordPlaceholder',
signIn: 'auth.login.signIn',
noAccount: 'auth.login.noAccount',
signUpLink: 'auth.login.signUpLink',
};
const commonStrings = {
continueWith: 'common.continueWith',
};
export const Login: React.FC<{ hasUsers: boolean }> = ({ hasUsers }) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await login(email, password);
navigate('/');
} catch {
setError('Invalid email or password');
} finally {
setIsLoading(false);
}
};
return (
<div className={styles.container}>
<Card className={styles.card}>
<div className={styles.header}>
<h1>{t(loginStrings.title)}</h1>
<p>{t(loginStrings.subtitle)}</p>
</div>
{error && <div className={styles.error}>{error}</div>}
<form onSubmit={handleSubmit} className={styles.form}>
<Input
label={t(loginStrings.emailLabel)}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t(loginStrings.emailPlaceholder)}
required
/>
<Input
label={t(loginStrings.passwordLabel)}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t(loginStrings.passwordPlaceholder)}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{t(loginStrings.signIn)}
</Button>
</form>
<div className={styles.divider}>
<span>{t(commonStrings.continueWith)}</span>
</div>
<Button variant="secondary" fullWidth>
<Github size={18} />
GitHub
</Button>
{!hasUsers && (
<p className={styles.footer}>
{t(loginStrings.noAccount)} <Link to="/signup">{t(loginStrings.signUpLink)}</Link>
</p>
)}
</Card>
</div>
);
};
+89
View File
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Github } from 'lucide-react';
import { Button, Input, Card } from '@/components';
import { useAuth } from '@/hooks';
import styles from './Auth.module.scss';
export const Signup: React.FC<{ hasUsers: boolean }> = ({ hasUsers }) => {
const { t } = useTranslation();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { signup } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await signup(name, email, password);
navigate('/');
} catch {
setError('Could not create account');
} finally {
setIsLoading(false);
}
};
return (
<div className={styles.container}>
<Card className={styles.card}>
<div className={styles.header}>
<h1>{t('auth.signup.title')}</h1>
<p>{t('auth.signup.subtitle')}</p>
</div>
{error && <div className={styles.error}>{error}</div>}
<form onSubmit={handleSubmit} className={styles.form}>
<Input
label={t('auth.signup.nameLabel')}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('auth.signup.namePlaceholder')}
required
/>
<Input
label={t('auth.signup.emailLabel')}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('auth.signup.emailPlaceholder')}
required
/>
<Input
label={t('auth.signup.passwordLabel')}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('auth.signup.passwordPlaceholder')}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{t('auth.signup.createAccount')}
</Button>
</form>
<div className={styles.divider}>
<span>{t('common.continueWith')}</span>
</div>
<Button variant="secondary" fullWidth>
<Github size={18} />
GitHub
</Button>
{hasUsers && (
<p className={styles.footer}>
{t('auth.signup.hasAccount')} <Link to="/login">{t('auth.signup.signInLink')}</Link>
</p>
)}
</Card>
</div>
);
};
@@ -0,0 +1,310 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
display: flex;
align-items: center;
justify-content: space-between;
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.quickActions {
display: flex;
align-items: center;
gap: var(--space-3);
@media (max-width: 768px) {
flex-wrap: wrap;
}
}
.actionBtn {
display: flex;
align-items: center;
gap: var(--space-2);
}
.createButton {
display: flex;
align-items: center;
gap: var(--space-2);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-6);
margin-bottom: var(--space-8);
@media (max-width: 1024px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
.statCard {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-4);
}
.statIcon {
color: var(--color-primary);
margin-bottom: var(--space-3);
}
.statValue {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--color-gray-85);
line-height: 1;
}
.statLabel {
font-size: var(--text-sm);
color: var(--color-muted);
margin-top: var(--space-1);
}
.chartBarWrap {
position: relative;
width: 100%;
height: 6px;
margin-top: var(--space-3);
border-radius: var(--border-radius-full);
overflow: hidden;
}
.chartBarBg {
position: absolute;
inset: 0;
background: var(--color-gray-20);
border-radius: var(--border-radius-full);
}
.chartBar {
position: absolute;
inset: 0;
border-radius: var(--border-radius-full);
transition: width 0.4s var(--ease-out);
}
.activityResource {
display: inline-block;
padding: 1px 6px;
border-radius: var(--border-radius-sm);
background: var(--color-surface-low);
color: var(--color-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-left: var(--space-1);
}
.twoColumn {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
.column {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.empty {
text-align: center;
padding: var(--space-8);
}
.emptySub {
color: var(--color-muted);
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.drawingList {
list-style: none;
}
.drawingItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.drawingThumb {
width: 48px;
height: 48px;
border-radius: var(--border-radius-md);
overflow: hidden;
background: var(--color-surface-low);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.drawingInfo {
flex: 1;
min-width: 0;
}
.drawingTitle {
font-weight: 500;
color: var(--color-gray-85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawingMeta {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
.templateGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-4);
}
.templateCard {
cursor: pointer;
transition: transform var(--duration-fast) var(--ease-out);
&:hover {
transform: translateY(-2px);
}
}
.templatePreview {
aspect-ratio: 16 / 10;
border-radius: var(--border-radius-md);
overflow: hidden;
background: var(--color-surface-low);
margin-bottom: var(--space-2);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.templatePlaceholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.templateName {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-70);
text-align: center;
}
.activityCard {
margin-top: var(--space-6);
}
.activityList {
list-style: none;
}
.activityItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.activityAvatar {
width: 32px;
height: 32px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
font-weight: 600;
flex-shrink: 0;
}
.activityInfo {
flex: 1;
}
.activityText {
font-size: var(--text-sm);
color: var(--color-gray-80);
}
.activityTime {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
+269
View File
@@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Clock, Star, Users, FileText, Plus, Loader2, FolderPlus, UserPlus, BookOpen, Activity } from 'lucide-react';
import { Button, Card, CardHeader, CardContent, TemplatePicker } from '@/components';
import { useDrawingStore, useAuthStore } from '@/stores';
import { api } from '@/services';
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import type { PickedTemplate } from '@/components/TemplatePicker/TemplatePicker';
import styles from './Dashboard.module.scss';
const StatChart: React.FC<{ value: number; max: number; color?: string }> = ({ value, max, color = '#6965db' }) => {
const pct = max > 0 ? (value / max) * 100 : 0;
return (
<div className={styles.chartBarWrap} aria-hidden="true">
<div className={styles.chartBarBg} />
<div className={styles.chartBar} style={{ width: `${pct}%`, background: color }} />
</div>
);
};
export const Dashboard: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { recentDrawings, setRecentDrawings, activity, setActivity } = useDrawingStore();
const { user } = useAuthStore();
const [isCreating, setIsCreating] = useState(false);
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [statsData, setStatsData] = useState({
teams: 0,
members: 0,
projects: 0,
folders: 0,
drawings: 0,
templates: 0,
revisions: 0,
assets: 0,
storage_bytes: 0,
});
useEffect(() => {
const loadData = async () => {
try {
const [drawings, stats, activityData] = await Promise.all([
api.drawings.list(),
api.stats.get(),
api.activity.list(),
]);
setRecentDrawings(drawings);
setStatsData(stats);
setActivity(activityData);
} catch (err) {
console.error('Failed to load dashboard data:', err);
}
};
loadData();
}, [setRecentDrawings, setActivity]);
const handleCreateDrawing = async (template: PickedTemplate = 'blank') => {
setIsCreating(true);
try {
const newDrawing = await api.drawings.create({
title: template === 'blank' ? 'Untitled Drawing' : `${template.charAt(0).toUpperCase() + template.slice(1)}`,
visibility: 'team',
});
setRecentDrawings([newDrawing, ...recentDrawings]);
if (template !== 'blank' && BUILTIN_TEMPLATES[template]) {
localStorage.setItem(`template_${newDrawing.id}`, JSON.stringify({
elements: BUILTIN_TEMPLATES[template],
appState: {},
files: {},
}));
}
navigate(`/drawing/${newDrawing.id}`);
} catch (err) {
console.error('Failed to create drawing:', err);
} finally {
setIsCreating(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const maxStat = Math.max(statsData.drawings, statsData.projects + statsData.folders, statsData.teams, statsData.revisions, 1);
const stats = [
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, icon: FileText, color: '#6965db' },
{ label: t('dashboard.stats.projects'), value: statsData.projects + statsData.folders, icon: FolderPlus, color: '#4dabf7' },
{ label: t('dashboard.stats.teams'), value: statsData.teams, icon: Users, color: '#51cf66' },
{ label: t('dashboard.stats.revisions'), value: statsData.revisions, icon: Clock, color: '#fcc419' },
{ label: t('dashboard.stats.storage'), value: formatBytes(Number(statsData.storage_bytes)), raw: statsData.storage_bytes, icon: Star, color: '#ff6b6b' },
];
return (
<div className={styles.container}>
<div className={styles.header}>
<div>
<h1>{t('dashboard.welcome', { name: user?.name || t('common.user') })}</h1>
<p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
</div>
<div className={styles.quickActions}>
<TemplatePicker
isOpen={showTemplatePicker}
onClose={() => setShowTemplatePicker(false)}
onSelect={(t) => { setShowTemplatePicker(false); handleCreateDrawing(t); }}
/>
<Button
variant="secondary"
onClick={() => navigate('/files')}
className={styles.actionBtn}
>
<FolderPlus size={16} />
New Project
</Button>
<Button
variant="secondary"
onClick={() => navigate('/team')}
className={styles.actionBtn}
>
<UserPlus size={16} />
Invite
</Button>
<Button
variant="secondary"
onClick={() => navigate('/library')}
className={styles.actionBtn}
>
<BookOpen size={16} />
Library
</Button>
<Button
onClick={() => setShowTemplatePicker(true)}
loading={isCreating}
className={styles.createButton}
>
{isCreating ? (
<Loader2 size={18} className={styles.spinner} />
) : (
<Plus size={18} />
)}
{t('dashboard.newDrawing')}
</Button>
</div>
</div>
<div className={styles.statsGrid}>
{stats.map((stat) => (
<Card key={stat.label}>
<CardContent className={styles.statCard}>
<div className={styles.statIcon}>
<stat.icon size={24} />
</div>
<div className={styles.statValue}>{stat.value}</div>
<div className={styles.statLabel}>{stat.label}</div>
<StatChart value={typeof stat.value === 'number' ? stat.value : 0} max={maxStat} color={stat.color} />
</CardContent>
</Card>
))}
</div>
<div className={styles.twoColumn}>
<div className={styles.column}>
<Card>
<CardHeader>
<h3>{t('dashboard.recentDrawings')}</h3>
</CardHeader>
<CardContent>
{recentDrawings.length === 0 ? (
<div className={styles.empty}>
<p>{t('dashboard.noDrawings')}</p>
<p className={styles.emptySub}>{t('dashboard.noDrawingsSub')}</p>
</div>
) : (
<ul className={styles.drawingList} role="list" aria-label="Recent drawings">
{recentDrawings.slice(0, 5).map((drawing) => (
<li
key={drawing.id}
className={styles.drawingItem}
role="listitem"
tabIndex={0}
onClick={() => {
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<div className={styles.drawingThumb}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.drawingInfo}>
<p className={styles.drawingTitle}>{drawing.title}</p>
<p className={styles.drawingMeta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<div className={styles.column}>
<Card className={styles.activityCard}>
<CardHeader>
<h3><Activity size={16} style={{ display: 'inline', marginRight: 8, verticalAlign: 'middle' }} />Recent Activity</h3>
</CardHeader>
<CardContent>
{activity.length === 0 ? (
<div className={styles.empty}>
<p className={styles.emptySub}>No recent activity</p>
</div>
) : (
<ul className={styles.activityList}>
{activity.slice(0, 8).map((event) => (
<li key={event.id} className={styles.activityItem}>
<div className={styles.activityAvatar}>
{event.actor?.name?.[0] || '?'}
</div>
<div className={styles.activityInfo}>
<p className={styles.activityText}>
<strong>{event.actor?.name || 'Unknown'}</strong>{' '}
{event.event_type.replace(/_/g, ' ')}{' '}
<span className={styles.activityResource}>{event.resource_type}</span>
</p>
<p className={styles.activityTime}>
{new Date(event.created_at).toLocaleString()}
</p>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
};
@@ -0,0 +1,418 @@
@use '../../styles/variables' as *;
.container {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--color-surface-lowest);
}
.toolbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-4);
background: var(--island-bg-color);
border-bottom: 1px solid var(--color-gray-20);
}
.left {
display: flex;
align-items: center;
gap: var(--space-4);
}
.right {
display: flex;
align-items: center;
gap: var(--space-2);
}
.title {
font-weight: 500;
color: var(--color-gray-85);
font-size: var(--text-md);
}
.saveStatus {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--color-gray-60);
}
.unsaved {
color: var(--color-warning);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.canvas {
flex: 1;
position: relative;
overflow: hidden;
:global(.excalidraw) {
width: 100%;
height: 100%;
}
}
.loadingCanvas {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface-lowest);
color: var(--color-gray-60);
font-size: var(--text-md);
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--color-muted);
background: repeating-linear-gradient(
45deg,
var(--color-gray-10),
var(--color-gray-10) 10px,
transparent 10px,
transparent 20px
);
p {
margin: 0;
}
}
.loading,
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-4);
color: var(--color-gray-60);
}
.sub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.revisionBadge {
background: var(--color-primary);
color: white;
font-size: 10px;
border-radius: 999px;
padding: 1px 6px;
margin-left: 4px;
}
.canvasWrapper {
display: flex;
flex: 1;
overflow: hidden;
}
.canvasNarrow {
flex: 0 0 calc(100% - 280px);
}
.revisionPanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.revisionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.revisionList {
flex: 1;
overflow-y: auto;
padding: var(--space-2);
}
.revisionItem {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.15s ease;
&:hover {
background: var(--color-gray-10);
}
}
.revisionActive {
background: var(--color-primary-10);
color: var(--color-primary);
&:hover {
background: var(--color-primary-20);
}
}
.revisionMeta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.revisionLabel {
font-size: var(--text-sm);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.revisionDate {
font-size: 11px;
color: var(--color-gray-60);
}
.revisionEditor {
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-gray-50);
flex-shrink: 0;
}
.notesPanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.notesHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.notesTextarea {
flex: 1;
resize: none;
border: none;
padding: var(--space-3) var(--space-4);
font-family: inherit;
font-size: var(--text-sm);
line-height: 1.5;
background: var(--color-surface-lowest);
color: var(--color-on-surface);
outline: none;
&::placeholder {
color: var(--color-gray-50);
}
}
.revisionEmpty {
text-align: center;
color: var(--color-gray-50);
font-size: var(--text-sm);
padding: var(--space-4);
}
.sidePanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.sidePanelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.sidePanelContent {
flex: 1;
overflow-y: auto;
padding: var(--space-2);
}
.sidePanelItem {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.15s ease;
margin-bottom: var(--space-1);
&:hover {
background: var(--color-gray-10);
}
}
.sidePanelItemTitle {
font-size: var(--text-sm);
font-weight: 500;
}
.sidePanelItemDesc {
font-size: 11px;
color: var(--color-gray-50);
}
.sidePanelSearch {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
background: var(--color-surface-low);
border-radius: var(--border-radius-md);
border: 1px solid var(--color-gray-20);
svg {
color: var(--color-gray-50);
flex-shrink: 0;
}
}
.sidePanelInput {
background: transparent;
border: none;
outline: none;
color: var(--color-on-surface);
font-size: var(--text-sm);
width: 100%;
}
.sidePanelSelect {
width: 100%;
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
color: var(--color-on-surface);
font-size: var(--text-sm);
cursor: pointer;
}
.sidePanelLoading,
.sidePanelEmpty,
.sidePanelError {
text-align: center;
padding: var(--space-4);
font-size: var(--text-sm);
color: var(--color-gray-50);
}
.sidePanelError {
color: var(--color-danger);
}
@media (max-width: 768px) {
.toolbar {
height: auto;
padding: var(--space-2) var(--space-3);
gap: var(--space-2);
flex-wrap: wrap;
}
.left {
flex: 1;
min-width: 0;
gap: var(--space-2);
}
.title {
font-size: var(--text-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.canvasNarrow {
flex: 1 !important;
}
.revisionPanel,
.notesPanel,
.sidePanel {
position: fixed;
right: 0;
top: 48px;
bottom: 0;
width: 100%;
max-width: 340px;
z-index: 80;
}
}
+616
View File
@@ -0,0 +1,616 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, Bot, StickyNote, LayoutTemplate, BookOpen, Search } from 'lucide-react';
import { Button, ChatPanel } from '@/components';
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import { useThemeStore } from '@/stores';
import { api } from '@/services';
import type { Drawing, DrawingRevision } from '@/types';
import styles from './Editor.module.scss';
// Dynamic import for Excalidraw to avoid SSR issues
const Excalidraw = React.lazy(() => import('@excalidraw/excalidraw').then(mod => ({ default: mod.Excalidraw })));
interface ExcalidrawElement {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
[key: string]: unknown;
}
interface ExcalidrawState {
elements: ExcalidrawElement[];
appState: Record<string, unknown>;
files: Record<string, { dataURL: string; mimeType: string }>;
}
function prepareElementsForImport(sourceElements: any[], offsetX: number, offsetY: number): any[] {
if (!sourceElements || !sourceElements.length) return [];
const idMap = new Map<string, string>();
sourceElements.forEach((el: any) => {
idMap.set(el.id, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
});
return sourceElements.map((el: any) => {
const newEl = { ...el };
newEl.id = idMap.get(el.id) || el.id;
newEl.x = (el.x || 0) + offsetX;
newEl.y = (el.y || 0) + offsetY;
newEl.version = (el.version || 1) + 1;
newEl.versionNonce = Math.floor(Math.random() * 1000000);
newEl.updated = Date.now();
newEl.seed = Math.floor(Math.random() * 100000);
if (newEl.boundElements) {
newEl.boundElements = newEl.boundElements.map((be: any) => ({
...be,
id: idMap.get(be.id) || be.id,
}));
}
if (newEl.containerId && idMap.has(newEl.containerId)) {
newEl.containerId = idMap.get(newEl.containerId);
}
return newEl;
});
}
export const Editor: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [drawing, setDrawing] = useState<Drawing | null>(null);
const [revisions, setRevisions] = useState<DrawingRevision[]>([]);
const [initialData, setInitialData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved');
const [error, setError] = useState<string | null>(null);
const [showRevisions, setShowRevisions] = useState(false);
const [showChat, setShowChat] = useState(false);
const [showNotes, setShowNotes] = useState(false);
const [notes, setNotes] = useState('');
const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
const { theme: appTheme } = useThemeStore();
const currentStateRef = useRef<ExcalidrawState | null>(null);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedDataRef = useRef<string>('');
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null);
const [showTemplates, setShowTemplates] = useState(false);
const [showLibrary, setShowLibrary] = useState(false);
const [libraryItems, setLibraryItems] = useState<any[]>([]);
const [libraryFiltered, setLibraryFiltered] = useState<any[]>([]);
const [libraryLoading, setLibraryLoading] = useState(false);
const [libraryError, setLibraryError] = useState('');
const [librarySearch, setLibrarySearch] = useState('');
const [libraryCategory, setLibraryCategory] = useState('All');
// Load drawing data
useEffect(() => {
const loadDrawing = async () => {
if (!id) return;
try {
setIsLoading(true);
const [drawingData, revisionsData] = await Promise.all([
api.drawings.get(id),
api.revisions.list(id),
]);
setDrawing(drawingData);
setRevisions(revisionsData);
// Load latest revision data if available
if (revisionsData.length > 0 && revisionsData[0].snapshot) {
const snapshot = JSON.parse(String(revisionsData[0].snapshot));
setInitialData({
elements: snapshot.elements || [],
appState: snapshot.appState || {},
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
} else {
// Check for pending template from dashboard
const pendingTemplate = localStorage.getItem(`template_${id}`);
if (pendingTemplate) {
const tpl = JSON.parse(pendingTemplate);
setInitialData({
elements: tpl.elements || [],
appState: tpl.appState || {},
files: tpl.files || {},
});
lastSavedDataRef.current = JSON.stringify(tpl);
localStorage.removeItem(`template_${id}`);
} else {
// Start with empty canvas
setInitialData({
elements: [],
appState: {},
files: {},
});
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
}
}
} catch (err) {
setError('Failed to load drawing');
console.error(err);
} finally {
setIsLoading(false);
}
};
loadDrawing();
}, [id]);
// Handle changes from Excalidraw
const handleExcalidrawChange = useCallback((elements: readonly unknown[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
currentStateRef.current = {
elements: elements as ExcalidrawElement[],
appState,
files,
};
setSaveStatus('unsaved');
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
saveDrawing();
}, 2000);
}, []);
// Auto-save functionality
const saveDrawing = useCallback(async () => {
if (!id || !currentStateRef.current || isSaving) return;
const { elements, appState, files } = currentStateRef.current;
const snapshot = {
type: 'excalidraw',
version: 2,
source: window.location.hostname,
elements,
appState: {
viewBackgroundColor: appState.viewBackgroundColor,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
theme: appState.theme,
zenModeEnabled: appState.zenModeEnabled,
viewModeEnabled: appState.viewModeEnabled,
editingGroup: appState.editingGroup,
selectedElementIds: appState.selectedElementIds,
},
files,
};
const snapshotJson = JSON.stringify(snapshot);
if (snapshotJson === lastSavedDataRef.current) {
setSaveStatus('saved');
return;
}
try {
setIsSaving(true);
setSaveStatus('saving');
await api.revisions.create(id, snapshot, 'Auto-save');
lastSavedDataRef.current = snapshotJson;
setSaveStatus('saved');
} catch (err) {
console.error('Failed to save:', err);
setSaveStatus('unsaved');
} finally {
setIsSaving(false);
}
}, [id, isSaving]);
// Remove unused revisions warning by displaying count in UI
const revisionCount = revisions.length;
// Restore a specific revision
const handleRestoreRevision = (revision: DrawingRevision) => {
if (!revision.snapshot) return;
try {
const snapshot = JSON.parse(String(revision.snapshot));
setInitialData({
elements: snapshot.elements || [],
appState: snapshot.appState || {},
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
setSelectedRevision(revision.id);
setSaveStatus('saved');
} catch (err) {
console.error('Failed to restore revision:', err);
}
};
// Manual save
const handleManualSave = async () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
await saveDrawing();
};
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
// Load library marketplace when panel opens
useEffect(() => {
if (!showLibrary || libraryItems.length > 0) return;
const load = async () => {
setLibraryLoading(true);
try {
const res = await fetch('https://libraries.excalidraw.com/libraries.json', {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error('Failed to load libraries');
const data = await res.json();
const items = Object.entries(data).map(([key, lib]: [string, any]) => ({
key,
name: lib.name || key,
description: lib.description || '',
authors: lib.authors || [{ name: 'Unknown' }],
source: `https://libraries.excalidraw.com/${key}.excalidrawlib`,
preview: lib.preview?.startsWith('http') ? lib.preview : `https://libraries.excalidraw.com/${key}.png`,
tags: lib.tags || [],
downloads: lib.downloads || 0,
}));
setLibraryItems(items);
setLibraryFiltered(items);
} catch (err) {
console.error(err);
setLibraryError('Could not load library marketplace.');
} finally {
setLibraryLoading(false);
}
};
load();
}, [showLibrary, libraryItems.length]);
// Filter library items
useEffect(() => {
let result = libraryItems;
if (librarySearch.trim()) {
const q = librarySearch.toLowerCase();
result = result.filter((l: any) =>
l.name.toLowerCase().includes(q) ||
l.description.toLowerCase().includes(q) ||
l.tags.some((t: string) => t.toLowerCase().includes(q))
);
}
if (libraryCategory !== 'All') {
result = result.filter((l: any) => l.tags.some((t: string) => t.toLowerCase() === libraryCategory.toLowerCase()));
}
setLibraryFiltered(result);
}, [librarySearch, libraryCategory, libraryItems]);
const handleLoadTemplate = (templateKey: string) => {
const templateElements = BUILTIN_TEMPLATES[templateKey as keyof typeof BUILTIN_TEMPLATES];
if (!templateElements || !excalidrawAPI) return;
const currentElements = excalidrawAPI.getSceneElements?.() || [];
let offsetX = 100;
let offsetY = 100;
if (currentElements.length > 0) {
const maxX = Math.max(...currentElements.map((el: any) => (el.x || 0) + (el.width || 0)));
offsetX = maxX + 100;
}
const newElements = prepareElementsForImport(templateElements, offsetX, offsetY);
const mergedElements = [...currentElements, ...newElements];
excalidrawAPI.updateScene({ elements: mergedElements });
setShowTemplates(false);
setSaveStatus('unsaved');
};
const handleLoadLibraryItem = async (item: any) => {
if (!excalidrawAPI || !item.source) return;
try {
const res = await fetch(item.source);
if (!res.ok) throw new Error('Failed to load library');
const libData = await res.json();
let sourceElements: any[] = [];
if (libData.libraryItems && Array.isArray(libData.libraryItems)) {
sourceElements = libData.libraryItems[0]?.elements || [];
} else if (Array.isArray(libData)) {
sourceElements = libData;
} else if (libData.elements && Array.isArray(libData.elements)) {
sourceElements = libData.elements;
}
if (!sourceElements.length) {
alert('This library appears to be empty');
return;
}
const currentElements = excalidrawAPI.getSceneElements?.() || [];
let offsetX = 100;
let offsetY = 100;
if (currentElements.length > 0) {
const maxX = Math.max(...currentElements.map((el: any) => (el.x || 0) + (el.width || 0)));
offsetX = maxX + 100;
}
const newElements = prepareElementsForImport(sourceElements, offsetX, offsetY);
const mergedElements = [...currentElements, ...newElements];
excalidrawAPI.updateScene({ elements: mergedElements });
setShowLibrary(false);
setSaveStatus('unsaved');
} catch (err) {
console.error('Failed to load library item:', err);
alert('Failed to load library item');
}
};
const templateOptions = [
{ id: 'blank', label: 'Blank', description: 'Empty canvas start', icon: null },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks', icon: null },
{ id: 'checklist', label: 'Checklist', description: 'Status checklist', icon: null },
{ id: 'list', label: 'Bullet List', description: 'Bulleted notes', icon: null },
{ id: 'flow', label: 'Flow Chart', description: 'Process diagram', icon: null },
];
const libraryCategories = ['All', 'Arrows', 'Charts', 'Cloud', 'Devops', 'Diagrams', 'Education', 'Food', 'Frames', 'Gaming', 'Icons', 'Illustrations', 'Machines', 'Misc', 'People', 'Software', 'Systems', 'Tech', 'Workflow'];
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>{t('common.loading')}</p>
</div>
</div>
);
}
if (error || !drawing) {
return (
<div className={styles.container}>
<div className={styles.error}>
<p>{error || t('editor.notFound')}</p>
<Button onClick={() => navigate('/')}>{t('editor.goToDashboard')}</Button>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.toolbar}>
<div className={styles.left}>
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft size={18} />
{t('editor.back')}
</Button>
<span className={styles.title}>{drawing.title}</span>
<span className={styles.saveStatus}>
{saveStatus === 'saving' && <><Loader2 size={14} className={styles.spinner} /> {t('editor.saving')}</>}
{saveStatus === 'saved' && <><Check size={14} /> {t('editor.saved')} {revisionCount > 0 && `(${revisionCount} ${t('editor.revisions')})`}</>}
{saveStatus === 'unsaved' && <span className={styles.unsaved}>{t('editor.unsaved')}</span>}
</span>
</div>
<div className={styles.right}>
<Button
variant="ghost"
size="sm"
onClick={() => setShowChat(!showChat)}
title="AI Assistant"
aria-pressed={showChat}
aria-label="Toggle AI chat panel"
>
<Bot size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowNotes(!showNotes)}
title="Presenter notes"
aria-pressed={showNotes}
aria-label="Toggle presenter notes"
>
<StickyNote size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowRevisions(!showRevisions)}
title={t('editor.revisionBrowser')}
aria-pressed={showRevisions}
aria-label="Toggle revision browser"
>
<History size={16} />
{revisionCount > 0 && <span className={styles.revisionBadge}>{revisionCount}</span>}
</Button>
<Button
size="sm"
onClick={handleManualSave}
loading={isSaving}
disabled={saveStatus === 'saved'}
>
<Save size={16} />
{t('editor.saveNow')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setShowTemplates(!showTemplates); setShowLibrary(false); }}
title="Templates"
aria-pressed={showTemplates}
aria-label="Toggle templates panel"
>
<LayoutTemplate size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setShowLibrary(!showLibrary); setShowTemplates(false); }}
title="Library Marketplace"
aria-pressed={showLibrary}
aria-label="Toggle library panel"
>
<BookOpen size={16} />
</Button>
</div>
</div>
<div className={styles.canvasWrapper}>
<div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates || showLibrary) ? styles.canvasNarrow : ''}`}>
{initialData && (
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
<Excalidraw
excalidrawAPI={(api: any) => setExcalidrawAPI(api)}
initialData={initialData}
onChange={handleExcalidrawChange}
theme={appTheme === 'dark' ? 'dark' : 'light'}
gridModeEnabled={true}
UIOptions={{
canvasActions: {
saveToActiveFile: false,
loadScene: false,
export: { saveFileToDisk: false },
},
}}
/>
</React.Suspense>
)}
</div>
{showRevisions && (
<div className={styles.revisionPanel}>
<div className={styles.revisionHeader}>
<h3>{t('editor.revisionBrowser')}</h3>
<Button variant="ghost" size="sm" onClick={() => setShowRevisions(false)}>
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.revisionList}>
{revisions.length === 0 ? (
<p className={styles.revisionEmpty}>{t('editor.noRevisions')}</p>
) : (
revisions.map((rev) => (
<button
key={rev.id}
className={`${styles.revisionItem} ${selectedRevision === rev.id ? styles.revisionActive : ''}`}
onClick={() => handleRestoreRevision(rev)}
>
<div className={styles.revisionMeta}>
<span className={styles.revisionLabel}>{rev.change_summary || t('editor.revision')}</span>
<span className={styles.revisionDate}>
{new Date(rev.created_at).toLocaleString()}
</span>
</div>
{rev.created_by && (
<span className={styles.revisionEditor}>{rev.created_by.slice(0, 8)}</span>
)}
</button>
))
)}
</div>
</div>
)}
{showNotes && (
<div className={styles.notesPanel} role="complementary" aria-label={t('editor.presenterNotes')}>
<div className={styles.notesHeader}>
<h3>{t('editor.presenterNotes')}</h3>
<Button variant="ghost" size="sm" onClick={() => setShowNotes(false)} aria-label={t('common.close')}>
<ChevronRight size={16} />
</Button>
</div>
<textarea
className={styles.notesTextarea}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('editor.notesPlaceholder')}
aria-label={t('editor.presenterNotes')}
/>
</div>
)}
{showTemplates && (
<div className={styles.sidePanel}>
<div className={styles.sidePanelHeader}>
<h3>Templates</h3>
<Button variant="ghost" size="sm" onClick={() => setShowTemplates(false)} aria-label="Close">
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.sidePanelContent}>
{templateOptions.map((opt) => (
<button
key={opt.id}
className={styles.sidePanelItem}
onClick={() => handleLoadTemplate(opt.id)}
>
<span className={styles.sidePanelItemTitle}>{opt.label}</span>
<span className={styles.sidePanelItemDesc}>{opt.description}</span>
</button>
))}
</div>
</div>
)}
{showLibrary && (
<div className={styles.sidePanel}>
<div className={styles.sidePanelHeader}>
<h3>Library Marketplace</h3>
<Button variant="ghost" size="sm" onClick={() => setShowLibrary(false)} aria-label="Close">
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.sidePanelContent}>
<div className={styles.sidePanelSearch}>
<Search size={14} />
<input
type="text"
placeholder="Search libraries..."
value={librarySearch}
onChange={(e) => setLibrarySearch(e.target.value)}
className={styles.sidePanelInput}
/>
</div>
<select
className={styles.sidePanelSelect}
value={libraryCategory}
onChange={(e) => setLibraryCategory(e.target.value)}
>
{libraryCategories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{libraryLoading && (
<div className={styles.sidePanelLoading}>
<Loader2 size={20} className={styles.spinner} />
<span>Loading...</span>
</div>
)}
{libraryError && (
<div className={styles.sidePanelError}>{libraryError}</div>
)}
{!libraryLoading && !libraryError && libraryFiltered.length === 0 && (
<div className={styles.sidePanelEmpty}>No libraries found</div>
)}
{!libraryLoading && libraryFiltered.map((item: any) => (
<button
key={item.key}
className={styles.sidePanelItem}
onClick={() => handleLoadLibraryItem(item)}
>
<span className={styles.sidePanelItemTitle}>{item.name}</span>
<span className={styles.sidePanelItemDesc}>{item.description || item.tags.slice(0, 3).join(', ')}</span>
</button>
))}
</div>
</div>
)}
{showChat && (
<ChatPanel
onClose={() => setShowChat(false)}
drawingContext={drawing?.title}
/>
)}
</div>
</div>
);
};
@@ -0,0 +1,398 @@
@use '../../styles/variables' as *;
.container {
height: 100%;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
flex-wrap: wrap;
@media (max-width: 640px) {
flex-direction: column;
align-items: flex-start;
}
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-lg);
font-weight: 500;
color: var(--color-gray-85);
svg {
color: var(--color-muted);
}
}
.breadcrumbLink {
background: none;
border: none;
color: var(--color-primary);
font-size: var(--text-lg);
font-weight: 500;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
.breadcrumbCurrent {
color: var(--color-gray-85);
}
.actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.filterSelect {
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.viewToggle {
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
padding: var(--space-2);
color: var(--color-muted);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
&:hover, &.active {
background: var(--color-surface-primary-container);
color: var(--color-primary);
border-color: var(--color-primary-light);
}
}
.content {
display: flex;
gap: var(--space-6);
flex: 1;
@media (max-width: 768px) {
flex-direction: column;
gap: var(--space-4);
}
}
.sidebar {
width: 200px;
flex-shrink: 0;
@media (max-width: 768px) {
width: 100%;
}
}
.folderTree {
display: flex;
flex-direction: column;
gap: var(--space-1);
list-style: none;
padding: 0;
margin: 0;
}
.folderItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
color: var(--color-gray-70);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
background: none;
border: none;
width: 100%;
text-align: left;
font-size: var(--text-sm);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.folderActive {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
}
svg {
color: var(--color-primary);
flex-shrink: 0;
}
}
.grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-4);
align-content: start;
@media (max-width: 640px) {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
}
.list {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-16);
color: var(--color-muted);
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
height: 100%;
color: var(--color-muted);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.drawingCard {
position: relative;
}
.thumbnail {
aspect-ratio: 4 / 3;
background: var(--color-surface-low);
border-radius: var(--border-radius-md);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.info {
padding: var(--space-3);
}
.title {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
.more {
position: absolute;
top: var(--space-2);
right: var(--space-2);
background: var(--island-bg-color);
border: none;
border-radius: var(--border-radius-md);
padding: var(--space-1);
color: var(--color-muted);
cursor: pointer;
opacity: 0;
transition: all var(--duration-fast) var(--ease-out);
.drawingCard:hover & {
opacity: 1;
}
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.moreWrap {
position: absolute;
top: var(--space-2);
right: var(--space-2);
}
.dropdown {
position: absolute;
top: calc(100% + var(--space-1));
right: 0;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-island);
min-width: 160px;
z-index: 10;
display: flex;
flex-direction: column;
padding: var(--space-1);
}
.dropdownItem {
background: none;
border: none;
text-align: left;
padding: var(--space-2) var(--space-3);
cursor: pointer;
border-radius: var(--border-radius-sm);
color: var(--color-on-surface);
font-size: var(--text-sm);
&:hover {
background: var(--color-surface-low);
}
}
.dropdownDanger {
color: #e03131;
&:hover {
background: rgba(224, 49, 49, 0.08);
}
}
.dropdownDivider {
height: 1px;
background: var(--default-border-color);
margin: var(--space-1) 0;
}
.dropdownSubmenu {
display: flex;
flex-direction: column;
}
.dropdownSubheader {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.newProjectForm {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.newProjectInput {
flex: 1;
min-width: 120px;
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
color: var(--text-primary-color);
font-size: var(--text-sm);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.newProjectBtn {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
font-weight: 500;
&:hover {
background: var(--color-primary-darkest);
}
}
.newProjectBtnCancel {
background: none;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-on-surface);
&:hover {
background: var(--color-surface-low);
}
}
.renameInput {
width: 100%;
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-1) var(--space-2);
color: var(--text-primary-color);
font-size: var(--text-sm);
&:focus {
outline: none;
border-color: var(--color-primary);
}
}
@@ -0,0 +1,476 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2 } from 'lucide-react';
import { Card, Button, Modal } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
import type { Drawing } from '@/types';
import styles from './FileBrowser.module.scss';
export const FileBrowser: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const urlParams = useParams<{ folderId?: string }>();
const { drawings, folders, setDrawings, setFolders } = useDrawingStore();
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [sortBy, setSortBy] = useState<'name' | 'updated' | 'created'>('updated');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [visibilityFilter, setVisibilityFilter] = useState<'all' | 'private' | 'team' | 'public-link'>('all');
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [activeFolderId, setActiveFolderId] = useState<string | null>(urlParams.folderId || null);
// Dropdown menu state
const [activeMenu, setActiveMenu] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
// New project (folder) state
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
// Rename state
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// Move state
const [movingId, setMovingId] = useState<string | null>(null);
// Modal state
const [modal, setModal] = useState<{
open: boolean;
type: 'confirm' | 'alert' | 'info';
title: string;
message: string;
onConfirm?: () => void;
onCancel?: () => void;
}>({ open: false, type: 'info', title: '', message: '' });
const showModal = (type: 'confirm' | 'alert' | 'info', title: string, message: string, onConfirm?: () => void) => {
setModal({ open: true, type, title, message, onConfirm, onCancel: () => setModal(m => ({ ...m, open: false })) });
};
// Load real data on mount
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
const [drawingsData, foldersData] = await Promise.all([
api.drawings.list(),
api.folders.list(),
]);
setDrawings(drawingsData);
setFolders(foldersData);
} catch (err) {
console.error('Failed to load file browser data:', err);
} finally {
setIsLoading(false);
}
};
loadData();
}, [setDrawings, setFolders]);
// Update active folder when URL changes
useEffect(() => {
setActiveFolderId(urlParams.folderId || null);
}, [urlParams.folderId]);
const activeFolder = folders.find((f) => f.id === activeFolderId);
// Filter drawings by active folder + visibility, then sort
let visibleDrawings = activeFolderId
? drawings.filter((d) => d.folder_id === activeFolderId)
: drawings;
if (visibilityFilter !== 'all') {
visibleDrawings = visibleDrawings.filter((d) => d.visibility === visibilityFilter);
}
visibleDrawings = [...visibleDrawings].sort((a, b) => {
let cmp = 0;
if (sortBy === 'name') cmp = a.title.localeCompare(b.title);
else if (sortBy === 'updated') cmp = new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime();
else if (sortBy === 'created') cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
return sortOrder === 'asc' ? cmp : -cmp;
});
const handleFolderClick = useCallback(
(folderId: string | null) => {
setActiveFolderId(folderId);
if (folderId) {
navigate(`/files/folder/${folderId}`);
} else {
navigate('/files');
}
},
[navigate]
);
const handleDrawingClick = useCallback(
(drawing: Drawing) => {
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
},
[navigate]
);
const handleCreateDrawing = async () => {
setIsCreating(true);
try {
const newDrawing = await api.drawings.create({
title: 'Untitled Drawing',
visibility: 'team',
folder_id: activeFolderId || null,
});
setDrawings([newDrawing, ...drawings]);
if (newDrawing.folder_id) {
navigate(`/folder/${newDrawing.folder_id}/drawing/${newDrawing.id}`);
} else {
navigate(`/drawing/${newDrawing.id}`);
}
} catch (err) {
console.error('Failed to create drawing:', err);
showModal('alert', 'Error', 'Failed to create drawing. Please try again.');
} finally {
setIsCreating(false);
}
};
const handleCreateFolder = async () => {
const name = newProjectName.trim();
if (!name) return;
try {
const newFolder = await api.folders.create({ name });
setFolders([...folders, newFolder]);
setShowNewProject(false);
setNewProjectName('');
navigate(`/files/folder/${newFolder.id}`);
} catch (err) {
console.error('Failed to create project:', err);
showModal('alert', 'Error', 'Failed to create project. Please try again.');
}
};
const handleDeleteDrawing = (drawing: Drawing) => {
showModal('confirm', 'Delete Drawing', `Delete "${drawing.title}"? This cannot be undone.`, async () => {
try {
await api.drawings.delete(drawing.id);
setDrawings(drawings.filter(d => d.id !== drawing.id));
setActiveMenu(null);
setModal(m => ({ ...m, open: false }));
} catch (err) {
console.error('Failed to delete drawing:', err);
setModal(m => ({ ...m, open: false }));
setTimeout(() => showModal('alert', 'Error', 'Failed to delete drawing.'), 100);
}
});
};
const handleDuplicateDrawing = async (drawing: Drawing) => {
try {
const newDrawing = await api.drawings.create({
title: `Copy of ${drawing.title}`,
visibility: drawing.visibility,
folder_id: drawing.folder_id || null,
});
setDrawings([newDrawing, ...drawings]);
setActiveMenu(null);
navigate(`/drawing/${newDrawing.id}`);
} catch (err) {
console.error('Failed to duplicate drawing:', err);
showModal('alert', 'Error', 'Failed to duplicate drawing. Please try again.');
}
};
const handleRenameDrawing = async (drawing: Drawing) => {
const title = renameValue.trim();
if (!title || title === drawing.title) {
setRenamingId(null);
return;
}
try {
await api.drawings.update(drawing.id, { title });
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, title } : d));
setRenamingId(null);
} catch (err) {
console.error('Failed to rename drawing:', err);
showModal('alert', 'Error', 'Failed to rename drawing. Please try again.');
}
};
const handleMoveDrawing = async (drawing: Drawing, folderId: string | null) => {
try {
await api.drawings.update(drawing.id, { folder_id: folderId });
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
setMovingId(null);
setActiveMenu(null);
} catch (err) {
console.error('Failed to move drawing:', err);
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
}
};
// Close menu on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setActiveMenu(null);
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>{t('common.loading')}</p>
</div>
</div>
);
}
return (
<>
<Modal
isOpen={modal.open}
type={modal.type}
title={modal.title}
message={modal.message}
onConfirm={modal.onConfirm}
onCancel={modal.onCancel}
confirmText={modal.type === 'confirm' ? 'Delete' : 'OK'}
/>
<div className={styles.container} role="region" aria-label={t('fileBrowser.title')}>
<div className={styles.header}>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<button
className={styles.breadcrumbLink}
onClick={() => handleFolderClick(null)}
aria-current={!activeFolderId ? 'page' : undefined}
>
All Projects
</button>
{activeFolder && (
<>
<ChevronRight size={16} aria-hidden="true" />
<span className={styles.breadcrumbCurrent} aria-current="page">
{activeFolder.name}
</span>
</>
)}
</nav>
<div className={styles.actions}>
<select
className={styles.filterSelect}
value={visibilityFilter}
onChange={(e) => setVisibilityFilter(e.target.value as any)}
aria-label="Filter by visibility"
title="Filter by visibility"
>
<option value="all">All</option>
<option value="private">Private</option>
<option value="team">Team</option>
<option value="public-link">Public</option>
</select>
<select
className={styles.filterSelect}
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [sb, so] = e.target.value.split('-');
setSortBy(sb as any);
setSortOrder(so as any);
}}
aria-label="Sort drawings"
title="Sort drawings"
>
<option value="updated-desc">Recently updated</option>
<option value="updated-asc">Oldest updated</option>
<option value="created-desc">Recently created</option>
<option value="created-asc">Oldest created</option>
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
</select>
<button
className={`${styles.viewToggle} ${viewMode === 'grid' ? styles.active : ''}`}
onClick={() => setViewMode('grid')}
aria-label="Grid view"
aria-pressed={viewMode === 'grid'}
>
<Grid size={18} />
</button>
<button
className={`${styles.viewToggle} ${viewMode === 'list' ? styles.active : ''}`}
onClick={() => setViewMode('list')}
aria-label="List view"
aria-pressed={viewMode === 'list'}
>
<List size={18} />
</button>
<Button onClick={handleCreateDrawing} loading={isCreating} aria-label="Create new drawing">
<Plus size={16} />
New Drawing
</Button>
<Button variant="secondary" onClick={() => { setShowNewProject(true); setNewProjectName(''); }} aria-label="Create new project">
<Folder size={16} />
New Project
</Button>
</div>
</div>
<div className={styles.content}>
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
{showNewProject && (
<div className={styles.newProjectForm}>
<input
type="text"
autoFocus
placeholder="Project name..."
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder();
if (e.key === 'Escape') { setShowNewProject(false); setNewProjectName(''); }
}}
className={styles.newProjectInput}
/>
<button className={styles.newProjectBtn} onClick={handleCreateFolder}>Create</button>
<button className={styles.newProjectBtnCancel} onClick={() => { setShowNewProject(false); setNewProjectName(''); }}>Cancel</button>
</div>
)}
<ul className={styles.folderTree} role="tree">
<li>
<button
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(null)}
aria-current={!activeFolderId ? 'true' : undefined}
role="treeitem"
>
<Folder size={18} aria-hidden="true" />
<span>All Projects</span>
</button>
</li>
{folders.map((folder) => (
<li key={folder.id}>
<button
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(folder.id)}
aria-current={activeFolderId === folder.id ? 'true' : undefined}
role="treeitem"
>
<Folder size={18} aria-hidden="true" />
<span>{folder.name}</span>
</button>
</li>
))}
</ul>
</aside>
<main className={viewMode === 'grid' ? styles.grid : styles.list} role="list" aria-label="Drawing list">
{visibleDrawings.length === 0 ? (
<div className={styles.empty} role="status">
<p>No drawings yet</p>
<p className={styles.emptySub}>
{activeFolder ? 'Create a new drawing in this project' : 'Create a new drawing or import existing files'}
</p>
</div>
) : (
visibleDrawings.map((drawing) => (
<Card
key={drawing.id}
className={styles.drawingCard}
hover
role="listitem"
tabIndex={0}
onClick={() => handleDrawingClick(drawing)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDrawingClick(drawing);
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<div className={styles.thumbnail}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.info}>
{renamingId === drawing.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameDrawing(drawing);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameDrawing(drawing)}
/>
) : (
<>
<h4 className={styles.title}>{drawing.title}</h4>
<p className={styles.meta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</>
)}
</div>
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
<button
className={styles.more}
onClick={(e) => {
e.stopPropagation();
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
setRenamingId(null);
}}
aria-label={`More options for ${drawing.title}`}
aria-expanded={activeMenu === drawing.id}
>
<MoreVertical size={16} />
</button>
{activeMenu === drawing.id && (
<div className={styles.dropdown}>
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
{movingId === drawing.id ? (
<div className={styles.dropdownSubmenu}>
<button className={styles.dropdownSubheader}>Move to:</button>
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
{folders.map(f => (
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
))}
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
</div>
) : (
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
)}
<div className={styles.dropdownDivider} />
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
</div>
)}
</div>
</Card>
))
)}
</main>
</div>
</div>
</>
);
};
@@ -0,0 +1,186 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-8);
h1 {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-2xl);
font-weight: 700;
margin: 0;
}
}
.subtitle {
color: var(--color-gray-60);
margin-top: var(--space-2);
}
.errorBanner {
background: var(--color-danger-background);
color: var(--color-danger-text);
padding: var(--space-4);
border-radius: var(--border-radius-lg);
margin-bottom: var(--space-6);
}
.filters {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-8);
}
.searchBox {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
padding: var(--space-2) var(--space-4);
input {
border: none;
background: transparent;
outline: none;
flex: 1;
font-size: var(--text-base);
}
}
.categories {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.categoryChip {
padding: var(--space-1) var(--space-3);
border-radius: var(--border-radius-full);
border: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--duration-fast);
&.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
&:hover:not(.active) {
background: var(--color-surface-low);
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}
.libraryCard {
overflow: hidden;
}
.preview {
height: 160px;
background: var(--color-gray-10);
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
color: var(--color-gray-50);
}
.info {
padding: var(--space-4);
}
.name {
font-weight: 600;
margin: 0 0 var(--space-2);
}
.description {
font-size: var(--text-sm);
color: var(--color-gray-60);
margin: 0 0 var(--space-3);
}
.meta {
display: flex;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--color-gray-50);
margin-bottom: var(--space-3);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.tag {
font-size: 11px;
padding: 2px 8px;
border-radius: var(--border-radius-full);
background: var(--color-primary-light);
color: var(--color-primary-darkest);
}
.importBtn {
width: 100%;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: var(--space-4);
}
.spinner {
animation: spin 1s linear infinite;
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-12);
color: var(--color-gray-50);
}
.emptySub {
font-size: var(--text-sm);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -0,0 +1,183 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Download, Loader2, BookOpen, ExternalLink, Heart, Filter } from 'lucide-react';
import { Button, Card, CardContent, Input } from '@/components';
import { api } from '@/services';
import styles from './LibraryMarketplace.module.scss';
interface LibraryItem {
name: string;
description: string;
authors: { name: string; github?: string }[];
source: string;
preview?: string;
tags: string[];
downloads: number;
}
const CATEGORIES = ['All', 'Arrows', 'Charts', 'Cloud', 'Devops', 'Diagrams', 'Education', 'Food', 'Frames', 'Gaming', 'Icons', 'Illustrations', 'Machines', 'Misc', 'People', 'Software', 'Systems', 'Tech', 'Workflow'];
export const LibraryMarketplace: React.FC = () => {
const navigate = useNavigate();
const [libraries, setLibraries] = useState<LibraryItem[]>([]);
const [filtered, setFiltered] = useState<LibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState('All');
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setLoading(true);
// Try to fetch from excalidraw libraries
const res = await fetch('https://libraries.excalidraw.com/libraries.json', {
headers: { Accept: 'application/json' }
});
if (!res.ok) throw new Error('Failed to load libraries');
const data = await res.json();
const items: LibraryItem[] = Object.entries(data).map(([key, lib]: [string, any]) => ({
name: lib.name || key,
description: lib.description || '',
authors: lib.authors || [{ name: 'Unknown' }],
source: `https://libraries.excalidraw.com/${key}.excalidrawlib`,
preview: lib.preview?.startsWith('http') ? lib.preview : `https://libraries.excalidraw.com/${key}.png`,
tags: lib.tags || [],
downloads: lib.downloads || 0,
}));
setLibraries(items);
setFiltered(items);
} catch (err) {
console.error(err);
setError('Could not load library marketplace. You can still browse libraries at libraries.excalidraw.com');
// Fallback: show some popular libraries as placeholders
setLibraries([
{ name: 'Software Architecture', description: 'Common architecture diagrams and icons', authors: [{ name: 'Excalidraw Community' }], source: '', preview: '', tags: ['Software', 'Architecture'], downloads: 0 },
{ name: 'AWS Icons', description: 'Amazon Web Services icons', authors: [{ name: 'AWS' }], source: '', preview: '', tags: ['Cloud', 'AWS'], downloads: 0 },
{ name: 'Kubernetes', description: 'K8s components and diagrams', authors: [{ name: 'K8s Community' }], source: '', preview: '', tags: ['Devops', 'Cloud'], downloads: 0 },
]);
} finally {
setLoading(false);
}
};
load();
}, []);
useEffect(() => {
let result = libraries;
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter(l => l.name.toLowerCase().includes(q) || l.description.toLowerCase().includes(q) || l.tags.some(t => t.toLowerCase().includes(q)));
}
if (activeCategory !== 'All') {
result = result.filter(l => l.tags.some(t => t.toLowerCase() === activeCategory.toLowerCase()));
}
setFiltered(result);
}, [search, activeCategory, libraries]);
const handleImport = useCallback(async (lib: LibraryItem) => {
if (!lib.source) {
window.open('https://libraries.excalidraw.com', '_blank');
return;
}
try {
// Create a new drawing and navigate to it, the library will be loaded client-side
const drawing = await api.drawings.create({
title: lib.name,
visibility: 'team',
});
// Store selected library in localStorage for the editor to pick up
localStorage.setItem('pending_library', JSON.stringify({ drawingId: drawing.id, source: lib.source }));
navigate(`/drawing/${drawing.id}`);
} catch (err) {
console.error('Failed to create drawing from library:', err);
}
}, [navigate]);
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>Loading library marketplace...</p>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<div>
<h1><BookOpen size={24} /> Library Marketplace</h1>
<p className={styles.subtitle}>Browse and import templates from the Excalidraw community library</p>
</div>
<Button variant="secondary" onClick={() => window.open('https://libraries.excalidraw.com', '_blank')}>
<ExternalLink size={16} /> Open External
</Button>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
<div className={styles.filters}>
<div className={styles.searchBox}>
<Search size={16} />
<Input
placeholder="Search libraries..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className={styles.searchInput}
/>
</div>
<div className={styles.categories}>
<Filter size={16} />
{CATEGORIES.map(cat => (
<button
key={cat}
className={`${styles.categoryChip} ${activeCategory === cat ? styles.active : ''}`}
onClick={() => setActiveCategory(cat)}
>
{cat}
</button>
))}
</div>
</div>
<div className={styles.grid}>
{filtered.length === 0 ? (
<div className={styles.empty}>
<BookOpen size={48} />
<p>No libraries found</p>
<p className={styles.emptySub}>Try a different search or category</p>
</div>
) : filtered.map((lib, idx) => (
<Card key={idx} className={styles.libraryCard} hover>
<div className={styles.preview}>
{lib.preview ? (
<img src={lib.preview} alt={lib.name} loading="lazy" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
) : (
<div className={styles.placeholder}><BookOpen size={32} /></div>
)}
</div>
<CardContent className={styles.info}>
<h4 className={styles.name}>{lib.name}</h4>
<p className={styles.description}>{lib.description || 'No description'}</p>
<div className={styles.meta}>
<span className={styles.authors}>{lib.authors.map(a => a.name).join(', ')}</span>
{lib.downloads > 0 && <span className={styles.downloads}><Download size={12} /> {lib.downloads}</span>}
</div>
<div className={styles.tags}>
{lib.tags.slice(0, 4).map(tag => (
<span key={tag} className={styles.tag}>{tag}</span>
))}
</div>
<Button size="sm" className={styles.importBtn} onClick={() => handleImport(lib)}>
<Heart size={14} /> Import
</Button>
</CardContent>
</Card>
))}
</div>
</div>
);
};
@@ -0,0 +1,158 @@
@use '../../styles/variables' as *;
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--space-8);
}
.sidebar {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.tab {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-md);
color: var(--color-gray-70);
background: none;
border: none;
cursor: pointer;
font-size: var(--text-sm);
transition: all var(--duration-fast) var(--ease-out);
text-align: left;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
}
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.avatarSection {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.avatar {
width: 64px;
height: 64px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-2xl);
font-weight: 600;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: var(--space-4);
}
.toggleList {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.toggle {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-gray-70);
input {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
}
}
.themeSelect {
margin-bottom: var(--space-4);
}
.label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--input-label-color);
margin-bottom: var(--space-3);
}
.themeOptions {
display: flex;
gap: var(--space-3);
}
.themeOption {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-gray-30);
border-radius: var(--border-radius-md);
background: var(--island-bg-color);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
&.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
}
@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { User, Key, Bell, Palette, Sun, Moon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardContent, Button, Input } from '@/components';
import { useAuthStore, useThemeStore } from '@/stores';
import styles from './Settings.module.scss';
export const UserSettings: React.FC = () => {
const { t } = useTranslation();
const { user } = useAuthStore();
const { theme, setTheme } = useThemeStore();
const [activeTab, setActiveTab] = useState('profile');
const tabs = [
{ id: 'profile', label: t('userSettings.tabProfile'), icon: User },
{ id: 'account', label: t('userSettings.tabAccount'), icon: Key },
{ id: 'notifications', label: t('userSettings.tabNotifications'), icon: Bell },
{ id: 'appearance', label: t('userSettings.tabAppearance'), icon: Palette },
];
return (
<div className={styles.container}>
<div className={styles.header}>
<h1>{t('userSettings.title')}</h1>
<p className={styles.subtitle}>{t('userSettings.subtitle')}</p>
</div>
<div className={styles.layout}>
<div className={styles.sidebar} role="tablist" aria-label="Settings tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={`${styles.tab} ${activeTab === tab.id ? styles.active : ''}`}
onClick={() => setActiveTab(tab.id)}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
aria-label={tab.label}
>
<tab.icon size={18} aria-hidden="true" />
<span>{tab.label}</span>
</button>
))}
</div>
<div className={styles.content}>
{activeTab === 'profile' && (
<Card role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
<CardHeader>
<h3>{t('userSettings.profileInfo')}</h3>
</CardHeader>
<CardContent>
<div className={styles.form}>
<div className={styles.avatarSection}>
<div className={styles.avatar}>
{user?.avatar_url ? (
<img src={user.avatar_url} alt={user.name} />
) : (
user?.name?.[0] || '?'
)}
</div>
<Button variant="secondary" size="sm">{t('userSettings.changeAvatar')}</Button>
</div>
<Input label={t('auth.signup.nameLabel')} defaultValue={user?.name} />
<Input label={t('userSettings.username')} defaultValue={user?.username} />
<Input label={t('auth.login.emailLabel')} type="email" defaultValue={user?.email} />
<div className={styles.actions}>
<Button>{t('userSettings.saveChanges')}</Button>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === 'account' && (
<Card role="tabpanel" id="panel-account" aria-labelledby="tab-account">
<CardHeader>
<h3>{t('userSettings.accountSecurity')}</h3>
</CardHeader>
<CardContent>
<div className={styles.form}>
<Input label={t('userSettings.currentPassword')} type="password" />
<Input label={t('userSettings.newPassword')} type="password" />
<Input label={t('userSettings.confirmPassword')} type="password" />
<div className={styles.actions}>
<Button>{t('userSettings.updatePassword')}</Button>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === 'notifications' && (
<Card role="tabpanel" id="panel-notifications" aria-labelledby="tab-notifications">
<CardHeader>
<h3>{t('userSettings.notificationPrefs')}</h3>
</CardHeader>
<CardContent>
<div className={styles.toggleList}>
<label className={styles.toggle}>
<input type="checkbox" defaultChecked />
<span>{t('userSettings.emailMentions')}</span>
</label>
<label className={styles.toggle}>
<input type="checkbox" defaultChecked />
<span>{t('userSettings.emailInvites')}</span>
</label>
<label className={styles.toggle}>
<input type="checkbox" />
<span>{t('userSettings.weeklySummary')}</span>
</label>
</div>
</CardContent>
</Card>
)}
{activeTab === 'appearance' && (
<Card role="tabpanel" id="panel-appearance" aria-labelledby="tab-appearance">
<CardHeader>
<h3>{t('userSettings.appearance')}</h3>
</CardHeader>
<CardContent>
<div className={styles.themeSelect}>
<p className={styles.label}>{t('userSettings.theme')}</p>
<div className={styles.themeOptions}>
<button
className={`${styles.themeOption} ${theme === 'light' ? styles.active : ''}`}
onClick={() => setTheme('light')}
>
<Sun size={16} />
{t('userSettings.light')}
</button>
<button
className={`${styles.themeOption} ${theme === 'dark' ? styles.active : ''}`}
onClick={() => setTheme('dark')}
>
<Moon size={16} />
{t('userSettings.dark')}
</button>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
};
+207
View File
@@ -0,0 +1,207 @@
@use '../../styles/variables' as *;
.container {
max-width: 900px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-6);
}
.sidePanel {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.membersList {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.empty {
text-align: center;
padding: var(--space-8);
color: var(--color-muted);
svg {
margin-bottom: var(--space-4);
opacity: 0.5;
}
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.memberItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.memberAvatar {
width: 40px;
height: 40px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
}
.memberInfo {
flex: 1;
}
.memberName {
font-weight: 500;
color: var(--color-gray-85);
}
.memberEmail {
font-size: var(--text-sm);
color: var(--color-muted);
}
.memberRole {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--color-surface-low);
border-radius: var(--border-radius-full);
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-gray-70);
text-transform: capitalize;
}
.inviteForm {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.inviteInput {
padding: var(--space-3);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
background: var(--input-bg-color);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.pendingCard {
margin-top: var(--space-2);
}
.inviteItem {
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.inviteEmail {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-80);
}
.inviteRole {
font-size: var(--text-xs);
color: var(--color-muted);
text-transform: capitalize;
margin-top: var(--space-1);
}
.roleLabel {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--color-gray-70);
}
.roleSelect {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
background: var(--input-bg-color);
cursor: pointer;
}
.error {
color: var(--color-danger-text);
font-size: var(--text-sm);
background: var(--color-danger-background);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
}
.success {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-success-text);
font-size: var(--text-sm);
background: var(--color-success);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: var(--space-4);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
+185
View File
@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { Users, Crown, Shield, User, Loader2, Check, UserPlus } from 'lucide-react';
import { Card, CardHeader, CardContent, Button, Input } from '@/components';
import { useTeamStore } from '@/stores';
import { api } from '@/services';
import styles from './Team.module.scss';
const roleIcons: Record<string, React.ElementType> = {
owner: Crown,
admin: Shield,
editor: User,
viewer: User,
};
const ROLES = ['viewer', 'editor', 'admin'];
export const TeamSettings: React.FC = () => {
const { currentTeam, members, setMembers, setCurrentTeam } = useTeamStore();
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('editor');
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setLoading(true);
const [teamsData, membersData] = await Promise.all([
api.teams.list(),
currentTeam ? api.teams.members(currentTeam.id) : Promise.resolve([]),
]);
if (teamsData.length > 0) {
setCurrentTeam(teamsData[0]);
}
setMembers(membersData);
} catch (err) {
console.error('Failed to load team data:', err);
} finally {
setLoading(false);
}
};
load();
}, [currentTeam?.id, setMembers, setCurrentTeam]);
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim() || !newEmail.trim() || !newPassword.trim() || !currentTeam) return;
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setSending(true);
setError('');
setSent(false);
try {
await api.teams.createUser(currentTeam.id, {
name: newName.trim(),
email: newEmail.trim(),
password: newPassword,
role: newRole,
});
const membersData = await api.teams.members(currentTeam.id);
setMembers(membersData);
setSent(true);
setNewName('');
setNewEmail('');
setNewPassword('');
setNewRole('editor');
} catch (err: any) {
setError(err?.message || 'Failed to create user');
} finally {
setSending(false);
}
};
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}><Loader2 size={32} className={styles.spinner} /><p>Loading team...</p></div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h1>Team Settings</h1>
<p className={styles.subtitle} aria-label="Current team">{currentTeam?.name || 'My Team'}</p>
</div>
<div className={styles.grid}>
<Card role="region" aria-label="Team members">
<CardHeader>
<h3>Members ({members.length})</h3>
</CardHeader>
<CardContent>
<div className={styles.membersList} role="list" aria-label="Team members list">
{members.length === 0 ? (
<div className={styles.empty}>
<Users size={32} />
<p>No team members yet</p>
<p className={styles.emptySub}>Add members to collaborate</p>
</div>
) : (
members.map((member) => {
const RoleIcon = roleIcons[member.role] || User;
return (
<div key={member.id} className={styles.memberItem} role="listitem" aria-label={`Member ${member.user?.name || 'Unknown'}`}>
<div className={styles.memberAvatar} aria-hidden="true">
{member.user?.name?.[0] || '?'}
</div>
<div className={styles.memberInfo}>
<p className={styles.memberName}>{member.user?.name || 'Unknown'}</p>
<p className={styles.memberEmail}>{member.user?.email}</p>
</div>
<div className={styles.memberRole} aria-label={`Role: ${member.role}`}>
<RoleIcon size={14} aria-hidden="true" />
<span>{member.role}</span>
</div>
</div>
);
})
)}
</div>
</CardContent>
</Card>
<div className={styles.sidePanel}>
<Card role="region" aria-label="Add team member">
<CardHeader>
<h3>Add Member</h3>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateUser} className={styles.inviteForm}>
<Input
type="text"
label="Full name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Jane Doe"
required
className={styles.inviteInput}
/>
<Input
type="email"
label="Email address"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="[email protected]"
required
className={styles.inviteInput}
/>
<Input
type="password"
label="Initial password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min 8 characters"
required
className={styles.inviteInput}
/>
<label className={styles.roleLabel}>
Role
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className={styles.roleSelect}>
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</label>
{error && <p className={styles.error}>{error}</p>}
{sent && <p className={styles.success}><Check size={14} /> User created!</p>}
<Button fullWidth type="submit" loading={sending} disabled={!newName.trim() || !newEmail.trim() || !newPassword.trim()}>
<UserPlus size={16} />
Create User
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
</div>
);
};
@@ -0,0 +1,228 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
}
.categories {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-6);
border-bottom: 1px solid var(--color-gray-20);
padding-bottom: var(--space-4);
}
.category {
padding: var(--space-2) var(--space-4);
border: none;
background: none;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
border-radius: var(--border-radius-md);
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary);
font-weight: 500;
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-16);
color: var(--color-muted);
svg {
margin-bottom: var(--space-4);
opacity: 0.5;
}
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.templateCard {
overflow: hidden;
}
.preview {
aspect-ratio: 16 / 10;
background: var(--color-surface-low);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
color: var(--color-gray-50);
}
.info {
padding: var(--space-4);
}
.name {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-1);
}
.description {
font-size: var(--text-sm);
color: var(--color-muted);
margin-bottom: var(--space-3);
}
.meta {
display: flex;
gap: var(--space-2);
}
.scope, .type {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
border-radius: var(--border-radius-full);
text-transform: capitalize;
font-weight: 500;
}
.scope {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
}
.type {
background: var(--color-gray-20);
color: var(--color-gray-70);
}
.useBtn {
margin-top: var(--space-3);
width: 100%;
}
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--space-4);
}
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--modal-shadow);
width: 100%;
max-width: 420px;
padding: var(--space-6);
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
h2 {
font-size: var(--text-xl);
font-weight: 600;
color: var(--color-gray-85);
}
button {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: var(--space-1);
border-radius: var(--border-radius-md);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
}
.modalBody {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.spinner {
animation: spin 1s linear infinite;
align-self: center;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
}
.categories {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.grid {
grid-template-columns: 1fr;
}
}
+114
View File
@@ -0,0 +1,114 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Sparkles, X, Loader2, FilePlus } from 'lucide-react';
import { Card, CardContent, Button, Input } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
import type { Template, TemplateScope } from '@/types';
import styles from './Templates.module.scss';
const categories: { id: TemplateScope | 'all'; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'system', label: 'System' },
{ id: 'team', label: 'Team' },
{ id: 'personal', label: 'Personal' },
];
export const Templates: React.FC = () => {
const navigate = useNavigate();
const { templates, setTemplates, addDrawing } = useDrawingStore();
const [active, setActive] = useState<TemplateScope | 'all'>('all');
const [showModal, setShowModal] = useState(false);
const [creating, setCreating] = useState(false);
const [applyingId, setApplyingId] = useState<string | null>(null);
const [name, setName] = useState('');
const [error, setError] = useState('');
useEffect(() => {
api.templates.list().then(setTemplates).catch(console.error);
}, [setTemplates]);
const filtered = active === 'all' ? templates : templates.filter((t) => t.scope === active);
const handleCreate = async () => {
if (!name.trim()) { setError('Name required'); return; }
setCreating(true); setError('');
try {
const t = await api.templates.create({ name: name.trim(), type: 'empty', scope: 'personal' });
setTemplates([t, ...templates]); setShowModal(false); setName('');
} catch (err) { setError('Create failed'); }
finally { setCreating(false); }
};
const handleUseTemplate = async (template: Template) => {
setApplyingId(template.id);
try {
const drawing = await api.drawings.create({
title: template.name,
visibility: 'team',
});
addDrawing(drawing);
navigate(`/drawing/${drawing.id}`);
} catch (err) {
console.error('Failed to create drawing from template:', err);
} finally {
setApplyingId(null);
}
};
return (
<div className={styles.container}>
<div className={styles.header}>
<div><h1>Templates</h1><p className={styles.subtitle}>Start from a template or create your own</p></div>
<Button onClick={() => setShowModal(true)}><Plus size={18} />Create</Button>
</div>
<div className={styles.categories} role="tablist">
{categories.map((c) => (
<button key={c.id} className={`${styles.category} ${active === c.id ? styles.active : ''}`}
onClick={() => setActive(c.id)} role="tab" aria-selected={active === c.id}>{c.label}</button>
))}
</div>
<div className={styles.grid} role="tabpanel">
{filtered.length === 0 ? (
<div className={styles.empty} role="status"><Sparkles size={48} aria-hidden="true" />
<p>No templates</p><p className={styles.emptySub}>Create your first template</p></div>
) : filtered.map((t) => (
<Card key={t.id} className={styles.templateCard} hover>
<div className={styles.preview}>
{t.preview_url ? <img src={t.preview_url} alt="" loading="lazy" /> : <div className={styles.placeholder} role="img" aria-label="No preview"><Sparkles size={32} aria-hidden="true" /></div>}
</div>
<CardContent className={styles.info}>
<h4 className={styles.name}>{t.name}</h4>
<p className={styles.description}>{t.description || 'No description'}</p>
<div className={styles.meta}>
<span className={styles.scope}>{t.scope}</span>
<span className={styles.type}>{t.type}</span>
</div>
<Button
size="sm"
className={styles.useBtn}
onClick={() => handleUseTemplate(t)}
loading={applyingId === t.id}
aria-label={`Use template ${t.name}`}
>
<FilePlus size={14} />
Use Template
</Button>
</CardContent>
</Card>
))}
</div>
{showModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="tm-title" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className={styles.modal}>
<div className={styles.modalHeader}><h2 id="tm-title">Create Template</h2><button onClick={() => setShowModal(false)} aria-label="Close"><X size={18} /></button></div>
<div className={styles.modalBody}>
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} error={error} />
{creating ? <Loader2 className={styles.spinner} size={20} /> : <Button onClick={handleCreate}>Create</Button>}
</div>
</div>
</div>
)}
</div>
);
};
+7
View File
@@ -0,0 +1,7 @@
export { Dashboard } from './Dashboard/Dashboard';
export { Login } from './Auth/Login';
export { Signup } from './Auth/Signup';
export { FileBrowser } from './FileBrowser/FileBrowser';
export { TeamSettings } from './Team/TeamSettings';
export { UserSettings } from './Settings/UserSettings';
export { Templates } from './Templates/Templates';