feat: implement recent snaps feature with import/export functionality and UI enhancements
This commit is contained in:
+75
-2
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
import Canvas from './components/Canvas';
|
import Canvas from './components/Canvas';
|
||||||
import TopBar from './components/TopBar';
|
import TopBar from './components/TopBar';
|
||||||
@@ -7,10 +7,12 @@ import Inspector from './components/Inspector';
|
|||||||
import LayersPanel from './components/LayersPanel';
|
import LayersPanel from './components/LayersPanel';
|
||||||
import FontLoader from './components/FontLoader';
|
import FontLoader from './components/FontLoader';
|
||||||
import { useCanvasStore } from './store/canvasStore';
|
import { useCanvasStore } from './store/canvasStore';
|
||||||
|
import { useRecentSnapsStore } from './store/recentSnapsStore';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const stageRef = useRef<Konva.Stage>(null);
|
const stageRef = useRef<Konva.Stage>(null);
|
||||||
const {
|
const {
|
||||||
|
snap,
|
||||||
deleteElement,
|
deleteElement,
|
||||||
duplicateElement,
|
duplicateElement,
|
||||||
selectedElementId,
|
selectedElementId,
|
||||||
@@ -22,13 +24,84 @@ function App() {
|
|||||||
showGrid,
|
showGrid,
|
||||||
setTool,
|
setTool,
|
||||||
selectElement,
|
selectElement,
|
||||||
|
newSnap,
|
||||||
|
importSnap,
|
||||||
|
exportSnap,
|
||||||
|
saveToHistory,
|
||||||
} = useCanvasStore();
|
} = 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
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const isMeta = e.metaKey || e.ctrlKey;
|
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
|
// Delete
|
||||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||||
if (selectedElementId && !isInputFocused()) {
|
if (selectedElementId && !isInputFocused()) {
|
||||||
@@ -103,7 +176,7 @@ function App() {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('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 (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-neutral-900 text-white">
|
<div className="h-screen flex flex-col bg-neutral-900 text-white">
|
||||||
|
|||||||
+11
-11
@@ -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 { Stage, Layer, Rect, Line, Text } from 'react-konva';
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
import { useCanvasStore, createCodeElement, createTextElement, createArrowElement } from '../store/canvasStore';
|
import { useCanvasStore, createCodeElement, createTextElement, createArrowElement } from '../store/canvasStore';
|
||||||
@@ -112,8 +112,8 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Grid overlay
|
// Grid overlay - memoized for performance
|
||||||
const renderGrid = () => {
|
const gridLines = useMemo(() => {
|
||||||
if (!showGrid) return null;
|
if (!showGrid) return null;
|
||||||
const gridSize = 50;
|
const gridSize = 50;
|
||||||
const lines = [];
|
const lines = [];
|
||||||
@@ -143,10 +143,10 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <>{lines}</>;
|
return <>{lines}</>;
|
||||||
};
|
}, [showGrid, width, height]);
|
||||||
|
|
||||||
// Brand strip
|
// Brand strip - memoized
|
||||||
const renderBrandStrip = () => {
|
const brandStripElement = useMemo(() => {
|
||||||
const brandStrip = background.brandStrip;
|
const brandStrip = background.brandStrip;
|
||||||
if (!brandStrip?.enabled) return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -200,8 +200,8 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
>
|
>
|
||||||
<Layer>
|
<Layer>
|
||||||
{renderBackground()}
|
{renderBackground()}
|
||||||
{renderBrandStrip()}
|
{brandStripElement}
|
||||||
{renderGrid()}
|
{gridLines}
|
||||||
|
|
||||||
{snap.elements.map((element) => {
|
{snap.elements.map((element) => {
|
||||||
if (!element.visible) return null;
|
if (!element.visible) return null;
|
||||||
@@ -247,4 +247,4 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Canvas;
|
export default memo(Canvas);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { memo, useMemo, useCallback } from 'react';
|
||||||
import { useCanvasStore } from '../store/canvasStore';
|
import { useCanvasStore } from '../store/canvasStore';
|
||||||
import type { CodeElement, TextElement, ArrowElement } from '../types';
|
import type { CodeElement, TextElement, ArrowElement } from '../types';
|
||||||
import BackgroundPanel from './inspector/BackgroundPanel';
|
import BackgroundPanel from './inspector/BackgroundPanel';
|
||||||
@@ -16,7 +16,34 @@ const Inspector: React.FC = () => {
|
|||||||
moveElementDown,
|
moveElementDown,
|
||||||
} = useCanvasStore();
|
} = 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 (
|
return (
|
||||||
<div className="w-80 bg-[#09090b] border-l border-white/5 overflow-y-auto h-full">
|
<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>
|
</h3>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<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"
|
className="p-1.5 hover:bg-white/10 rounded-md text-neutral-400 hover:text-white transition-colors"
|
||||||
title="Duplicate (⌘D)"
|
title="Duplicate (⌘D)"
|
||||||
>
|
>
|
||||||
@@ -40,7 +67,7 @@ const Inspector: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<div className="w-px h-3 bg-white/10 mx-1" />
|
<div className="w-px h-3 bg-white/10 mx-1" />
|
||||||
<button
|
<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"
|
className="p-1.5 hover:bg-red-500/10 hover:text-red-500 rounded-md text-neutral-400 transition-colors"
|
||||||
title="Delete (⌫)"
|
title="Delete (⌫)"
|
||||||
>
|
>
|
||||||
@@ -54,7 +81,7 @@ const Inspector: React.FC = () => {
|
|||||||
{/* Layer Controls */}
|
{/* Layer Controls */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<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"
|
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">
|
<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
|
Send Backward
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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">
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -98,4 +125,4 @@ const Inspector: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default Inspector;
|
export default memo(Inspector);
|
||||||
|
|||||||
+133
-86
@@ -1,20 +1,21 @@
|
|||||||
import React from 'react';
|
import React, { memo, useMemo, useCallback } from 'react';
|
||||||
import { useCanvasStore } from '../store/canvasStore';
|
import { useCanvasStore } from '../store/canvasStore';
|
||||||
import type { CanvasElement } from '../types';
|
import type { CanvasElement } from '../types';
|
||||||
|
|
||||||
const LayersPanel: React.FC = () => {
|
// Memoized layer item component for performance
|
||||||
const {
|
const LayerItem = memo(({
|
||||||
snap,
|
element,
|
||||||
selectedElementId,
|
isSelected,
|
||||||
selectElement,
|
onSelect,
|
||||||
updateElement,
|
onToggleLock,
|
||||||
moveElementUp,
|
onToggleVisibility
|
||||||
moveElementDown,
|
}: {
|
||||||
deleteElement,
|
element: CanvasElement;
|
||||||
} = useCanvasStore();
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
const elements = [...snap.elements].reverse(); // Show top layers first
|
onToggleLock: () => void;
|
||||||
|
onToggleVisibility: () => void;
|
||||||
|
}) => {
|
||||||
const getElementIcon = (type: CanvasElement['type']) => {
|
const getElementIcon = (type: CanvasElement['type']) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'code':
|
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 (
|
return (
|
||||||
<div className="w-56 bg-[#09090b] border-r border-white/5 flex flex-col h-full">
|
<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">
|
<div className="p-4 border-b border-white/5">
|
||||||
@@ -65,75 +173,14 @@ const LayersPanel: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{elements.map((element) => (
|
{elements.map((element) => (
|
||||||
<div
|
<LayerItem
|
||||||
key={element.id}
|
key={element.id}
|
||||||
onClick={() => selectElement(element.id)}
|
element={element}
|
||||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
|
isSelected={selectedElementId === element.id}
|
||||||
selectedElementId === element.id
|
onSelect={() => selectElement(element.id)}
|
||||||
? 'bg-blue-600/20 border border-blue-500/50'
|
onToggleLock={() => handleToggleLock(element.id, element.locked)}
|
||||||
: 'hover:bg-white/5 border border-transparent'
|
onToggleVisibility={() => handleToggleVisibility(element.id, element.visible)}
|
||||||
}`}
|
/>
|
||||||
>
|
|
||||||
{/* 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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -145,7 +192,7 @@ const LayersPanel: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => moveElementDown(selectedElementId)}
|
onClick={handleMoveDown}
|
||||||
className="p-1.5 rounded-md hover:bg-white/10 text-neutral-400 hover:text-white transition-colors"
|
className="p-1.5 rounded-md hover:bg-white/10 text-neutral-400 hover:text-white transition-colors"
|
||||||
title="Move Down (Back)"
|
title="Move Down (Back)"
|
||||||
>
|
>
|
||||||
@@ -154,7 +201,7 @@ const LayersPanel: React.FC = () => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="p-1.5 rounded-md hover:bg-white/10 text-neutral-400 hover:text-white transition-colors"
|
||||||
title="Move Up (Front)"
|
title="Move Up (Front)"
|
||||||
>
|
>
|
||||||
@@ -164,7 +211,7 @@ const LayersPanel: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
className="p-1.5 rounded-md hover:bg-red-500/10 text-neutral-400 hover:text-red-400 transition-colors"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
@@ -179,4 +226,4 @@ const LayersPanel: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LayersPanel;
|
export default memo(LayersPanel);
|
||||||
|
|||||||
@@ -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
@@ -1,13 +1,18 @@
|
|||||||
import React from 'react';
|
import React, { useState, useRef, useCallback, memo } from 'react';
|
||||||
import { useCanvasStore } from '../store/canvasStore';
|
import { useCanvasStore } from '../store/canvasStore';
|
||||||
|
import { useRecentSnapsStore } from '../store/recentSnapsStore';
|
||||||
import { ASPECT_RATIOS } from '../types';
|
import { ASPECT_RATIOS } from '../types';
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
|
import RecentSnapsDropdown from './RecentSnapsDropdown';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
stageRef: React.RefObject<Konva.Stage | null>;
|
stageRef: React.RefObject<Konva.Stage | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||||
|
const [showRecentSnaps, setShowRecentSnaps] = useState(false);
|
||||||
|
const recentButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
snap,
|
snap,
|
||||||
updateMeta,
|
updateMeta,
|
||||||
@@ -17,15 +22,22 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
|||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
history,
|
history,
|
||||||
|
saveToHistory,
|
||||||
} = useCanvasStore();
|
} = useCanvasStore();
|
||||||
|
|
||||||
|
const { addRecentSnap } = useRecentSnapsStore();
|
||||||
|
|
||||||
const handleNewSnap = () => {
|
const handleNewSnap = useCallback(() => {
|
||||||
if (confirm('Create a new canvas? Unsaved changes will be lost.')) {
|
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 });
|
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);
|
const ratio = ASPECT_RATIOS.find(r => r.name === e.target.value);
|
||||||
if (ratio) {
|
if (ratio) {
|
||||||
updateMeta({
|
updateMeta({
|
||||||
@@ -34,9 +46,9 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
|||||||
height: ratio.height,
|
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;
|
const stage = stageRef.current;
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
|
|
||||||
@@ -64,9 +76,9 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
|||||||
link.download = `${snap.meta.title || 'canvas'}.${format}`;
|
link.download = `${snap.meta.title || 'canvas'}.${format}`;
|
||||||
link.href = dataUrl;
|
link.href = dataUrl;
|
||||||
link.click();
|
link.click();
|
||||||
};
|
}, [stageRef, snap.meta]);
|
||||||
|
|
||||||
const handleExportJSON = () => {
|
const handleExportJSON = useCallback(() => {
|
||||||
const json = exportSnap();
|
const json = exportSnap();
|
||||||
const blob = new Blob([json], { type: 'application/json' });
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -75,15 +87,23 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
|||||||
link.href = url;
|
link.href = url;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
|
||||||
|
// Save to recent snaps when exporting
|
||||||
|
addRecentSnap(snap);
|
||||||
|
}, [exportSnap, snap, addRecentSnap]);
|
||||||
|
|
||||||
const handleImportJSON = () => {
|
const handleImportJSON = useCallback(() => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
input.accept = '.json';
|
input.accept = '.json';
|
||||||
input.onchange = (e) => {
|
input.onchange = (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
|
// Save current snap to recent before importing
|
||||||
|
if (snap.elements.length > 0) {
|
||||||
|
addRecentSnap(snap);
|
||||||
|
}
|
||||||
|
saveToHistory();
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
const json = e.target?.result as string;
|
const json = e.target?.result as string;
|
||||||
@@ -93,7 +113,11 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
};
|
}, [snap, addRecentSnap, saveToHistory, importSnap]);
|
||||||
|
|
||||||
|
const toggleRecentSnaps = useCallback(() => {
|
||||||
|
setShowRecentSnaps((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
<button
|
||||||
onClick={handleImportJSON}
|
onClick={handleImportJSON}
|
||||||
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-white/5 transition-all active:scale-95"
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,4 +272,4 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TopBar;
|
export default memo(TopBar);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user