feat: implement recent snaps feature with import/export functionality and UI enhancements

This commit is contained in:
2026-01-07 18:09:39 +02:00
parent 955d54d2f2
commit d642b7b5da
7 changed files with 577 additions and 119 deletions
+75 -2
View File
@@ -1,4 +1,4 @@
import { useRef, useEffect } from 'react';
import { useRef, useEffect, useCallback } from 'react';
import type Konva from 'konva';
import Canvas from './components/Canvas';
import TopBar from './components/TopBar';
@@ -7,10 +7,12 @@ import Inspector from './components/Inspector';
import LayersPanel from './components/LayersPanel';
import FontLoader from './components/FontLoader';
import { useCanvasStore } from './store/canvasStore';
import { useRecentSnapsStore } from './store/recentSnapsStore';
function App() {
const stageRef = useRef<Konva.Stage>(null);
const {
snap,
deleteElement,
duplicateElement,
selectedElementId,
@@ -22,13 +24,84 @@ function App() {
showGrid,
setTool,
selectElement,
newSnap,
importSnap,
exportSnap,
saveToHistory,
} = useCanvasStore();
const { addRecentSnap } = useRecentSnapsStore();
// Handle file import
const handleImportFile = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
// Save current snap to recent before importing
if (snap.elements.length > 0) {
addRecentSnap(snap);
}
saveToHistory();
const reader = new FileReader();
reader.onload = (evt) => {
const json = evt.target?.result as string;
importSnap(json);
};
reader.readAsText(file);
}
};
input.click();
}, [snap, addRecentSnap, saveToHistory, importSnap]);
// Handle file export
const handleExportFile = useCallback(() => {
const json = exportSnap();
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = `${snap.meta.title || 'canvas'}.json`;
link.href = url;
link.click();
URL.revokeObjectURL(url);
addRecentSnap(snap);
}, [exportSnap, snap, addRecentSnap]);
// Handle new snap
const handleNewSnap = useCallback(() => {
if (confirm('Create a new canvas? Unsaved changes will be lost.')) {
if (snap.elements.length > 0) {
addRecentSnap(snap);
}
newSnap({ title: 'Untitled', aspect: '16:9', width: 1920, height: 1080 });
}
}, [snap, addRecentSnap, newSnap]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isMeta = e.metaKey || e.ctrlKey;
// New snap (⌘N)
if (isMeta && e.key === 'n') {
e.preventDefault();
handleNewSnap();
}
// Open file (⌘O)
if (isMeta && e.key === 'o') {
e.preventDefault();
handleImportFile();
}
// Save/Export (⌘S)
if (isMeta && e.key === 's') {
e.preventDefault();
handleExportFile();
}
// Delete
if (e.key === 'Backspace' || e.key === 'Delete') {
if (selectedElementId && !isInputFocused()) {
@@ -103,7 +176,7 @@ function App() {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedElementId, deleteElement, duplicateElement, undo, redo, zoom, setZoom, showGrid, setShowGrid, setTool, selectElement]);
}, [selectedElementId, deleteElement, duplicateElement, undo, redo, zoom, setZoom, showGrid, setShowGrid, setTool, selectElement, handleNewSnap, handleImportFile, handleExportFile]);
return (
<div className="h-screen flex flex-col bg-neutral-900 text-white">
+11 -11
View File
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import React, { useRef, useState, useEffect, useCallback, useMemo, memo } from 'react';
import { Stage, Layer, Rect, Line, Text } from 'react-konva';
import type Konva from 'konva';
import { useCanvasStore, createCodeElement, createTextElement, createArrowElement } from '../store/canvasStore';
@@ -112,8 +112,8 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
);
};
// Grid overlay
const renderGrid = () => {
// Grid overlay - memoized for performance
const gridLines = useMemo(() => {
if (!showGrid) return null;
const gridSize = 50;
const lines = [];
@@ -143,10 +143,10 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
}
return <>{lines}</>;
};
}, [showGrid, width, height]);
// Brand strip
const renderBrandStrip = () => {
// Brand strip - memoized
const brandStripElement = useMemo(() => {
const brandStrip = background.brandStrip;
if (!brandStrip?.enabled) return null;
@@ -178,9 +178,9 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
)}
</>
);
};
}, [background.brandStrip, width, height]);
const stagePosition = getStagePosition();
const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]);
return (
<div
@@ -200,8 +200,8 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
>
<Layer>
{renderBackground()}
{renderBrandStrip()}
{renderGrid()}
{brandStripElement}
{gridLines}
{snap.elements.map((element) => {
if (!element.visible) return null;
@@ -247,4 +247,4 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
);
};
export default Canvas;
export default memo(Canvas);
+34 -7
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { memo, useMemo, useCallback } from 'react';
import { useCanvasStore } from '../store/canvasStore';
import type { CodeElement, TextElement, ArrowElement } from '../types';
import BackgroundPanel from './inspector/BackgroundPanel';
@@ -16,7 +16,34 @@ const Inspector: React.FC = () => {
moveElementDown,
} = useCanvasStore();
const selectedElement = snap.elements.find(el => el.id === selectedElementId);
const selectedElement = useMemo(
() => snap.elements.find(el => el.id === selectedElementId),
[snap.elements, selectedElementId]
);
const handleDelete = useCallback(() => {
if (selectedElement) {
deleteElement(selectedElement.id);
}
}, [selectedElement, deleteElement]);
const handleDuplicate = useCallback(() => {
if (selectedElement) {
duplicateElement(selectedElement.id);
}
}, [selectedElement, duplicateElement]);
const handleMoveUp = useCallback(() => {
if (selectedElement) {
moveElementUp(selectedElement.id);
}
}, [selectedElement, moveElementUp]);
const handleMoveDown = useCallback(() => {
if (selectedElement) {
moveElementDown(selectedElement.id);
}
}, [selectedElement, moveElementDown]);
return (
<div className="w-80 bg-[#09090b] border-l border-white/5 overflow-y-auto h-full">
@@ -30,7 +57,7 @@ const Inspector: React.FC = () => {
</h3>
<div className="flex items-center gap-1">
<button
onClick={() => duplicateElement(selectedElement.id)}
onClick={handleDuplicate}
className="p-1.5 hover:bg-white/10 rounded-md text-neutral-400 hover:text-white transition-colors"
title="Duplicate (⌘D)"
>
@@ -40,7 +67,7 @@ const Inspector: React.FC = () => {
</button>
<div className="w-px h-3 bg-white/10 mx-1" />
<button
onClick={() => deleteElement(selectedElement.id)}
onClick={handleDelete}
className="p-1.5 hover:bg-red-500/10 hover:text-red-500 rounded-md text-neutral-400 transition-colors"
title="Delete (⌫)"
>
@@ -54,7 +81,7 @@ const Inspector: React.FC = () => {
{/* Layer Controls */}
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => moveElementDown(selectedElement.id)}
onClick={handleMoveDown}
className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-xs font-medium text-neutral-400 hover:text-white transition-colors border border-white/5"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -63,7 +90,7 @@ const Inspector: React.FC = () => {
Send Backward
</button>
<button
onClick={() => moveElementUp(selectedElement.id)}
onClick={handleMoveUp}
className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-xs font-medium text-neutral-400 hover:text-white transition-colors border border-white/5"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -98,4 +125,4 @@ const Inspector: React.FC = () => {
</div>
);
};
export default Inspector;
export default memo(Inspector);
+133 -86
View File
@@ -1,20 +1,21 @@
import React from 'react';
import React, { memo, useMemo, useCallback } from 'react';
import { useCanvasStore } from '../store/canvasStore';
import type { CanvasElement } from '../types';
const LayersPanel: React.FC = () => {
const {
snap,
selectedElementId,
selectElement,
updateElement,
moveElementUp,
moveElementDown,
deleteElement,
} = useCanvasStore();
const elements = [...snap.elements].reverse(); // Show top layers first
// Memoized layer item component for performance
const LayerItem = memo(({
element,
isSelected,
onSelect,
onToggleLock,
onToggleVisibility
}: {
element: CanvasElement;
isSelected: boolean;
onSelect: () => void;
onToggleLock: () => void;
onToggleVisibility: () => void;
}) => {
const getElementIcon = (type: CanvasElement['type']) => {
switch (type) {
case 'code':
@@ -49,6 +50,113 @@ const LayersPanel: React.FC = () => {
}
};
return (
<div
onClick={onSelect}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
isSelected
? 'bg-blue-600/20 border border-blue-500/50'
: 'hover:bg-white/5 border border-transparent'
}`}
>
{/* Element type icon */}
<div className={`shrink-0 ${isSelected ? 'text-blue-400' : 'text-neutral-400'}`}>
{getElementIcon(element.type)}
</div>
{/* Element name */}
<span className={`flex-1 text-sm truncate ${
isSelected ? 'text-white' : 'text-neutral-300'
} ${!element.visible ? 'opacity-50' : ''}`}>
{getElementLabel(element)}
</span>
{/* Action buttons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{/* Lock toggle */}
<button
onClick={(e) => {
e.stopPropagation();
onToggleLock();
}}
className={`p-1 rounded hover:bg-white/10 transition-colors ${
element.locked ? 'text-yellow-400' : 'text-neutral-500 hover:text-neutral-300'
}`}
title={element.locked ? 'Unlock' : 'Lock'}
>
{element.locked ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
)}
</button>
{/* Visibility toggle */}
<button
onClick={(e) => {
e.stopPropagation();
onToggleVisibility();
}}
className={`p-1 rounded hover:bg-white/10 transition-colors ${
element.visible ? 'text-neutral-500 hover:text-neutral-300' : 'text-red-400'
}`}
title={element.visible ? 'Hide' : 'Show'}
>
{element.visible ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
)}
</button>
</div>
</div>
);
});
LayerItem.displayName = 'LayerItem';
const LayersPanel: React.FC = () => {
const {
snap,
selectedElementId,
selectElement,
updateElement,
moveElementUp,
moveElementDown,
deleteElement,
} = useCanvasStore();
const elements = useMemo(() => [...snap.elements].reverse(), [snap.elements]); // Show top layers first
const handleToggleLock = useCallback((id: string, locked: boolean) => {
updateElement(id, { locked: !locked });
}, [updateElement]);
const handleToggleVisibility = useCallback((id: string, visible: boolean) => {
updateElement(id, { visible: !visible });
}, [updateElement]);
const handleMoveUp = useCallback(() => {
if (selectedElementId) moveElementUp(selectedElementId);
}, [selectedElementId, moveElementUp]);
const handleMoveDown = useCallback(() => {
if (selectedElementId) moveElementDown(selectedElementId);
}, [selectedElementId, moveElementDown]);
const handleDelete = useCallback(() => {
if (selectedElementId) deleteElement(selectedElementId);
}, [selectedElementId, deleteElement]);
return (
<div className="w-56 bg-[#09090b] border-r border-white/5 flex flex-col h-full">
<div className="p-4 border-b border-white/5">
@@ -65,75 +173,14 @@ const LayersPanel: React.FC = () => {
) : (
<div className="space-y-1">
{elements.map((element) => (
<div
<LayerItem
key={element.id}
onClick={() => selectElement(element.id)}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
selectedElementId === element.id
? 'bg-blue-600/20 border border-blue-500/50'
: 'hover:bg-white/5 border border-transparent'
}`}
>
{/* Element type icon */}
<div className={`shrink-0 ${selectedElementId === element.id ? 'text-blue-400' : 'text-neutral-400'}`}>
{getElementIcon(element.type)}
</div>
{/* Element name */}
<span className={`flex-1 text-sm truncate ${
selectedElementId === element.id ? 'text-white' : 'text-neutral-300'
} ${!element.visible ? 'opacity-50' : ''}`}>
{getElementLabel(element)}
</span>
{/* Action buttons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{/* Lock toggle */}
<button
onClick={(e) => {
e.stopPropagation();
updateElement(element.id, { locked: !element.locked });
}}
className={`p-1 rounded hover:bg-white/10 transition-colors ${
element.locked ? 'text-yellow-400' : 'text-neutral-500 hover:text-neutral-300'
}`}
title={element.locked ? 'Unlock' : 'Lock'}
>
{element.locked ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
)}
</button>
{/* Visibility toggle */}
<button
onClick={(e) => {
e.stopPropagation();
updateElement(element.id, { visible: !element.visible });
}}
className={`p-1 rounded hover:bg-white/10 transition-colors ${
element.visible ? 'text-neutral-500 hover:text-neutral-300' : 'text-red-400'
}`}
title={element.visible ? 'Hide' : 'Show'}
>
{element.visible ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
)}
</button>
</div>
</div>
element={element}
isSelected={selectedElementId === element.id}
onSelect={() => selectElement(element.id)}
onToggleLock={() => handleToggleLock(element.id, element.locked)}
onToggleVisibility={() => handleToggleVisibility(element.id, element.visible)}
/>
))}
</div>
)}
@@ -145,7 +192,7 @@ const LayersPanel: React.FC = () => {
<div className="flex items-center justify-between gap-2">
<div className="flex gap-1">
<button
onClick={() => moveElementDown(selectedElementId)}
onClick={handleMoveDown}
className="p-1.5 rounded-md hover:bg-white/10 text-neutral-400 hover:text-white transition-colors"
title="Move Down (Back)"
>
@@ -154,7 +201,7 @@ const LayersPanel: React.FC = () => {
</svg>
</button>
<button
onClick={() => moveElementUp(selectedElementId)}
onClick={handleMoveUp}
className="p-1.5 rounded-md hover:bg-white/10 text-neutral-400 hover:text-white transition-colors"
title="Move Up (Front)"
>
@@ -164,7 +211,7 @@ const LayersPanel: React.FC = () => {
</button>
</div>
<button
onClick={() => deleteElement(selectedElementId)}
onClick={handleDelete}
className="p-1.5 rounded-md hover:bg-red-500/10 text-neutral-400 hover:text-red-400 transition-colors"
title="Delete"
>
@@ -179,4 +226,4 @@ const LayersPanel: React.FC = () => {
);
};
export default LayersPanel;
export default memo(LayersPanel);
+161
View File
@@ -0,0 +1,161 @@
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
import { useRecentSnapsStore, formatRelativeTime, type RecentSnapEntry } from '../store/recentSnapsStore';
import { useCanvasStore } from '../store/canvasStore';
interface RecentSnapsDropdownProps {
isOpen: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLElement>;
}
const RecentSnapItem = memo(({
entry,
onOpen,
onDelete
}: {
entry: RecentSnapEntry;
onOpen: (snap: RecentSnapEntry) => void;
onDelete: (id: string) => void;
}) => {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={() => onOpen(entry)}
>
{/* Thumbnail Preview */}
<div
className="w-12 h-9 rounded bg-neutral-800 border border-white/10 flex items-center justify-center overflow-hidden flex-shrink-0"
style={{
background: entry.snap.background.type === 'gradient'
? `linear-gradient(${entry.snap.background.gradient.angle}deg, ${entry.snap.background.gradient.from}, ${entry.snap.background.gradient.to})`
: entry.snap.background.solid.color
}}
>
<span className="text-[8px] text-white/40">{entry.snap.elements.length} el</span>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="text-sm text-neutral-200 truncate font-medium">{entry.title}</div>
<div className="text-xs text-neutral-500">{formatRelativeTime(entry.savedAt)}</div>
</div>
{/* Actions */}
{isHovered && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(entry.id);
}}
className="p-1 rounded hover:bg-red-500/20 text-neutral-500 hover:text-red-400 transition-colors"
title="Remove from recent"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
);
});
RecentSnapItem.displayName = 'RecentSnapItem';
const RecentSnapsDropdown: React.FC<RecentSnapsDropdownProps> = ({ isOpen, onClose, anchorRef }) => {
const dropdownRef = useRef<HTMLDivElement>(null);
const { recentSnaps, removeRecentSnap, clearRecentSnaps } = useRecentSnapsStore();
const { setSnap, saveToHistory } = useCanvasStore();
// Close on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
anchorRef.current &&
!anchorRef.current.contains(e.target as Node)
) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onClose, anchorRef]);
// Close on escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
const handleOpenSnap = useCallback((entry: RecentSnapEntry) => {
saveToHistory();
setSnap(entry.snap);
onClose();
}, [setSnap, saveToHistory, onClose]);
const handleDeleteSnap = useCallback((id: string) => {
removeRecentSnap(id);
}, [removeRecentSnap]);
if (!isOpen) return null;
return (
<div
ref={dropdownRef}
className="absolute left-0 top-full mt-2 w-72 bg-[#0a0a0c] border border-white/10 rounded-xl shadow-2xl shadow-black/50 overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200"
>
<div className="p-3 border-b border-white/5 flex items-center justify-between">
<span className="text-xs font-semibold text-neutral-500 uppercase tracking-wider">Recent Projects</span>
{recentSnaps.length > 0 && (
<button
onClick={clearRecentSnaps}
className="text-xs text-neutral-500 hover:text-red-400 transition-colors"
>
Clear all
</button>
)}
</div>
<div className="max-h-80 overflow-y-auto">
{recentSnaps.length === 0 ? (
<div className="p-6 text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-white/5 flex items-center justify-center">
<svg className="w-6 h-6 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-sm text-neutral-500">No recent projects</p>
<p className="text-xs text-neutral-600 mt-1">Projects you save will appear here</p>
</div>
) : (
<div className="p-2 space-y-1">
{recentSnaps.map((entry) => (
<RecentSnapItem
key={entry.id}
entry={entry}
onOpen={handleOpenSnap}
onDelete={handleDeleteSnap}
/>
))}
</div>
)}
</div>
</div>
);
};
export default memo(RecentSnapsDropdown);
+60 -13
View File
@@ -1,13 +1,18 @@
import React from 'react';
import React, { useState, useRef, useCallback, memo } from 'react';
import { useCanvasStore } from '../store/canvasStore';
import { useRecentSnapsStore } from '../store/recentSnapsStore';
import { ASPECT_RATIOS } from '../types';
import type Konva from 'konva';
import RecentSnapsDropdown from './RecentSnapsDropdown';
interface TopBarProps {
stageRef: React.RefObject<Konva.Stage | null>;
}
const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
const [showRecentSnaps, setShowRecentSnaps] = useState(false);
const recentButtonRef = useRef<HTMLButtonElement>(null);
const {
snap,
updateMeta,
@@ -17,15 +22,22 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
undo,
redo,
history,
saveToHistory,
} = useCanvasStore();
const handleNewSnap = () => {
const { addRecentSnap } = useRecentSnapsStore();
const handleNewSnap = useCallback(() => {
if (confirm('Create a new canvas? Unsaved changes will be lost.')) {
// Save current snap to recent before creating new
if (snap.elements.length > 0) {
addRecentSnap(snap);
}
newSnap({ title: 'Untitled', aspect: '16:9', width: 1920, height: 1080 });
}
};
}, [snap, addRecentSnap, newSnap]);
const handleAspectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const handleAspectChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
const ratio = ASPECT_RATIOS.find(r => r.name === e.target.value);
if (ratio) {
updateMeta({
@@ -34,9 +46,9 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
height: ratio.height,
});
}
};
}, [updateMeta]);
const handleExportImage = async (format: 'png' | 'jpeg', scale: number = 2) => {
const handleExportImage = useCallback(async (format: 'png' | 'jpeg', scale: number = 2) => {
const stage = stageRef.current;
if (!stage) return;
@@ -64,9 +76,9 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
link.download = `${snap.meta.title || 'canvas'}.${format}`;
link.href = dataUrl;
link.click();
};
}, [stageRef, snap.meta]);
const handleExportJSON = () => {
const handleExportJSON = useCallback(() => {
const json = exportSnap();
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -75,15 +87,23 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
link.href = url;
link.click();
URL.revokeObjectURL(url);
};
const handleImportJSON = () => {
// Save to recent snaps when exporting
addRecentSnap(snap);
}, [exportSnap, snap, addRecentSnap]);
const handleImportJSON = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
// Save current snap to recent before importing
if (snap.elements.length > 0) {
addRecentSnap(snap);
}
saveToHistory();
const reader = new FileReader();
reader.onload = (e) => {
const json = e.target?.result as string;
@@ -93,7 +113,11 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
}
};
input.click();
};
}, [snap, addRecentSnap, saveToHistory, importSnap]);
const toggleRecentSnaps = useCallback(() => {
setShowRecentSnaps((prev) => !prev);
}, []);
return (
<div className="h-16 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-40 fixed top-0 w-full">
@@ -123,12 +147,35 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
<button
onClick={handleImportJSON}
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-white/5 transition-all active:scale-95"
title="Open"
title="Open File (⌘O)"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
</svg>
</button>
{/* Recent Snaps Button */}
<div className="relative">
<button
ref={recentButtonRef}
onClick={toggleRecentSnaps}
className={`p-2 rounded-lg transition-all active:scale-95 ${
showRecentSnaps
? 'text-white bg-white/10'
: 'text-neutral-400 hover:text-white hover:bg-white/5'
}`}
title="Recent Projects"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<RecentSnapsDropdown
isOpen={showRecentSnaps}
onClose={() => setShowRecentSnaps(false)}
anchorRef={recentButtonRef as React.RefObject<HTMLElement>}
/>
</div>
</div>
</div>
@@ -225,4 +272,4 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
);
};
export default TopBar;
export default memo(TopBar);
+103
View File
@@ -0,0 +1,103 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Snap } from '../types';
export interface RecentSnapEntry {
id: string;
title: string;
thumbnail?: string;
savedAt: number;
snap: Snap;
}
interface RecentSnapsState {
recentSnaps: RecentSnapEntry[];
maxRecent: number;
// Actions
addRecentSnap: (snap: Snap, thumbnail?: string) => void;
removeRecentSnap: (id: string) => void;
clearRecentSnaps: () => void;
getRecentSnaps: () => RecentSnapEntry[];
}
export const useRecentSnapsStore = create<RecentSnapsState>()(
persist(
(set, get) => ({
recentSnaps: [],
maxRecent: 10,
addRecentSnap: (snap: Snap, thumbnail?: string) => {
const id = `snap_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const entry: RecentSnapEntry = {
id,
title: snap.meta.title || 'Untitled',
thumbnail,
savedAt: Date.now(),
snap: JSON.parse(JSON.stringify(snap)), // Deep clone
};
set((state) => {
// Remove duplicates with same title and similar content
const filtered = state.recentSnaps.filter(
(s) => s.title !== snap.meta.title
);
// Add new entry at the beginning
const updated = [entry, ...filtered];
// Keep only maxRecent entries
return {
recentSnaps: updated.slice(0, state.maxRecent),
};
});
},
removeRecentSnap: (id: string) => {
set((state) => ({
recentSnaps: state.recentSnaps.filter((s) => s.id !== id),
}));
},
clearRecentSnaps: () => {
set({ recentSnaps: [] });
},
getRecentSnaps: () => {
return get().recentSnaps;
},
}),
{
name: 'code-canvas-recent-snaps',
// Only persist essential data to keep storage size reasonable
partialize: (state) => ({
recentSnaps: state.recentSnaps.map((entry) => ({
...entry,
thumbnail: undefined, // Don't persist thumbnails to save space
})),
}),
}
)
);
// Helper to format relative time
export const formatRelativeTime = (timestamp: number): string => {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return days === 1 ? '1 day ago' : `${days} days ago`;
}
if (hours > 0) {
return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
}
if (minutes > 0) {
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
}
return 'Just now';
};