feat(ui,api,db): implement notifications and custom templates with hand-drawn aesthetic

This commit introduces a significant update to both the frontend and backend, focusing on enhanced user engagement and a consistent visual identity.

Key changes include:

- **Frontend UI/UX Refactor**:
  - Implemented a "hand-drawn" aesthetic across the entire application using CSS overrides, custom SVG charts, and specific border/shadow styles to match the Excalidraw experience.
  - Added a new notification system in the Header to display user updates.
  - Enhanced the Template Picker with more variety and improved interaction models.
  - Added a "Presentation Mode" in the Editor.
  - Improved Dashboard visualizations with hand-drawn style sparklines and charts.
  - Added modal dialogs for creating drawings and templates with custom names.

- **Backend & API Enhancements**:
  - Implemented full CRUD support for custom templates, allowing users to save their drawings as reusable templates.
  - Added a notification service with endpoints to list, mark as read, and mark all as read.
  - Updated the API client to handle more robust JSON responses and error states.
  - Improved CORS/Origin validation in the HTTP middleware to handle proxy headers (`X-Forwarded-Host`, `X-Forwarded-Proto`) more reliably.

- **Database & Infrastructure**:
  - Added a new PostgreSQL migration for the `notifications` table.
  - Updated the data models in the workspace to support templates (including snapshot storage) and notifications.
  - Updated `.gitignore` to exclude graphify cache and AST files.
