mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-07-29 15:43:47 +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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user