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
+1
View File
@@ -10,6 +10,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<script>try{var t=JSON.parse(localStorage.getItem('excalidraw-theme')||'{}').state?.theme;if(t)document.documentElement.setAttribute('data-theme',t)}catch(e){}</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+32
View File
@@ -6,3 +6,35 @@
font-size: var(--text-lg);
color: var(--color-muted);
}
/* Excalidraw Context Menu Styling Overrides */
:global(.excalidraw .context-menu) {
background: var(--island-bg-color) !important;
border: 2px solid var(--color-gray-85) !important;
border-radius: 2px !important;
box-shadow: 4px 4px 0 var(--color-gray-85) !important;
transform: rotate(-0.2deg) !important;
padding: 2px !important;
}
:global(.excalidraw .context-menu-item) {
border-radius: 2px !important;
color: var(--color-gray-85) !important;
font-weight: 500 !important;
padding: 6px 12px !important;
}
:global(.excalidraw .context-menu-item:hover) {
background: var(--color-primary-light) !important;
color: var(--color-primary-darkest) !important;
transform: translateX(1px) !important;
}
:global(.excalidraw .context-menu-item-separator) {
border-top: 2px solid var(--color-gray-30) !important;
margin: 2px 4px !important;
}
:global(.excalidraw .context-menu-item-keybinding) {
color: var(--color-muted) !important;
}
+104 -5
View File
@@ -1,13 +1,23 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, Bell, Plus, FileText, Loader2, Sun, Moon } from 'lucide-react';
import { Search, Bell, Plus, FileText, Loader2, Sun, Moon, Check, X } from 'lucide-react';
import { Button } from '@/components';
import { useThemeStore } from '@/stores';
import { api } from '@/services';
import type { Drawing } from '@/types';
import styles from './Layout.module.scss';
interface AppNotification {
id: string;
type: 'share' | 'comment' | 'mention' | 'update';
title: string;
description: string;
time: string;
read: boolean;
drawingId?: string;
}
export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -17,9 +27,37 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
const [isSearching, setIsSearching] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [showResults, setShowResults] = useState(false);
const [showNameModal, setShowNameModal] = useState(false);
const [newDrawingName, setNewDrawingName] = useState('');
const [showNotifications, setShowNotifications] = useState(false);
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const searchRef = useRef<HTMLDivElement>(null);
const notifRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const unreadCount = notifications.filter((n) => !n.read).length;
// Fetch notifications on mount
useEffect(() => {
const fetchNotifications = async () => {
try {
const res = await api.notifications.list();
setNotifications(res as unknown as AppNotification[]);
} catch {
setNotifications([]);
}
};
fetchNotifications();
}, []);
const markAllRead = () => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
};
const dismissNotification = (id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
};
const performSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
@@ -56,11 +94,18 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
}
};
const handleCreateDrawing = async () => {
const handleCreateDrawing = () => {
setNewDrawingName('');
setShowNameModal(true);
};
const confirmCreateDrawing = async () => {
const title = newDrawingName.trim() || 'Untitled Drawing';
setIsCreating(true);
setShowNameModal(false);
try {
const drawing = await api.drawings.create({
title: 'Untitled Drawing',
title,
visibility: 'team',
});
navigate(`/drawing/${drawing.id}`);
@@ -76,6 +121,9 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
if (!searchRef.current?.contains(e.target as Node)) {
setShowResults(false);
}
if (!notifRef.current?.contains(e.target as Node)) {
setShowNotifications(false);
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
@@ -125,18 +173,69 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
)}
</div>
<div className={styles.actions}>
<div className={styles.actions} ref={notifRef}>
<button className={styles.iconButton} onClick={toggleTheme} title={t('userSettings.theme')} aria-label={t('userSettings.theme')}>
{theme === 'light' ? <Sun size={20} aria-hidden="true" /> : <Moon size={20} aria-hidden="true" />}
</button>
<button className={styles.iconButton} aria-label="Notifications" title="Notifications">
<button className={styles.iconButton} aria-label="Notifications" title="Notifications" onClick={() => setShowNotifications((v) => !v)}>
<Bell size={20} aria-hidden="true" />
{unreadCount > 0 && <span className={styles.notifBadge}>{unreadCount}</span>}
</button>
{showNotifications && (
<div className={styles.notifDropdown}>
<div className={styles.notifHeader}>
<span className={styles.notifTitle}>Notifications</span>
<button className={styles.notifMarkAll} onClick={markAllRead} aria-label="Mark all as read">
<Check size={14} aria-hidden="true" /> All read
</button>
</div>
{notifications.length === 0 ? (
<div className={styles.notifEmpty}>No notifications yet</div>
) : (
notifications.map((n) => (
<div key={n.id} className={`${styles.notifItem} ${!n.read ? styles.notifUnread : ''}`}>
<div className={styles.notifContent}>
<div className={styles.notifItemTitle}>{n.title}</div>
<div className={styles.notifItemDesc}>{n.description}</div>
<div className={styles.notifItemTime}>{n.time}</div>
</div>
<button className={styles.notifDismiss} onClick={() => dismissNotification(n.id)} aria-label="Dismiss notification">
<X size={14} aria-hidden="true" />
</button>
</div>
))
)}
</div>
)}
<Button onClick={handleCreateDrawing} loading={isCreating}>
<Plus size={18} />
{t('dashboard.newDrawing')}
</Button>
</div>
{showNameModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" onClick={(e) => { if (e.target === e.currentTarget) setShowNameModal(false); }}>
<div className={styles.nameModal}>
<h3>New Drawing</h3>
<input
autoFocus
type="text"
placeholder="Drawing name..."
value={newDrawingName}
onChange={(e) => setNewDrawingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') confirmCreateDrawing();
if (e.key === 'Escape') setShowNameModal(false);
}}
className={styles.nameInput}
/>
<div className={styles.nameModalActions}>
<button className={styles.nameModalCancel} onClick={() => setShowNameModal(false)}>Cancel</button>
<button className={styles.nameModalConfirm} onClick={confirmCreateDrawing}>Create</button>
</div>
</div>
</div>
)}
</header>
);
};
@@ -8,7 +8,7 @@
.sidebar {
width: var(--sidebar-width);
background: var(--island-bg-color);
border-right: 1px solid var(--color-gray-20);
border-right: 2px solid var(--color-gray-85);
display: flex;
flex-direction: column;
padding: var(--space-4);
@@ -18,6 +18,15 @@
bottom: 0;
z-index: 100;
transition: transform var(--duration-normal) var(--ease-out);
box-shadow: 3px 0 0 var(--color-gray-85);
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 23px,
var(--color-gray-20) 23px,
var(--color-gray-20) 24px
);
@media (max-width: 768px) {
transform: translateX(-100%);
@@ -71,6 +80,13 @@
min-width: 0;
}
.logoImg {
width: 28px;
height: 28px;
flex-shrink: 0;
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.1));
}
.logoMark {
width: 32px;
height: 32px;
@@ -125,18 +141,25 @@
padding: var(--space-3) var(--space-4);
color: var(--color-gray-70);
text-decoration: none;
border-radius: var(--border-radius-md);
border: 2px solid transparent;
border-radius: 2px;
transition: all var(--duration-fast) var(--ease-out);
font-weight: 500;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
border-color: var(--color-gray-30);
transform: rotate(-0.5deg);
}
&.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.3deg);
}
}
@@ -213,7 +236,8 @@
.header {
height: var(--header-height);
background: var(--island-bg-color);
border-bottom: 1px solid var(--color-gray-20);
border-bottom: 2px solid var(--color-gray-85);
box-shadow: 0 3px 0 var(--color-gray-85);
display: flex;
align-items: center;
justify-content: space-between;
@@ -267,23 +291,28 @@
display: flex;
align-items: center;
gap: var(--space-3);
position: relative;
}
.iconButton {
position: relative;
background: none;
border: none;
border: 2px solid transparent;
color: var(--color-gray-60);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
display: flex;
border-radius: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
background: var(--color-surface-low);
border-color: var(--color-gray-30);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-1deg);
}
}
@@ -354,3 +383,206 @@
font-size: var(--text-sm);
color: var(--color-gray-50);
}
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.nameModal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: var(--modal-shadow);
padding: var(--space-5);
width: 360px;
max-width: 90vw;
h3 {
margin: 0 0 var(--space-4);
font-size: var(--text-lg);
color: var(--color-gray-85);
}
}
.nameInput {
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;
margin-bottom: var(--space-4);
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.nameModalActions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
.nameModalCancel {
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);
}
}
.nameModalConfirm {
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);
}
}
// Notification dropdown styles
.notifBadge {
position: absolute;
top: 4px;
right: 4px;
background: #e03131;
color: white;
font-size: 10px;
font-weight: 700;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.notifDropdown {
position: absolute;
top: calc(100% + var(--space-2));
right: 100px;
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: 320px;
max-height: 400px;
overflow-y: auto;
z-index: 100;
transform: rotate(-0.2deg);
}
.notifHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 2px solid var(--color-gray-85);
}
.notifTitle {
font-weight: 600;
font-size: var(--text-sm);
color: var(--color-gray-85);
font-family: 'Georgia', serif;
}
.notifMarkAll {
background: none;
border: none;
font-size: var(--text-xs);
color: var(--color-primary);
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
&:hover {
text-decoration: underline;
}
}
.notifEmpty {
padding: var(--space-6);
text-align: center;
color: var(--color-muted);
font-size: var(--text-sm);
}
.notifItem {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
transition: background 0.15s ease;
&:hover {
background: var(--color-surface-low);
}
&:last-child {
border-bottom: none;
}
}
.notifUnread {
border-left: 3px solid var(--color-primary);
}
.notifContent {
flex: 1;
}
.notifItemTitle {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-85);
}
.notifItemDesc {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: 2px;
}
.notifItemTime {
font-size: var(--text-xs);
color: var(--color-muted);
opacity: 0.7;
margin-top: 2px;
}
.notifDismiss {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: 2px;
flex-shrink: 0;
&:hover {
color: #e03131;
}
}
+7 -1
View File
@@ -37,7 +37,13 @@ export const Sidebar: React.FC<SidebarProps> = ({ open, onClose }) => {
>
<div className={styles.sidebarHeader}>
<div className={styles.logo}>
<span className={styles.logoMark} aria-hidden="true">E</span>
<img
src="https://plus.excalidraw.com/images/logo.svg"
alt="Excalidraw"
className={styles.logoImg}
width={28}
height={28}
/>
<span className={styles.logoText}>Excalidraw</span>
</div>
{onClose && (
@@ -13,13 +13,15 @@
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--modal-shadow);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 8px 8px 0 var(--color-gray-85);
width: 100%;
max-width: 720px;
max-height: 80vh;
overflow-y: auto;
padding: var(--space-6);
transform: rotate(-0.1deg);
}
.header {
@@ -40,15 +42,17 @@
.closeBtn {
background: none;
border: none;
border: 2px solid transparent;
cursor: pointer;
color: var(--color-gray-60);
padding: var(--space-2);
border-radius: var(--border-radius-md);
border-radius: 2px;
&:hover {
background: var(--color-gray-10);
border-color: var(--color-gray-85);
color: var(--color-gray-90);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-1deg);
}
}
@@ -65,12 +69,21 @@
text-align: center;
padding: var(--space-6) var(--space-4);
cursor: pointer;
border: 2px solid transparent;
border: 2px solid var(--color-gray-30);
border-radius: 2px;
background: var(--island-bg-color);
box-shadow: 2px 2px 0 var(--color-gray-85);
transition: all var(--duration-fast);
&:hover {
border-color: var(--color-primary);
transform: translateY(-2px);
transform: translateY(-2px) rotate(-0.3deg);
box-shadow: 4px 4px 0 var(--color-gray-85);
}
&:active {
transform: translateY(0) rotate(0);
box-shadow: 1px 1px 0 var(--color-gray-85);
}
}
@@ -1,9 +1,9 @@
import React from 'react';
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool, KanbanSquare, MessageSquare, PanelsTopLeft, GitFork } from 'lucide-react';
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool, KanbanSquare, MessageSquare, PanelsTopLeft, GitFork, Lightbulb, RotateCcw, Shield, Map, Timer, Layers } from 'lucide-react';
import { Card } from '@/components';
import styles from './TemplatePicker.module.scss';
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow' | 'kanban' | 'meeting' | 'wireframe' | 'mindmap';
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow' | 'kanban' | 'meeting' | 'wireframe' | 'mindmap' | 'brainstorm' | 'retrospective' | 'swot' | 'storymap' | 'timeline' | 'architecture';
interface TemplatePickerProps {
isOpen: boolean;
@@ -21,7 +21,7 @@ interface TemplateOption {
elements: RawElement[];
}
function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string) {
function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string, groupId?: string) {
return {
id: `el-${Math.random().toString(36).slice(2)}`,
type: 'rectangle',
@@ -34,7 +34,7 @@ function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: st
strokeStyle: 'solid',
roughness: 1,
opacity: 100,
groupIds: [],
groupIds: groupId ? [groupId] : [],
frameId: null,
roundness: { type: 3, value: 32 },
seed: Math.floor(Math.random() * 10000),
@@ -88,6 +88,7 @@ function makeCheckbox(x: number, y: number, checked = false) {
const box = makeHandDrawnRect(x, y, 20, 20);
return Object.assign(box, {
backgroundColor: checked ? '#a5eba8' : 'transparent',
fillStyle: checked ? 'solid' : 'hachure',
customData: {
templateRole: 'checkbox',
checked,
@@ -95,6 +96,56 @@ function makeCheckbox(x: number, y: number, checked = false) {
});
}
function makeAddButton(x: number, y: number, label: string, templateRole: string) {
const btn = makeHandDrawnRect(x, y, 24, 24);
return Object.assign(btn, {
backgroundColor: '#d0ecff',
fillStyle: 'solid',
strokeColor: '#1971c2',
roundness: { type: 3, value: 12 },
customData: { templateRole, action: 'add', label },
});
}
function makeArrow(x1: number, y1: number, x2: number, y2: number) {
return {
id: `arrow-${Math.random().toString(36).slice(2)}`,
type: 'arrow',
x: x1,
y: y1,
width: x2 - x1,
height: y2 - y1,
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],
[x2 - x1, y2 - y1],
],
};
}
export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
blank: [],
todo: [
@@ -106,8 +157,10 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(90, 170, 'Second task'),
makeCheckbox(60, 210, false),
makeText(90, 210, 'Third task'),
makeHandDrawnRect(50, 280, 500, 2),
makeText(60, 300, 'Notes:', 18),
makeAddButton(60, 250, '+', 'todo-add'),
makeText(92, 250, 'Add task...', 16),
makeHandDrawnRect(50, 290, 500, 2),
makeText(60, 310, 'Notes:', 18),
],
checklist: [
makeHandDrawnRect(50, 50, 500, 50),
@@ -118,28 +171,33 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(90, 170, 'Pending item', 18),
makeCheckbox(60, 210, false),
makeText(90, 210, 'Another task', 18),
makeHandDrawnRect(60, 250, 480, 1),
makeText(70, 265, 'Add more items below', 14),
makeAddButton(60, 250, '+', 'checklist-add'),
makeText(92, 250, 'Add item...', 16),
],
list: [
makeHandDrawnRect(50, 50, 500, 50),
makeText(70, 65, 'Bullet List', 28),
makeText(60, 130, '- First bullet point'),
makeText(60, 170, '- Second bullet point'),
makeText(60, 210, '- Third bullet point'),
makeText(60, 250, '- Fourth item with details'),
makeHandDrawnRect(50, 300, 500, 2),
makeText(60, 320, 'Add your own items...', 14),
makeText(60, 130, ' First bullet point'),
makeText(60, 170, ' Second bullet point'),
makeText(60, 210, ' Third bullet point'),
makeText(60, 250, ' Fourth item with details'),
makeAddButton(60, 290, '+', 'list-add'),
makeText(92, 290, 'Add bullet...', 16),
],
flow: [
makeHandDrawnRect(200, 50, 200, 60),
makeText(230, 70, 'Start', 20),
makeArrow(300, 110, 300, 150),
makeHandDrawnRect(200, 150, 200, 60),
makeText(220, 170, 'Process A', 20),
makeArrow(300, 210, 300, 250),
makeHandDrawnRect(200, 250, 200, 60),
makeText(220, 270, 'Process B', 20),
makeArrow(300, 310, 300, 350),
makeHandDrawnRect(200, 350, 200, 60),
makeText(230, 370, 'End', 20),
makeAddButton(420, 180, '+', 'flow-add'),
makeText(452, 180, 'Add step', 14),
],
kanban: [
makeText(50, 40, 'Kanban Board', 30),
@@ -149,12 +207,19 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(75, 120, 'Backlog', 20),
makeText(285, 120, 'Doing', 20),
makeText(495, 120, 'Done', 20),
makeHandDrawnRect(70, 170, 140, 70),
// Card 1 - grouped
makeHandDrawnRect(70, 170, 140, 70, undefined, 'card1'),
makeText(85, 190, 'User research', 16),
makeHandDrawnRect(280, 170, 140, 70),
// Card 2 - grouped
makeHandDrawnRect(280, 170, 140, 70, undefined, 'card2'),
makeText(295, 190, 'Sketch flow', 16),
makeHandDrawnRect(490, 170, 140, 70),
// Card 3 - grouped
makeHandDrawnRect(490, 170, 140, 70, undefined, 'card3'),
makeText(505, 190, 'Project brief', 16),
// Add card buttons per column
makeAddButton(110, 380, '+', 'kanban-add-backlog'),
makeAddButton(320, 380, '+', 'kanban-add-doing'),
makeAddButton(530, 380, '+', 'kanban-add-done'),
],
meeting: [
makeText(50, 40, 'Meeting Notes', 30),
@@ -168,6 +233,8 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(70, 350, 'Action Items', 20),
makeCheckbox(70, 390, false),
makeText(105, 390, 'Owner and next step', 18),
makeAddButton(70, 430, '+', 'meeting-add-action'),
makeText(102, 430, 'Add action...', 14),
],
wireframe: [
makeText(50, 35, 'Page Wireframe', 30),
@@ -180,31 +247,198 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(50, 380, 190, 110),
makeHandDrawnRect(265, 380, 190, 110),
makeHandDrawnRect(480, 380, 190, 110),
makeAddButton(480, 500, '+', 'wireframe-add-section'),
makeText(512, 500, 'Add section', 14),
],
mindmap: [
makeHandDrawnRect(240, 200, 200, 70),
makeText(275, 220, 'Main idea', 22),
makeArrow(240, 235, 200, 108),
makeHandDrawnRect(50, 80, 150, 55),
makeText(75, 96, 'Research', 18),
makeAddButton(50, 150, '+', 'mindmap-add-research'),
makeArrow(440, 235, 490, 108),
makeHandDrawnRect(490, 80, 150, 55),
makeText(520, 96, 'Design', 18),
makeAddButton(490, 150, '+', 'mindmap-add-design'),
makeArrow(240, 270, 200, 377),
makeHandDrawnRect(50, 350, 150, 55),
makeText(80, 366, 'Build', 18),
makeAddButton(50, 420, '+', 'mindmap-add-build'),
makeArrow(440, 270, 490, 377),
makeHandDrawnRect(490, 350, 150, 55),
makeText(520, 366, 'Review', 18),
makeAddButton(490, 420, '+', 'mindmap-add-review'),
],
brainstorm: [
makeText(50, 30, 'Brainstorm', 30),
makeHandDrawnRect(220, 80, 240, 60),
makeText(280, 100, 'Central Topic', 22),
// Idea bubbles around
makeHandDrawnRect(50, 180, 160, 50),
makeText(70, 196, 'Idea 1', 18),
makeArrow(210, 140, 130, 180),
makeHandDrawnRect(280, 180, 160, 50),
makeText(300, 196, 'Idea 2', 18),
makeArrow(340, 140, 360, 180),
makeHandDrawnRect(500, 180, 160, 50),
makeText(520, 196, 'Idea 3', 18),
makeArrow(460, 110, 580, 180),
makeAddButton(50, 240, '+', 'brainstorm-add'),
makeText(82, 240, 'Add idea...', 16),
// Notes area
makeHandDrawnRect(50, 280, 610, 100),
makeText(70, 300, 'Notes & connections:', 18),
makeText(70, 330, '- Write insights here', 16),
],
retrospective: [
makeText(50, 30, 'Retrospective', 30),
// Went Well
makeHandDrawnRect(50, 90, 200, 250),
makeText(70, 110, 'Went Well ✓', 18),
makeText(70, 150, '- Good thing 1', 16),
makeText(70, 180, '- Good thing 2', 16),
makeAddButton(70, 310, '+', 'retro-add-well'),
// Improve
makeHandDrawnRect(270, 90, 200, 250),
makeText(290, 110, 'Improve ⚡', 18),
makeText(290, 150, '- Issue 1', 16),
makeText(290, 180, '- Issue 2', 16),
makeAddButton(290, 310, '+', 'retro-add-improve'),
// Actions
makeHandDrawnRect(490, 90, 200, 250),
makeText(510, 110, 'Actions →', 18),
makeCheckbox(510, 150, false),
makeText(540, 150, 'Action item 1', 16),
makeCheckbox(510, 180, false),
makeText(540, 180, 'Action item 2', 16),
makeAddButton(510, 310, '+', 'retro-add-action'),
],
swot: [
makeText(50, 30, 'SWOT Analysis', 30),
// Strengths
makeHandDrawnRect(50, 90, 280, 180),
makeText(70, 110, 'Strengths 💪', 20),
makeText(70, 150, '- Advantage 1', 16),
makeText(70, 180, '- Advantage 2', 16),
makeAddButton(70, 240, '+', 'swot-add-strength'),
// Weaknesses
makeHandDrawnRect(350, 90, 280, 180),
makeText(370, 110, 'Weaknesses ⚠️', 20),
makeText(370, 150, '- Weakness 1', 16),
makeText(370, 180, '- Weakness 2', 16),
makeAddButton(370, 240, '+', 'swot-add-weakness'),
// Opportunities
makeHandDrawnRect(50, 290, 280, 180),
makeText(70, 310, 'Opportunities 🔭', 20),
makeText(70, 350, '- Opportunity 1', 16),
makeText(70, 380, '- Opportunity 2', 16),
makeAddButton(70, 440, '+', 'swot-add-opportunity'),
// Threats
makeHandDrawnRect(350, 290, 280, 180),
makeText(370, 310, 'Threats 🛡️', 20),
makeText(370, 350, '- Threat 1', 16),
makeText(370, 380, '- Threat 2', 16),
makeAddButton(370, 440, '+', 'swot-add-threat'),
],
storymap: [
makeText(50, 30, 'User Story Map', 30),
// Epic row
makeHandDrawnRect(50, 80, 600, 50),
makeText(70, 96, 'Epic: User Journey', 20),
// Steps row
makeText(50, 160, 'Steps →', 14),
makeHandDrawnRect(130, 150, 120, 40),
makeText(145, 164, 'Step 1', 16),
makeHandDrawnRect(270, 150, 120, 40),
makeText(285, 164, 'Step 2', 16),
makeHandDrawnRect(410, 150, 120, 40),
makeText(425, 164, 'Step 3', 16),
makeAddButton(550, 155, '+', 'storymap-add-step'),
// Stories
makeText(50, 220, 'Stories ↓', 14),
makeHandDrawnRect(130, 210, 120, 35),
makeText(140, 222, 'Story A', 14),
makeHandDrawnRect(270, 210, 120, 35),
makeText(280, 222, 'Story B', 14),
makeHandDrawnRect(410, 210, 120, 35),
makeText(420, 222, 'Story C', 14),
makeAddButton(130, 255, '+', 'storymap-add-story'),
// Priority labels
makeHandDrawnRect(50, 300, 600, 2),
makeText(50, 320, 'Priority: High → Low (top to bottom)', 14),
makeAddButton(50, 350, '+', 'storymap-add-row'),
makeText(82, 350, 'Add row...', 14),
],
timeline: [
makeText(50, 30, 'Project Timeline', 30),
makeHandDrawnRect(50, 90, 600, 4),
// Milestones
makeHandDrawnRect(80, 70, 20, 44, undefined, 'milestone-1'),
makeText(60, 125, 'Q1 Kickoff', 14),
makeHandDrawnRect(220, 70, 20, 44, undefined, 'milestone-2'),
makeText(200, 125, 'Design', 14),
makeHandDrawnRect(360, 70, 20, 44, undefined, 'milestone-3'),
makeText(340, 125, 'Build', 14),
makeHandDrawnRect(500, 70, 20, 44, undefined, 'milestone-4'),
makeText(480, 125, 'Launch', 14),
// Tasks below timeline
makeHandDrawnRect(50, 170, 130, 50),
makeText(65, 185, 'Research', 14),
makeHandDrawnRect(200, 170, 130, 50),
makeText(215, 185, 'Prototype', 14),
makeHandDrawnRect(350, 170, 130, 50),
makeText(365, 185, 'Develop', 14),
makeHandDrawnRect(500, 170, 130, 50),
makeText(515, 185, 'Deploy', 14),
makeAddButton(80, 240, '+', 'timeline-add'),
makeText(112, 240, 'Add phase...', 14),
],
architecture: [
makeText(50, 30, 'System Architecture', 30),
// Client
makeHandDrawnRect(50, 90, 160, 70),
makeText(90, 110, 'Client', 18),
makeArrow(210, 125, 260, 125),
// API Gateway
makeHandDrawnRect(260, 90, 180, 70),
makeText(290, 110, 'API Gateway', 18),
makeArrow(440, 125, 490, 125),
// Services
makeHandDrawnRect(490, 90, 160, 70),
makeText(520, 110, 'Services', 18),
makeArrow(570, 160, 570, 200),
// Database
makeHandDrawnRect(490, 200, 160, 70),
makeText(520, 220, 'Database', 18),
// Cache
makeHandDrawnRect(260, 200, 180, 70),
makeText(300, 220, 'Cache', 18),
makeArrow(440, 235, 490, 235),
// CDN
makeHandDrawnRect(50, 200, 160, 70),
makeText(90, 220, 'CDN', 18),
makeAddButton(300, 290, '+', 'architecture-add'),
makeText(332, 290, 'Add component...', 14),
],
};
const OPTIONS: TemplateOption[] = [
{ id: 'blank', label: 'Blank Canvas', description: 'Start with an empty canvas', icon: PenTool, elements: [] },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks with a title', icon: ListTodo, elements: [] },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks with add button', icon: ListTodo, elements: [] },
{ id: 'checklist', label: 'Checklist', description: 'Simple checklist with status', icon: CheckSquare, elements: [] },
{ id: 'list', label: 'Bullet List', description: 'Bulleted list with notes area', icon: List, elements: [] },
{ id: 'flow', label: 'Flow Chart', description: 'Simple process flow diagram', icon: ArrowRight, elements: [] },
{ id: 'kanban', label: 'Kanban Board', description: 'Three editable work columns', icon: KanbanSquare, elements: [] },
{ id: 'list', label: 'Bullet List', description: 'Bulleted list with add button', icon: List, elements: [] },
{ id: 'flow', label: 'Flow Chart', description: 'Connected process with add step', icon: ArrowRight, elements: [] },
{ id: 'kanban', label: 'Kanban Board', description: 'Three columns with add cards', icon: KanbanSquare, elements: [] },
{ id: 'meeting', label: 'Meeting Notes', description: 'Agenda, decisions, actions', icon: MessageSquare, elements: [] },
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: PanelsTopLeft, elements: [] },
{ id: 'mindmap', label: 'Mind Map', description: 'Branching idea map', icon: GitFork, elements: [] },
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: GitFork, elements: [] },
{ id: 'brainstorm', label: 'Brainstorm', description: 'Ideas around a central topic', icon: Lightbulb, elements: [] },
{ id: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: RotateCcw, elements: [] },
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opportunities, threats', icon: Shield, elements: [] },
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: Map, elements: [] },
{ id: 'timeline', label: 'Timeline', description: 'Project phases and milestones', icon: Timer, elements: [] },
{ id: 'architecture', label: 'Architecture', description: 'System components and connections', icon: Layers, elements: [] },
];
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
@@ -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>
);
};
+14 -3
View File
@@ -9,10 +9,14 @@ class ApiError extends Error {
}
async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
const headers: Record<string, string> = {};
if (options?.body) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...headers,
...options?.headers,
},
});
@@ -38,7 +42,7 @@ export const api = {
fetchApi('/drawings', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: object): Promise<Drawing> =>
fetchApi(`/drawings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string): Promise<void> =>
delete: (id: string): Promise<{ ok: boolean }> =>
fetchApi(`/drawings/${id}`, { method: 'DELETE' }),
},
revisions: {
@@ -66,8 +70,10 @@ export const api = {
},
templates: {
list: (): Promise<Template[]> => fetchApi('/templates'),
create: (data: { name: string; type: string; scope: string }): Promise<Template> =>
create: (data: { name: string; description?: string; team_id?: string; snapshot: object; metadata?: Record<string, unknown> }): Promise<Template> =>
fetchApi('/templates', { method: 'POST', body: JSON.stringify(data) }),
delete: (id: string): Promise<{ ok: boolean }> =>
fetchApi(`/templates/${id}`, { method: 'DELETE' }),
},
stats: {
get: (teamId?: string): Promise<{
@@ -88,4 +94,9 @@ export const api = {
search: {
get: (q: string): Promise<Drawing[]> => fetchApi(`/search?q=${encodeURIComponent(q)}`),
},
notifications: {
list: (): Promise<Notification[]> => fetchApi('/notifications'),
markRead: (id: string): Promise<{ ok: boolean }> => fetchApi(`/notifications/${id}/read`, { method: 'POST' }),
markAllRead: (): Promise<{ ok: boolean }> => fetchApi('/notifications/read-all', { method: 'POST' }),
},
};
+20
View File
@@ -116,3 +116,23 @@ a {
background: var(--color-primary-light);
color: var(--color-primary-darkest);
}
// ============================================
// Excalidraw UI Overrides (hand-drawn aesthetic)
// ============================================
.excalidraw {
--border-radius-md: 2px;
.context-menu {
border: 2px solid var(--color-gray-85) !important;
border-radius: 2px !important;
box-shadow: 3px 3px 0 var(--color-gray-85) !important;
}
.library-menu-items-container {
border: 2px solid var(--color-gray-85) !important;
border-radius: 2px !important;
box-shadow: 3px 3px 0 var(--color-gray-85) !important;
}
}
-53
View File
@@ -189,59 +189,6 @@ $color-danger-icon-color: #700000;
--scrollbar-thumb-hover: var(--color-gray-40);
}
// Dark Mode
@media (prefers-color-scheme: dark) {
:root {
// Grays (inverted)
--color-gray-10: #1a1a1a;
--color-gray-20: #2d2d2d;
--color-gray-30: #3d3d3d;
--color-gray-40: #5c5c5c;
--color-gray-50: #7a7a7a;
--color-gray-60: #999999;
--color-gray-70: #b8b8b8;
--color-gray-80: #d6d6d6;
--color-gray-85: #ebebeb;
--color-gray-90: #f5f5f5;
--color-gray-100: #ffffff;
--island-bg-color: #252525;
--island-bg-color-alt: #2d2d2d;
--color-surface-lowest: #121212;
--color-surface-low: #1a1a1a;
--color-surface-high: #2d2d2d;
--color-on-surface: #f5f5f5;
--color-on-primary-container: #e3e2fe;
--color-muted: var(--color-gray-50);
--color-muted-darker: var(--color-gray-60);
--input-bg-color: #2d2d2d;
--input-border-color: #3d3d3d;
--input-hover-bg-color: #363636;
--input-label-color: var(--color-gray-70);
--default-border-color: #3d3d3d;
--dialog-border-color: #2d2d2d;
--text-primary-color: #f5f5f5;
--link-color: #74c0fc;
--link-color-hover: #a5d8ff;
--scrollbar-thumb: var(--color-gray-40);
--scrollbar-thumb-hover: var(--color-gray-60);
// Shadows need less opacity in dark mode
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.15);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.35);
}
}
// Manual dark mode override
[data-theme="dark"] {
// Grays (inverted)
+9 -6
View File
@@ -156,15 +156,18 @@ export interface DrawingAsset {
// ============================================
export type TemplateScope = 'system' | 'team' | 'personal';
export type TemplateType =
| 'todo'
| 'kanban'
| 'brainstorm'
| 'flowchart'
| 'meeting-notes'
export type TemplateType =
| 'todo'
| 'kanban'
| 'brainstorm'
| 'flowchart'
| 'meeting-notes'
| 'architecture'
| 'mindmap'
| 'wireframe'
| 'retrospective'
| 'swot'
| 'storymap'
| 'empty';
export interface Template {