Add 4 card types, evidence board with red yarn strings, and 8 unique persistent boards

This commit is contained in:
airent106 committed 2026-10-13 10:55:09 -06:00
1 parent 19eb968f86
commit 2bc4f55c44
10 files changed
+1637 -332

No files matched your search

+26 -2
View File
@@ -75,7 +75,20 @@ function readDeskState() {
try {
if (fs.existsSync(DESK_FILE)) {
const raw = fs.readFileSync(DESK_FILE, 'utf-8');
return JSON.parse(raw);
const desk = JSON.parse(raw);
if (desk && (!desk.boards || typeof desk.boards !== 'object')) {
const currentBoard = desk.activeBoard || (desk.settings && desk.settings.deskTexture) || 'oak';
desk.boards = {
[currentBoard]: {
cards: desk.cards || [],
props: desk.props || [],
strokes: desk.strokes || [],
settings: desk.settings || {}
}
};
desk.activeBoard = currentBoard;
}
return desk;
}
} catch (err) {
console.error('Error reading desk state:', err);
@@ -142,17 +155,28 @@ app.get('/api/desk', (_req, res) => {
// Save desk state
app.post('/api/desk', (req, res) => {
const { cards, props, strokes, settings, originClientId } = req.body;
const { cards, props, strokes, settings, activeBoard, boards, originClientId } = req.body;
if (!Array.isArray(cards)) {
return res.status(400).json({ success: false, error: 'Invalid desk state: cards required' });
}
const currentBoard = activeBoard || (settings && settings.deskTexture) || 'oak';
const updatedBoards = boards && typeof boards === 'object' ? { ...boards } : {};
updatedBoards[currentBoard] = {
cards,
props: Array.isArray(props) ? props : [],
strokes: Array.isArray(strokes) ? strokes : [],
settings: settings || {}
};
const newState = {
cards,
props: Array.isArray(props) ? props : [],
strokes: Array.isArray(strokes) ? strokes : [],
settings: settings || {},
activeBoard: currentBoard,
boards: updatedBoards,
updatedAt: Date.now()
};
+164 -38
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { CardItem, DeskProp, DeskSettings, InkStroke, SyncStatus } from './types';
import { CardItem, DeskProp, DeskSettings, InkStroke, SyncStatus, DeskTexture, BoardsMap } from './types';
import { generateProceduralCards, SAMPLE_PRESETS } from './utils/proceduralCards';
import { soundEngine } from './utils/sound';
import { TopBar } from './components/TopBar';
@@ -8,19 +8,10 @@ import { DeskBackground } from './components/DeskTextures';
import { PaperCard } from './components/PaperCard';
import { DeskPropsLayer } from './components/DeskProps';
import { PenLayer } from './components/PenLayer';
import { EvidenceStringsLayer } from './components/EvidenceStringsLayer';
import { AddCardModal, EditCardModal } from './components/AddCardModal';
import { PortfolioModal } from './components/PortfolioModal';
const INITIAL_PROPS: DeskProp[] = [
{ id: 'prop-pencil-1', type: 'pencil', x: 120, y: 160, rotation: 38, zIndex: 12 },
{ id: 'prop-pen-1', type: 'pen', x: 890, y: 170, rotation: -28, zIndex: 15 },
{ id: 'prop-pencil-2', type: 'pencil', x: 230, y: 720, rotation: -42, zIndex: 10 },
{ id: 'prop-pen-2', type: 'pen', x: 440, y: 880, rotation: 92, zIndex: 14 },
{ id: 'prop-tape-1', type: 'tape-roll', x: 410, y: 120, rotation: 12, zIndex: 9 },
{ id: 'prop-clip-1', type: 'paperclip', x: 640, y: 760, rotation: 55, zIndex: 8 },
{ id: 'prop-clip-2', type: 'paperclip', x: 1190, y: 920, rotation: -35, zIndex: 8 },
{ id: 'prop-pin-1', type: 'pin', x: 1210, y: 130, rotation: 0, zIndex: 16 }
];
import { getDefaultBoardData, INITIAL_PROPS } from './utils/boardPresets';
export const App: React.FC = () => {
// Desk Settings matching the original Paper Desk UI
@@ -37,6 +28,7 @@ export const App: React.FC = () => {
penMode: false,
soundEnabled: true,
isLocked: false,
showEvidenceStrings: false,
activeFilter: 'all',
zoom: typeof window !== 'undefined' && window.innerWidth < 768 ? 0.65 : 1.0
});
@@ -45,6 +37,7 @@ export const App: React.FC = () => {
const [propsList, setPropsList] = useState<DeskProp[]>(INITIAL_PROPS);
const [strokes, setStrokes] = useState<InkStroke[]>([]);
const [maxZIndex, setMaxZIndex] = useState(50);
const boardsRef = useRef<BoardsMap>({});
// Modals state
const [isAddOpen, setIsAddOpen] = useState(false);
@@ -71,7 +64,9 @@ export const App: React.FC = () => {
currentProps: DeskProp[],
currentStrokes: InkStroke[],
currentSettings: DeskSettings,
immediate = false
immediate = false,
targetBoard?: DeskTexture,
targetBoards?: BoardsMap
) => {
if (!isLoadedRef.current) return;
@@ -79,6 +74,18 @@ export const App: React.FC = () => {
clearTimeout(saveTimeoutRef.current);
}
const currentBoard = targetBoard || currentSettings.deskTexture || 'oak';
const updatedBoards = targetBoards || {
...boardsRef.current,
[currentBoard]: {
cards: currentCards,
props: currentProps,
strokes: currentStrokes,
settings: currentSettings
}
};
boardsRef.current = updatedBoards;
const doSave = () => {
setSyncStatus('syncing');
fetch('/api/desk', {
@@ -88,11 +95,14 @@ export const App: React.FC = () => {
cards: currentCards,
props: currentProps,
strokes: currentStrokes,
activeBoard: currentBoard,
boards: updatedBoards,
settings: {
deskTexture: currentSettings.deskTexture,
deskTexture: currentBoard,
lightAngle: currentSettings.lightAngle,
grain: currentSettings.grain,
layoutMode: currentSettings.layoutMode
layoutMode: currentSettings.layoutMode,
showEvidenceStrings: currentSettings.showEvidenceStrings
},
originClientId: clientIdRef.current
})
@@ -122,24 +132,44 @@ export const App: React.FC = () => {
fetch('/api/desk')
.then((res) => res.json())
.then((data) => {
if (data.success && data.desk && Array.isArray(data.desk.cards) && data.desk.cards.length > 0) {
setCards(data.desk.cards);
if (Array.isArray(data.desk.props) && data.desk.props.length > 0) {
setPropsList(data.desk.props);
if (data.success && data.desk) {
const desk = data.desk;
const loadedBoards: BoardsMap = desk.boards && typeof desk.boards === 'object' ? desk.boards : {};
const activeBoard: DeskTexture = desk.activeBoard || desk.settings?.deskTexture || 'oak';
if (!loadedBoards[activeBoard] && Array.isArray(desk.cards) && desk.cards.length > 0) {
loadedBoards[activeBoard] = {
cards: desk.cards,
props: desk.props || INITIAL_PROPS,
strokes: desk.strokes || [],
settings: desk.settings || {}
};
}
if (Array.isArray(data.desk.strokes)) {
setStrokes(data.desk.strokes);
boardsRef.current = loadedBoards;
let currentBoardData = loadedBoards[activeBoard];
if (!currentBoardData || !Array.isArray(currentBoardData.cards) || currentBoardData.cards.length === 0) {
currentBoardData = activeBoard === 'oak'
? { cards: generateProceduralCards(window.innerWidth < 768 ? 9 : 20, window.innerWidth, window.innerHeight, 0.55, 'mixed'), props: INITIAL_PROPS, strokes: [] }
: getDefaultBoardData(activeBoard, window.innerWidth, window.innerHeight);
loadedBoards[activeBoard] = currentBoardData;
}
if (data.desk.settings) {
setSettings((s) => ({
...s,
deskTexture: data.desk.settings.deskTexture ?? s.deskTexture,
lightAngle: data.desk.settings.lightAngle ?? s.lightAngle,
grain: data.desk.settings.grain ?? s.grain,
layoutMode: data.desk.settings.layoutMode ?? s.layoutMode
}));
}
const maxZ = Math.max(15, ...data.desk.cards.map((c: CardItem) => c.zIndex || 1));
setCards(currentBoardData.cards || []);
if (currentBoardData.props) setPropsList(currentBoardData.props);
if (currentBoardData.strokes) setStrokes(currentBoardData.strokes);
setSettings((s) => ({
...s,
deskTexture: activeBoard,
lightAngle: desk.settings?.lightAngle ?? s.lightAngle,
grain: desk.settings?.grain ?? s.grain,
layoutMode: desk.settings?.layoutMode ?? s.layoutMode,
showEvidenceStrings: activeBoard === 'evidence-board' ? true : (desk.settings?.showEvidenceStrings ?? false)
}));
const maxZ = Math.max(15, ...(currentBoardData.cards || []).map((c: CardItem) => c.zIndex || 1));
setMaxZIndex(maxZ + 5);
setSyncStatus('synced');
isLoadedRef.current = true;
@@ -148,7 +178,7 @@ export const App: React.FC = () => {
const w = typeof window !== 'undefined' ? window.innerWidth : 1400;
const h = typeof window !== 'undefined' ? window.innerHeight : 900;
const isMobile = w < 768;
const initialCount = isMobile ? 9 : 22;
const initialCount = isMobile ? 9 : 20;
const initialCards = generateProceduralCards(
initialCount,
@@ -158,11 +188,15 @@ export const App: React.FC = () => {
settings.fontMode
);
setCards(initialCards);
const initialBoards: BoardsMap = {
oak: { cards: initialCards, props: INITIAL_PROPS, strokes: [] }
};
boardsRef.current = initialBoards;
setMaxZIndex(initialCards.length + 20);
setSyncStatus('synced');
isLoadedRef.current = true;
saveToServer(initialCards, INITIAL_PROPS, [], settings, true);
saveToServer(initialCards, INITIAL_PROPS, [], settings, true, 'oak', initialBoards);
}
})
.catch((err) => {
@@ -186,6 +220,10 @@ export const App: React.FC = () => {
const payload = JSON.parse(e.data);
if (payload.type === 'desk_updated' && payload.originClientId !== clientIdRef.current) {
if (payload.desk) {
if (payload.desk.boards && typeof payload.desk.boards === 'object') {
boardsRef.current = payload.desk.boards;
}
const activeBoard = payload.desk.activeBoard || (payload.desk.settings && payload.desk.settings.deskTexture) || 'oak';
if (Array.isArray(payload.desk.cards)) {
setCards(payload.desk.cards);
const maxZ = Math.max(15, ...payload.desk.cards.map((c: CardItem) => c.zIndex || 1));
@@ -196,10 +234,11 @@ export const App: React.FC = () => {
if (payload.desk.settings) {
setSettings((prev) => ({
...prev,
deskTexture: payload.desk.settings.deskTexture ?? prev.deskTexture,
deskTexture: activeBoard,
lightAngle: payload.desk.settings.lightAngle ?? prev.lightAngle,
grain: payload.desk.settings.grain ?? prev.grain,
layoutMode: payload.desk.settings.layoutMode ?? prev.layoutMode
layoutMode: payload.desk.settings.layoutMode ?? prev.layoutMode,
showEvidenceStrings: payload.desk.settings.showEvidenceStrings ?? prev.showEvidenceStrings
}));
}
setSyncStatus('synced');
@@ -221,11 +260,83 @@ export const App: React.FC = () => {
};
}, []);
// Update card count when slider changes
// Update desk settings or switch unique workspace board
const updateSettings = (newSettings: Partial<DeskSettings>) => {
setSettings((prev) => {
const updated = { ...prev, ...newSettings };
// 1. Switch between 8 unique workspace boards
if (newSettings.deskTexture !== undefined && newSettings.deskTexture !== prev.deskTexture) {
const prevDesk = prev.deskTexture;
const nextDesk = newSettings.deskTexture;
// Snapshot current board state
const updatedBoards = {
...boardsRef.current,
[prevDesk]: {
cards,
props: propsList,
strokes,
settings: prev
}
};
// Load or initialize target board state
let nextBoardData = updatedBoards[nextDesk];
if (!nextBoardData || !Array.isArray(nextBoardData.cards) || nextBoardData.cards.length === 0) {
if (nextDesk === 'oak') {
const w = window.innerWidth;
const h = window.innerHeight;
const seed = generateProceduralCards(w < 768 ? 9 : 20, w, h, updated.attachChance, updated.fontMode);
nextBoardData = { cards: seed, props: INITIAL_PROPS, strokes: [] };
} else {
nextBoardData = getDefaultBoardData(nextDesk, window.innerWidth, window.innerHeight);
}
updatedBoards[nextDesk] = nextBoardData;
}
boardsRef.current = updatedBoards;
setCards(nextBoardData.cards || []);
setPropsList(nextBoardData.props || INITIAL_PROPS);
setStrokes(nextBoardData.strokes || []);
const nextEvidenceStrings = nextDesk === 'evidence-board' ? true : (newSettings.showEvidenceStrings ?? false);
updated.deskTexture = nextDesk;
updated.showEvidenceStrings = nextEvidenceStrings;
saveToServer(
nextBoardData.cards || [],
nextBoardData.props || INITIAL_PROPS,
nextBoardData.strokes || [],
updated,
true,
nextDesk,
updatedBoards
);
soundEngine.rustle(updated.soundEnabled);
}
// 2. Real-time typography mode update across desk cards
if (newSettings.fontMode !== undefined && newSettings.fontMode !== prev.fontMode) {
const mode = newSettings.fontMode;
setCards((prevCards) => {
const next = prevCards.map((c) => {
const fonts = mode === 'handwritten'
? ['font-caveat', 'font-reenie', 'font-indie', 'font-shadows']
: mode === 'typed'
? ['font-courier', 'font-special', 'font-mono']
: ['font-caveat', 'font-courier', 'font-reenie', 'font-special', 'font-indie', 'font-mono'];
const chosen = fonts[Math.floor(Math.random() * fonts.length)];
return { ...c, fontFamily: chosen };
});
saveToServer(next, propsList, strokes, updated, false);
return next;
});
}
// 3. Card density adjustment
if (newSettings.cardCount !== undefined && newSettings.cardCount !== prev.cardCount) {
const w = window.innerWidth;
const h = window.innerHeight;
@@ -238,6 +349,7 @@ export const App: React.FC = () => {
}
}
// 4. Attach chance adjustment
if (newSettings.attachChance !== undefined && newSettings.attachChance !== prev.attachChance) {
setCards((c) =>
c.map((card) => {
@@ -761,6 +873,12 @@ export const App: React.FC = () => {
/>
))}
{/* Evidence Board Red Yarn Connections Layer */}
<EvidenceStringsLayer
cards={visibleCards}
enabled={settings.showEvidenceStrings}
/>
{/* 4. Stationery Props Layer */}
{settings.showProps && (
<DeskPropsLayer
@@ -825,14 +943,22 @@ export const App: React.FC = () => {
id: `card-custom-${Date.now()}`,
type: newCard.type || 'sticky',
title: newCard.title,
subtitle: newCard.subtitle,
content: newCard.content || '',
date: newCard.date,
tagNumber: newCard.tagNumber,
imageUrl: newCard.imageUrl,
url: newCard.url,
faviconUrl: newCard.faviconUrl,
photoCaption: newCard.photoCaption,
stamp: newCard.stamp,
stampAngle: newCard.stampAngle,
graphType: newCard.graphType,
todoItems: newCard.todoItems,
receiptItems: newCard.receiptItems,
color: newCard.color || '#FFF9A6',
x: w / 2 - 90 + (Math.random() - 0.5) * 60,
y: h / 2 - 90 + (Math.random() - 0.5) * 60,
x: w / 2 - (newCard.width || 180) / 2 + (Math.random() - 0.5) * 60,
y: h / 2 - (newCard.height || 180) / 2 + (Math.random() - 0.5) * 60,
vx: 0,
vy: 0,
rotation: (Math.random() - 0.5) * 8,
+364 -258
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { CardItem, CardType, AttachType } from '../types';
import { X } from 'lucide-react';
import { CardItem, CardType, AttachType, StampType } from '../types';
import { X, Plus, Trash2 } from 'lucide-react';
interface AddCardModalProps {
isOpen: boolean;
@@ -8,9 +8,24 @@ interface AddCardModalProps {
onAdd: (card: Partial<CardItem>) => void;
}
const CARD_TYPES: { id: CardType; label: string; icon: string }[] = [
{ id: 'sticky', label: 'Sticky Note', icon: '📝' },
{ id: 'polaroid', label: 'Photo', icon: '📷' },
{ id: 'bookmark', label: 'Bookmark', icon: '🌐' },
{ id: 'receipt', label: 'Receipt', icon: '🧾' },
{ id: 'graph', label: 'Graph / Sketch', icon: '📐' },
{ id: 'checklist', label: 'Checklist', icon: '☑️' },
{ id: 'ticket', label: 'Ticket / Pass', icon: '🎟️' },
{ id: 'tag', label: 'Luggage Tag', icon: '🏷️' },
{ id: 'torn', label: 'Torn Memo', icon: '📄' },
{ id: 'business', label: 'Cardstock', icon: '📇' },
{ id: 'moodboard', label: 'Moodboard', icon: '🎨' }
];
export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onAdd }) => {
const [type, setType] = useState<CardType>('sticky');
const [title, setTitle] = useState('');
const [subtitle, setSubtitle] = useState('');
const [content, setContent] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [bookmarkUrl, setBookmarkUrl] = useState('');
@@ -18,6 +33,29 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
const [color, setColor] = useState('#FFF9A6');
const [attachType, setAttachType] = useState<AttachType>('washi');
const [fontFamily, setFontFamily] = useState('font-caveat');
const [stamp, setStamp] = useState<StampType | 'none'>('none');
const [tagNumber, setTagNumber] = useState('');
const [dateStr, setDateStr] = useState('');
// Graph state
const [graphType, setGraphType] = useState<'barchart' | 'flowchart' | 'curve'>('curve');
// Checklist state
const [todoItems, setTodoItems] = useState<{ text: string; done: boolean }[]>([
{ text: 'Review project layout', done: true },
{ text: 'Add tactile stickers', done: false }
]);
const [newTodoText, setNewTodoText] = useState('');
// Receipt state
const [receiptItems, setReceiptItems] = useState<{ name: string; price: string }[]>([
{ name: 'FLAT WHITE', price: '4.75' },
{ name: 'CROISSANT', price: '3.50' },
{ name: 'SUBTOTAL', price: '8.25' },
{ name: 'TOTAL', price: '$8.99' }
]);
const [newReceiptName, setNewReceiptName] = useState('');
const [newReceiptPrice, setNewReceiptPrice] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
@@ -50,6 +88,21 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
}
};
const handleAddTodo = () => {
if (newTodoText.trim()) {
setTodoItems([...todoItems, { text: newTodoText.trim(), done: false }]);
setNewTodoText('');
}
};
const handleAddReceiptItem = () => {
if (newReceiptName.trim() && newReceiptPrice.trim()) {
setReceiptItems([...receiptItems, { name: newReceiptName.trim().toUpperCase(), price: newReceiptPrice.trim() }]);
setNewReceiptName('');
setNewReceiptPrice('');
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isUploading || isFetchingMeta) return;
@@ -64,22 +117,43 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
? (imageUrl.trim() || (formattedBookmarkUrl ? `https://s0.wp.com/mshots/v1/${encodeURIComponent(formattedBookmarkUrl)}?w=600` : undefined))
: undefined;
let defaultWidth = 180;
let defaultHeight = 180;
let defaultColor = color;
if (type === 'polaroid') { defaultWidth = 175; defaultHeight = 220; defaultColor = '#FFFFFF'; }
else if (type === 'bookmark') { defaultWidth = 210; defaultHeight = 250; defaultColor = '#FAF8F5'; }
else if (type === 'tag') { defaultWidth = 140; defaultHeight = 220; defaultColor = '#CBB296'; }
else if (type === 'receipt') { defaultWidth = 160; defaultHeight = 310; defaultColor = '#F9F8F4'; }
else if (type === 'graph') { defaultWidth = 220; defaultHeight = 195; defaultColor = '#F4F6F9'; }
else if (type === 'checklist') { defaultWidth = 200; defaultHeight = 240; defaultColor = '#FCFAF2'; }
else if (type === 'ticket') { defaultWidth = 200; defaultHeight = 165; defaultColor = '#F4EDE2'; }
onAdd({
type,
title: title.trim() || (type === 'bookmark' && formattedBookmarkUrl ? new URL(formattedBookmarkUrl).hostname : undefined),
subtitle: subtitle.trim() || undefined,
content: content.trim(),
date: dateStr.trim() || undefined,
tagNumber: tagNumber.trim() || undefined,
imageUrl: effectiveImageUrl,
url: type === 'bookmark' ? formattedBookmarkUrl : undefined,
faviconUrl: type === 'bookmark' ? (faviconUrl || (formattedBookmarkUrl ? `https://www.google.com/s2/favicons?domain=${encodeURIComponent(new URL(formattedBookmarkUrl).hostname)}&sz=128` : undefined)) : undefined,
photoCaption: type === 'polaroid' ? (title.trim() || content.trim()) : undefined,
color: type === 'bookmark' ? '#FAF8F5' : color,
color: defaultColor,
attachType: type === 'bookmark' ? 'washi' : attachType,
fontFamily,
width: type === 'polaroid' ? 175 : type === 'tag' ? 140 : type === 'bookmark' ? 210 : 180,
height: type === 'polaroid' ? 220 : type === 'tag' ? 220 : type === 'bookmark' ? 250 : 180
stamp: stamp !== 'none' ? stamp : undefined,
stampAngle: stamp !== 'none' ? Math.floor(Math.random() * 20 - 10) : undefined,
graphType: type === 'graph' ? graphType : undefined,
todoItems: type === 'checklist' ? todoItems : undefined,
receiptItems: type === 'receipt' ? receiptItems : undefined,
width: defaultWidth,
height: defaultHeight
});
onClose();
setTitle('');
setSubtitle('');
setContent('');
setImageUrl('');
setBookmarkUrl('');
@@ -90,7 +164,6 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
const file = e.target.files?.[0];
if (!file) return;
// Immediate preview
const reader = new FileReader();
reader.onload = (ev) => {
if (typeof ev.target?.result === 'string') {
@@ -99,7 +172,6 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
};
reader.readAsDataURL(file);
// Upload to server
setIsUploading(true);
const formData = new FormData();
formData.append('file', file);
@@ -122,40 +194,41 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-[#1C1A17] border border-neutral-700 w-full max-w-md rounded-xl p-5 text-neutral-200 shadow-2xl">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 select-none">
<div className="bg-[#1C1A17] border border-neutral-700 w-full max-w-lg rounded-xl p-5 text-neutral-200 shadow-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4 pb-2 border-b border-neutral-800">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<span>Add New Paper Card</span>
</h2>
<button onClick={onClose} className="text-neutral-400 hover:text-white">
<button onClick={onClose} className="text-neutral-400 hover:text-white p-1">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} noValidate className="space-y-4 text-xs">
{/* Card Type */}
{/* Card Type Grid */}
<div>
<label className="block text-neutral-400 mb-1">Card Type</label>
<div className="grid grid-cols-4 gap-1.5">
{(['sticky', 'polaroid', 'bookmark', 'tag', 'torn', 'business', 'moodboard'] as CardType[]).map((t) => (
<label className="block text-neutral-400 mb-1.5 font-semibold">Select Sheet Type</label>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-1.5">
{CARD_TYPES.map((t) => (
<button
type="button"
key={t}
onClick={() => setType(t)}
className={`py-1.5 rounded capitalize border transition text-center ${
type === t
key={t.id}
onClick={() => setType(t.id)}
className={`py-1.5 px-2 rounded border transition text-center flex items-center gap-1.5 justify-center ${
type === t.id
? 'bg-amber-500/20 text-amber-300 border-amber-500 font-bold'
: 'bg-neutral-800 text-neutral-400 border-neutral-700 hover:bg-neutral-700'
}`}
>
{t}
<span>{t.icon}</span>
<span className="truncate">{t.label}</span>
</button>
))}
</div>
</div>
{/* Bookmark URL input & preview fetcher */}
{/* Bookmark URL input */}
{type === 'bookmark' && (
<div className="space-y-2 p-3 bg-neutral-900/80 rounded-lg border border-neutral-800">
<div className="flex justify-between items-center text-neutral-400">
@@ -171,135 +244,211 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
type="text"
value={bookmarkUrl}
onChange={(e) => setBookmarkUrl(e.target.value)}
onBlur={() => {
if (bookmarkUrl.trim()) handleFetchBookmarkMeta(bookmarkUrl.trim());
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (bookmarkUrl.trim()) handleFetchBookmarkMeta(bookmarkUrl.trim());
}
}}
onBlur={() => { if (bookmarkUrl.trim()) handleFetchBookmarkMeta(bookmarkUrl.trim()); }}
placeholder="e.g. https://news.ycombinator.com"
className="flex-1 bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500 font-mono text-[11px]"
className="flex-1 bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 font-mono text-[11px]"
/>
<button
type="button"
onClick={() => handleFetchBookmarkMeta(bookmarkUrl.trim())}
disabled={isFetchingMeta || !bookmarkUrl.trim()}
className="px-3 py-1.5 rounded bg-amber-500 hover:bg-amber-400 active:scale-95 text-neutral-950 font-bold transition disabled:opacity-50 shrink-0"
className="px-3 py-1.5 rounded bg-amber-500 hover:bg-amber-400 text-neutral-950 font-bold transition disabled:opacity-50"
>
{isFetchingMeta ? '...' : 'Preview'}
{isFetchingMeta ? '...' : 'Fetch'}
</button>
</div>
{/* Bookmark thumbnail */}
{imageUrl && (
<div className="flex items-center gap-2.5 mt-2 bg-neutral-950/70 p-2 rounded border border-neutral-800">
<div className="w-16 aspect-[16/10] rounded border border-neutral-700 overflow-hidden relative shrink-0 bg-neutral-900">
<img src={imageUrl} alt="Thumbnail" className="w-full h-full object-cover" />
</div>
<div className="flex flex-col text-[11px] overflow-hidden min-w-0">
<span className="text-emerald-400 font-medium">✓ Snapshot Ready</span>
<span className="truncate text-neutral-400 font-mono text-[10px]">{imageUrl}</span>
</div>
</div>
)}
</div>
)}
{/* Title */}
<div>
<label className="block text-neutral-400 mb-1">Title / Header</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Brainstorm note, Project Retro"
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500"
/>
{/* Graph Type Selector */}
{type === 'graph' && (
<div className="p-3 bg-neutral-900/80 rounded-lg border border-neutral-800 space-y-2">
<label className="block text-neutral-400 font-semibold">Diagram / Chart Type</label>
<div className="grid grid-cols-3 gap-2">
{[
{ id: 'curve', label: 'Wave / Vector' },
{ id: 'barchart', label: 'Bar Chart' },
{ id: 'flowchart', label: 'Flowchart' }
].map((g) => (
<button
type="button"
key={g.id}
onClick={() => setGraphType(g.id as any)}
className={`py-1.5 px-2 rounded border text-center font-mono ${
graphType === g.id
? 'bg-amber-500/20 text-amber-300 border-amber-500 font-bold'
: 'bg-neutral-800 text-neutral-400 border-neutral-700'
}`}
>
{g.label}
</button>
))}
</div>
</div>
)}
{/* Checklist Task Builder */}
{type === 'checklist' && (
<div className="p-3 bg-neutral-900/80 rounded-lg border border-neutral-800 space-y-2">
<label className="block text-neutral-400 font-semibold">Checklist Items</label>
<div className="space-y-1.5 max-h-36 overflow-y-auto">
{todoItems.map((item, idx) => (
<div key={idx} className="flex items-center justify-between gap-2 bg-neutral-800 px-2.5 py-1 rounded">
<span className="truncate">{item.text}</span>
<button
type="button"
onClick={() => setTodoItems(todoItems.filter((_, i) => i !== idx))}
className="text-neutral-400 hover:text-red-400 p-0.5"
>
<X size={12} />
</button>
</div>
))}
</div>
<div className="flex gap-2 pt-1">
<input
type="text"
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTodo(); } }}
placeholder="Add a new task..."
className="flex-1 bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1 text-neutral-100"
/>
<button
type="button"
onClick={handleAddTodo}
className="px-3 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-white font-bold"
>
<Plus size={14} />
</button>
</div>
</div>
)}
{/* Receipt Items Builder */}
{type === 'receipt' && (
<div className="p-3 bg-neutral-900/80 rounded-lg border border-neutral-800 space-y-2 font-mono">
<label className="block text-neutral-400 font-semibold font-sans">Line Items & Prices</label>
<div className="space-y-1 max-h-32 overflow-y-auto">
{receiptItems.map((it, idx) => (
<div key={idx} className="flex items-center justify-between gap-2 bg-neutral-800 px-2 py-1 rounded text-[11px]">
<span className="truncate">{it.name}</span>
<div className="flex items-center gap-2">
<span className="text-amber-300">{it.price}</span>
<button
type="button"
onClick={() => setReceiptItems(receiptItems.filter((_, i) => i !== idx))}
className="text-neutral-400 hover:text-red-400"
>
<X size={11} />
</button>
</div>
</div>
))}
</div>
<div className="flex gap-2 pt-1">
<input
type="text"
value={newReceiptName}
onChange={(e) => setNewReceiptName(e.target.value)}
placeholder="ITEM"
className="flex-1 bg-neutral-800 border border-neutral-700 rounded px-2 py-1 uppercase text-[11px]"
/>
<input
type="text"
value={newReceiptPrice}
onChange={(e) => setNewReceiptPrice(e.target.value)}
placeholder="$0.00"
className="w-20 bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-[11px]"
/>
<button
type="button"
onClick={handleAddReceiptItem}
className="px-2.5 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-white"
>
<Plus size={14} />
</button>
</div>
</div>
)}
{/* Title & Subtitle */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label className="block text-neutral-400 mb-1">Title / Store / Subject</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={type === 'receipt' ? 'STORE NAME' : type === 'ticket' ? 'EVENT / PASS' : 'Note Title'}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500"
/>
</div>
<div>
<label className="block text-neutral-400 mb-1">Subtitle / Section / Serial</label>
<input
type="text"
value={subtitle}
onChange={(e) => setSubtitle(e.target.value)}
placeholder={type === 'ticket' ? 'SEAT 14A · VIP' : type === 'receipt' ? 'LOCATION' : 'Subtitle or Date'}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500"
/>
</div>
</div>
{/* Content */}
<div>
<label className="block text-neutral-400 mb-1">Content / Body</label>
<textarea
rows={3}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write your note or thoughts here..."
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500"
/>
</div>
{/* Content / Body */}
{type !== 'checklist' && type !== 'receipt' && (
<div>
<label className="block text-neutral-400 mb-1">Content / Description</label>
<textarea
rows={3}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write your note, specs, or thoughts here..."
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500"
/>
</div>
)}
{/* Image Upload or URL for Polaroid */}
{/* Photo upload for Polaroid */}
{type === 'polaroid' && (
<div className="space-y-2">
<div className="flex justify-between items-center text-neutral-400">
<span>Photo / Snapshot</span>
{isUploading && (
<span className="text-[10px] text-amber-400 font-mono animate-pulse">
Uploading to server...
</span>
)}
</div>
<div className="flex gap-2">
<label className="flex-1 flex items-center justify-center gap-1.5 py-1.5 px-3 bg-neutral-800 border border-neutral-700 hover:border-amber-500 rounded cursor-pointer text-amber-300 font-medium transition text-center">
<span>📸 {isUploading ? 'Uploading...' : 'Camera / Photos'}</span>
<input
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
disabled={isUploading}
/>
</label>
{isUploading && <span className="text-amber-400 animate-pulse font-mono">Uploading...</span>}
</div>
<label className="w-full flex items-center justify-center gap-1.5 py-1.5 px-3 bg-neutral-800 border border-neutral-700 hover:border-amber-500 rounded cursor-pointer text-amber-300 font-medium transition text-center">
<span>📸 Camera / Photos</span>
<input type="file" accept="image/*" onChange={handleFileUpload} className="hidden" disabled={isUploading} />
</label>
<input
type="text"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="Or paste an image URL (https://... or /uploads/...)"
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500 text-[11px]"
placeholder="Or paste an image URL"
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 font-mono text-[11px]"
/>
{imageUrl && (
<div className="flex items-center gap-2.5 mt-1 bg-neutral-900/80 p-2 rounded-lg border border-neutral-800">
<div className="w-12 h-12 rounded border border-neutral-700 overflow-hidden relative shrink-0">
<img src={imageUrl} alt="Preview" className="w-full h-full object-cover" />
</div>
<div className="flex flex-col text-[11px] overflow-hidden min-w-0">
<span className="text-emerald-400 font-medium flex items-center gap-1">
<span>✓ Photo Ready</span>
</span>
<span className="truncate text-neutral-400 font-mono text-[10px]">{imageUrl}</span>
<button
type="button"
onClick={() => setImageUrl('')}
className="text-neutral-400 hover:text-red-400 text-left underline text-[10px] mt-0.5"
>
Clear photo
</button>
</div>
</div>
)}
</div>
)}
{/* Color & Fastener */}
<div className="grid grid-cols-2 gap-3">
{/* Rubber Stamp & Fastener Controls */}
<div className="grid grid-cols-2 gap-3 pt-1 border-t border-neutral-800">
<div>
<label className="block text-neutral-400 mb-1">Paper Tint</label>
<div className="flex gap-1.5 flex-wrap">
{['#FFF9A6', '#FFDFD3', '#E0D4FC', '#BEE7F5', '#C8F7C5', '#FFFFFF', '#282C34'].map((c) => (
<button
type="button"
key={c}
onClick={() => setColor(c)}
className={`w-6 h-6 rounded-full border-2 ${color === c ? 'border-amber-400 scale-110' : 'border-neutral-600'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
<label className="block text-neutral-400 mb-1">Ink Rubber Stamp</label>
<select
value={stamp}
onChange={(e) => setStamp(e.target.value as any)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200 cursor-pointer font-special"
>
<option value="none">No Stamp</option>
<option value="SAMPLE">SAMPLE</option>
<option value="APPROVED">APPROVED</option>
<option value="DRAFT">DRAFT</option>
<option value="PROOF">PROOF</option>
<option value="CONFIDENTIAL">CONFIDENTIAL</option>
<option value="URGENT">URGENT</option>
<option value="EVIDENCE">EVIDENCE</option>
</select>
</div>
<div>
@@ -307,16 +456,32 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
<select
value={attachType}
onChange={(e) => setAttachType(e.target.value as AttachType)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200"
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200 cursor-pointer"
>
<option value="none">None</option>
<option value="washi">Washi Tape</option>
<option value="pin">Red Pushpin</option>
<option value="paperclip">Paperclip</option>
<option value="none">None (Loose)</option>
</select>
</div>
</div>
{/* Paper Tint */}
<div>
<label className="block text-neutral-400 mb-1">Paper Tint</label>
<div className="flex gap-1.5 flex-wrap">
{['#FFF9A6', '#FFDFD3', '#E0D4FC', '#BEE7F5', '#C8F7C5', '#FFFFFF', '#FAF8F5', '#E8DCC8', '#282C34'].map((c) => (
<button
type="button"
key={c}
onClick={() => setColor(c)}
className={`w-6 h-6 rounded-full border-2 ${color === c ? 'border-amber-400 scale-110' : 'border-neutral-600'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-neutral-800">
<button
type="button"
@@ -356,44 +521,12 @@ export const EditCardModal: React.FC<EditCardModalProps> = ({
if (!isOpen || !card) return null;
const [title, setTitle] = useState(card.title || '');
const [subtitle, setSubtitle] = useState(card.subtitle || '');
const [content, setContent] = useState(card.content || '');
const [color, setColor] = useState(card.color || '#FFF9A6');
const [imageUrl, setImageUrl] = useState(card.imageUrl || '');
const [url, setUrl] = useState(card.url || '');
const [isUploading, setIsUploading] = useState(false);
const handleEditFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
if (typeof ev.target?.result === 'string') {
setImageUrl(ev.target.result);
}
};
reader.readAsDataURL(file);
setIsUploading(true);
const formData = new FormData();
formData.append('file', file);
fetch('/api/upload', {
method: 'POST',
body: formData
})
.then((res) => res.json())
.then((data) => {
setIsUploading(false);
if (data.success && data.url) {
setImageUrl(data.url);
}
})
.catch((err) => {
console.warn('Upload error:', err);
setIsUploading(false);
});
};
const [stamp, setStamp] = useState<StampType | 'none'>(card.stamp || 'none');
const handleSave = () => {
const formattedUrl = url.trim()
@@ -403,147 +536,120 @@ export const EditCardModal: React.FC<EditCardModalProps> = ({
onSave({
...card,
title: title.trim() || undefined,
subtitle: subtitle.trim() || undefined,
content: content.trim(),
color,
url: card.type === 'bookmark' ? formattedUrl : card.url,
imageUrl: (card.type === 'polaroid' || card.type === 'bookmark') ? (imageUrl.trim() || undefined) : card.imageUrl,
photoCaption: card.type === 'polaroid' ? (title.trim() || content.trim()) : undefined
photoCaption: card.type === 'polaroid' ? (title.trim() || content.trim()) : undefined,
stamp: stamp !== 'none' ? stamp : undefined
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-[#1C1A17] border border-neutral-700 w-full max-w-md rounded-xl p-5 text-neutral-200 shadow-2xl">
<div className="flex justify-between items-center mb-4 pb-2 border-b border-neutral-800">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 select-none">
<div className="bg-[#1C1A17] border border-neutral-700 w-full max-w-md rounded-xl p-5 text-neutral-200 shadow-2xl space-y-4 text-xs">
<div className="flex justify-between items-center pb-2 border-b border-neutral-800">
<h2 className="text-sm font-bold text-white flex items-center gap-2">
<span>Edit Card ({card.type})</span>
</h2>
<button onClick={onClose} className="text-neutral-400 hover:text-white">
<button onClick={onClose} className="text-neutral-400 hover:text-white p-1">
<X size={18} />
</button>
</div>
<div className="space-y-4 text-xs">
<div>
<label className="block text-neutral-400 mb-1">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100"
/>
</div>
<div>
<label className="block text-neutral-400 mb-1">Subtitle / Details</label>
<input
type="text"
value={subtitle}
onChange={(e) => setSubtitle(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100"
/>
</div>
<div>
<label className="block text-neutral-400 mb-1">Body Text</label>
<textarea
rows={3}
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100"
/>
</div>
{card.type === 'bookmark' && (
<div>
<label className="block text-neutral-400 mb-1">Title</label>
<label className="block text-neutral-400 mb-1">Bookmark URL</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 font-mono text-[11px]"
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-neutral-400 mb-1">
{card.type === 'polaroid' ? 'Back / Note Text' : 'Content'}
</label>
<textarea
rows={3}
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500"
/>
<label className="block text-neutral-400 mb-1">Ink Stamp</label>
<select
value={stamp}
onChange={(e) => setStamp(e.target.value as any)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200 font-special"
>
<option value="none">No Stamp</option>
<option value="SAMPLE">SAMPLE</option>
<option value="APPROVED">APPROVED</option>
<option value="DRAFT">DRAFT</option>
<option value="PROOF">PROOF</option>
<option value="CONFIDENTIAL">CONFIDENTIAL</option>
<option value="URGENT">URGENT</option>
<option value="EVIDENCE">EVIDENCE</option>
</select>
</div>
{/* Bookmark URL field */}
{card.type === 'bookmark' && (
<div className="space-y-1">
<label className="block text-neutral-400 mb-1">Bookmark URL</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500 font-mono text-[11px]"
/>
</div>
)}
{/* Photo management for Polaroid */}
{card.type === 'polaroid' && (
<div className="space-y-2">
<div className="flex justify-between items-center text-neutral-400">
<span>Photo / Snapshot</span>
{isUploading && (
<span className="text-[10px] text-amber-400 font-mono animate-pulse">
Uploading...
</span>
)}
</div>
<div className="flex gap-2">
<label className="flex-1 flex items-center justify-center gap-1.5 py-1.5 px-3 bg-neutral-800 border border-neutral-700 hover:border-amber-500 rounded cursor-pointer text-amber-300 font-medium transition text-center">
<span>📸 {isUploading ? 'Uploading...' : 'Replace Photo'}</span>
<input
type="file"
accept="image/*"
onChange={handleEditFileUpload}
className="hidden"
disabled={isUploading}
/>
</label>
</div>
<input
type="text"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="Or paste an image URL (https://... or /uploads/...)"
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500 text-[11px]"
/>
{imageUrl && (
<div className="flex items-center gap-2.5 mt-1 bg-neutral-900/80 p-2 rounded-lg border border-neutral-800">
<div className="w-12 h-12 rounded border border-neutral-700 overflow-hidden relative shrink-0">
<img src={imageUrl} alt="Preview" className="w-full h-full object-cover" />
</div>
<div className="flex flex-col text-[11px] overflow-hidden min-w-0">
<span className="text-emerald-400 font-medium">✓ Photo Attached</span>
<span className="truncate text-neutral-400 font-mono text-[10px]">{imageUrl}</span>
</div>
</div>
)}
</div>
)}
<div>
<label className="block text-neutral-400 mb-1">Paper Tint</label>
<div className="flex gap-2">
<div className="flex gap-1.5 flex-wrap pt-1">
{['#FFF9A6', '#FFDFD3', '#E0D4FC', '#BEE7F5', '#C8F7C5', '#FFFFFF', '#282C34'].map((c) => (
<button
type="button"
key={c}
onClick={() => setColor(c)}
className={`w-6 h-6 rounded-full border-2 ${color === c ? 'border-amber-400 scale-110' : 'border-neutral-600'}`}
className={`w-5 h-5 rounded-full border ${color === c ? 'border-amber-400 scale-110' : 'border-neutral-600'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
</div>
<div className="flex justify-between items-center pt-3 border-t border-neutral-800">
<button
onClick={() => {
onDelete(card.id);
onClose();
}}
className="px-3 py-1.5 rounded bg-red-900/40 text-red-300 hover:bg-red-800/60 font-medium"
>
Delete Card
<div className="flex justify-between items-center pt-3 border-t border-neutral-800">
<button
onClick={() => { onDelete(card.id); onClose(); }}
className="px-3 py-1.5 rounded bg-red-900/40 text-red-300 hover:bg-red-800/60 font-medium"
>
Delete Card
</button>
<div className="flex gap-2">
<button onClick={onClose} className="px-3 py-1.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-medium">
Cancel
</button>
<button onClick={handleSave} className="px-4 py-1.5 rounded bg-amber-500 hover:bg-amber-400 text-neutral-950 font-bold">
Save Changes
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-medium"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-1.5 rounded bg-amber-500 hover:bg-amber-400 text-neutral-950 font-bold"
>
Save Changes
</button>
</div>
</div>
</div>
</div>
+16
View File
@@ -183,6 +183,22 @@ export const DeskBackground: React.FC<DeskBackgroundProps> = ({ texture, grain }
/>
)}
{texture === 'evidence-board' && (
<div
className="w-full h-full"
style={{
backgroundColor: '#6A4A28',
backgroundImage: `
radial-gradient(rgba(30, 15, 5, 0.45) 1.5px, transparent 1.5px),
radial-gradient(rgba(180, 130, 80, 0.35) 1px, transparent 1px),
radial-gradient(circle at 50% 50%, rgba(0,0,0,0.2), transparent 75%),
repeating-linear-gradient(45deg, rgba(0,0,0,0.02) 0px, transparent 10px, rgba(0,0,0,0.03) 20px)
`,
backgroundSize: '9px 9px, 15px 15px, 100% 100%, 40px 40px'
}}
/>
)}
{/* Global procedural paper grain overlay */}
<div
className="absolute inset-0 pointer-events-none opacity-40 mix-blend-overlay"
+156
View File
@@ -0,0 +1,156 @@
import React, { useMemo } from 'react';
import { CardItem } from '../types';
interface EvidenceStringsLayerProps {
cards: CardItem[];
enabled: boolean;
}
export const EvidenceStringsLayer: React.FC<EvidenceStringsLayerProps> = ({ cards, enabled }) => {
if (!enabled || cards.length < 2) return null;
// Build stable connection pairs so threads don't flicker randomly
const connections = useMemo(() => {
const pairs: [number, number][] = [];
const n = Math.min(cards.length, 18);
// 1. Perimeter ring connections
for (let i = 0; i < n; i++) {
pairs.push([i, (i + 1) % n]);
}
// 2. Cross-board conspiracy chords
if (n >= 4) {
pairs.push([0, Math.floor(n / 2)]);
}
if (n >= 6) {
pairs.push([1, Math.floor(n / 2) + 1]);
pairs.push([Math.floor(n / 3), (Math.floor(n / 3) + Math.floor(n / 2)) % n]);
}
if (n >= 8) {
pairs.push([2, n - 2]);
}
return pairs;
}, [cards.length]);
return (
<svg
className="absolute inset-0 w-full h-full pointer-events-none z-20 overflow-visible"
style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.45))' }}
>
<defs>
{/* Subtle red yarn texture pattern filter */}
<filter id="yarn-fiber" x="0%" y="0%" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" result="noise" />
<feColorMatrix
type="matrix"
values="1 0 0 0 0
0 0.1 0 0 0
0 0 0.1 0 0
0 0 0 0.25 0"
/>
<feComposite in="SourceGraphic" in2="noise" operator="over" />
</filter>
</defs>
{/* Render Yarn Threads */}
{connections.map(([idxA, idxB], i) => {
const cardA = cards[idxA];
const cardB = cards[idxB];
if (!cardA || !cardB) return null;
const ax = cardA.x + cardA.width / 2;
const ay = cardA.y + 12;
const bx = cardB.x + cardB.width / 2;
const by = cardB.y + 12;
const dist = Math.hypot(bx - ax, by - ay);
// Realistic catenary droop / sag
const sag = Math.min(45, Math.max(8, dist * 0.065));
const midX = (ax + bx) / 2;
const midY = (ay + by) / 2 + sag;
const pathData = `M ${ax.toFixed(1)} ${ay.toFixed(1)} Q ${midX.toFixed(1)} ${midY.toFixed(1)} ${bx.toFixed(1)} ${by.toFixed(1)}`;
return (
<g key={`thread-${cardA.id}-${cardB.id}-${i}`}>
{/* Thread shadow */}
<path
d={pathData}
fill="none"
stroke="rgba(0,0,0,0.3)"
strokeWidth="2.5"
strokeLinecap="round"
transform="translate(1, 3)"
/>
{/* Primary crimson red yarn */}
<path
d={pathData}
fill="none"
stroke="#DC2626"
strokeWidth="2.2"
strokeLinecap="round"
strokeDasharray={i % 3 === 0 ? 'none' : 'none'}
opacity="0.92"
/>
{/* Fiber highlight core */}
<path
d={pathData}
fill="none"
stroke="#F87171"
strokeWidth="0.8"
strokeLinecap="round"
opacity="0.6"
/>
</g>
);
})}
{/* Render Pushpins at each connection point on cards */}
{cards.map((card) => {
const px = card.x + card.width / 2;
const py = card.y + 12;
return (
<g key={`pin-${card.id}`}>
{/* Pin shadow */}
<ellipse
cx={px + 1}
cy={py + 3.5}
rx="4.5"
ry="2.5"
fill="rgba(0,0,0,0.4)"
/>
{/* Metallic brass bevel ring */}
<circle
cx={px}
cy={py}
r="5.5"
fill="#D97706"
stroke="#78350F"
strokeWidth="0.8"
/>
{/* Red plastic pushpin cap */}
<circle
cx={px}
cy={py}
r="4.2"
fill="#DC2626"
stroke="#991B1B"
strokeWidth="0.8"
/>
{/* Shiny specular highlight */}
<circle
cx={px - 1.2}
cy={py - 1.2}
r="1.4"
fill="#FECACA"
opacity="0.9"
/>
</g>
);
})}
</svg>
);
};
+3
View File
@@ -84,9 +84,12 @@ export const Stamp: React.FC<{ type?: StampType; angle?: number }> = ({ type, an
if (!type) return null;
const colorClass = type === 'APPROVED' ? 'text-red-700 border-red-700/80 bg-red-700/5' :
type === 'EVIDENCE' ? 'text-red-800 border-red-800 bg-red-900/10 tracking-widest font-black' :
type === 'URGENT' ? 'text-rose-700 border-rose-700 bg-rose-700/10' :
type === 'SAMPLE' ? 'text-amber-800 border-amber-800/80 bg-amber-800/5' :
type === 'PROOF' ? 'text-emerald-800 border-emerald-800/80 bg-emerald-800/5' :
type === 'DRAFT' ? 'text-blue-800 border-blue-800/80 bg-blue-800/5' :
type === 'CONFIDENTIAL' ? 'text-red-900 border-red-900 bg-red-900/10' :
'text-neutral-800 border-neutral-800/80 bg-neutral-800/5';
return (
+95
View File
@@ -531,6 +531,101 @@ export const PaperCard: React.FC<PaperCardProps> = ({
</div>
</div>
)}
{/* 10. INTERACTIVE CHECKLIST CARD */}
{card.type === 'checklist' && (
<div className="p-4 flex flex-col justify-between h-full bg-[#FCFAF2] text-neutral-900 border border-neutral-300/80">
<div>
<div className="flex justify-between items-center pb-2 mb-2 border-b border-neutral-300">
<span className="font-bold text-sm tracking-tight">{card.title || 'To-Do List'}</span>
{card.todoItems && card.todoItems.length > 0 && (
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-neutral-200 text-neutral-700">
{card.todoItems.filter((t) => t.done).length}/{card.todoItems.length}
</span>
)}
</div>
{card.todoItems && card.todoItems.length > 0 ? (
<div className={`space-y-1.5 py-1 ${card.fontFamily || 'font-caveat'} text-base`}>
{card.todoItems.map((item, idx) => (
<div
key={idx}
className="flex items-center gap-2 cursor-pointer group"
onClick={(e) => {
e.stopPropagation();
onToggleTodo?.(card.id, idx);
}}
>
<input
type="checkbox"
checked={item.done}
readOnly
className="cursor-pointer accent-neutral-900 rounded-sm w-3.5 h-3.5 pointer-events-none"
/>
<span className={`${item.done ? 'line-through text-neutral-400' : 'text-neutral-800'}`}>
{item.text}
</span>
</div>
))}
</div>
) : (
<div className="text-xs text-neutral-500 italic py-2">
{card.content || 'No tasks listed'}
</div>
)}
</div>
<div className="pt-2 border-t border-neutral-200 flex justify-between text-[10px] font-mono text-neutral-500">
<span>CHECKLIST</span>
<span>{card.date || 'PENDING'}</span>
</div>
</div>
)}
{/* 11. VINTAGE TICKET / BOARDING PASS */}
{card.type === 'ticket' && (
<div
className="p-3.5 flex flex-col justify-between bg-[#F4EDE2] text-neutral-900 font-mono text-xs border border-amber-900/30"
style={{
clipPath: 'polygon(0% 0%, 100% 0%, 100% 40%, 96% 45%, 96% 55%, 100% 60%, 100% 100%, 0% 100%, 0% 60%, 4% 55%, 4% 45%, 0% 40%)'
}}
>
<div className="flex justify-between items-center pb-1.5 border-b border-amber-900/30">
<span className="text-[10px] font-black tracking-widest text-amber-900 uppercase">
{card.subtitle || 'PASS // TICKET'}
</span>
<span className="text-[10px] text-neutral-600">
NO. {card.tagNumber || '78429'}
</span>
</div>
<div className="my-auto py-2 text-center">
<div className="text-sm font-bold tracking-wider text-neutral-900 uppercase">
{card.title || 'BOARDING PASS'}
</div>
<div className="text-[11px] text-neutral-700 mt-1 whitespace-pre-line">
{card.content || 'ADMIT ONE · GENERAL ACCESS'}
</div>
{card.date && (
<div className="text-[10px] text-amber-900 mt-1.5 font-bold">
{card.date}
</div>
)}
</div>
{/* Perforated barcode tear stub */}
<div className="pt-2 border-t border-dashed border-amber-900/40 flex flex-col items-center">
<div className="h-4 w-28 flex items-stretch gap-[1.5px] justify-center opacity-70">
{[1, 2, 1, 3, 1, 2, 1, 4, 1, 2, 3, 1, 2, 1, 3].map((w, i) => (
<div key={i} className="bg-neutral-900" style={{ width: `${w}px` }} />
))}
</div>
<span className="text-[8px] tracking-widest text-neutral-500 mt-0.5">
★ NON-TRANSFERABLE ★
</span>
</div>
</div>
)}
</div>
</div>
);
+64 -31
View File
@@ -159,24 +159,39 @@ export const TopBar: React.FC<TopBarProps> = ({
))}
</div>
{/* Desk Texture Selector */}
{/* Board Selector */}
<div className="flex items-center gap-1.5">
<span className="text-neutral-400">Desk:</span>
<span className="text-neutral-400">Board:</span>
<select
value={settings.deskTexture}
onChange={(e) => onUpdateSettings({ deskTexture: e.target.value as DeskTexture })}
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-neutral-200 focus:outline-none focus:border-amber-500 cursor-pointer text-xs"
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-neutral-200 focus:outline-none focus:border-amber-500 cursor-pointer text-xs font-medium"
>
<option value="oak">Oak desk</option>
<option value="walnut">Walnut</option>
<option value="cutting-mat">Cutting mat</option>
<option value="corkboard">Corkboard</option>
<option value="blueprint">Blueprint</option>
<option value="dark-slate">Dark slate</option>
<option value="marble">Marble</option>
<option value="oak">1. Oak (Paper Desk)</option>
<option value="walnut">2. Walnut (Photos)</option>
<option value="cutting-mat">3. Cutting Mat (Designer)</option>
<option value="corkboard">4. Corkboard (Brainstorm)</option>
<option value="blueprint">5. Blueprint (Tech)</option>
<option value="dark-slate">6. Dark Slate (Night)</option>
<option value="marble">7. Marble (Atelier)</option>
<option value="evidence-board">8. Evidence Board (Red Yarn)</option>
</select>
</div>
{/* Evidence Strings Toggle Button */}
<button
onClick={() => onUpdateSettings({ showEvidenceStrings: !settings.showEvidenceStrings })}
className={`px-2 py-1 rounded transition flex items-center gap-1.5 shrink-0 ${
settings.showEvidenceStrings
? 'bg-red-600 hover:bg-red-500 text-white font-bold shadow-xs'
: 'bg-neutral-800 hover:bg-neutral-700 text-neutral-300 border border-neutral-700'
}`}
title={settings.showEvidenceStrings ? "Hide Red Yarn Evidence Strings" : "Show Red Yarn Evidence Strings"}
>
<span className="w-2 h-2 rounded-full bg-red-400 inline-block" />
<span>Strings</span>
</button>
{/* Cards Count */}
<div className="flex items-center gap-1.5" title="Number of paper sheets on desk">
<span className="text-neutral-400">Cards</span>
@@ -235,11 +250,13 @@ export const TopBar: React.FC<TopBarProps> = ({
<option value="sticky">Sticky Notes</option>
<option value="polaroid">Photos</option>
<option value="bookmark">Bookmarks</option>
<option value="receipt">Receipts</option>
<option value="graph">Sketches</option>
<option value="checklist">Checklists</option>
<option value="ticket">Tickets</option>
<option value="tag">Tags</option>
<option value="torn">Letters</option>
<option value="business">Cards</option>
<option value="receipt">Receipts</option>
<option value="graph">Sketches</option>
</select>
{/* Portfolio */}
@@ -280,23 +297,24 @@ export const TopBar: React.FC<TopBarProps> = ({
</button>
</div>
{/* Desk Surface Texture */}
{/* Desk Surface / Board */}
<div>
<label className="block text-neutral-400 mb-1 font-semibold">Desk Surface</label>
<div className="grid grid-cols-3 gap-1.5">
<label className="block text-neutral-400 mb-1 font-semibold">Workspace Board</label>
<div className="grid grid-cols-4 gap-1.5">
{[
{ id: 'oak', label: 'Oak Desk' },
{ id: 'walnut', label: 'Walnut' },
{ id: 'cutting-mat', label: 'Mat' },
{ id: 'corkboard', label: 'Cork' },
{ id: 'blueprint', label: 'Blueprint' },
{ id: 'dark-slate', label: 'Slate' },
{ id: 'marble', label: 'Marble' }
{ id: 'oak', label: '1. Oak' },
{ id: 'walnut', label: '2. Walnut' },
{ id: 'cutting-mat', label: '3. Mat' },
{ id: 'corkboard', label: '4. Cork' },
{ id: 'blueprint', label: '5. Blue' },
{ id: 'dark-slate', label: '6. Slate' },
{ id: 'marble', label: '7. Marble' },
{ id: 'evidence-board', label: '8. Evidence' }
].map((d) => (
<button
key={d.id}
onClick={() => onUpdateSettings({ deskTexture: d.id as DeskTexture })}
className={`py-1.5 px-2 rounded border text-center transition ${
className={`py-1.5 px-1 rounded border text-center transition text-[11px] truncate ${
settings.deskTexture === d.id
? 'bg-amber-500/20 text-amber-300 border-amber-500 font-bold'
: 'bg-neutral-800 text-neutral-400 border-neutral-700 hover:bg-neutral-700'
@@ -381,30 +399,45 @@ export const TopBar: React.FC<TopBarProps> = ({
</div>
{/* Quick Primary Actions */}
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-neutral-800">
<div className="grid grid-cols-3 gap-1.5 pt-2 border-t border-neutral-800">
<button
onClick={() => onUpdateSettings({ isLocked: !settings.isLocked })}
className={`py-2 px-3 rounded border text-left flex items-center justify-between ${
className={`py-2 px-2 rounded border text-left flex flex-col justify-between ${
settings.isLocked
? 'bg-amber-500/20 border-amber-500 text-amber-300 font-bold'
: 'bg-neutral-800 border-neutral-700 text-neutral-300'
}`}
>
<span className="flex items-center gap-1.5">
{settings.isLocked ? <Lock size={13} /> : <Unlock size={13} />}
<span>Lock Desk</span>
<span className="flex items-center gap-1">
{settings.isLocked ? <Lock size={12} /> : <Unlock size={12} />}
<span>Desk Lock</span>
</span>
<span className="font-mono text-[10px]">{settings.isLocked ? 'LOCKED' : 'OPEN'}</span>
<span className="font-mono text-[9px] mt-0.5">{settings.isLocked ? 'LOCKED' : 'OPEN'}</span>
</button>
<button
onClick={() => onUpdateSettings({ showEvidenceStrings: !settings.showEvidenceStrings })}
className={`py-2 px-2 rounded border text-left flex flex-col justify-between ${
settings.showEvidenceStrings
? 'bg-red-950/60 border-red-600 text-red-300 font-bold'
: 'bg-neutral-800 border-neutral-700 text-neutral-300'
}`}
>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block" />
<span>Red Yarn</span>
</span>
<span className="font-mono text-[9px] mt-0.5">{settings.showEvidenceStrings ? 'ACTIVE' : 'OFF'}</span>
</button>
<button
onClick={() => onUpdateSettings({ soundEnabled: !settings.soundEnabled })}
className={`py-2 px-3 rounded border text-left flex items-center justify-between ${
className={`py-2 px-2 rounded border text-left flex flex-col justify-between ${
settings.soundEnabled ? 'bg-amber-500/10 border-amber-500 text-amber-300' : 'bg-neutral-800 border-neutral-700 text-neutral-400'
}`}
>
<span>Tactile Audio</span>
<span className="font-mono text-[10px]">{settings.soundEnabled ? 'ON' : 'OFF'}</span>
<span className="font-mono text-[9px] mt-0.5">{settings.soundEnabled ? 'ON' : 'OFF'}</span>
</button>
</div>
+16 -3
View File
@@ -7,7 +7,9 @@ export type CardType =
| 'receipt'
| 'graph'
| 'moodboard'
| 'bookmark';
| 'bookmark'
| 'checklist'
| 'ticket';
export type AttachType = 'none' | 'washi' | 'paperclip' | 'pin';
@@ -18,11 +20,12 @@ export type DeskTexture =
| 'corkboard'
| 'blueprint'
| 'dark-slate'
| 'marble';
| 'marble'
| 'evidence-board';
export type FontMode = 'mixed' | 'handwritten' | 'typed';
export type StampType = 'SAMPLE' | 'DRAFT' | 'PROOF' | 'APPROVED' | 'KEEP' | 'CONFIDENTIAL';
export type StampType = 'SAMPLE' | 'DRAFT' | 'PROOF' | 'APPROVED' | 'KEEP' | 'CONFIDENTIAL' | 'URGENT' | 'EVIDENCE';
export interface CardItem {
id: string;
@@ -93,6 +96,16 @@ export interface DeskSettings {
activeFilter: 'all' | CardType;
zoom: number; // 0.4 - 1.5 zoom level
isLocked: boolean; // lock desk so cards cannot be dragged
showEvidenceStrings: boolean; // evidence board red yarn connections
}
export interface BoardState {
cards: CardItem[];
props?: DeskProp[];
strokes?: InkStroke[];
settings?: Partial<DeskSettings>;
}
export type BoardsMap = Partial<Record<DeskTexture, BoardState>>;
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'loading';
+733
View File
@@ -0,0 +1,733 @@
import { CardItem, DeskTexture, DeskProp, InkStroke, BoardState } from '../types';
export const INITIAL_PROPS: DeskProp[] = [
{ id: 'prop-pencil-1', type: 'pencil', x: 120, y: 160, rotation: 38, zIndex: 12 },
{ id: 'prop-pen-1', type: 'pen', x: 890, y: 170, rotation: -28, zIndex: 15 },
{ id: 'prop-pencil-2', type: 'pencil', x: 230, y: 720, rotation: -42, zIndex: 10 },
{ id: 'prop-pen-2', type: 'pen', x: 440, y: 880, rotation: 92, zIndex: 14 },
{ id: 'prop-tape-1', type: 'tape-roll', x: 410, y: 120, rotation: 12, zIndex: 9 },
{ id: 'prop-clip-1', type: 'paperclip', x: 640, y: 760, rotation: 55, zIndex: 8 },
{ id: 'prop-clip-2', type: 'paperclip', x: 1190, y: 920, rotation: -35, zIndex: 8 },
{ id: 'prop-pin-1', type: 'pin', x: 1210, y: 130, rotation: 0, zIndex: 16 }
];
// Helper to generate seed boards
export function getDefaultBoardData(desk: DeskTexture, w = 1400, h = 900): BoardState {
const isMobile = w < 768;
const sx = isMobile ? 0.65 : 1.0;
switch (desk) {
case 'walnut': {
// Photographer Journal Board
const cards: CardItem[] = [
{
id: 'photo-1',
type: 'polaroid',
title: 'DAY!',
photoCaption: 'DAY!\nSunny afternoon on the coast',
content: 'Pacific coastal highway shoot',
imageUrl: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=600&q=80',
x: 100 * sx,
y: 120 * sx,
vx: 0, vy: 0, rotation: -4,
width: 175, height: 220, zIndex: 2,
isPinned: false, attachType: 'washi', fontFamily: 'font-special'
},
{
id: 'photo-2',
type: 'polaroid',
title: 'summit jump',
photoCaption: 'summit jump\nHigh Sierra trail',
content: 'Early dawn hike',
imageUrl: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=600&q=80',
x: 320 * sx,
y: 130 * sx,
vx: 0, vy: 0, rotation: 3,
width: 185, height: 225, zIndex: 3,
isPinned: false, attachType: 'pin', fontFamily: 'font-caveat'
},
{
id: 'photo-3',
type: 'polaroid',
title: 'so many towels',
photoCaption: 'so many towels\nSept 1',
content: 'Mediterranean beach',
imageUrl: 'https://images.unsplash.com/photo-1519046904884-53103b34b206?auto=format&fit=crop&w=600&q=80',
x: 550 * sx,
y: 115 * sx,
vx: 0, vy: 0, rotation: -2,
width: 175, height: 225, zIndex: 4,
isPinned: false, attachType: 'washi', fontFamily: 'font-indie'
},
{
id: 'photo-4',
type: 'polaroid',
title: 'City Walk',
photoCaption: 'Downtown reflections',
content: 'Glass towers at dusk',
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=600&q=80',
x: 120 * sx,
y: 380 * sx,
vx: 0, vy: 0, rotation: 5,
width: 180, height: 230, zIndex: 5,
isPinned: false, attachType: 'paperclip', fontFamily: 'font-caveat'
},
{
id: 'photo-5',
type: 'polaroid',
title: 'Desert Dunes',
photoCaption: 'golden hour',
content: 'Warm light on ridges',
imageUrl: 'https://images.unsplash.com/photo-1509316975850-ff9c5deb0cd9?auto=format&fit=crop&w=600&q=80',
x: 340 * sx,
y: 395 * sx,
vx: 0, vy: 0, rotation: -5,
width: 180, height: 230, zIndex: 6,
isPinned: false, attachType: 'washi', fontFamily: 'font-indie'
},
{
id: 'photo-check',
type: 'checklist',
title: 'Gear & Film Stock',
content: 'Photo kit checklist',
color: '#FAF6EE',
x: 560 * sx,
y: 380 * sx,
vx: 0, vy: 0, rotation: 2,
width: 190, height: 210, zIndex: 7,
isPinned: false, attachType: 'pin', fontFamily: 'font-courier',
todoItems: [
{ text: 'Kodak Portra 400 (3 rolls)', done: true },
{ text: 'Ilford HP5 Plus', done: true },
{ text: 'Leica M6 + 35mm f/2', done: true },
{ text: 'ND filter 3-stop', done: false },
{ text: 'Lens cleaning cloth', done: true }
]
},
{
id: 'photo-receipt',
type: 'receipt',
title: 'CITY LAB',
subtitle: 'PHOTO LAB & DARKROOM',
date: '09/18 16:20',
content: 'Film Development',
color: '#FAF7F2',
x: 790 * sx,
y: 140 * sx,
vx: 0, vy: 0, rotation: -3,
width: 160, height: 310, zIndex: 8,
isPinned: false, attachType: 'washi', fontFamily: 'font-mono',
receiptItems: [
{ name: 'DEV + SCAN 35MM', price: '18.50' },
{ name: 'DEV + SCAN 120', price: '21.00' },
{ name: 'NEGATIVE SLEEVES', price: '4.50' },
{ name: 'SUBTOTAL', price: '44.00' },
{ name: 'TAX', price: '3.96' },
{ name: 'TOTAL', price: '$47.96' }
]
},
{
id: 'photo-ticket',
type: 'ticket',
title: 'COASTAL EXPRESS',
subtitle: 'RAILWAY TRANSIT',
date: 'OCT 04 · SEAT 14A',
content: 'CAR 03 · WINDOW · ROUND TRIP',
tagNumber: 'PASS #4902',
color: '#EFE6D8',
x: 790 * sx,
y: 480 * sx,
vx: 0, vy: 0, rotation: 4,
width: 195, height: 160, zIndex: 9,
isPinned: false, attachType: 'paperclip', fontFamily: 'font-mono'
}
];
return { cards, props: INITIAL_PROPS, strokes: [], settings: { deskTexture: 'walnut', lightAngle: 225, showEvidenceStrings: false } };
}
case 'cutting-mat': {
// Product Designer Studio Board
const cards: CardItem[] = [
{
id: 'design-sprint',
type: 'checklist',
title: 'Sprint 12 Tasks',
content: 'Design sprint deliverables',
color: '#FFFDF9',
x: 100 * sx,
y: 110 * sx,
vx: 0, vy: 0, rotation: -2,
width: 200, height: 260, zIndex: 2,
isPinned: false, attachType: 'washi', fontFamily: 'font-caveat',
todoItems: [
{ text: 'Wireframe hero layout', done: true },
{ text: 'Tactile sound effects pass', done: true },
{ text: 'Color contrast audit', done: false },
{ text: 'User testing interview (Sam)', done: true },
{ text: 'Export mobile responsive specs', done: false }
]
},
{
id: 'design-graph-1',
type: 'graph',
title: 'User growth & metrics',
content: 'Q3 Analytics trajectory',
color: '#F0F2F5',
graphType: 'barchart',
x: 330 * sx,
y: 120 * sx,
vx: 0, vy: 0, rotation: 1.5,
width: 210, height: 195, zIndex: 3,
isPinned: false, attachType: 'paperclip', fontFamily: 'font-courier'
},
{
id: 'design-graph-2',
type: 'graph',
title: 'Architecture flowchart',
content: 'Component data pipeline',
color: '#F8F6F0',
graphType: 'flowchart',
x: 570 * sx,
y: 110 * sx,
vx: 0, vy: 0, rotation: -3,
width: 225, height: 195, zIndex: 4,
isPinned: false, attachType: 'pin', fontFamily: 'font-courier'
},
{
id: 'design-sticky-1',
type: 'sticky',
title: 'Layout Idea',
content: 'what if the\ngrid was 5\ncolumns?',
color: '#E0D4FC',
x: 120 * sx,
y: 400 * sx,
vx: 0, vy: 0, rotation: 3,
width: 165, height: 165, zIndex: 5,
isPinned: false, attachType: 'washi', fontFamily: 'font-caveat'
},
{
id: 'design-sticky-2',
type: 'sticky',
title: 'Hero Concept',
content: 'idea: paper-\ntextured\nhero banner',
color: '#FFDE9E',
x: 310 * sx,
y: 350 * sx,
vx: 0, vy: 0, rotation: -2,
width: 170, height: 170, zIndex: 6,
isPinned: false, attachType: 'washi', fontFamily: 'font-caveat'
},
{
id: 'design-biz',
type: 'business',
title: 'JULES LAURENT',
subtitle: 'PRODUCT DESIGNER',
content: 'studio@juleslaurent.design\nportfolio / archive 2026',
color: '#BFA89B',
x: 510 * sx,
y: 340 * sx,
vx: 0, vy: 0, rotation: 2,
width: 210, height: 130, zIndex: 7,
isPinned: false, attachType: 'washi', fontFamily: 'font-courier'
},
{
id: 'design-tag',
type: 'tag',
title: 'SPECIMEN 18',
subtitle: 'DESIGN SYSTEM',
date: 'OCT 12',
content: 'COMPONENT LIB // V2',
tagNumber: '#SYS-18',
color: '#CBB296',
x: 750 * sx,
y: 330 * sx,
vx: 0, vy: 0, rotation: -6,
width: 140, height: 230, zIndex: 8,
isPinned: false, attachType: 'pin', fontFamily: 'font-courier'
}
];
return { cards, props: INITIAL_PROPS, strokes: [], settings: { deskTexture: 'cutting-mat', lightAngle: 180, showEvidenceStrings: false } };
}
case 'blueprint': {
// Creative Technologist Board
const cards: CardItem[] = [
{
id: 'code-sine',
type: 'graph',
title: 'Sine wave vectors',
content: 'y = sin(x) * 45 + noise(x)',
graphType: 'curve',
x: 120 * sx,
y: 120 * sx,
vx: 0, vy: 0, rotation: -2,
width: 230, height: 195, zIndex: 2,
isPinned: false, attachType: 'washi', fontFamily: 'font-mono'
},
{
id: 'code-shader',
type: 'torn',
title: 'Fragment shader',
subtitle: 'precision mediump float;',
content: 'uniform vec2 u_res;\nvoid main() {\n vec2 st = gl_FragCoord.xy / u_res;\n gl_FragColor = vec4(st.x, st.y, 1.0, 1.0);\n}',
color: '#E8DCC8',
x: 390 * sx,
y: 110 * sx,
vx: 0, vy: 0, rotation: 2,
width: 250, height: 250, zIndex: 3,
isPinned: false, attachType: 'pin', fontFamily: 'font-mono'
},
{
id: 'code-flow',
type: 'graph',
title: 'Shader pipeline',
content: 'Render graph buffers',
graphType: 'flowchart',
color: '#F4F6F9',
x: 670 * sx,
y: 130 * sx,
vx: 0, vy: 0, rotation: -1.5,
width: 220, height: 190, zIndex: 4,
isPinned: false, attachType: 'paperclip', fontFamily: 'font-mono'
},
{
id: 'code-plotter',
type: 'sticky',
title: 'AxiDraw v3 Settings',
content: 'Pen Plotter Test\nSpeed: 30%\nPen: Sakura Pigma Micron 05\nPaper: Fabriano 300gsm',
color: '#FFF9A6',
x: 140 * sx,
y: 350 * sx,
vx: 0, vy: 0, rotation: 3,
width: 190, height: 185, zIndex: 5,
isPinned: false, attachType: 'washi', fontFamily: 'font-courier'
},
{
id: 'code-check',
type: 'checklist',
title: 'Hardware Build',
content: 'Prototype steps',
color: '#EBF3FA',
x: 360 * sx,
y: 390 * sx,
vx: 0, vy: 0, rotation: -3,
width: 200, height: 210, zIndex: 6,
isPinned: false, attachType: 'pin', fontFamily: 'font-mono',
todoItems: [
{ text: 'Flash ESP32 firmware', done: true },
{ text: 'Calibrate stepper drivers', done: true },
{ text: 'Test limit switches', done: false },
{ text: 'Export SVG G-code toolpath', done: true }
]
},
{
id: 'code-parts-receipt',
type: 'receipt',
title: 'FAB LAB ELECTRONICS',
subtitle: 'HARDWARE & COMPONENT',
date: '10/11 11:42',
content: 'Components',
color: '#F9F8F4',
x: 600 * sx,
y: 350 * sx,
vx: 0, vy: 0, rotation: 2,
width: 170, height: 290, zIndex: 7,
isPinned: false, attachType: 'washi', fontFamily: 'font-mono',
receiptItems: [
{ name: 'NEMA 17 MOTOR x2', price: '28.00' },
{ name: 'TIMING BELT 2M', price: '6.50' },
{ name: 'A4988 DRIVER x2', price: '7.20' },
{ name: 'SUBTOTAL', price: '41.70' },
{ name: 'TAX', price: '3.75' },
{ name: 'TOTAL', price: '$45.45' }
]
}
];
return { cards, props: INITIAL_PROPS, strokes: [], settings: { deskTexture: 'blueprint', lightAngle: 270, showEvidenceStrings: false } };
}
case 'evidence-board': {
// Detective Evidence Board (with red string yarn connections!)
const cards: CardItem[] = [
{
id: 'ev-photo-1',
type: 'polaroid',
title: 'SUBJECT ALPHA',
photoCaption: 'SUBJECT ALPHA\nDowntown sighting — 23:40',
content: 'Surveillance photo outside central depot',
imageUrl: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=600&q=80',
x: 110 * sx,
y: 110 * sx,
vx: 0, vy: 0, rotation: -3,
width: 180, height: 230, zIndex: 2,
isPinned: true, attachType: 'pin', fontFamily: 'font-special',
stamp: 'CONFIDENTIAL', stampAngle: -8
},
{
id: 'ev-tag-1',
type: 'tag',
title: 'EVIDENCE ITEM #4',
subtitle: 'CASE #2026-X',
date: 'OCT 09',
content: 'ENVELOPE RECOVERED AT DOCK 7',
tagNumber: '#EV-04',
color: '#D4B895',
x: 340 * sx,
y: 105 * sx,
vx: 0, vy: 0, rotation: 4,
width: 140, height: 230, zIndex: 3,
isPinned: true, attachType: 'pin', fontFamily: 'font-courier'
},
{
id: 'ev-memo',
type: 'torn',
title: 'INCIDENT REPORT',
subtitle: 'DEPT OF INVESTIGATION',
content: 'Suspect arrived via overnight train. Left parcel in locker 14. Meeting planned for Friday midnight near warehouse 3.',
color: '#EDE4D4',
x: 520 * sx,
y: 100 * sx,
vx: 0, vy: 0, rotation: -2,
width: 240, height: 240, zIndex: 4,
isPinned: true, attachType: 'pin', fontFamily: 'font-special',
stamp: 'EVIDENCE', stampAngle: 10
},
{
id: 'ev-photo-2',
type: 'polaroid',
title: 'MEETING LOCATION',
photoCaption: 'WAREHOUSE 3\nOct 11 surveillance',
content: 'Industrial sector perimeter',
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=600&q=80',
x: 800 * sx,
y: 120 * sx,
vx: 0, vy: 0, rotation: 5,
width: 180, height: 230, zIndex: 5,
isPinned: true, attachType: 'pin', fontFamily: 'font-courier'
},
{
id: 'ev-ticket',
type: 'ticket',
title: 'ONE-WAY TRANSIT',
subtitle: 'EVIDENCE EX-12',
date: 'OCT 08 · COACH 04',
content: 'PASSENGER TICKET · FLIGHT 409\nSEAT 22B',
tagNumber: '#TK-409',
color: '#EADFC9',
x: 130 * sx,
y: 380 * sx,
vx: 0, vy: 0, rotation: 3,
width: 200, height: 165, zIndex: 6,
isPinned: true, attachType: 'pin', fontFamily: 'font-mono'
},
{
id: 'ev-check',
type: 'checklist',
title: 'Witness Follow-Ups',
content: 'Detective task checklist',
color: '#F8F4EA',
x: 360 * sx,
y: 375 * sx,
vx: 0, vy: 0, rotation: -4,
width: 205, height: 220, zIndex: 7,
isPinned: true, attachType: 'pin', fontFamily: 'font-courier',
todoItems: [
{ text: 'Interview night clerk', done: true },
{ text: 'Subpoena station security tapes', done: true },
{ text: 'Trace burner phone calls', done: false },
{ text: 'Analyze typewriter ribbon', done: false }
]
},
{
id: 'ev-receipt',
type: 'receipt',
title: 'HIGHWAY DINER',
subtitle: 'COFFEE & PIE',
date: '10/08 02:14',
content: 'Midnight transaction',
color: '#F6F3EC',
x: 590 * sx,
y: 360 * sx,
vx: 0, vy: 0, rotation: -1,
width: 160, height: 290, zIndex: 8,
isPinned: true, attachType: 'pin', fontFamily: 'font-mono',
receiptItems: [
{ name: 'BLACK COFFEE x2', price: '6.00' },
{ name: 'CHERRY PIE', price: '5.50' },
{ name: 'CIGARETTES', price: '12.00' },
{ name: 'SUBTOTAL', price: '23.50' },
{ name: 'TAX', price: '2.11' },
{ name: 'TOTAL', price: '$25.61' }
]
},
{
id: 'ev-graph',
type: 'graph',
title: 'Timeline & Movements',
content: 'Suspect travel vector',
graphType: 'curve',
color: '#F4EFEA',
x: 780 * sx,
y: 380 * sx,
vx: 0, vy: 0, rotation: 2,
width: 210, height: 190, zIndex: 9,
isPinned: true, attachType: 'pin', fontFamily: 'font-mono'
}
];
return {
cards,
props: INITIAL_PROPS,
strokes: [],
settings: { deskTexture: 'evidence-board', lightAngle: 215, showEvidenceStrings: true }
};
}
case 'corkboard': {
// Inspiration Pinboard
const cards: CardItem[] = [
{
id: 'cork-mood-1',
type: 'moodboard',
title: 'Moodboard',
content: 'ship it on Friday\nremember: kerning on headline\nthank-you notes for team',
color: '#FAF8F5',
fontFamily: 'font-caveat',
width: 230, height: 185, zIndex: 2,
isPinned: true, attachType: 'pin', stamp: 'SAMPLE', stampAngle: -4,
x: 120 * sx, y: 120 * sx, vx: 0, vy: 0, rotation: -2
},
{
id: 'cork-sticky-1',
type: 'sticky',
title: 'Idea',
content: 'what if the\ngrid was 5\ncolumns?',
color: '#E0D4FC',
fontFamily: 'font-caveat',
width: 160, height: 160, zIndex: 3,
isPinned: true, attachType: 'pin',
x: 380 * sx, y: 110 * sx, vx: 0, vy: 0, rotation: 3
},
{
id: 'cork-sticky-2',
type: 'sticky',
title: 'Hero Concept',
content: 'idea: paper-\ntextured\nhero',
color: '#FFDE9E',
fontFamily: 'font-caveat',
width: 165, height: 165, zIndex: 4,
isPinned: true, attachType: 'pin',
x: 570 * sx, y: 130 * sx, vx: 0, vy: 0, rotation: -1
},
{
id: 'cork-receipt',
type: 'receipt',
title: 'MALL STORE',
subtitle: 'COFFEE & STATIONERY',
date: '10/24 15:34',
content: 'Receipt #9482',
color: '#F9F8F4',
fontFamily: 'font-mono',
width: 155, height: 300, zIndex: 5,
isPinned: true, attachType: 'pin', rotation: 4,
x: 770 * sx, y: 120 * sx, vx: 0, vy: 0,
receiptItems: [
{ name: 'PAPERBACK', price: '14.99' },
{ name: 'FLAT WHITE', price: '5.25' },
{ name: 'WASHI TAPE', price: '3.75' },
{ name: 'SUBTOTAL', price: '23.99' },
{ name: 'TOTAL', price: '$26.15' }
]
},
{
id: 'cork-check',
type: 'checklist',
title: 'Reading list',
content: 'Weekly chores & reading',
color: '#BEE7F5',
fontFamily: 'font-caveat',
width: 180, height: 210, zIndex: 6,
isPinned: true, attachType: 'pin', rotation: -3,
x: 130 * sx, y: 350 * sx, vx: 0, vy: 0,
todoItems: [
{ text: 'draft blog post', done: true },
{ text: 'order business cards', done: false },
{ text: 'water plants', done: true },
{ text: 'bread', done: false }
]
},
{
id: 'cork-torn',
type: 'torn',
title: 'Fri — late',
subtitle: 'Dear Ava,',
content: 'Less is more. Rename project.\nSketch 20 ideas. Keep 3.',
color: '#E8DCC8',
fontFamily: 'font-special',
width: 220, height: 230, zIndex: 7,
isPinned: true, attachType: 'pin', rotation: 2,
x: 340 * sx, y: 340 * sx, vx: 0, vy: 0
},
{
id: 'cork-goals',
type: 'moodboard',
title: 'Q3 goals',
content: 'idea: paper-textured hero\ncoffee w/ Sam — 3pm\nexplore SVG filters',
color: '#FFF8E8',
fontFamily: 'font-indie',
width: 220, height: 190, zIndex: 8,
isPinned: true, attachType: 'pin', stamp: 'APPROVED', stampAngle: 12,
x: 580 * sx, y: 360 * sx, vx: 0, vy: 0, rotation: -2
}
];
return { cards, props: INITIAL_PROPS, strokes: [], settings: { deskTexture: 'corkboard', lightAngle: 225, showEvidenceStrings: false } };
}
case 'dark-slate': {
// Night Mode Minimalist Focus Board
const cards: CardItem[] = [
{
id: 'slate-keep',
type: 'sticky',
title: 'Keep',
content: 'Keep\nLess, but better.',
color: '#282C34',
fontFamily: 'font-reenie',
width: 175, height: 130, zIndex: 2,
isPinned: false, attachType: 'washi', rotation: -5,
x: 140 * sx, y: 140 * sx, vx: 0, vy: 0
},
{
id: 'slate-memo',
type: 'torn',
title: 'Terminal Spec',
subtitle: 'night build',
content: 'cargo build --release\nall targets compiled clean.\nsystem latency < 12ms',
color: '#242830',
fontFamily: 'font-mono',
width: 230, height: 220, zIndex: 3,
isPinned: false, attachType: 'paperclip', rotation: 2,
x: 350 * sx, y: 130 * sx, vx: 0, vy: 0
},
{
id: 'slate-graph',
type: 'graph',
title: 'Latency distribution',
content: 'P99 runtime specs',
graphType: 'curve',
color: '#1E232A',
fontFamily: 'font-mono',
width: 220, height: 190, zIndex: 4,
isPinned: false, attachType: 'washi', rotation: -2,
x: 610 * sx, y: 120 * sx, vx: 0, vy: 0
},
{
id: 'slate-check',
type: 'checklist',
title: 'Deployment Checklist',
content: 'Night checklist',
color: '#2A2E38',
fontFamily: 'font-mono',
width: 210, height: 220, zIndex: 5,
isPinned: false, attachType: 'washi', rotation: 3,
x: 160 * sx, y: 350 * sx, vx: 0, vy: 0,
todoItems: [
{ text: 'Verify SSL renewal', done: true },
{ text: 'Docker prune unused', done: true },
{ text: 'Backup database snapshot', done: true },
{ text: 'Recheck healthcheck probe', done: false }
]
},
{
id: 'slate-biz',
type: 'business',
title: 'BLACK BOX LABS',
subtitle: 'AUTONOMOUS SYSTEMS',
content: 'research@blackbox.sh\nencrypted telemetry',
color: '#343842',
fontFamily: 'font-courier',
width: 220, height: 130, zIndex: 6,
isPinned: false, attachType: 'paperclip', rotation: -3,
x: 410 * sx, y: 390 * sx, vx: 0, vy: 0
}
];
return { cards, props: INITIAL_PROPS, strokes: [], settings: { deskTexture: 'dark-slate', lightAngle: 180, showEvidenceStrings: false } };
}
case 'marble': {
// Luxury Atelier / Brand Moodboard
const cards: CardItem[] = [
{
id: 'marble-biz',
type: 'business',
title: 'JULES LAURENT',
subtitle: 'HAUTE HORLOGERIE',
content: 'place vendôme · paris\narchive & collection',
color: '#FAF8F5',
fontFamily: 'font-courier',
width: 220, height: 135, zIndex: 2,
isPinned: false, attachType: 'paperclip', rotation: -2,
x: 130 * sx, y: 130 * sx, vx: 0, vy: 0
},
{
id: 'marble-mood',
type: 'moodboard',
title: 'Autumn Atelier',
content: 'neutral palettes\nalabaster, travertine, brass\nfine linen binding',
color: '#F8F6F2',
fontFamily: 'font-caveat',
width: 240, height: 200, zIndex: 3,
isPinned: false, attachType: 'washi', stamp: 'APPROVED', stampAngle: 8,
x: 380 * sx, y: 120 * sx, vx: 0, vy: 0, rotation: 3
},
{
id: 'marble-photo',
type: 'polaroid',
title: 'Private View',
photoCaption: 'Salon d\'Automne',
content: 'Exhibition opening',
imageUrl: 'https://images.unsplash.com/photo-1519046904884-53103b34b206?auto=format&fit=crop&w=600&q=80',
x: 650 * sx, y: 110 * sx, vx: 0, vy: 0, rotation: -3,
width: 180, height: 225, zIndex: 4,
isPinned: false, attachType: 'washi', fontFamily: 'font-special'
},
{
id: 'marble-ticket',
type: 'ticket',
title: 'SALON DES ARTS',
subtitle: 'VIP VERNISSAGE',
date: 'NOV 14 · PARIS',
content: 'INVITATION PRIVÉE\nGALERIE MONTAIGNE',
tagNumber: '#PARIS-01',
color: '#F4ECE1',
x: 140 * sx, y: 340 * sx, vx: 0, vy: 0, rotation: 4,
width: 205, height: 165, zIndex: 5,
isPinned: false, attachType: 'paperclip', fontFamily: 'font-mono'
},
{
id: 'marble-letter',
type: 'torn',
title: 'Dear Ava,',
subtitle: 'Paris memo',
content: 'The travertine textures arrived from Milan. The warmth is extraordinary.\n\nWarmest regards —',
color: '#FAF8F4',
fontFamily: 'font-special',
width: 240, height: 230, zIndex: 6,
isPinned: false, attachType: 'washi', rotation: -2,
x: 390 * sx, y: 350 * sx, vx: 0, vy: 0
}
];
return { cards, props: INITIAL_PROPS, strokes: [], settings: { deskTexture: 'marble', lightAngle: 225, showEvidenceStrings: false } };
}
case 'oak':
default: {
// Default Paper Desk (Eclectic 16 cards from SAMPLE_PRESETS)
return {
cards: [], // Will be filled dynamically by procedural generator
props: INITIAL_PROPS,
strokes: [],
settings: { deskTexture: 'oak', lightAngle: 225, showEvidenceStrings: false }
};
}
}
}