This commit is contained in:
Tomas Dvorak
2026-05-01 15:07:38 +02:00
parent f3f9e99a97
commit 462a70933d
28 changed files with 26645 additions and 289 deletions
@@ -13,15 +13,18 @@
gap: var(--space-6);
padding: var(--space-5) var(--space-6);
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(-0.3deg);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
font-weight: 700;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
font-family: 'Georgia', serif;
letter-spacing: -0.02em;
}
}
@@ -80,6 +83,24 @@
}
}
.statCardWrapper {
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(0.15deg);
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: rotate(0) translate(-1px, -1px);
box-shadow: 5px 5px 0 var(--color-gray-85);
}
&:nth-child(2) { transform: rotate(-0.1deg); }
&:nth-child(3) { transform: rotate(0.25deg); }
&:nth-child(4) { transform: rotate(-0.2deg); }
&:nth-child(5) { transform: rotate(0.05deg); }
}
.statCard {
display: flex;
flex-direction: column;
@@ -89,23 +110,45 @@
min-height: 150px;
}
.statTop {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: var(--space-3);
}
.statIcon {
width: 40px;
height: 40px;
border-radius: var(--border-radius-md);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-primary-darkest);
background: var(--color-primary-light);
margin-bottom: var(--space-3);
border: 2px solid var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-2deg);
}
.handChart {
width: 80px;
height: 40px;
flex-shrink: 0;
transform: rotate(1deg);
}
.sparkline {
width: 100%;
height: 28px;
margin-top: var(--space-2);
}
.statValue {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--color-gray-85);
line-height: 1;
font-family: 'Georgia', serif;
}
.statLabel {
@@ -185,21 +228,37 @@
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
padding: var(--space-3) var(--space-2);
margin-bottom: var(--space-2);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
cursor: pointer;
transition: all 0.15s ease;
box-shadow: 2px 2px 0 var(--color-gray-85);
background: var(--island-bg-color);
&:hover {
border-color: var(--color-primary);
background: var(--color-surface-low);
transform: translateX(2px) rotate(-0.3deg);
box-shadow: 3px 3px 0 var(--color-gray-85);
}
&:last-child {
border-bottom: none;
border-bottom: 2px solid var(--color-gray-30);
margin-bottom: 0;
}
}
.drawingThumb {
width: 48px;
height: 48px;
border-radius: var(--border-radius-md);
border-radius: 2px;
overflow: hidden;
background: var(--color-surface-low);
flex-shrink: 0;
border: 2px solid var(--color-gray-30);
box-shadow: 2px 2px 0 var(--color-gray-85);
img {
width: 100%;
@@ -300,7 +359,7 @@
.activityAvatar {
width: 32px;
height: 32px;
border-radius: var(--border-radius-full);
border-radius: 2px;
background: var(--color-primary);
color: white;
display: flex;
@@ -309,6 +368,8 @@
font-size: var(--text-xs);
font-weight: 600;
flex-shrink: 0;
border: 2px solid var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
}
.activityInfo {
@@ -325,3 +386,104 @@
color: var(--color-muted);
margin-top: var(--space-1);
}
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 5px 5px 0 var(--color-gray-85);
width: 420px;
max-width: 90vw;
transform: rotate(-0.3deg);
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 2px solid var(--color-gray-85);
h3 {
margin: 0;
font-size: var(--text-lg);
color: var(--color-gray-85);
font-family: 'Georgia', serif;
}
}
.modalClose {
background: none;
border: none;
font-size: 22px;
color: var(--color-gray-60);
cursor: pointer;
line-height: 1;
&:hover { color: var(--color-gray-85); }
}
.modalBody {
padding: var(--space-4) var(--space-5);
label {
display: block;
font-size: var(--text-sm);
color: var(--color-gray-70);
margin-bottom: var(--space-2);
}
}
.modalInput {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
background: var(--input-bg-color);
color: var(--color-on-surface);
font-size: var(--text-sm);
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 3px 3px 0 var(--color-gray-85);
}
}
.modalFooter {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-3) var(--space-5) var(--space-4);
}
.modalBtnSecondary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-30);
background: transparent;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover { background: var(--color-surface-low); transform: rotate(-0.5deg); }
}
.modalBtnPrimary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-85);
background: var(--color-primary);
color: white;
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover { background: var(--color-primary-darker); transform: rotate(-0.5deg); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
+142 -18
View File
@@ -9,13 +9,86 @@ import styles from './Dashboard.module.scss';
const ACTIVITY_LIMIT = 5;
const StatChart: React.FC<{ value: number; max: number }> = ({ value, max }) => {
const HandDrawnChart: React.FC<{ value: number; max: number; color?: string }> = ({ value, max, color = '#6965db' }) => {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
const w = 120;
const h = 60;
const pad = 6;
const barW = ((w - pad * 2) * pct) / 100;
const roughness = 1.2;
const r = () => (Math.random() - 0.5) * roughness;
return (
<div className={styles.chartBarWrap} aria-hidden="true">
<div className={styles.chartBarBg} />
<div className={styles.chartBar} style={{ width: `${pct}%` }} />
</div>
<svg className={styles.handChart} viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
<path
d={`M${pad + r()},${pad + r()} L${w - pad + r()},${pad + r()} L${w - pad + r()},${h - pad + r()} L${pad + r()},${h - pad + r()} Z`}
fill="none"
stroke="var(--color-gray-40)"
strokeWidth="1"
strokeLinecap="round"
/>
{pct > 0 && (
<path
d={`M${pad + r()},${h - pad + r()} L${pad + r()},${pad + r()} L${pad + barW + r()},${pad + r()} L${pad + barW + r()},${h - pad + r()} Z`}
fill={color}
stroke={color}
strokeWidth="1"
opacity="0.35"
strokeLinecap="round"
/>
)}
<path
d={`M${pad + r()},${h - pad + r()} L${pad + barW + r()},${h - pad + r()}`}
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
<path
d={`M${pad + r()},${pad + r()} L${pad + barW + r()},${pad + r()}`}
fill="none"
stroke={color}
strokeWidth="1"
strokeLinecap="round"
opacity="0.5"
/>
</svg>
);
};
const MiniSparkline: React.FC<{ data: number[]; color?: string }> = ({ data, color = '#6965db' }) => {
if (!data.length) return null;
const w = 140;
const h = 40;
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
const stepX = w / (data.length - 1 || 1);
const points = data.map((v, i) => {
const x = i * stepX;
const y = h - ((v - min) / range) * (h - 4) - 2;
return `${x + (Math.random() - 0.5) * 0.8},${y + (Math.random() - 0.5) * 0.8}`;
}).join(' ');
return (
<svg className={styles.sparkline} viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
<polyline
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
opacity="0.7"
/>
{data.map((v, i) => {
const x = i * stepX;
const y = h - ((v - min) / range) * (h - 4) - 2;
return <circle key={i} cx={x} cy={y} r="2" fill={color} opacity="0.5" />;
})}
</svg>
);
};
@@ -25,6 +98,8 @@ export const Dashboard: React.FC = () => {
const { recentDrawings, setRecentDrawings, activity, setActivity } = useDrawingStore();
const { user } = useAuthStore();
const [isCreating, setIsCreating] = useState(false);
const [showNameModal, setShowNameModal] = useState(false);
const [newDrawingName, setNewDrawingName] = useState('');
const [statsData, setStatsData] = useState({
teams: 0,
members: 0,
@@ -55,11 +130,18 @@ export const Dashboard: React.FC = () => {
loadData();
}, [setRecentDrawings, setActivity]);
const handleCreateDrawing = async () => {
const handleCreateDrawing = () => {
setNewDrawingName('');
setShowNameModal(true);
};
const confirmCreateDrawing = async () => {
const title = newDrawingName.trim() || 'Untitled Drawing';
setIsCreating(true);
setShowNameModal(false);
try {
const newDrawing = await api.drawings.create({
title: 'Untitled Drawing',
title,
visibility: 'team',
});
setRecentDrawings([newDrawing, ...recentDrawings]);
@@ -82,12 +164,21 @@ export const Dashboard: React.FC = () => {
const maxStat = Math.max(statsData.drawings, statsData.projects + statsData.folders, statsData.teams, statsData.revisions, 1);
const storageMax = Math.max(Number(statsData.storage_bytes), 1024 * 1024);
const statColors = ['#6965db', '#339af0', '#40c057', '#fcc419', '#ff6b6b'];
const sparkData = [
[2, 4, 3, 8, 5, 9, statsData.drawings],
[1, 2, 3, 3, 4, 5, statsData.projects + statsData.folders],
[1, 1, 1, 1, 2, 2, statsData.teams],
[5, 8, 12, 15, 20, 25, statsData.revisions],
[1024, 2048, 4096, 8192, 16384, 32768, Number(statsData.storage_bytes)],
];
const stats = [
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, chartValue: statsData.drawings, max: maxStat, icon: FileText },
{ label: t('dashboard.stats.projects'), value: statsData.projects + statsData.folders, chartValue: statsData.projects + statsData.folders, max: maxStat, icon: FolderPlus },
{ label: t('dashboard.stats.teams'), value: statsData.teams, chartValue: statsData.teams, max: maxStat, icon: Users },
{ label: t('dashboard.stats.revisions'), value: statsData.revisions, chartValue: statsData.revisions, max: maxStat, icon: Clock },
{ label: t('dashboard.stats.storage'), value: formatBytes(Number(statsData.storage_bytes)), chartValue: Number(statsData.storage_bytes), max: storageMax, icon: Database },
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, chartValue: statsData.drawings, max: maxStat, icon: FileText, color: statColors[0] },
{ label: t('dashboard.stats.projects'), value: statsData.projects + statsData.folders, chartValue: statsData.projects + statsData.folders, max: maxStat, icon: FolderPlus, color: statColors[1] },
{ label: t('dashboard.stats.teams'), value: statsData.teams, chartValue: statsData.teams, max: maxStat, icon: Users, color: statColors[2] },
{ label: t('dashboard.stats.revisions'), value: statsData.revisions, chartValue: statsData.revisions, max: maxStat, icon: Clock, color: statColors[3] },
{ label: t('dashboard.stats.storage'), value: formatBytes(Number(statsData.storage_bytes)), chartValue: Number(statsData.storage_bytes), max: storageMax, icon: Database, color: statColors[4] },
];
const visibleActivity = activity
.filter((event) => event.event_type !== 'revision_created')
@@ -133,15 +224,18 @@ export const Dashboard: React.FC = () => {
</div>
<div className={styles.statsGrid}>
{stats.map((stat) => (
<Card key={stat.label}>
{stats.map((stat, idx) => (
<Card key={stat.label} className={styles.statCardWrapper}>
<CardContent className={styles.statCard}>
<div className={styles.statIcon}>
<stat.icon size={24} />
<div className={styles.statTop}>
<div className={styles.statIcon} style={{ color: stat.color, borderColor: stat.color }}>
<stat.icon size={22} />
</div>
<HandDrawnChart value={stat.chartValue} max={stat.max} color={stat.color} />
</div>
<div className={styles.statValue}>{stat.value}</div>
<div className={styles.statValue} style={{ color: stat.color }}>{stat.value}</div>
<div className={styles.statLabel}>{stat.label}</div>
<StatChart value={stat.chartValue} max={stat.max} />
<MiniSparkline data={sparkData[idx]} color={stat.color} />
</CardContent>
</Card>
))}
@@ -247,6 +341,36 @@ export const Dashboard: React.FC = () => {
</Card>
</div>
</div>
{showNameModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="new-drawing-title" onClick={(e) => { if (e.target === e.currentTarget) setShowNameModal(false); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="new-drawing-title">New Drawing</h3>
<button className={styles.modalClose} onClick={() => setShowNameModal(false)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<label htmlFor="drawing-name">Name</label>
<input
id="drawing-name"
type="text"
autoFocus
placeholder="Untitled Drawing"
value={newDrawingName}
onChange={(e) => setNewDrawingName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') confirmCreateDrawing(); if (e.key === 'Escape') setShowNameModal(false); }}
className={styles.modalInput}
/>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setShowNameModal(false)}>Cancel</button>
<button className={styles.modalBtnPrimary} onClick={confirmCreateDrawing} disabled={isCreating}>
{isCreating ? <Loader2 size={16} className={styles.spinner} /> : 'Create'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
@@ -15,6 +15,15 @@
padding: 0 var(--space-4);
background: var(--island-bg-color);
border-bottom: 1px solid var(--color-gray-20);
transition: opacity var(--duration-fast) var(--ease-out);
}
.toolbarHidden {
height: 0;
overflow: hidden;
padding: 0;
border: none;
opacity: 0;
}
.left {
@@ -379,6 +388,136 @@
color: var(--color-danger);
}
.presentationOverlay {
position: fixed;
top: 12px;
right: 12px;
z-index: 200;
pointer-events: auto;
animation: presentationFadeIn 0.3s var(--ease-out);
}
@keyframes presentationFadeIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.presentationToolbar {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
padding: var(--space-2) var(--space-4);
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(-0.3deg);
}
.presentationLabel {
font-size: var(--text-sm);
color: var(--color-gray-70);
font-weight: 500;
font-family: 'Georgia', serif;
}
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: var(--modal-shadow);
width: 420px;
max-width: 90vw;
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-lg);
color: var(--color-gray-85);
}
}
.modalClose {
background: none;
border: none;
font-size: 22px;
color: var(--color-gray-60);
cursor: pointer;
line-height: 1;
&:hover { color: var(--color-gray-85); }
}
.modalBody {
padding: var(--space-4) var(--space-5);
label {
display: block;
font-size: var(--text-sm);
color: var(--color-gray-70);
margin-bottom: var(--space-2);
}
}
.modalInput {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
background: var(--input-bg-color);
color: var(--color-on-surface);
font-size: var(--text-sm);
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.modalFooter {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-3) var(--space-5) var(--space-4);
}
.modalBtnSecondary {
padding: var(--space-2) var(--space-4);
border-radius: var(--border-radius-md);
border: 1px solid var(--color-gray-20);
background: transparent;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
&:hover { background: var(--color-surface-low); }
}
.modalBtnPrimary {
padding: var(--space-2) var(--space-4);
border-radius: var(--border-radius-md);
border: none;
background: var(--color-primary);
color: white;
font-size: var(--text-sm);
cursor: pointer;
&:hover { background: var(--color-primary-darker); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
@media (max-width: 768px) {
.toolbar {
height: auto;
+366 -57
View File
@@ -1,7 +1,7 @@
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, StickyNote, LayoutTemplate } from 'lucide-react';
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, StickyNote, LayoutTemplate, MonitorPlay, X, Plus } from 'lucide-react';
import { Button } from '@/components';
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import { useThemeStore } from '@/stores';
@@ -10,7 +10,15 @@ 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 })));
const ExcalidrawWithLibrary = React.lazy(() =>
import('@excalidraw/excalidraw').then((mod) => {
const { Excalidraw } = mod;
const ExcalidrawWrapper: React.FC<any> = (props) => {
return <Excalidraw {...props} />;
};
return { default: ExcalidrawWrapper };
})
);
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/types';
@@ -83,6 +91,11 @@ export const Editor: React.FC = () => {
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
const [showTemplates, setShowTemplates] = useState(false);
const [presentationMode, setPresentationMode] = useState(false);
const [showSaveTemplate, setShowSaveTemplate] = useState(false);
const [templateName, setTemplateName] = useState('');
const [templateDesc, setTemplateDesc] = useState('');
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
// Load drawing data
useEffect(() => {
@@ -90,22 +103,38 @@ export const Editor: React.FC = () => {
if (!id) return;
try {
setIsLoading(true);
const [drawingData, revisionsData] = await Promise.all([
api.drawings.get(id),
api.revisions.list(id),
]);
const drawingData = await api.drawings.get(id);
setDrawing(drawingData);
setRevisions(revisionsData);
// Load revisions
let revisionsData: DrawingRevision[] = [];
try {
revisionsData = await api.revisions.list(id);
setRevisions(revisionsData);
} catch (revErr) {
console.warn('Failed to load revisions, starting with empty canvas:', revErr);
}
// 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: appStateWithoutGrid(snapshot.appState || {}),
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
try {
const rawSnapshot = revisionsData[0].snapshot;
const snapshot = typeof rawSnapshot === 'string' ? JSON.parse(rawSnapshot) : rawSnapshot;
setInitialData({
elements: snapshot.elements || [],
appState: appStateWithoutGrid(snapshot.appState || {}),
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
} catch (parseErr) {
console.error('Failed to parse revision snapshot:', parseErr);
setInitialData({
elements: [],
appState: appStateWithoutGrid(),
files: {},
});
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
}
} else {
// Check for pending template from dashboard
const pendingTemplate = localStorage.getItem(`template_${id}`);
@@ -129,8 +158,8 @@ export const Editor: React.FC = () => {
}
}
} catch (err) {
console.error('Failed to load drawing:', err);
setError('Failed to load drawing');
console.error(err);
} finally {
setIsLoading(false);
}
@@ -138,45 +167,207 @@ export const Editor: React.FC = () => {
loadDrawing();
}, [id]);
// Sync Excalidraw theme with global theme
useEffect(() => {
if (excalidrawAPI) {
excalidrawAPI.updateScene({ appState: { theme: appTheme === 'dark' ? 'dark' : 'light' } });
}
}, [appTheme, excalidrawAPI]);
// Handle changes from Excalidraw
const handleExcalidrawChange = useCallback((elements: readonly ExcalidrawElement[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
const selectedCheckbox = selectedIds.length === 1
? elements.find((el) => (
el.id === selectedIds[0] &&
!el.isDeleted &&
(el.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox'
))
const selectedEl = selectedIds.length === 1
? elements.find((el) => el.id === selectedIds[0] && !el.isDeleted)
: null;
if (!selectedCheckbox) {
// Handle checkbox toggle
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox') {
if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedEl.id) {
lastToggledCheckboxRef.current = selectedEl.id;
const nextChecked = !((selectedEl.customData as Record<string, unknown> | undefined)?.checked as boolean);
const nextElements = elements.map((el) => (
el.id === selectedEl.id
? {
...el,
backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
fillStyle: (nextChecked ? 'solid' : 'hachure') as 'solid' | 'hachure',
customData: {
...((el.customData as Record<string, unknown> | undefined) || {}),
checked: nextChecked,
},
version: el.version + 1,
versionNonce: Math.floor(Math.random() * 1000000),
updated: Date.now(),
}
: el
));
excalidrawAPI.updateScene({ elements: nextElements as ExcalidrawElement[] });
currentStateRef.current = {
elements: nextElements,
appState: appStateWithoutGrid(appState),
files,
};
setSaveStatus('unsaved');
return;
}
} else {
lastToggledCheckboxRef.current = null;
} else if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedCheckbox.id) {
lastToggledCheckboxRef.current = selectedCheckbox.id;
const nextChecked = !((selectedCheckbox.customData as Record<string, unknown> | undefined)?.checked as boolean);
const nextElements = elements.map((el) => (
el.id === selectedCheckbox.id
? {
...el,
backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
customData: {
...((el.customData as Record<string, unknown> | undefined) || {}),
checked: nextChecked,
},
version: el.version + 1,
versionNonce: Math.floor(Math.random() * 1000000),
updated: Date.now(),
}
: el
));
excalidrawAPI.updateScene({ elements: nextElements as ExcalidrawElement[] });
currentStateRef.current = {
elements: nextElements,
appState: appStateWithoutGrid(appState),
files,
};
setSaveStatus('unsaved');
return;
}
// Handle "+" add button click
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.action === 'add' && excalidrawAPI) {
const customData = (selectedEl.customData as Record<string, unknown>) || {};
const role = customData.templateRole as string;
const btnX = (selectedEl.x as number) || 0;
const btnY = (selectedEl.y as number) || 0;
const newElements: LooseElement[] = [];
const uid = () => `el-${Math.random().toString(36).slice(2)}`;
const tid = () => `txt-${Math.random().toString(36).slice(2)}`;
if (role.startsWith('todo-add') || role.startsWith('checklist-add')) {
// Add a new checkbox + text row below the button
const newY = btnY + 30;
newElements.push({
id: uid(), type: 'rectangle', x: btnX, y: newY, width: 20, height: 20,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
frameId: null, roundness: { type: 3, value: 32 }, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
customData: { templateRole: 'checkbox', checked: false },
});
newElements.push({
id: tid(), type: 'text', x: btnX + 30, y: newY + 2, width: 120, height: 24,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
frameId: null, roundness: null, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
text: 'New task', fontSize: 18, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
baseline: 16, containerId: null, originalText: 'New task', lineHeight: 1.25,
});
// Move the add button down
const updated = elements.map((el) =>
el.id === selectedEl.id
? { ...el, y: newY + 40, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
setSaveStatus('unsaved');
return;
}
if (role.startsWith('kanban-add')) {
// Add a new card in the column
const cardW = 140, cardH = 60;
const newY = btnY - 70;
const colX = btnX - 20;
const gid = `card-${uid()}`;
newElements.push({
id: uid(), type: 'rectangle', x: colX, y: newY, width: cardW, height: cardH,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [gid],
frameId: null, roundness: { type: 3, value: 32 }, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
});
newElements.push({
id: tid(), type: 'text', x: colX + 15, y: newY + 20, width: 100, height: 22,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [gid],
frameId: null, roundness: null, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
text: 'New card', fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
baseline: 14, containerId: null, originalText: 'New card', lineHeight: 1.25,
});
// Move the add button down
const updated = elements.map((el) =>
el.id === selectedEl.id
? { ...el, y: newY + cardH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
setSaveStatus('unsaved');
return;
}
if (role.startsWith('mindmap-add')) {
// Add a new branch node below the current one
const nodeW = 150, nodeH = 55;
const newY = btnY + 20;
const gid = `branch-${uid()}`;
newElements.push({
id: uid(), type: 'rectangle', x: btnX, y: newY, width: nodeW, height: nodeH,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [gid],
frameId: null, roundness: { type: 3, value: 32 }, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
});
newElements.push({
id: tid(), type: 'text', x: btnX + 25, y: newY + 16, width: 100, height: 22,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [gid],
frameId: null, roundness: null, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
text: 'New branch', fontSize: 18, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
baseline: 16, containerId: null, originalText: 'New branch', lineHeight: 1.25,
});
// Add connecting arrow from parent to new node
const parentCenterX = btnX + nodeW / 2;
const parentBottomY = btnY - 20;
newElements.push({
id: `arrow-${Math.random().toString(36).slice(2)}`, type: 'arrow',
x: parentCenterX, y: parentBottomY, width: 0, height: newY - parentBottomY,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
frameId: null, roundness: { type: 2 }, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
startBinding: null, endBinding: null, lastCommittedPoint: null,
startArrowhead: null, endArrowhead: 'arrow',
points: [[0, 0], [0, newY - parentBottomY]],
});
// Move the add button down
const updated = elements.map((el) =>
el.id === selectedEl.id
? { ...el, y: newY + nodeH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
setSaveStatus('unsaved');
return;
}
// Generic add: add a text line below
if (role.startsWith('list-add') || role.startsWith('meeting-add') || role.startsWith('flow-add') ||
role.startsWith('brainstorm-add') || role.startsWith('retro-add') || role.startsWith('swot-add') ||
role.startsWith('storymap-add') || role.startsWith('wireframe-add') || role.startsWith('timeline-add') ||
role.startsWith('architecture-add')) {
const newY = btnY + 30;
newElements.push({
id: tid(), type: 'text', x: btnX + 30, y: newY, width: 150, height: 22,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
frameId: null, roundness: null, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
text: role.startsWith('list-add') ? '• New item' : '- New item',
fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
baseline: 14, containerId: null, originalText: role.startsWith('list-add') ? '• New item' : '- New item', lineHeight: 1.25,
});
const updated = elements.map((el) =>
el.id === selectedEl.id
? { ...el, y: newY + 30, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
setSaveStatus('unsaved');
return;
}
}
currentStateRef.current = {
@@ -245,7 +436,7 @@ export const Editor: React.FC = () => {
const handleRestoreRevision = (revision: DrawingRevision) => {
if (!revision.snapshot) return;
try {
const snapshot = JSON.parse(String(revision.snapshot));
const snapshot = typeof revision.snapshot === 'string' ? JSON.parse(revision.snapshot) : revision.snapshot;
setInitialData({
elements: snapshot.elements || [],
appState: appStateWithoutGrid(snapshot.appState || {}),
@@ -267,6 +458,23 @@ export const Editor: React.FC = () => {
await saveDrawing();
};
// Ctrl+S keyboard shortcut
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
if (saveStatus !== 'saved' && !isSaving) {
saveDrawing();
}
}
if (e.key === 'Escape' && presentationMode) {
setPresentationMode(false);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [saveStatus, isSaving, saveDrawing, presentationMode]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
@@ -295,14 +503,18 @@ export const Editor: React.FC = () => {
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 },
{ id: 'kanban', label: 'Kanban Board', description: 'Backlog, doing, done columns', icon: null },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks with +', icon: null },
{ id: 'checklist', label: 'Checklist', description: 'Status checklist with +', icon: null },
{ id: 'list', label: 'Bullet List', description: 'Bulleted notes with +', icon: null },
{ id: 'flow', label: 'Flow Chart', description: 'Process diagram with +', icon: null },
{ id: 'kanban', label: 'Kanban Board', description: 'Backlog, doing, done with +', icon: null },
{ id: 'meeting', label: 'Meeting Notes', description: 'Agenda, decisions, actions', icon: null },
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: null },
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with branches', icon: null },
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: null },
{ id: 'brainstorm', label: 'Brainstorm', description: 'Ideas around a topic', icon: null },
{ id: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: null },
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opps, threats', icon: null },
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: null },
];
useEffect(() => {
@@ -341,7 +553,7 @@ export const Editor: React.FC = () => {
return (
<div className={styles.container}>
<div className={styles.toolbar}>
<div className={`${styles.toolbar} ${presentationMode ? styles.toolbarHidden : ''}`}>
<div className={styles.left}>
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft size={18} />
@@ -395,18 +607,40 @@ export const Editor: React.FC = () => {
>
<LayoutTemplate size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setPresentationMode(true)}
title="Presentation mode"
aria-label="Start presentation mode"
>
<MonitorPlay size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setTemplateName(drawing?.title || ''); setTemplateDesc(''); setShowSaveTemplate(true); }}
title="Save as template"
aria-label="Save current drawing as a custom template"
>
<Plus size={16} />
</Button>
</div>
</div>
<div className={styles.canvasWrapper}>
<div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates) ? styles.canvasNarrow : ''}`}>
{initialData && (
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
<Excalidraw
<ExcalidrawWithLibrary
excalidrawAPI={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
initialData={initialData}
onChange={handleExcalidrawChange}
theme={appTheme === 'dark' ? 'dark' : 'light'}
gridModeEnabled={false}
viewModeEnabled={presentationMode}
zenModeEnabled={presentationMode}
validateEmbeddable={() => true}
validateLibraryUrl={() => true}
UIOptions={{
canvasActions: {
saveToActiveFile: false,
@@ -494,6 +728,81 @@ export const Editor: React.FC = () => {
</div>
)}
{presentationMode && (
<div className={styles.presentationOverlay} role="presentation">
<div className={styles.presentationToolbar}>
<span className={styles.presentationLabel}>Presentation Mode Press Esc to exit</span>
<Button variant="ghost" size="sm" onClick={() => setPresentationMode(false)} aria-label="Exit presentation">
<X size={16} />
</Button>
</div>
</div>
)}
{showSaveTemplate && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="save-template-title" onClick={(e) => { if (e.target === e.currentTarget) setShowSaveTemplate(false); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="save-template-title">Save as Template</h3>
<button className={styles.modalClose} onClick={() => setShowSaveTemplate(false)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<label htmlFor="template-name">Template Name</label>
<input
id="template-name"
type="text"
autoFocus
placeholder="My Custom Template"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
className={styles.modalInput}
/>
<label htmlFor="template-desc" style={{ marginTop: 'var(--space-3)' }}>Description (optional)</label>
<input
id="template-desc"
type="text"
placeholder="Brief description..."
value={templateDesc}
onChange={(e) => setTemplateDesc(e.target.value)}
className={styles.modalInput}
/>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setShowSaveTemplate(false)}>Cancel</button>
<button
className={styles.modalBtnPrimary}
onClick={async () => {
if (!templateName.trim() || !excalidrawAPI) return;
setIsSavingTemplate(true);
try {
const elements = excalidrawAPI.getSceneElements();
const appState = excalidrawAPI.getAppState();
const files = excalidrawAPI.getFiles();
const snapshot = { type: 'excalidraw', version: 2, source: window.location.hostname, elements, appState, files };
await api.templates.create({
name: templateName.trim(),
description: templateDesc.trim(),
snapshot,
metadata: { category: 'custom' },
});
setShowSaveTemplate(false);
alert('Template saved successfully!');
} catch (err) {
console.error('Failed to save template:', err);
alert('Failed to save template. Please try again.');
} finally {
setIsSavingTemplate(false);
}
}}
disabled={isSavingTemplate || !templateName.trim()}
>
{isSavingTemplate ? <Loader2 size={16} className={styles.spinner} /> : 'Save Template'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
@@ -18,9 +18,10 @@
flex-wrap: wrap;
padding: var(--space-5);
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(0.2deg);
@media (max-width: 640px) {
flex-direction: column;
@@ -114,8 +115,9 @@
width: 240px;
flex-shrink: 0;
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
padding: var(--space-3);
align-self: flex-start;
@@ -138,12 +140,12 @@
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
border-radius: 2px;
color: var(--color-gray-70);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
background: none;
border: none;
border: 2px solid transparent;
width: 100%;
text-align: left;
font-size: var(--text-sm);
@@ -151,12 +153,17 @@
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
border-color: var(--color-gray-30);
transform: rotate(-0.3deg);
}
&.folderActive {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
font-weight: 600;
border-color: var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-0.2deg);
}
svg {
@@ -221,6 +228,16 @@
.drawingCard {
position: relative;
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(0.1deg);
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: rotate(0) translate(-1px, -1px);
box-shadow: 5px 5px 0 var(--color-gray-85);
}
}
.thumbnail {
@@ -295,9 +312,9 @@
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);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
min-width: 160px;
z-index: 10;
display: flex;
@@ -354,16 +371,17 @@
flex-wrap: wrap;
padding: var(--space-3);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
box-shadow: 2px 2px 0 var(--color-gray-85);
}
.newProjectInput {
flex: 1;
min-width: 120px;
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
padding: var(--space-2) var(--space-3);
color: var(--text-primary-color);
font-size: var(--text-sm);
@@ -371,36 +389,40 @@
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
box-shadow: 3px 3px 0 var(--color-gray-85);
}
}
.newProjectBtn {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--border-radius-md);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
font-weight: 500;
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover {
background: var(--color-primary-darkest);
transform: rotate(-0.5deg);
}
}
.newProjectBtnCancel {
background: none;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-on-surface);
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover {
background: var(--color-surface-low);
transform: rotate(-0.5deg);
}
}
@@ -431,3 +453,110 @@
border-color: var(--color-primary);
}
}
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 5px 5px 0 var(--color-gray-85);
width: 420px;
max-width: 90vw;
transform: rotate(-0.3deg);
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 2px solid var(--color-gray-85);
h3 {
margin: 0;
font-size: var(--text-lg);
color: var(--color-gray-85);
font-family: 'Georgia', serif;
}
}
.modalClose {
background: none;
border: none;
font-size: 22px;
color: var(--color-gray-60);
cursor: pointer;
line-height: 1;
&:hover { color: var(--color-gray-85); }
}
.modalBody {
padding: var(--space-4) var(--space-5);
label {
display: block;
font-size: var(--text-sm);
color: var(--color-gray-70);
margin-bottom: var(--space-2);
}
}
.modalInput {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
background: var(--input-bg-color);
color: var(--color-on-surface);
font-size: var(--text-sm);
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 3px 3px 0 var(--color-gray-85);
}
}
.modalFooter {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-3) var(--space-5) var(--space-4);
}
.modalBtnSecondary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-30);
background: transparent;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover { background: var(--color-surface-low); transform: rotate(-0.5deg); }
}
.modalBtnPrimary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-85);
background: var(--color-primary);
color: white;
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover { background: var(--color-primary-darker); transform: rotate(-0.5deg); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
+45 -4
View File
@@ -12,7 +12,7 @@ export const FileBrowser: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const urlParams = useParams<{ folderId?: string }>();
const { drawings, folders, setDrawings, setFolders } = useDrawingStore();
const { drawings, folders, setDrawings, setFolders, removeDrawing } = useDrawingStore();
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [sortBy, setSortBy] = useState<'name' | 'updated' | 'created'>('updated');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
@@ -37,6 +37,10 @@ export const FileBrowser: React.FC = () => {
// Move state
const [movingId, setMovingId] = useState<string | null>(null);
// New drawing name modal state
const [showNameModal, setShowNameModal] = useState(false);
const [newDrawingName, setNewDrawingName] = useState('');
// Modal state
const [modal, setModal] = useState<{
open: boolean;
@@ -118,11 +122,18 @@ export const FileBrowser: React.FC = () => {
[navigate]
);
const handleCreateDrawing = async () => {
const handleCreateDrawing = () => {
setNewDrawingName('');
setShowNameModal(true);
};
const confirmCreateDrawing = async () => {
const title = newDrawingName.trim() || 'Untitled Drawing';
setIsCreating(true);
setShowNameModal(false);
try {
const newDrawing = await api.drawings.create({
title: 'Untitled Drawing',
title,
visibility: 'team',
folder_id: activeFolderId || null,
});
@@ -161,7 +172,7 @@ export const FileBrowser: React.FC = () => {
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));
removeDrawing(drawing.id);
setActiveMenu(null);
setModal(m => ({ ...m, open: false }));
} catch (err) {
@@ -489,6 +500,36 @@ export const FileBrowser: React.FC = () => {
)}
</main>
</div>
{showNameModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="new-drawing-title" onClick={(e) => { if (e.target === e.currentTarget) setShowNameModal(false); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="new-drawing-title">New Drawing</h3>
<button className={styles.modalClose} onClick={() => setShowNameModal(false)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<label htmlFor="drawing-name">Name</label>
<input
id="drawing-name"
type="text"
autoFocus
placeholder="Untitled Drawing"
value={newDrawingName}
onChange={(e) => setNewDrawingName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') confirmCreateDrawing(); if (e.key === 'Escape') setShowNameModal(false); }}
className={styles.modalInput}
/>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setShowNameModal(false)}>Cancel</button>
<button className={styles.modalBtnPrimary} onClick={confirmCreateDrawing} disabled={isCreating}>
{isCreating ? <Loader2 size={16} className={styles.spinner} /> : 'Create'}
</button>
</div>
</div>
</div>
)}
</div>
</>
);
@@ -7,12 +7,19 @@
.header {
margin-bottom: var(--space-8);
padding: var(--space-5);
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(0.1deg);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
font-weight: 700;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
font-family: 'Georgia', serif;
}
}
@@ -38,10 +45,10 @@
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-md);
border-radius: 2px;
color: var(--color-gray-70);
background: none;
border: none;
border: 2px solid transparent;
cursor: pointer;
font-size: var(--text-sm);
transition: all var(--duration-fast) var(--ease-out);
@@ -50,12 +57,17 @@
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
border-color: var(--color-gray-30);
transform: rotate(-0.2deg);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
font-weight: 600;
border-color: var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-0.1deg);
}
}
@@ -75,15 +87,17 @@
.avatar {
width: 64px;
height: 64px;
border-radius: var(--border-radius-full);
border-radius: 50%;
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-2xl);
font-weight: 600;
font-weight: 700;
overflow: hidden;
border: 2px solid var(--color-gray-85);
box-shadow: 3px 3px 0 var(--color-gray-85);
img {
width: 100%;
@@ -137,22 +151,26 @@
.themeOption {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-gray-30);
border-radius: var(--border-radius-md);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
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);
box-shadow: 2px 2px 0 var(--color-gray-30);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
transform: translate(-1px, -1px);
box-shadow: 3px 3px 0 var(--color-primary);
}
&.active {
background: var(--color-primary);
border-color: var(--color-primary);
border-color: var(--color-gray-85);
color: white;
box-shadow: 2px 2px 0 var(--color-gray-85);
}
}
+39 -14
View File
@@ -7,12 +7,19 @@
.header {
margin-bottom: var(--space-8);
padding: var(--space-5);
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(-0.2deg);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
font-weight: 700;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
font-family: 'Georgia', serif;
}
}
@@ -59,25 +66,40 @@
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
padding: var(--space-3) var(--space-2);
margin-bottom: var(--space-2);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
transition: all 0.15s ease;
box-shadow: 2px 2px 0 var(--color-gray-85);
background: var(--island-bg-color);
&:hover {
border-color: var(--color-primary);
background: var(--color-surface-low);
transform: translateX(2px) rotate(-0.2deg);
box-shadow: 3px 3px 0 var(--color-gray-85);
}
&:last-child {
border-bottom: none;
border-bottom: 2px solid var(--color-gray-30);
margin-bottom: 0;
}
}
.memberAvatar {
width: 40px;
height: 40px;
border-radius: var(--border-radius-full);
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-weight: 700;
flex-shrink: 0;
border: 2px solid var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
}
.memberInfo {
@@ -100,11 +122,13 @@
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--color-surface-low);
border-radius: var(--border-radius-full);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-gray-70);
text-transform: capitalize;
box-shadow: 1px 1px 0 var(--color-gray-85);
}
.inviteForm {
@@ -115,15 +139,15 @@
.inviteInput {
padding: var(--space-3);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
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);
box-shadow: 3px 3px 0 var(--color-gray-85);
}
}
@@ -163,11 +187,12 @@
.roleSelect {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
font-size: var(--text-sm);
background: var(--input-bg-color);
cursor: pointer;
box-shadow: 1px 1px 0 var(--color-gray-85);
}
.error {
@@ -78,6 +78,16 @@
.templateCard {
overflow: hidden;
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(0.1deg);
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: rotate(0) translate(-1px, -1px);
box-shadow: 5px 5px 0 var(--color-gray-85);
}
}
.preview {
@@ -143,8 +153,14 @@
}
.useBtn {
flex: 1;
}
.actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-3);
width: 100%;
align-items: center;
}
.modalOverlay {
+57 -41
View File
@@ -1,8 +1,8 @@
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 { Sparkles, FilePlus, Trash2 } from 'lucide-react';
import { Card, CardContent, Button } from '@/components';
import { useDrawingStore, useAuthStore } from '@/stores';
import { api } from '@/services';
import type { Template, TemplateScope } from '@/types';
import styles from './Templates.module.scss';
@@ -17,12 +17,10 @@ const categories: { id: TemplateScope | 'all'; label: string }[] = [
export const Templates: React.FC = () => {
const navigate = useNavigate();
const { templates, setTemplates, addDrawing } = useDrawingStore();
const { user } = useAuthStore();
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('');
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
api.templates.list().then(setTemplates).catch(console.error);
@@ -30,16 +28,6 @@ export const Templates: React.FC = () => {
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 { setError('Create failed'); }
finally { setCreating(false); }
};
const handleUseTemplate = async (template: Template) => {
setApplyingId(template.id);
try {
@@ -47,6 +35,11 @@ export const Templates: React.FC = () => {
title: template.name,
visibility: 'team',
});
// Apply template snapshot if available - store in localStorage for editor to pick up
if (template.snapshot_path) {
// The template data would need to be fetched separately
// For now, just navigate to the new drawing
}
addDrawing(drawing);
navigate(`/drawing/${drawing.id}`);
} catch (err) {
@@ -56,11 +49,31 @@ export const Templates: React.FC = () => {
}
};
const handleDeleteTemplate = async (template: Template) => {
if (!confirm(`Delete template "${template.name}"? This cannot be undone.`)) return;
setDeletingId(template.id);
try {
await api.templates.delete(template.id);
setTemplates(templates.filter((t) => t.id !== template.id));
} catch (err) {
console.error('Failed to delete template:', err);
alert('Failed to delete template. You may not have permission.');
} finally {
setDeletingId(null);
}
};
const canDelete = (template: Template) => {
return template.scope !== 'system' && template.created_by === user?.id;
};
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>
<h1>Templates</h1>
<p className={styles.subtitle}>Start from a template. Create custom templates from any drawing using the "Save as Template" button in the editor.</p>
</div>
</div>
<div className={styles.categories} role="tablist">
{categories.map((c) => (
@@ -71,7 +84,7 @@ export const Templates: React.FC = () => {
<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>
<p>No templates</p><p className={styles.emptySub}>Create your first template from any drawing</p></div>
) : filtered.map((t) => (
<Card key={t.id} className={styles.templateCard} hover>
<div className={styles.preview}>
@@ -84,31 +97,34 @@ export const Templates: React.FC = () => {
<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>
<div className={styles.actions}>
<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>
{canDelete(t) && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTemplate(t)}
loading={deletingId === t.id}
aria-label={`Delete template ${t.name}`}
title="Delete template"
>
<Trash2 size={14} />
</Button>
)}
</div>
</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>
);
};