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