mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-04 06:12:56 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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">×</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;
|
||||
|
||||
@@ -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">×</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; }
|
||||
}
|
||||
|
||||
@@ -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">×</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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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' }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user