Initial commit of Glimpse: Procedural paper desk and moodboard workspace

This commit is contained in:
airent106 committed 2026-10-13 00:26:15 -06:00
1 parent eb0ad24141
commit eb3787dd16
29 files changed
+6333

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.local
+23
View File
@@ -0,0 +1,23 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . ./
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
+49
View File
@@ -0,0 +1,49 @@
# Glimpse
> **Glimpse** — A procedural, skeuomorphic paper desk, sticky notes, photo, and visual moodboard workspace.
Inspired by creative coding desktop experiments, Glimpse renders tactile sheets of paper, Polaroid prints, luggage tags, crumpled receipts, and stationery on realistic desk surfaces with procedural paper grain shaders, dynamic 360° directional lighting, and realistic momentum drag-and-throw physics.
---
## Features
- **Procedural Paper Texture & Grain**: Dynamic SVG fractal noise filters simulating authentic physical paper fibers.
- **Dynamic 360° Directional Lighting**: Interactive `Light` angle control casting synchronized vector shadows across all sheets and props.
- **Tactile Sound Synthesis**: Built-in Web Audio API synthesizer for paper rustle, soft drop thuds, throw whooshes, tape peel, and pin taps without external audio assets.
- **Physics Engine ("Flick to Throw")**: Momentum drag-and-throw mechanics with velocity tracking, rotational inertia, air damping, and boundary collisions.
- **Layout Toggling**: Instant transition between organic desktop disarray (`Scatter`) and clean moodboards (`Grid`).
- **Freehand Pen Doodling**: Draw, underline, cross out tasks, or sketch arrows directly onto cards or the desk with smooth ink strokes.
- **Multiple Desk Textures**: Oak wood, walnut, self-healing cutting mat, corkboard, blueprint drafting, dark slate, and Italian Carrara marble.
- **Card Variety**:
- Sticky notes with interactive to-do checkboxes
- Polaroid instant photos with mounting corners
- Kraft luggage tags with brass eyelets & twine
- Ripped memo sheets with typewriter lettering
- Minimalist business cards
- Itemized store receipts with barcodes
- Hand-drawn SVG graph and flowchart sketches
- Washi tape strips, paperclips, and pushpins
---
## Keyboard Shortcuts
- `S` or `Space` — **Shuffle** cards across the desk
- `G` — Toggle **Grid** vs. **Scatter** layout
- `P` — Toggle freehand **Pen** mode
- `N` — **Add New** custom card
- `M` — **Replay** entry motion
- `Double-click` — **Edit** or delete card
---
## Self-Hosting with Docker & Traefik
Glimpse runs in a lightweight, multi-stage Alpine Nginx container:
```bash
docker compose up -d --build
```
Configured for Traefik reverse proxy via dynamic rules on `t3_proxy` network at `https://glimpse.vloser.us`.
+21
View File
@@ -0,0 +1,21 @@
services:
glimpse:
build:
context: .
dockerfile: Dockerfile
container_name: glimpse
restart: unless-stopped
networks:
- t3_proxy
ports:
- "127.0.0.1:8476:80"
healthcheck:
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:80/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
t3_proxy:
external: true
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='70' height='70' x='15' y='15' fill='%23FFE279' rx='4' transform='rotate(6 50 50)'/><rect width='65' height='65' x='18' y='18' fill='%23FFF9A6' rx='3' transform='rotate(-4 50 50)'/></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Glimpse — Procedural Paper Desk & Workspace</title>
<!-- Google Fonts for authentic handwriting, typewriter, and desk typography -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@400..700&family=Courier+Prime:ital,wght@0,400;0,700;1,400&family=Indie+Flower&family=Nanum+Pen+Script&family=Playfair+Display:ital,wght@0,400..700;1,400&family=Reenie+Beanie&family=Shadows+Into+Light&family=Special+Elite&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body class="overflow-hidden select-none bg-[#111] text-[#222]">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(?:ico|css|js|gif|jpe?g|png|svg|woff2?|eot|ttf|otf)$ {
root /usr/share/nginx/html;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+2856
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "glimpse",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.2"
},
"devDependencies": {
"@types/node": "^22.13.9",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2",
"vite": "^6.2.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+643
View File
@@ -0,0 +1,643 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { CardItem, DeskProp, DeskSettings, InkStroke } from './types';
import { generateProceduralCards, SAMPLE_PRESETS } from './utils/proceduralCards';
import { soundEngine } from './utils/sound';
import { TopBar } from './components/TopBar';
import { BottomBar } from './components/BottomBar';
import { DeskBackground } from './components/DeskTextures';
import { PaperCard } from './components/PaperCard';
import { DeskPropsLayer } from './components/DeskProps';
import { PenLayer } from './components/PenLayer';
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 }
];
export const App: React.FC = () => {
// Desk Settings matching the original Paper Desk UI
const [settings, setSettings] = useState<DeskSettings>({
lightAngle: 225, // Exactly 225° from Maksim's post
grain: 0.70, // Exactly 0.70 Grain
attachChance: 0.55, // Exactly 55% Attach
cardCount: 22, // High-density aesthetic desk
deskTexture: 'oak', // Oak desk
layoutMode: 'scatter',
fontMode: 'mixed',
showProps: true,
enableMotion: true,
penMode: false,
soundEnabled: false,
activeFilter: 'all'
});
const [cards, setCards] = useState<CardItem[]>([]);
const [propsList, setPropsList] = useState<DeskProp[]>(INITIAL_PROPS);
const [strokes, setStrokes] = useState<InkStroke[]>([]);
const [maxZIndex, setMaxZIndex] = useState(50);
// Modals state
const [isAddOpen, setIsAddOpen] = useState(false);
const [editingCard, setEditingCard] = useState<CardItem | null>(null);
const [isPortfolioOpen, setIsPortfolioOpen] = useState(false);
// Dragging & Physics references
const draggingIdRef = useRef<string | null>(null);
const dragTypeRef = useRef<'card' | 'prop'>('card');
const dragOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const pointerHistoryRef = useRef<{ x: number; y: number; time: number }[]>([]);
const [draggingCardId, setDraggingCardId] = useState<string | null>(null);
// Initialize cards on mount
useEffect(() => {
const w = typeof window !== 'undefined' ? window.innerWidth : 1400;
const h = typeof window !== 'undefined' ? window.innerHeight : 900;
const initialCards = generateProceduralCards(
settings.cardCount,
w,
h,
settings.attachChance,
settings.fontMode
);
setCards(initialCards);
setMaxZIndex(initialCards.length + 20);
}, []);
// Update card count when slider changes
const updateSettings = (newSettings: Partial<DeskSettings>) => {
setSettings((prev) => {
const updated = { ...prev, ...newSettings };
if (newSettings.cardCount !== undefined && newSettings.cardCount !== prev.cardCount) {
const w = window.innerWidth;
const h = window.innerHeight;
const diff = newSettings.cardCount - cards.length;
if (diff > 0) {
const added = generateProceduralCards(diff, w, h, updated.attachChance, updated.fontMode);
setCards((c) => [...c, ...added]);
} else if (diff < 0) {
setCards((c) => c.slice(0, newSettings.cardCount));
}
}
if (newSettings.attachChance !== undefined && newSettings.attachChance !== prev.attachChance) {
setCards((c) =>
c.map((card) => {
if (Math.random() < (newSettings.attachChance ?? 0.55)) {
const types: CardItem['attachType'][] = ['washi', 'pin', 'paperclip'];
return { ...card, attachType: types[Math.floor(Math.random() * types.length)] };
}
return { ...card, attachType: 'none' };
})
);
}
return updated;
});
};
// Re-shuffle cards
const handleShuffle = useCallback(() => {
soundEngine.shuffle(settings.soundEnabled);
const w = window.innerWidth;
const h = window.innerHeight;
setCards((prev) => {
return prev.map((card, i) => {
const padding = 100;
const targetX = padding + Math.random() * (w - card.width - padding * 2);
const targetY = padding + Math.random() * (h - card.height - padding * 2);
const randRot = (Math.random() - 0.5) * 24;
return {
...card,
x: targetX,
y: targetY,
rotation: randRot,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
zIndex: i + 1
};
});
});
}, [settings.soundEnabled]);
// Arrange in Grid mode
const arrangeGrid = useCallback(() => {
soundEngine.rustle(settings.soundEnabled);
const w = window.innerWidth;
const h = window.innerHeight;
const paddingX = 120;
const paddingY = 110;
const availableW = w - paddingX * 2;
const availableH = h - paddingY * 2;
const cols = Math.ceil(Math.sqrt(cards.length * 1.4));
const rows = Math.ceil(cards.length / cols);
const cellW = availableW / cols;
const cellH = availableH / rows;
setCards((prev) =>
prev.map((card, idx) => {
const col = idx % cols;
const row = Math.floor(idx / cols);
return {
...card,
x: paddingX + col * cellW + (cellW - card.width) / 2,
y: paddingY + row * cellH + (cellH - card.height) / 2,
rotation: (Math.random() - 0.5) * 4, // Subtle organic alignment
vx: 0,
vy: 0
};
})
);
}, [cards.length, settings.soundEnabled]);
// Trigger grid when layoutMode changes
useEffect(() => {
if (settings.layoutMode === 'grid') {
arrangeGrid();
} else {
handleShuffle();
}
}, [settings.layoutMode]);
// Physics animation loop on requestAnimationFrame
useEffect(() => {
if (!settings.enableMotion) return;
let animId: number;
const physicsLoop = () => {
setCards((prevCards) => {
let hasMotion = false;
const updated = prevCards.map((card) => {
// If being dragged, physics doesn't apply to it directly
if (card.id === draggingIdRef.current) return card;
const speed = Math.hypot(card.vx, card.vy);
if (speed < 0.05) return card;
hasMotion = true;
let newX = card.x + card.vx;
let newY = card.y + card.vy;
let newVx = card.vx * 0.92; // Friction damping
let newVy = card.vy * 0.92;
// Screen edge bounce
const maxX = window.innerWidth - card.width - 20;
const maxY = window.innerHeight - card.height - 20;
if (newX < 20) {
newX = 20;
newVx *= -0.5;
} else if (newX > maxX) {
newX = maxX;
newVx *= -0.5;
}
if (newY < 60) {
newY = 60;
newVy *= -0.5;
} else if (newY > maxY) {
newY = maxY;
newVy *= -0.5;
}
return {
...card,
x: newX,
y: newY,
vx: newVx,
vy: newVy
};
});
return hasMotion ? updated : prevCards;
});
animId = requestAnimationFrame(physicsLoop);
};
animId = requestAnimationFrame(physicsLoop);
return () => cancelAnimationFrame(animId);
}, [settings.enableMotion]);
// Pointer Down on a Card
const handleCardPointerDown = (e: React.PointerEvent, card: CardItem) => {
if (settings.penMode) return;
e.stopPropagation();
const newZ = maxZIndex + 1;
setMaxZIndex(newZ);
draggingIdRef.current = card.id;
dragTypeRef.current = 'card';
setDraggingCardId(card.id);
dragOffsetRef.current = {
x: e.clientX - card.x,
y: e.clientY - card.y
};
pointerHistoryRef.current = [{ x: e.clientX, y: e.clientY, time: Date.now() }];
soundEngine.rustle(settings.soundEnabled, 1.0);
setCards((prev) =>
prev.map((c) => (c.id === card.id ? { ...c, zIndex: newZ, vx: 0, vy: 0 } : c))
);
};
// Pointer Down on a Prop
const handlePropPointerDown = (e: React.PointerEvent, prop: DeskProp) => {
if (settings.penMode) return;
e.stopPropagation();
const newZ = maxZIndex + 1;
setMaxZIndex(newZ);
draggingIdRef.current = prop.id;
dragTypeRef.current = 'prop';
dragOffsetRef.current = {
x: e.clientX - prop.x,
y: e.clientY - prop.y
};
pointerHistoryRef.current = [{ x: e.clientX, y: e.clientY, time: Date.now() }];
soundEngine.rustle(settings.soundEnabled, 0.5);
setPropsList((prev) =>
prev.map((p) => (p.id === prop.id ? { ...p, zIndex: newZ } : p))
);
};
// Global Pointer Move
const handleGlobalPointerMove = (e: React.PointerEvent) => {
if (!draggingIdRef.current) return;
const currentX = e.clientX;
const currentY = e.clientY;
const targetX = currentX - dragOffsetRef.current.x;
const targetY = currentY - dragOffsetRef.current.y;
// Track pointer history for momentum / flick
pointerHistoryRef.current.push({ x: currentX, y: currentY, time: Date.now() });
if (pointerHistoryRef.current.length > 5) {
pointerHistoryRef.current.shift();
}
if (dragTypeRef.current === 'card') {
setCards((prev) =>
prev.map((c) => (c.id === draggingIdRef.current ? { ...c, x: targetX, y: targetY } : c))
);
} else {
setPropsList((prev) =>
prev.map((p) => (p.id === draggingIdRef.current ? { ...p, x: targetX, y: targetY } : p))
);
}
};
// Global Pointer Up (Release with Momentum / Throw)
const handleGlobalPointerUp = () => {
if (!draggingIdRef.current) return;
const id = draggingIdRef.current;
draggingIdRef.current = null;
setDraggingCardId(null);
// Calculate throw velocity from recent pointer samples
const history = pointerHistoryRef.current;
let vx = 0;
let vy = 0;
if (history.length >= 2) {
const recent = history[history.length - 1];
const older = history[0];
const dt = (recent.time - older.time) || 16;
if (dt < 250) {
vx = ((recent.x - older.x) / dt) * 14;
vy = ((recent.y - older.y) / dt) * 14;
}
}
const speed = Math.hypot(vx, vy);
if (dragTypeRef.current === 'card') {
if (speed > 4) {
soundEngine.flick(settings.soundEnabled);
} else {
soundEngine.drop(settings.soundEnabled);
}
setCards((prev) =>
prev.map((c) =>
c.id === id
? {
...c,
vx: Math.max(-25, Math.min(25, vx)),
vy: Math.max(-25, Math.min(25, vy))
}
: c
)
);
} else {
soundEngine.drop(settings.soundEnabled);
}
};
// Keyboard Shortcuts (S, G, M, P, N, Space)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is inside an input/textarea
if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) return;
if (e.key === 's' || e.key === 'S' || e.code === 'Space') {
e.preventDefault();
handleShuffle();
} else if (e.key === 'g' || e.key === 'G') {
e.preventDefault();
setSettings((prev) => ({
...prev,
layoutMode: prev.layoutMode === 'scatter' ? 'grid' : 'scatter'
}));
} else if (e.key === 'p' || e.key === 'P') {
e.preventDefault();
setSettings((prev) => ({ ...prev, penMode: !prev.penMode }));
} else if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
setIsAddOpen(true);
} else if (e.key === 'm' || e.key === 'M') {
e.preventDefault();
handleShuffle();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleShuffle]);
// Load Presets
const handleLoadPreset = (presetName: string) => {
const w = window.innerWidth;
const h = window.innerHeight;
if (presetName === 'original') {
const generated = generateProceduralCards(20, w, h, 0.6, 'mixed');
setCards(generated);
setSettings((s) => ({ ...s, deskTexture: 'oak', lightAngle: 225, grain: 0.7 }));
} else if (presetName === 'designer') {
const designerCards: CardItem[] = SAMPLE_PRESETS.slice(0, 14).map((p, i) => ({
id: `design-${i}`,
type: p.type || 'sticky',
title: p.title,
content: p.content || '',
color: p.color || '#FFF9A6',
x: 140 + (i % 4) * 260,
y: 120 + Math.floor(i / 4) * 230,
vx: 0,
vy: 0,
rotation: (Math.random() - 0.5) * 6,
width: p.width || 180,
height: p.height || 180,
zIndex: i + 1,
isPinned: false,
attachType: i % 2 === 0 ? 'washi' : 'paperclip',
fontFamily: p.fontFamily || 'font-caveat',
todoItems: p.todoItems
}));
setCards(designerCards);
setSettings((s) => ({ ...s, deskTexture: 'cutting-mat', lightAngle: 180 }));
} else if (presetName === 'creative-coder') {
const coderCards: CardItem[] = [
{
id: 'code-1',
type: 'graph',
title: 'Sine wave vectors',
content: 'y = sin(x) * 45 + noise(x)',
graphType: 'curve',
x: 200,
y: 200,
vx: 0,
vy: 0,
rotation: -2,
width: 220,
height: 190,
zIndex: 1,
isPinned: false,
attachType: 'washi',
fontFamily: 'font-mono'
},
{
id: 'code-2',
type: 'torn',
title: 'Shader experiment',
subtitle: 'Fragment shader',
content: 'precision mediump float;\nuniform vec2 u_res;\nvoid main() {\n gl_FragColor = vec4(1.0);\n}',
x: 480,
y: 180,
vx: 0,
vy: 0,
rotation: 3,
width: 240,
height: 250,
zIndex: 2,
isPinned: false,
attachType: 'pin',
fontFamily: 'font-mono'
},
{
id: 'code-3',
type: 'sticky',
title: 'Pen Plotter Test',
content: 'AxiDraw v3\nSpeed: 30%\nPen: Sakura Pigma Micron 05',
color: '#FFF9A6',
x: 780,
y: 240,
vx: 0,
vy: 0,
rotation: -4,
width: 170,
height: 170,
zIndex: 3,
isPinned: false,
attachType: 'washi',
fontFamily: 'font-courier'
}
];
setCards(coderCards);
setSettings((s) => ({ ...s, deskTexture: 'blueprint', lightAngle: 270 }));
} else if (presetName === 'photojournal') {
const photoCards = SAMPLE_PRESETS.filter((p) => p.type === 'polaroid').map((p, i) => ({
id: `photo-${i}`,
type: 'polaroid' as const,
title: p.title,
photoCaption: p.photoCaption,
content: p.content || '',
imageUrl: p.imageUrl,
x: 180 + (i % 3) * 280,
y: 140 + Math.floor(i / 3) * 260,
vx: 0,
vy: 0,
rotation: (Math.random() - 0.5) * 8,
width: 200,
height: 240,
zIndex: i + 1,
isPinned: false,
attachType: 'washi' as const,
fontFamily: 'font-caveat'
}));
setCards(photoCards);
setSettings((s) => ({ ...s, deskTexture: 'walnut', lightAngle: 225 }));
}
soundEngine.shuffle(settings.soundEnabled);
};
// Filtered Cards
const visibleCards = cards.filter((card) => {
if (settings.activeFilter === 'all') return true;
return card.type === settings.activeFilter;
});
return (
<div
className="relative w-screen h-screen overflow-hidden select-none"
onPointerMove={handleGlobalPointerMove}
onPointerUp={handleGlobalPointerUp}
>
{/* 1. Procedural Desk Background Surface */}
<DeskBackground texture={settings.deskTexture} grain={settings.grain} />
{/* 2. Top Bar Controls */}
<TopBar
settings={settings}
onUpdateSettings={updateSettings}
onShuffle={handleShuffle}
onReplay={handleShuffle}
onAddCard={() => setIsAddOpen(true)}
onOpenPortfolio={() => setIsPortfolioOpen(true)}
totalCards={visibleCards.length}
/>
{/* 3. Cards Layer */}
<div className="absolute inset-0 overflow-hidden">
{visibleCards.map((card) => (
<PaperCard
key={card.id}
card={card}
lightAngle={settings.lightAngle}
isDragging={draggingCardId === card.id}
onPointerDown={handleCardPointerDown}
onDoubleClick={(c) => setEditingCard(c)}
onToggleTodo={(cardId, idx) => {
setCards((prev) =>
prev.map((c) => {
if (c.id !== cardId || !c.todoItems) return c;
const updatedTodos = [...c.todoItems];
updatedTodos[idx] = {
...updatedTodos[idx],
done: !updatedTodos[idx].done
};
return { ...c, todoItems: updatedTodos };
})
);
}}
/>
))}
</div>
{/* 4. Stationery Props Layer (Pencils, Pens, Washi rolls, Paperclips) */}
{settings.showProps && (
<DeskPropsLayer
props={propsList}
lightAngle={settings.lightAngle}
onPointerDown={handlePropPointerDown}
/>
)}
{/* 5. Pen / Freehand Ink Layer */}
<PenLayer
active={settings.penMode}
strokes={strokes}
onAddStroke={(s) => setStrokes((prev) => [...prev, s])}
onClear={() => setStrokes([])}
/>
{/* 6. Bottom Shortcut Bar */}
<BottomBar />
{/* 7. Modals */}
<AddCardModal
isOpen={isAddOpen}
onClose={() => setIsAddOpen(false)}
onAdd={(newCard) => {
const w = window.innerWidth;
const h = window.innerHeight;
const created: CardItem = {
id: `card-custom-${Date.now()}`,
type: newCard.type || 'sticky',
title: newCard.title,
content: newCard.content || '',
imageUrl: newCard.imageUrl,
photoCaption: newCard.photoCaption,
color: newCard.color || '#FFF9A6',
x: w / 2 - 90 + (Math.random() - 0.5) * 60,
y: h / 2 - 90 + (Math.random() - 0.5) * 60,
vx: 0,
vy: 0,
rotation: (Math.random() - 0.5) * 8,
width: newCard.width || 180,
height: newCard.height || 180,
zIndex: maxZIndex + 1,
isPinned: false,
attachType: newCard.attachType || 'washi',
fontFamily: newCard.fontFamily || 'font-caveat'
};
setMaxZIndex(maxZIndex + 1);
setCards((prev) => [...prev, created]);
soundEngine.drop(settings.soundEnabled);
}}
/>
<EditCardModal
card={editingCard}
isOpen={!!editingCard}
onClose={() => setEditingCard(null)}
onSave={(updated) => {
setCards((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
soundEngine.rustle(settings.soundEnabled);
}}
onDelete={(id) => {
setCards((prev) => prev.filter((c) => c.id !== id));
soundEngine.rustle(settings.soundEnabled);
}}
/>
<PortfolioModal
isOpen={isPortfolioOpen}
onClose={() => setIsPortfolioOpen(false)}
cards={cards}
settings={settings}
onLoadPreset={handleLoadPreset}
onImportJson={(importedCards) => {
setCards(importedCards);
soundEngine.shuffle(settings.soundEnabled);
}}
/>
</div>
);
};
export default App;
+275
View File
@@ -0,0 +1,275 @@
import React, { useState } from 'react';
import { CardItem, CardType, AttachType } from '../types';
import { X } from 'lucide-react';
interface AddCardModalProps {
isOpen: boolean;
onClose: () => void;
onAdd: (card: Partial<CardItem>) => void;
}
export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onAdd }) => {
const [type, setType] = useState<CardType>('sticky');
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [color, setColor] = useState('#FFF9A6');
const [attachType, setAttachType] = useState<AttachType>('washi');
const [fontFamily, setFontFamily] = useState('font-caveat');
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onAdd({
type,
title: title.trim() || undefined,
content: content.trim(),
imageUrl: type === 'polaroid' ? (imageUrl.trim() || 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=600&q=80') : undefined,
photoCaption: type === 'polaroid' ? (title.trim() || content.trim()) : undefined,
color,
attachType,
fontFamily,
width: type === 'polaroid' ? 175 : type === 'tag' ? 140 : 180,
height: type === 'polaroid' ? 220 : type === 'tag' ? 220 : 180
});
onClose();
setTitle('');
setContent('');
setImageUrl('');
};
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">
<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">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
{/* Card Type */}
<div>
<label className="block text-neutral-400 mb-1">Card Type</label>
<div className="grid grid-cols-4 gap-1.5">
{(['sticky', 'polaroid', 'tag', 'torn', 'business', 'moodboard'] as CardType[]).map((t) => (
<button
type="button"
key={t}
onClick={() => setType(t)}
className={`py-1.5 rounded capitalize border transition text-center ${
type === t
? '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}
</button>
))}
</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"
/>
</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>
{/* Image URL for Polaroid */}
{type === 'polaroid' && (
<div>
<label className="block text-neutral-400 mb-1">Image URL (Optional)</label>
<input
type="url"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://images.unsplash.com/..."
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>
)}
{/* Color & Fastener */}
<div className="grid grid-cols-2 gap-3">
<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>
</div>
<div>
<label className="block text-neutral-400 mb-1">Fastener</label>
<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"
>
<option value="none">None</option>
<option value="washi">Washi Tape</option>
<option value="pin">Red Pushpin</option>
<option value="paperclip">Paperclip</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-neutral-800">
<button
type="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
type="submit"
className="px-4 py-1.5 rounded bg-amber-500 hover:bg-amber-400 text-neutral-950 font-bold"
>
Add Card
</button>
</div>
</form>
</div>
</div>
);
};
interface EditCardModalProps {
card: CardItem | null;
isOpen: boolean;
onClose: () => void;
onSave: (updated: CardItem) => void;
onDelete: (id: string) => void;
}
export const EditCardModal: React.FC<EditCardModalProps> = ({
card,
isOpen,
onClose,
onSave,
onDelete
}) => {
if (!isOpen || !card) return null;
const [title, setTitle] = useState(card.title || '');
const [content, setContent] = useState(card.content || '');
const [color, setColor] = useState(card.color || '#FFF9A6');
const handleSave = () => {
onSave({
...card,
title: title.trim() || undefined,
content: content.trim(),
color
});
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">
<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">
<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 focus:outline-none focus:border-amber-500"
/>
</div>
<div>
<label className="block text-neutral-400 mb-1">Content</label>
<textarea
rows={4}
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"
/>
</div>
<div>
<label className="block text-neutral-400 mb-1">Paper Tint</label>
<div className="flex gap-2">
{['#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>
</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
</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>
</div>
);
};
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
export const BottomBar: React.FC = () => {
return (
<footer className="fixed bottom-2 left-1/2 -translate-x-1/2 z-40 px-3.5 py-1 bg-black/60 text-neutral-400 rounded-full text-[11px] font-mono backdrop-blur-sm pointer-events-none select-none border border-white/5 flex items-center gap-2 shadow-lg">
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block" />
<span>drag anything</span>
</span>
<span className="text-neutral-600">·</span>
<span>flick to throw</span>
<span className="text-neutral-600">·</span>
<span>double-click to edit</span>
<span className="text-neutral-600">·</span>
<span className="text-neutral-300 font-semibold">S</span> shuffle
<span className="text-neutral-600">·</span>
<span className="text-neutral-300 font-semibold">G</span> grid
<span className="text-neutral-600">·</span>
<span className="text-neutral-300 font-semibold">P</span> pen
<span className="text-neutral-600">·</span>
<span className="text-neutral-300 font-semibold">N</span> new
<span className="text-neutral-600">·</span>
<span className="text-neutral-300 font-semibold">Space</span> scatter
</footer>
);
};
+144
View File
@@ -0,0 +1,144 @@
import React from 'react';
import { DeskProp } from '../types';
import { getPropShadow } from '../utils/lighting';
interface DeskPropsProps {
props: DeskProp[];
lightAngle: number;
onPointerDown: (e: React.PointerEvent, prop: DeskProp) => void;
}
export const DeskPropsLayer: React.FC<DeskPropsProps> = ({
props,
lightAngle,
onPointerDown
}) => {
return (
<div className="absolute inset-0 pointer-events-none">
{props.map((prop) => {
const shadow = getPropShadow(lightAngle, 6);
return (
<div
key={prop.id}
className="absolute cursor-grab active:cursor-grabbing pointer-events-auto select-none will-change-transform"
style={{
left: `${prop.x}px`,
top: `${prop.y}px`,
zIndex: prop.zIndex,
transform: `rotate(${prop.rotation}deg)`,
transformOrigin: 'center center',
filter: `drop-shadow(${shadow})`,
touchAction: 'none'
}}
onPointerDown={(e) => onPointerDown(e, prop)}
>
{/* 1. CLASSIC HB WOODEN PENCIL */}
{prop.type === 'pencil' && (
<div className="w-52 h-4 relative flex items-center">
{/* Graphite Lead Tip */}
<div
className="w-4 h-full bg-neutral-900"
style={{ clipPath: 'polygon(0% 50%, 100% 0%, 100% 100%)' }}
/>
{/* Sharpened Wood Bevel */}
<div
className="w-5 h-full bg-[#E3C59E]"
style={{ clipPath: 'polygon(0% 25%, 100% 0%, 100% 100%, 0% 75%)' }}
/>
{/* Yellow Hexagonal Body */}
<div
className="w-36 h-full bg-[#F5B025] flex flex-col justify-between py-[1px] px-1 border-y border-amber-600/40"
style={{
backgroundImage: 'linear-gradient(to bottom, rgba(255,255,255,0.4), transparent 40%, rgba(0,0,0,0.15) 80%)'
}}
>
<span className="text-[7px] font-mono text-amber-900/60 font-bold tracking-widest pl-2">
№ 2 // HB
</span>
</div>
{/* Silver Metal Ferrule */}
<div
className="w-5 h-full bg-neutral-300 border-x border-neutral-400"
style={{
backgroundImage: 'linear-gradient(to bottom, rgba(255,255,255,0.8), rgba(0,0,0,0.2))'
}}
/>
{/* Pink Rubber Eraser */}
<div className="w-4 h-full bg-[#F28B96] rounded-r-sm" />
</div>
)}
{/* 2. BLACK GEL PEN */}
{prop.type === 'pen' && (
<div className="w-48 h-3.5 relative flex items-center">
{/* Fine Metal Tip */}
<div
className="w-5 h-full bg-neutral-300"
style={{ clipPath: 'polygon(0% 50%, 100% 15%, 100% 85%)' }}
/>
{/* Pen Grip */}
<div className="w-8 h-full bg-neutral-800 rounded-l-[1px] border-r border-neutral-700" />
{/* Matte Pen Body */}
<div
className="w-28 h-full bg-neutral-900"
style={{
backgroundImage: 'linear-gradient(to bottom, rgba(255,255,255,0.2), transparent 45%, rgba(0,0,0,0.3))'
}}
/>
{/* Metal Clip & Cap End */}
<div className="w-7 h-full bg-neutral-800 rounded-r-md relative">
<div className="absolute top-[-3px] right-2 w-10 h-1.5 bg-neutral-300 rounded-full border border-neutral-400 shadow-sm" />
</div>
</div>
)}
{/* 3. HIGHLIGHTER / MARKER */}
{prop.type === 'marker' && (
<div className="w-44 h-6 relative flex items-center">
<div
className="w-4 h-full bg-[#38D9A9]"
style={{ clipPath: 'polygon(0% 40%, 100% 10%, 100% 90%, 0% 60%)' }}
/>
<div className="w-32 h-full bg-white rounded-r-sm border border-neutral-200 flex items-center px-2 shadow-inner">
<span className="text-[8px] font-bold tracking-widest text-neutral-400 font-mono">
CHISEL MARKER
</span>
</div>
<div className="w-8 h-full bg-[#38D9A9] rounded-r-md" />
</div>
)}
{/* 4. WASHI TAPE ROLL */}
{prop.type === 'tape-roll' && (
<div className="w-14 h-14 rounded-full border-[6px] border-[#8FB39A] bg-[#EFE9DD] flex items-center justify-center relative shadow-inner">
{/* Inner cardboard spool */}
<div className="w-7 h-7 rounded-full border-2 border-amber-900/40 bg-[#D4C3A3] flex items-center justify-center">
<div className="w-4 h-4 rounded-full bg-transparent border border-neutral-400/50" />
</div>
{/* Washi edge tab */}
<div className="absolute -top-1 -right-2 w-4 h-3 bg-[#8FB39A] rotate-45 rounded-[1px] shadow-sm" />
</div>
)}
{/* 5. LOOSE PAPERCLIP */}
{prop.type === 'paperclip' && (
<svg width="24" height="48" viewBox="0 0 24 48" fill="none" stroke="#94A3B8" strokeWidth="2.5" strokeLinecap="round">
<path d="M7 14 V38 C7 42, 17 42, 17 38 V10 C17 5, 2 5, 2 10 V34" />
<path d="M7 14 V38 C7 42, 17 42, 17 38 V10 C17 5, 2 5, 2 10 V34" stroke="rgba(255,255,255,0.7)" strokeWidth="1" />
</svg>
)}
{/* 6. RED PUSHPIN */}
{prop.type === 'pin' && (
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-red-400 via-red-600 to-red-800 border border-red-900/60 relative flex items-center justify-center shadow-lg">
<div className="w-2 h-2 rounded-full bg-white/70 absolute top-1 left-1" />
<div className="w-1.5 h-1.5 rounded-full bg-red-950" />
</div>
)}
</div>
);
})}
</div>
);
};
+205
View File
@@ -0,0 +1,205 @@
import React from 'react';
import { DeskTexture } from '../types';
interface DeskBackgroundProps {
texture: DeskTexture;
grain: number;
}
export const DeskBackground: React.FC<DeskBackgroundProps> = ({ texture, grain }) => {
return (
<>
{/* SVG Procedural Filter for Paper Fiber & Desk Grain */}
<svg className="absolute w-0 h-0 pointer-events-none" aria-hidden="true">
<defs>
<filter id="procedural-paper-grain" x="0%" y="0%" width="100%" height="100%">
<feTurbulence
type="fractalNoise"
baseFrequency={0.03 + grain * 0.05}
numOctaves="4"
result="noise"
/>
<feColorMatrix
type="matrix"
values="1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 0.15 0"
result="coloredNoise"
/>
<feComposite in="SourceGraphic" in2="coloredNoise" operator="over" />
</filter>
<filter id="desk-grain-overlay">
<feTurbulence
type="fractalNoise"
baseFrequency="0.04"
numOctaves="3"
result="noise"
/>
<feColorMatrix
type="matrix"
values="0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0.08 0"
/>
</filter>
</defs>
</svg>
{/* Desk Surface Layer */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{texture === 'oak' && (
<div
className="w-full h-full"
style={{
backgroundColor: '#A88258',
backgroundImage: `
repeating-linear-gradient(
90deg,
rgba(255,255,255,0.03) 0px,
rgba(0,0,0,0.04) 180px,
rgba(0,0,0,0.12) 181px,
rgba(255,255,255,0.05) 182px,
rgba(0,0,0,0.03) 360px
),
repeating-linear-gradient(
0deg,
rgba(0,0,0,0.03) 0px,
rgba(255,255,255,0.02) 2px,
transparent 4px,
rgba(0,0,0,0.02) 8px
),
radial-gradient(circle at 30% 40%, rgba(255,240,210,0.15), transparent 60%),
radial-gradient(circle at 75% 65%, rgba(60,40,15,0.15), transparent 70%)
`,
backgroundBlendMode: 'multiply, normal, screen, normal'
}}
/>
)}
{texture === 'walnut' && (
<div
className="w-full h-full"
style={{
backgroundColor: '#3E2A1E',
backgroundImage: `
repeating-linear-gradient(
90deg,
rgba(255,255,255,0.02) 0px,
rgba(0,0,0,0.08) 190px,
rgba(0,0,0,0.2) 191px,
rgba(255,255,255,0.03) 192px,
rgba(0,0,0,0.06) 380px
),
repeating-linear-gradient(
0deg,
rgba(0,0,0,0.05) 0px,
rgba(255,255,255,0.02) 2px,
transparent 4px,
rgba(0,0,0,0.04) 6px
),
radial-gradient(circle at 50% 50%, rgba(200,160,120,0.08), transparent 75%)
`
}}
/>
)}
{texture === 'cutting-mat' && (
<div
className="w-full h-full"
style={{
backgroundColor: '#1E4D3E',
backgroundImage: `
linear-gradient(to right, rgba(255,255,255,0.15) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.15) 1px, transparent 1px),
linear-gradient(to right, rgba(255,255,255,0.3) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.3) 1px, transparent 1px)
`,
backgroundSize: '20px 20px, 20px 20px, 100px 100px, 100px 100px'
}}
/>
)}
{texture === 'corkboard' && (
<div
className="w-full h-full"
style={{
backgroundColor: '#BE8C59',
backgroundImage: `
radial-gradient(rgba(80, 50, 20, 0.25) 1px, transparent 1px),
radial-gradient(rgba(240, 200, 150, 0.3) 1px, transparent 1px),
radial-gradient(rgba(100, 60, 30, 0.15) 2px, transparent 2px)
`,
backgroundSize: '8px 8px, 13px 13px, 21px 21px',
backgroundPosition: '0 0, 4px 4px, 8px 8px'
}}
/>
)}
{texture === 'blueprint' && (
<div
className="w-full h-full"
style={{
backgroundColor: '#0F4C81',
backgroundImage: `
linear-gradient(to right, rgba(255,255,255,0.1) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.1) 1px, transparent 1px),
linear-gradient(to right, rgba(255,255,255,0.25) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.25) 1px, transparent 1px)
`,
backgroundSize: '15px 15px, 15px 15px, 75px 75px, 75px 75px'
}}
/>
)}
{texture === 'dark-slate' && (
<div
className="w-full h-full"
style={{
backgroundColor: '#1E2024',
backgroundImage: `
radial-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
radial-gradient(rgba(0,0,0,0.3) 2px, transparent 2px)
`,
backgroundSize: '16px 16px, 32px 32px'
}}
/>
)}
{texture === 'marble' && (
<div
className="w-full h-full"
style={{
backgroundColor: '#EFEFEF',
backgroundImage: `
radial-gradient(circle at 20% 30%, rgba(0,0,0,0.03) 0%, transparent 60%),
radial-gradient(circle at 80% 70%, rgba(0,0,0,0.04) 0%, transparent 50%),
linear-gradient(135deg, rgba(0,0,0,0.02) 25%, transparent 25%, transparent 50%, rgba(0,0,0,0.02) 50%, rgba(0,0,0,0.02) 75%, transparent 75%, transparent)
`,
backgroundSize: '100% 100%, 100% 100%, 60px 60px'
}}
/>
)}
{/* Global procedural paper grain overlay */}
<div
className="absolute inset-0 pointer-events-none opacity-40 mix-blend-overlay"
style={{
backgroundImage: `radial-gradient(rgba(0,0,0,${grain * 0.4}) 1px, transparent 1px)`,
backgroundSize: '4px 4px'
}}
/>
{/* Subtle vignette around viewport edges */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background: 'radial-gradient(circle at 50% 50%, transparent 65%, rgba(0,0,0,0.3) 100%)'
}}
/>
</div>
</>
);
};
+121
View File
@@ -0,0 +1,121 @@
import React from 'react';
import { AttachType, StampType } from '../types';
import { getPinShadow, getTapeShadow } from '../utils/lighting';
interface FastenerProps {
type: AttachType;
color?: string;
angle?: number;
lightAngle: number;
}
export const Fastener: React.FC<FastenerProps> = ({ type, color = '#E6C280', angle = -4, lightAngle }) => {
if (type === 'none') return null;
if (type === 'washi') {
return (
<div
className="absolute -top-3 left-1/2 -translate-x-1/2 z-30 pointer-events-none select-none"
style={{
transform: `translateX(-50%) rotate(${angle}deg)`,
filter: `drop-shadow(${getTapeShadow(lightAngle)})`
}}
>
<div
className="w-20 h-6 relative opacity-85 backdrop-blur-[0.5px]"
style={{
backgroundColor: color,
backgroundImage: `
repeating-linear-gradient(
90deg,
rgba(255,255,255,0.15) 0px,
rgba(255,255,255,0.15) 2px,
transparent 2px,
transparent 4px
)
`,
clipPath: 'polygon(3% 0%, 97% 2%, 100% 95%, 97% 100%, 3% 98%, 0% 5%)'
}}
>
{/* Subtle tape sheen */}
<div className="absolute inset-0 bg-gradient-to-b from-white/20 to-transparent" />
</div>
</div>
);
}
if (type === 'pin') {
return (
<div
className="absolute -top-2 left-1/2 -translate-x-1/2 z-40 pointer-events-none select-none"
style={{
filter: `drop-shadow(${getPinShadow(lightAngle)})`
}}
>
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-red-400 via-red-600 to-red-800 border border-red-900/40 relative shadow-inner flex items-center justify-center">
{/* Pushpin specular highlight */}
<div className="w-1.5 h-1.5 rounded-full bg-white/70 absolute top-0.5 left-1" />
<div className="w-1 h-1 rounded-full bg-red-950/60" />
</div>
</div>
);
}
if (type === 'paperclip') {
return (
<div
className="absolute -top-3 right-5 z-40 pointer-events-none select-none"
style={{
filter: `drop-shadow(${getTapeShadow(lightAngle)})`
}}
>
<svg width="18" height="36" viewBox="0 0 24 48" fill="none" stroke="#A0A5AA" strokeWidth="2.5" strokeLinecap="round">
<path d="M7 14 V38 C7 42, 17 42, 17 38 V10 C17 5, 2 5, 2 10 V34" />
<path d="M7 14 V38 C7 42, 17 42, 17 38 V10 C17 5, 2 5, 2 10 V34" stroke="rgba(255,255,255,0.6)" strokeWidth="1" />
</svg>
</div>
);
}
return null;
};
export const Stamp: React.FC<{ type?: StampType; angle?: number }> = ({ type, angle = 8 }) => {
if (!type) return null;
const colorClass = type === 'APPROVED' ? 'text-red-700 border-red-700/80 bg-red-700/5' :
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' :
'text-neutral-800 border-neutral-800/80 bg-neutral-800/5';
return (
<div
className={`absolute bottom-3 right-3 pointer-events-none select-none z-20 font-special uppercase font-bold tracking-widest text-[11px] px-2 py-0.5 border-2 rounded-sm border-dashed opacity-75 mix-blend-multiply ${colorClass}`}
style={{
transform: `rotate(${angle}deg)`
}}
>
{type}
</div>
);
};
export const PhotoCorners: React.FC = () => {
return (
<>
<div className="absolute top-1 left-1 w-3.5 h-3.5 pointer-events-none z-20">
<div className="w-full h-full border-t-2 border-l-2 border-neutral-800 bg-neutral-900/60 [clip-path:polygon(0%_0%,100%_0%,0%_100%)]" />
</div>
<div className="absolute top-1 right-1 w-3.5 h-3.5 pointer-events-none z-20">
<div className="w-full h-full border-t-2 border-r-2 border-neutral-800 bg-neutral-900/60 [clip-path:polygon(0%_0%,100%_0%,100%_100%)]" />
</div>
<div className="absolute bottom-1 left-1 w-3.5 h-3.5 pointer-events-none z-20">
<div className="w-full h-full border-b-2 border-l-2 border-neutral-800 bg-neutral-900/60 [clip-path:polygon(0%_0%,0%_100%,100%_100%)]" />
</div>
<div className="absolute bottom-1 right-1 w-3.5 h-3.5 pointer-events-none z-20">
<div className="w-full h-full border-b-2 border-r-2 border-neutral-800 bg-neutral-900/60 [clip-path:polygon(100%_0%,100%_100%,0%_100%)]" />
</div>
</>
);
};
+406
View File
@@ -0,0 +1,406 @@
import React, { useState } from 'react';
import { CardItem } from '../types';
import { getShadowStyle } from '../utils/lighting';
import { Fastener, Stamp, PhotoCorners } from './Fastener';
interface PaperCardProps {
card: CardItem;
lightAngle: number;
isDragging: boolean;
onPointerDown: (e: React.PointerEvent, card: CardItem) => void;
onDoubleClick: (card: CardItem) => void;
onToggleTodo?: (cardId: string, index: number) => void;
}
export const PaperCard: React.FC<PaperCardProps> = ({
card,
lightAngle,
isDragging,
onPointerDown,
onDoubleClick,
onToggleTodo
}) => {
const [isHovered, setIsHovered] = useState(false);
// Get dynamic shadow based on light angle and drag state
const shadowStyle = getShadowStyle(
lightAngle,
isDragging,
isHovered ? 1.25 : 1.0,
card.type === 'sticky'
);
return (
<div
id={card.id}
className={`absolute cursor-grab active:cursor-grabbing select-none transition-transform will-change-transform ${
isDragging ? 'z-50 scale-[1.03]' : ''
}`}
style={{
left: `${card.x}px`,
top: `${card.y}px`,
width: `${card.width}px`,
zIndex: card.zIndex,
transform: `rotate(${card.rotation}deg)`,
transformOrigin: 'center center',
touchAction: 'none'
}}
onPointerDown={(e) => onPointerDown(e, card)}
onDoubleClick={() => onDoubleClick(card)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Top Attachment (Washi tape, pin, or paperclip) */}
<Fastener
type={card.attachType}
color={card.attachColor}
angle={card.attachAngle}
lightAngle={lightAngle}
/>
{/* Main Paper Body */}
<div
className="relative rounded-sm overflow-hidden text-neutral-900 transition-shadow duration-150"
style={{
backgroundColor: card.color || '#FFF9A6',
boxShadow: shadowStyle.boxShadow,
minHeight: `${card.height}px`
}}
>
{/* Subtle Paper Grain Filter Overlay */}
<div className="absolute inset-0 pointer-events-none opacity-25 mix-blend-multiply bg-[radial-gradient(#000_1px,transparent_1px)] [background-size:3px_3px]" />
{/* Paper Corner Curled Illusion for Sticky Notes */}
{card.type === 'sticky' && (
<div
className="absolute -bottom-1 -right-1 w-6 h-6 pointer-events-none"
style={{
background: 'linear-gradient(135deg, transparent 50%, rgba(0,0,0,0.12) 50%)',
borderTopLeftRadius: '2px'
}}
/>
)}
{/* Rubber Stamp */}
<Stamp type={card.stamp} angle={card.stampAngle} />
{/* 1. POLAROID CARD */}
{card.type === 'polaroid' && (
<div className="p-2.5 pb-4 bg-white flex flex-col items-center">
<PhotoCorners />
<div className="w-full aspect-[4/3] bg-neutral-200 overflow-hidden relative shadow-inner">
{card.imageUrl ? (
<img
src={card.imageUrl}
alt={card.title || 'Polaroid'}
className="w-full h-full object-cover pointer-events-none"
loading="lazy"
draggable={false}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-neutral-400">
No Image
</div>
)}
{/* Glossy photo finish sheen */}
<div className="absolute inset-0 bg-gradient-to-tr from-transparent via-white/10 to-transparent pointer-events-none" />
</div>
<div className="mt-3 text-center w-full px-1">
<span className={`text-base font-medium text-neutral-800 whitespace-pre-line tracking-wide ${card.fontFamily}`}>
{card.photoCaption || card.title}
</span>
</div>
</div>
)}
{/* 2. STICKY NOTE */}
{card.type === 'sticky' && (
<div className={`p-4 flex flex-col justify-between h-full ${card.fontFamily}`}>
{card.title && (
<div className="text-base font-bold tracking-tight text-neutral-900/90 mb-1 border-b border-black/5 pb-1">
{card.title}
</div>
)}
{card.todoItems && card.todoItems.length > 0 ? (
<div className="space-y-1 my-1">
{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-800 rounded-sm w-3.5 h-3.5 pointer-events-none"
/>
<span
className={`text-sm leading-tight transition-all ${
item.done ? 'line-through text-neutral-500/70' : 'text-neutral-800'
}`}
>
{item.text}
</span>
</div>
))}
</div>
) : (
<div
className={`text-base leading-snug whitespace-pre-line ${
card.color === '#282C34' ? 'text-white/90 text-2xl text-center my-auto py-2' : 'text-neutral-800'
}`}
>
{card.content}
</div>
)}
{card.date && (
<div className="text-[11px] text-neutral-500 text-right mt-2 font-mono">
{card.date}
</div>
)}
</div>
)}
{/* 3. KRAFT LUGGAGE / PARCEL TAG */}
{card.type === 'tag' && (
<div
className="p-4 flex flex-col items-center justify-between text-neutral-800 relative [clip-path:polygon(20%_0%,80%_0%,100%_15%,100%_100%,0%_100%,0%_15%)]"
style={{ backgroundColor: card.color || '#CBB296', minHeight: `${card.height}px` }}
>
{/* Tag string & brass eyelet */}
<div className="w-5 h-5 rounded-full border-2 border-amber-900/60 bg-amber-100/50 shadow-inner mt-1 mb-3 flex items-center justify-center">
<div className="w-2.5 h-2.5 rounded-full bg-neutral-900/80 shadow" />
</div>
<div className="text-center w-full my-auto space-y-2">
<div className="text-xs font-mono font-bold tracking-widest text-neutral-700 uppercase">
{card.tagNumber || '#1828'}
</div>
<div className="text-xl font-bold tracking-wider font-courier uppercase text-neutral-900">
{card.subtitle || 'fragile'}
</div>
<div className="text-xs font-mono text-neutral-600">
{card.date || 'Sept 4'}
</div>
</div>
<div className="w-full text-center border-t border-amber-900/20 pt-2 text-[10px] tracking-widest uppercase font-mono text-neutral-600">
PRIORITY PARCEL
</div>
</div>
)}
{/* 4. TORN PAPER / LETTER */}
{card.type === 'torn' && (
<div
className="p-5 flex flex-col justify-between"
style={{
clipPath: 'polygon(0% 2%, 8% 0%, 18% 2.5%, 28% 0.5%, 40% 3%, 52% 0.5%, 65% 2%, 78% 0%, 90% 2.5%, 100% 0%, 100% 100%, 0% 100%)'
}}
>
<div className="flex justify-between items-baseline border-b border-neutral-400/30 pb-2 mb-3">
<span className="font-mono text-xs uppercase tracking-wider text-neutral-500 font-bold">
{card.title || 'Memo'}
</span>
<span className="text-[11px] font-mono text-neutral-400">
{card.date || 'Fri — late'}
</span>
</div>
{card.subtitle && (
<div className="text-sm font-semibold mb-2 font-courier text-neutral-700">
{card.subtitle}
</div>
)}
{card.todoItems && card.todoItems.length > 0 ? (
<div className="space-y-1.5 font-caveat text-lg">
{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-800 rounded-sm w-4 h-4 pointer-events-none"
/>
<span className={`${item.done ? 'line-through text-neutral-400' : 'text-neutral-800'}`}>
{item.text}
</span>
</div>
))}
</div>
) : (
<div className={`text-sm leading-relaxed whitespace-pre-line text-neutral-800 ${card.fontFamily}`}>
{card.content}
</div>
)}
<div className="mt-4 pt-2 border-t border-dashed border-neutral-300 text-[10px] text-neutral-400 font-mono flex justify-between">
<span>NOTES // LOG</span>
<span>CONFIDENTIAL</span>
</div>
</div>
)}
{/* 5. MINIMAL BUSINESS CARD */}
{card.type === 'business' && (
<div className="p-5 flex flex-col justify-between h-full font-courier border border-black/5">
<div className="text-right text-[10px] tracking-widest text-neutral-500 font-mono">
[ JL ]
</div>
<div className="my-auto">
<div className="text-sm font-bold tracking-widest text-neutral-900 uppercase">
{card.title || 'JULES LAURENT'}
</div>
<div className="text-[11px] text-neutral-600 tracking-wider mt-0.5 uppercase">
{card.subtitle || 'PRODUCT DESIGNER'}
</div>
</div>
<div className="text-[10px] text-neutral-500 whitespace-pre-line font-mono tracking-tight pt-2 border-t border-neutral-400/20">
{card.content}
</div>
</div>
)}
{/* 6. STORE RECEIPT */}
{card.type === 'receipt' && (
<div
className="p-4 flex flex-col justify-between text-neutral-800 font-mono text-[11px]"
style={{
clipPath: 'polygon(0% 0%, 5% 1.5%, 10% 0%, 15% 1.5%, 20% 0%, 25% 1.5%, 30% 0%, 35% 1.5%, 40% 0%, 45% 1.5%, 50% 0%, 55% 1.5%, 60% 0%, 65% 1.5%, 70% 0%, 75% 1.5%, 80% 0%, 85% 1.5%, 90% 0%, 95% 1.5%, 100% 0%, 100% 100%, 95% 98.5%, 90% 100%, 85% 98.5%, 80% 100%, 75% 98.5%, 70% 100%, 65% 98.5%, 60% 100%, 55% 98.5%, 50% 100%, 45% 98.5%, 40% 100%, 35% 98.5%, 30% 100%, 25% 98.5%, 20% 100%, 15% 98.5%, 10% 100%, 5% 98.5%, 0% 100%)'
}}
>
<div className="text-center pb-2 border-b border-dashed border-neutral-400">
<div className="font-bold text-xs tracking-wider">{card.title || 'MALL STORE'}</div>
<div className="text-[10px] text-neutral-500">{card.subtitle}</div>
<div className="text-[10px] text-neutral-400 mt-1">{card.date || '10/24 15:34'}</div>
</div>
<div className="py-3 space-y-1">
{card.receiptItems?.map((it, idx) => (
<div key={idx} className="flex justify-between items-center text-[10px]">
<span className={idx >= (card.receiptItems?.length || 0) - 2 ? 'font-bold' : ''}>
{it.name}
</span>
<span className="font-mono">{it.price}</span>
</div>
))}
</div>
{/* Barcode representation */}
<div className="pt-2 border-t border-dashed border-neutral-400 flex flex-col items-center">
<div className="h-6 w-32 flex items-stretch gap-[1.5px] justify-center opacity-80 my-1">
{[2, 1, 3, 1, 2, 4, 1, 3, 2, 1, 3, 1, 2, 1, 4, 2, 1, 3, 2, 1].map((w, i) => (
<div key={i} className="bg-neutral-900" style={{ width: `${w}px` }} />
))}
</div>
<span className="text-[9px] tracking-widest text-neutral-500 font-mono">
* 8 4 9 2 0 1 8 5 *
</span>
</div>
</div>
)}
{/* 7. GRAPH / WIREFRAME SKETCH */}
{card.type === 'graph' && (
<div
className="p-4 flex flex-col justify-between"
style={{
backgroundImage: 'linear-gradient(to right, rgba(100,140,200,0.15) 1px, transparent 1px), linear-gradient(to bottom, rgba(100,140,200,0.15) 1px, transparent 1px)',
backgroundSize: '12px 12px'
}}
>
<div className="flex justify-between items-baseline mb-2">
<span className="font-courier text-xs font-bold text-neutral-700">
{card.title}
</span>
<span className="text-[10px] font-mono text-neutral-400">Fig. 1A</span>
</div>
{/* SVG Hand-drawn Charts */}
<div className="my-auto py-2 flex items-center justify-center">
{card.graphType === 'barchart' && (
<svg width="150" height="90" viewBox="0 0 150 90" fill="none" stroke="#2563EB" strokeWidth="1.8">
<path d="M 10 80 L 140 80" stroke="#64748B" />
<path d="M 10 10 L 10 80" stroke="#64748B" />
{/* Hatched bars */}
<rect x="25" y="45" width="18" height="35" fill="none" />
<path d="M 25 75 L 43 55 M 25 65 L 43 45 M 25 55 L 35 45" stroke="#3B82F6" strokeWidth="1.2" />
<rect x="55" y="25" width="18" height="55" fill="none" />
<path d="M 55 75 L 73 55 M 55 65 L 73 45 M 55 55 L 73 35 M 55 45 L 73 25" stroke="#3B82F6" strokeWidth="1.2" />
<rect x="85" y="15" width="18" height="65" fill="none" />
<path d="M 85 75 L 103 55 M 85 65 L 103 45 M 85 55 L 103 35 M 85 45 L 103 25 M 85 35 L 103 15" stroke="#3B82F6" strokeWidth="1.2" />
<rect x="115" y="38" width="18" height="42" fill="none" />
<path d="M 115 75 L 133 55 M 115 65 L 133 45 M 115 55 L 133 35" stroke="#3B82F6" strokeWidth="1.2" />
</svg>
)}
{card.graphType === 'flowchart' && (
<svg width="160" height="85" viewBox="0 0 160 85" fill="none" stroke="#475569" strokeWidth="1.5">
<rect x="10" y="10" width="40" height="25" rx="2" fill="white" />
<text x="16" y="26" fontSize="9" fill="#475569" fontFamily="monospace">INPUT</text>
<path d="M 50 22 L 70 22" stroke="#475569" markerEnd="url(#arrow)" />
<rect x="70" y="10" width="40" height="25" rx="2" fill="white" />
<text x="74" y="26" fontSize="9" fill="#475569" fontFamily="monospace">TRANS</text>
<path d="M 110 22 L 130 22" stroke="#475569" />
<circle cx="140" cy="22" r="10" fill="white" />
{/* Branch down */}
<path d="M 90 35 L 90 55 L 110 55" stroke="#475569" />
<rect x="110" y="45" width="40" height="22" rx="2" fill="white" />
<text x="115" y="59" fontSize="8" fill="#475569" fontFamily="monospace">OUTPUT</text>
</svg>
)}
{card.graphType === 'curve' && (
<svg width="150" height="85" viewBox="0 0 150 85" fill="none" stroke="#2563EB" strokeWidth="2">
<path d="M 10 75 L 140 75" stroke="#94A3B8" strokeWidth="1" />
<path d="M 10 10 L 10 75" stroke="#94A3B8" strokeWidth="1" />
<path d="M 10 65 Q 40 70 70 40 T 135 15" stroke="#2563EB" strokeWidth="2" strokeDasharray="3 2" />
<circle cx="70" cy="40" r="3" fill="#2563EB" />
<circle cx="135" cy="15" r="3" fill="#EF4444" />
</svg>
)}
</div>
<div className="text-[10px] text-neutral-500 font-courier text-right">
scale 1:1 · mm
</div>
</div>
)}
{/* 8. MOODBOARD CARD */}
{card.type === 'moodboard' && (
<div className="p-4 flex flex-col justify-between h-full font-caveat">
<div>
<div className="text-xl font-bold text-neutral-900 border-b border-neutral-300/60 pb-1 mb-2">
{card.title || 'Moodboard'}
</div>
<div className="text-base text-neutral-800 leading-snug whitespace-pre-line">
{card.content}
</div>
</div>
<div className="mt-3 pt-2 border-t border-neutral-200 flex items-center justify-between text-xs text-neutral-500 font-sans">
<span>{card.subtitle || 'Creative Direction'}</span>
<span className="font-mono text-[10px]">#mood</span>
</div>
</div>
)}
</div>
</div>
);
};
+149
View File
@@ -0,0 +1,149 @@
import React, { useRef, useState, useEffect } from 'react';
import { InkStroke } from '../types';
interface PenLayerProps {
active: boolean;
strokes: InkStroke[];
onAddStroke: (stroke: InkStroke) => void;
onClear: () => void;
}
export const PenLayer: React.FC<PenLayerProps> = ({
active,
strokes,
onAddStroke,
onClear
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
const currentPoints = useRef<{ x: number; y: number }[]>([]);
const [currentColor, setCurrentColor] = useState('#1E293B'); // Ink color
// Redraw all strokes
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Resize canvas to window
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
strokes.forEach((stroke) => {
if (stroke.points.length < 2) return;
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.beginPath();
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
ctx.stroke();
});
}, [strokes]);
const handlePointerDown = (e: React.PointerEvent) => {
if (!active) return;
setIsDrawing(true);
currentPoints.current = [{ x: e.clientX, y: e.clientY }];
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!active || !isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const newPt = { x: e.clientX, y: e.clientY };
currentPoints.current.push(newPt);
ctx.strokeStyle = currentColor;
ctx.lineWidth = 2.2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const pts = currentPoints.current;
if (pts.length > 1) {
ctx.beginPath();
ctx.moveTo(pts[pts.length - 2].x, pts[pts.length - 2].y);
ctx.lineTo(newPt.x, newPt.y);
ctx.stroke();
}
};
const handlePointerUp = () => {
if (!active || !isDrawing) return;
setIsDrawing(false);
if (currentPoints.current.length > 1) {
onAddStroke({
id: `stroke-${Date.now()}`,
points: [...currentPoints.current],
color: currentColor,
width: 2.2
});
}
currentPoints.current = [];
};
if (!active) {
// Show static rendered canvas layer if there are strokes, but without mouse interception
return (
<canvas
ref={canvasRef}
className="absolute inset-0 pointer-events-none z-30"
style={{ width: '100%', height: '100%' }}
/>
);
}
return (
<>
<canvas
ref={canvasRef}
className="absolute inset-0 z-40 cursor-crosshair touch-none"
style={{ width: '100%', height: '100%' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
/>
{/* Floating mini toolbar for ink color & clear */}
<div className="fixed bottom-12 right-6 z-50 bg-neutral-900/90 text-white p-2 rounded-lg backdrop-blur shadow-2xl flex items-center gap-3 border border-neutral-700">
<span className="text-xs font-mono text-neutral-400">Ink:</span>
<button
onClick={() => setCurrentColor('#1E293B')}
className={`w-5 h-5 rounded-full bg-slate-900 border-2 ${currentColor === '#1E293B' ? 'border-white' : 'border-transparent'}`}
title="Black Ink"
/>
<button
onClick={() => setCurrentColor('#DC2626')}
className={`w-5 h-5 rounded-full bg-red-600 border-2 ${currentColor === '#DC2626' ? 'border-white' : 'border-transparent'}`}
title="Red Ink"
/>
<button
onClick={() => setCurrentColor('#2563EB')}
className={`w-5 h-5 rounded-full bg-blue-600 border-2 ${currentColor === '#2563EB' ? 'border-white' : 'border-transparent'}`}
title="Blue Ballpoint"
/>
<button
onClick={() => setCurrentColor('#16A34A')}
className={`w-5 h-5 rounded-full bg-emerald-600 border-2 ${currentColor === '#16A34A' ? 'border-white' : 'border-transparent'}`}
title="Green Ink"
/>
<button
onClick={onClear}
className="text-xs px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-mono"
>
Clear Ink
</button>
</div>
</>
);
};
+189
View File
@@ -0,0 +1,189 @@
import React, { useState } from 'react';
import { X, Copy, Check, Download, Upload, Sparkles } from 'lucide-react';
import { CardItem, DeskSettings } from '../types';
interface PortfolioModalProps {
isOpen: boolean;
onClose: () => void;
cards: CardItem[];
settings: DeskSettings;
onLoadPreset: (presetName: string) => void;
onImportJson: (jsonCards: CardItem[]) => void;
}
export const PortfolioModal: React.FC<PortfolioModalProps> = ({
isOpen,
onClose,
cards,
settings,
onLoadPreset,
onImportJson
}) => {
const [copied, setCopied] = useState(false);
if (!isOpen) return null;
const exportJson = () => {
const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(cards, null, 2));
const downloadAnchor = document.createElement('a');
downloadAnchor.setAttribute('href', dataStr);
downloadAnchor.setAttribute('download', 'paper-desk-layout.json');
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
};
const handleCopyEmbed = () => {
const embedCode = `<iframe src="${window.location.origin}" width="100%" height="800" style="border:none; border-radius:12px; overflow:hidden;" title="Glimpse — Procedural Paper Desk"></iframe>`;
navigator.clipboard.writeText(embedCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const json = JSON.parse(event.target?.result as string);
if (Array.isArray(json)) {
onImportJson(json);
onClose();
}
} catch (err) {
alert('Invalid JSON file format');
}
};
reader.readAsText(file);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4 select-none">
<div className="bg-[#1C1A17] border border-neutral-700 w-full max-w-xl rounded-2xl p-6 text-neutral-200 shadow-2xl space-y-5">
<div className="flex justify-between items-center pb-3 border-b border-neutral-800">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Sparkles size={18} className="text-amber-400" />
<span>Use as Portfolio & Embed System</span>
</h2>
<p className="text-xs text-neutral-400 mt-0.5">
Turn your desk into an interactive digital portfolio, moodboard, or personal archive.
</p>
</div>
<button onClick={onClose} className="text-neutral-400 hover:text-white p-1">
<X size={18} />
</button>
</div>
{/* Preset Layouts */}
<div>
<h3 className="text-xs font-mono uppercase tracking-wider text-amber-400 mb-2 font-bold">
Curated Presets
</h3>
<div className="grid grid-cols-2 gap-2 text-xs">
<button
onClick={() => {
onLoadPreset('original');
onClose();
}}
className="p-3 rounded-lg bg-neutral-800/80 hover:bg-neutral-800 border border-neutral-700 hover:border-amber-500/50 text-left transition"
>
<div className="font-bold text-white">Paper Desk (Original)</div>
<div className="text-[11px] text-neutral-400 mt-1">
Faithful replica of Maksim Surguy's desk with notes, photos, receipts, and charts.
</div>
</button>
<button
onClick={() => {
onLoadPreset('designer');
onClose();
}}
className="p-3 rounded-lg bg-neutral-800/80 hover:bg-neutral-800 border border-neutral-700 hover:border-amber-500/50 text-left transition"
>
<div className="font-bold text-white">Product Designer</div>
<div className="text-[11px] text-neutral-400 mt-1">
Sprint tasks, wireframes, user testing stickies, design system specimens, and tags.
</div>
</button>
<button
onClick={() => {
onLoadPreset('creative-coder');
onClose();
}}
className="p-3 rounded-lg bg-neutral-800/80 hover:bg-neutral-800 border border-neutral-700 hover:border-amber-500/50 text-left transition"
>
<div className="font-bold text-white">Creative Technologist</div>
<div className="text-[11px] text-neutral-400 mt-1">
Generative shaders, pen plotter formulas, code snippets, and SVG filter experiments.
</div>
</button>
<button
onClick={() => {
onLoadPreset('photojournal');
onClose();
}}
className="p-3 rounded-lg bg-neutral-800/80 hover:bg-neutral-800 border border-neutral-700 hover:border-amber-500/50 text-left transition"
>
<div className="font-bold text-white">Photographer Journal</div>
<div className="text-[11px] text-neutral-400 mt-1">
High-density polaroids, photo corners, travel tickets, café receipts, and notes.
</div>
</button>
</div>
</div>
{/* Share & Embed */}
<div className="pt-2 border-t border-neutral-800">
<h3 className="text-xs font-mono uppercase tracking-wider text-amber-400 mb-2 font-bold">
Embed on Your Website
</h3>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`<iframe src="${window.location.origin}" width="100%" height="800" ... />`}
className="w-full bg-neutral-900 border border-neutral-700 rounded px-3 py-1.5 font-mono text-[11px] text-neutral-300"
/>
<button
onClick={handleCopyEmbed}
className="px-3 py-1.5 rounded bg-amber-500 hover:bg-amber-400 text-neutral-950 font-bold text-xs flex items-center gap-1.5 shrink-0"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
<span>{copied ? 'Copied!' : 'Copy Code'}</span>
</button>
</div>
</div>
{/* Import & Export */}
<div className="pt-2 border-t border-neutral-800 flex justify-between items-center text-xs">
<div className="flex gap-2">
<button
onClick={exportJson}
className="px-3 py-1.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700 flex items-center gap-1.5"
>
<Download size={13} />
<span>Export Layout JSON</span>
</button>
<label className="px-3 py-1.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700 flex items-center gap-1.5 cursor-pointer">
<Upload size={13} />
<span>Import JSON</span>
<input type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
</label>
</div>
<button
onClick={onClose}
className="px-4 py-1.5 rounded bg-neutral-700 hover:bg-neutral-600 text-white font-medium"
>
Done
</button>
</div>
</div>
</div>
);
};
+284
View File
@@ -0,0 +1,284 @@
import React from 'react';
import { DeskSettings, DeskTexture, FontMode, CardType } from '../types';
import { Volume2, VolumeX, Plus, Sparkles } from 'lucide-react';
interface TopBarProps {
settings: DeskSettings;
onUpdateSettings: (newSettings: Partial<DeskSettings>) => void;
onShuffle: () => void;
onReplay: () => void;
onAddCard: () => void;
onOpenPortfolio: () => void;
totalCards: number;
}
export const TopBar: React.FC<TopBarProps> = ({
settings,
onUpdateSettings,
onShuffle,
onReplay,
onAddCard,
onOpenPortfolio,
totalCards
}) => {
return (
<header className="fixed top-2.5 left-1/2 -translate-x-1/2 z-50 w-[98vw] max-w-[1440px] px-3.5 py-1.5 bg-[#1C1A17]/95 text-neutral-300 rounded-xl shadow-2xl border border-neutral-700/60 backdrop-blur-md flex flex-wrap items-center justify-between gap-y-2 text-xs select-none">
{/* 1. Left: Brand Title & Subtitle */}
<div className="flex items-center gap-2.5 mr-1">
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="font-bold text-white tracking-tight text-sm">
Glimpse
</span>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
</div>
<span className="text-[10px] text-neutral-400 font-mono tracking-tight leading-none">
procedural paper · visual desk
</span>
</div>
</div>
{/* 2. Primary Actions: Shuffle, Scatter/Grid */}
<div className="flex items-center gap-2">
<button
onClick={onShuffle}
className="px-3 py-1 rounded bg-[#ECA136] hover:bg-[#F2B04D] active:scale-95 text-neutral-950 font-bold transition shadow-sm flex items-center gap-1"
title="Press 'S' to shuffle"
>
Shuffle
</button>
{/* Scatter / Grid Toggle */}
<div className="flex items-center bg-neutral-800/90 rounded p-0.5 border border-neutral-700/80">
<button
onClick={() => onUpdateSettings({ layoutMode: 'scatter' })}
className={`px-2 py-0.5 rounded transition ${
settings.layoutMode === 'scatter'
? 'bg-neutral-600 text-white font-medium shadow-xs'
: 'text-neutral-400 hover:text-neutral-200'
}`}
title="Press 'G' to toggle"
>
Scatter
</button>
<button
onClick={() => onUpdateSettings({ layoutMode: 'grid' })}
className={`px-2 py-0.5 rounded transition ${
settings.layoutMode === 'grid'
? 'bg-neutral-600 text-white font-medium shadow-xs'
: 'text-neutral-400 hover:text-neutral-200'
}`}
title="Press 'G' to toggle"
>
Grid
</button>
</div>
{/* Typography Mode: Mixed, Handwritten, Typed */}
<div className="flex items-center bg-neutral-800/90 rounded p-0.5 border border-neutral-700/80">
{(['mixed', 'handwritten', 'typed'] as FontMode[]).map((mode) => (
<button
key={mode}
onClick={() => onUpdateSettings({ fontMode: mode })}
className={`px-2 py-0.5 rounded capitalize transition ${
settings.fontMode === mode
? 'bg-neutral-600 text-white font-medium shadow-xs'
: 'text-neutral-400 hover:text-neutral-200'
}`}
>
{mode}
</button>
))}
</div>
</div>
{/* 3. Desk Texture Selector */}
<div className="flex items-center gap-1.5">
<span className="text-neutral-400">Desk:</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"
>
<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>
</select>
</div>
{/* 4. Sliders: Cards, Grain, Attach, Light */}
<div className="flex items-center gap-3">
{/* Cards Count */}
<div className="flex items-center gap-1.5" title="Number of paper sheets on desk">
<span className="text-neutral-400">Cards</span>
<input
type="range"
min="10"
max="80"
step="1"
value={settings.cardCount}
onChange={(e) => onUpdateSettings({ cardCount: Number(e.target.value) })}
className="w-14 accent-amber-500 cursor-pointer h-1.5 bg-neutral-700 rounded-lg appearance-none"
/>
<span className="font-mono text-[11px] text-neutral-300 w-5">
{settings.cardCount}
</span>
</div>
{/* Grain */}
<div className="flex items-center gap-1.5" title="Paper texture grain density">
<span className="text-neutral-400">Grain</span>
<input
type="range"
min="0"
max="1"
step="0.05"
value={settings.grain}
onChange={(e) => onUpdateSettings({ grain: Number(e.target.value) })}
className="w-14 accent-amber-500 cursor-pointer h-1.5 bg-neutral-700 rounded-lg appearance-none"
/>
<span className="font-mono text-[11px] text-neutral-300 w-7">
{settings.grain.toFixed(2)}
</span>
</div>
{/* Attach Chance */}
<div className="flex items-center gap-1.5" title="Attachment frequency (washi tape, pins, clips)">
<span className="text-neutral-400">Attach</span>
<input
type="range"
min="0"
max="1"
step="0.05"
value={settings.attachChance}
onChange={(e) => onUpdateSettings({ attachChance: Number(e.target.value) })}
className="w-14 accent-amber-500 cursor-pointer h-1.5 bg-neutral-700 rounded-lg appearance-none"
/>
<span className="font-mono text-[11px] text-neutral-300 w-7">
{Math.round(settings.attachChance * 100)}%
</span>
</div>
{/* Dynamic Light Direction Dial */}
<div className="flex items-center gap-1.5" title="Directional desk lamp angle (shadow rotation)">
<span className="text-neutral-400">Light</span>
<input
type="range"
min="0"
max="360"
step="5"
value={settings.lightAngle}
onChange={(e) => onUpdateSettings({ lightAngle: Number(e.target.value) })}
className="w-14 accent-amber-400 cursor-pointer h-1.5 bg-neutral-700 rounded-lg appearance-none"
/>
<span className="font-mono text-[11px] text-amber-300 w-8">
{settings.lightAngle}°
</span>
</div>
</div>
{/* 5. Checkboxes: Props, Motion, Pen */}
<div className="flex items-center gap-2.5">
<label className="flex items-center gap-1 cursor-pointer text-neutral-300 hover:text-white">
<input
type="checkbox"
checked={settings.showProps}
onChange={(e) => onUpdateSettings({ showProps: e.target.checked })}
className="rounded accent-amber-500 cursor-pointer"
/>
<span>Props</span>
</label>
<label className="flex items-center gap-1 cursor-pointer text-neutral-300 hover:text-white">
<input
type="checkbox"
checked={settings.enableMotion}
onChange={(e) => onUpdateSettings({ enableMotion: e.target.checked })}
className="rounded accent-amber-500 cursor-pointer"
/>
<span>Motion</span>
</label>
<label className="flex items-center gap-1 cursor-pointer text-neutral-300 hover:text-white">
<input
type="checkbox"
checked={settings.penMode}
onChange={(e) => onUpdateSettings({ penMode: e.target.checked })}
className="rounded accent-amber-500 cursor-pointer"
/>
<span>Pen</span>
</label>
</div>
{/* 6. Action Buttons & Filter */}
<div className="flex items-center gap-2">
<button
onClick={onReplay}
className="px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white border border-neutral-700/80 transition"
title="Replay entry animations"
>
Replay
</button>
{/* Sound Toggle */}
<button
onClick={() => onUpdateSettings({ soundEnabled: !settings.soundEnabled })}
className={`px-2 py-0.5 rounded border transition flex items-center gap-1 ${
settings.soundEnabled
? 'bg-neutral-800 text-amber-400 border-amber-600/50'
: 'bg-neutral-800/60 text-neutral-400 border-neutral-700'
}`}
title="Toggle paper & stationery audio effects"
>
{settings.soundEnabled ? <Volume2 size={13} /> : <VolumeX size={13} />}
<span>{settings.soundEnabled ? 'Sound on' : 'Sound off'}</span>
</button>
{/* Card Type Filter */}
<select
value={settings.activeFilter}
onChange={(e) => onUpdateSettings({ activeFilter: e.target.value as DeskSettings['activeFilter'] })}
className="bg-neutral-800 border border-neutral-700 rounded px-1.5 py-0.5 text-neutral-300 focus:outline-none text-[11px] cursor-pointer"
>
<option value="all">Types: All</option>
<option value="sticky">Sticky Notes</option>
<option value="polaroid">Photos</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>
<option value="moodboard">Moodboards</option>
</select>
{/* Add New Card (+) Button */}
<button
onClick={onAddCard}
className="p-1 rounded bg-neutral-800 hover:bg-amber-500 hover:text-neutral-950 text-neutral-300 border border-neutral-700 transition"
title="Add a new custom note or card"
>
<Plus size={14} />
</button>
{/* Portfolio Mode / Presets Modal Button */}
<button
onClick={onOpenPortfolio}
className="px-2 py-0.5 rounded bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 border border-amber-500/40 transition flex items-center gap-1 font-medium"
title="Portfolio & Preset Modes"
>
<Sparkles size={12} />
<span>Portfolio</span>
</button>
{/* Stats readout like in original */}
<span className="font-mono text-[10px] text-neutral-400 pl-1 border-l border-neutral-700/80 hidden xl:inline">
{totalCards}/{totalCards} sheets
</span>
</div>
</header>
);
};
+61
View File
@@ -0,0 +1,61 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.font-caveat {
font-family: 'Caveat', cursive;
}
.font-reenie {
font-family: 'Reenie Beanie', cursive;
}
.font-indie {
font-family: 'Indie Flower', cursive;
}
.font-shadows {
font-family: 'Shadows Into Light', cursive;
}
.font-courier {
font-family: 'Courier Prime', Courier, monospace;
}
.font-special {
font-family: 'Special Elite', monospace;
}
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
}
html, body, #root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* Custom range sliders styling */
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #f59e0b;
cursor: pointer;
border: 1px solid #78350f;
box-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #f59e0b;
cursor: pointer;
border: 1px solid #78350f;
box-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+91
View File
@@ -0,0 +1,91 @@
export type CardType =
| 'sticky'
| 'polaroid'
| 'tag'
| 'torn'
| 'business'
| 'receipt'
| 'graph'
| 'moodboard';
export type AttachType = 'none' | 'washi' | 'paperclip' | 'pin';
export type DeskTexture =
| 'oak'
| 'walnut'
| 'cutting-mat'
| 'corkboard'
| 'blueprint'
| 'dark-slate'
| 'marble';
export type FontMode = 'mixed' | 'handwritten' | 'typed';
export type StampType = 'SAMPLE' | 'DRAFT' | 'PROOF' | 'APPROVED' | 'KEEP' | 'CONFIDENTIAL';
export interface CardItem {
id: string;
type: CardType;
title?: string;
content: string;
subtitle?: string;
date?: string;
author?: string;
imageUrl?: string;
photoCaption?: string;
color?: string; // pastel hex or theme
x: number;
y: number;
vx: number;
vy: number;
rotation: number;
targetRotation?: number;
width: number;
height: number;
zIndex: number;
isPinned: boolean;
attachType: AttachType;
attachColor?: string;
attachAngle?: number;
stamp?: StampType;
stampAngle?: number;
fontFamily: string;
todoItems?: { text: string; done: boolean }[];
graphType?: 'barchart' | 'flowchart' | 'wireframe' | 'curve';
receiptItems?: { name: string; price: string }[];
tagNumber?: string;
aspectRatio?: number;
}
export interface DeskProp {
id: string;
type: 'pencil' | 'pen' | 'marker' | 'paperclip' | 'pin' | 'tape-roll';
x: number;
y: number;
rotation: number;
color?: string;
scale?: number;
zIndex: number;
}
export interface InkStroke {
id: string;
points: { x: number; y: number }[];
color: string;
width: number;
}
export interface DeskSettings {
lightAngle: number; // 0 - 360
grain: number; // 0 - 1
attachChance: number; // 0 - 1
cardCount: number; // 10 - 80
deskTexture: DeskTexture;
layoutMode: 'scatter' | 'grid';
fontMode: FontMode;
showProps: boolean;
enableMotion: boolean;
penMode: boolean;
soundEnabled: boolean;
activeFilter: 'all' | CardType;
}
+90
View File
@@ -0,0 +1,90 @@
// Dynamic directional lighting and shadow calculation
export function getShadowStyle(
lightAngleDeg: number,
isDragging: boolean = false,
elevation: number = 1.0,
isCurled: boolean = false
): {
boxShadow: string;
transformStyle?: React.CSSProperties;
} {
// Light shines from lightAngleDeg, shadow casts toward opposite angle
const shadowAngleRad = ((lightAngleDeg + 180) % 360) * (Math.PI / 180);
const cos = Math.cos(shadowAngleRad);
const sin = Math.sin(shadowAngleRad);
if (isDragging) {
// Lifted high in the air
const dist1 = 18 * elevation;
const dist2 = 36 * elevation;
const x1 = Math.round(cos * dist1);
const y1 = Math.round(sin * dist1);
const x2 = Math.round(cos * dist2);
const y2 = Math.round(sin * dist2);
return {
boxShadow: `
${x1}px ${y1}px 24px rgba(0, 0, 0, 0.18),
${x2}px ${y2}px 48px rgba(0, 0, 0, 0.14),
0 0 1px rgba(0, 0, 0, 0.25)
`.trim()
};
}
// Resting on the desk
const distContact = 3 * elevation;
const distAmbient = 8 * elevation;
const distDiffuse = 16 * elevation;
const cx = Math.round(cos * distContact);
const cy = Math.round(sin * distContact);
const ax = Math.round(cos * distAmbient);
const ay = Math.round(sin * distAmbient);
const dx = Math.round(cos * distDiffuse);
const dy = Math.round(sin * distDiffuse);
// If curled, add asymmetric bottom shadow
const curlExtra = isCurled
? `, ${Math.round(cos * 10 + 3)}px ${Math.round(sin * 12 + 6)}px 14px rgba(0, 0, 0, 0.12)`
: '';
return {
boxShadow: `
${cx}px ${cy}px 4px rgba(0, 0, 0, 0.10),
${ax}px ${ay}px 12px rgba(0, 0, 0, 0.08),
${dx}px ${dy}px 24px rgba(0, 0, 0, 0.05)
${curlExtra}
`.trim()
};
}
export function getTapeShadow(lightAngleDeg: number) {
const shadowAngleRad = ((lightAngleDeg + 180) % 360) * (Math.PI / 180);
const cos = Math.cos(shadowAngleRad);
const sin = Math.sin(shadowAngleRad);
const x = Math.round(cos * 2);
const y = Math.round(sin * 2);
return `${x}px ${y}px 3px rgba(0,0,0,0.18)`;
}
export function getPinShadow(lightAngleDeg: number) {
const shadowAngleRad = ((lightAngleDeg + 180) % 360) * (Math.PI / 180);
const cos = Math.cos(shadowAngleRad);
const sin = Math.sin(shadowAngleRad);
const x = Math.round(cos * 6);
const y = Math.round(sin * 6);
return `${x}px ${y}px 8px rgba(0,0,0,0.35)`;
}
export function getPropShadow(lightAngleDeg: number, height = 8) {
const shadowAngleRad = ((lightAngleDeg + 180) % 360) * (Math.PI / 180);
const cos = Math.cos(shadowAngleRad);
const sin = Math.sin(shadowAngleRad);
const x = Math.round(cos * height);
const y = Math.round(sin * height);
return `${x}px ${y}px ${Math.round(height * 1.5)}px rgba(0,0,0,0.25)`;
}
+366
View File
@@ -0,0 +1,366 @@
import { CardItem, CardType, StampType } from '../types';
// Authentic seed data inspired directly by Paper Desk
export const SAMPLE_PRESETS: Partial<CardItem>[] = [
{
type: 'moodboard',
title: 'Moodboard',
content: 'ship it on Friday\nremember: kerning on the headline\nthank-you notes for the team',
color: '#FAF8F5',
fontFamily: 'font-caveat',
width: 250,
height: 190,
stamp: 'SAMPLE',
stampAngle: -4
},
{
type: 'sticky',
content: 'what if the\ngrid was 5\ncolumns?',
title: 'Moodboard',
color: '#E0D4FC', // lavender sticky
fontFamily: 'font-caveat',
width: 155,
height: 155,
rotation: 1.5
},
{
type: 'sticky',
title: 'idea: paper-textured hero',
content: 'idea: paper-\ntextured\nhero',
color: '#FFDE9E', // warm peach/orange sticky
fontFamily: 'font-caveat',
width: 170,
height: 170,
rotation: -1
},
{
type: 'tag',
title: 'No. 1828',
subtitle: 'fragile',
date: 'Sept 4',
content: 'PARCEL TAG',
color: '#CBB296', // kraft luggage tag
fontFamily: 'font-courier',
width: 130,
height: 220,
rotation: -7,
tagNumber: '#1828'
},
{
type: 'business',
title: 'JULES LAURENT',
subtitle: 'PRODUCT DESIGNER',
content: 'studio@juleslaurent.design\nportfolio / archive',
color: '#BFA89B', // muted mauve/kraft cardstock
fontFamily: 'font-courier',
width: 210,
height: 125,
rotation: -2
},
{
type: 'sticky',
title: 'Keep',
content: 'Keep',
color: '#282C34', // dark matte card
fontFamily: 'font-reenie',
width: 170,
height: 120,
rotation: -8
},
{
type: 'torn',
title: 'Fri — late',
subtitle: 'Dear Ava,',
content: 'Less is more. Rename the project. Also: try warmer whites, and sketch 20 ideas. Keep 3.\n\nMore soon —',
color: '#E8DCC8', // aged stationary paper
fontFamily: 'font-special',
width: 240,
height: 270,
rotation: 0.5
},
{
type: 'polaroid',
title: 'DAY!',
photoCaption: 'DAY!',
content: 'Sunny afternoon on the coast',
imageUrl: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=600&q=80',
color: '#FFFFFE',
fontFamily: 'font-special',
width: 160,
height: 200,
rotation: 2
},
{
type: 'polaroid',
title: 'summit jump',
photoCaption: 'summit jump',
content: 'High Sierra trail',
imageUrl: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=600&q=80',
color: '#FFFFFE',
fontFamily: 'font-caveat',
width: 220,
height: 210,
rotation: -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',
color: '#F4EFEA',
fontFamily: 'font-indie',
width: 175,
height: 230,
rotation: -4
},
{
type: 'sticky',
title: 'Reading list',
content: 'Weekly chores & reading',
color: '#BEE7F5', // pastel sky blue
fontFamily: 'font-caveat',
width: 175,
height: 205,
rotation: 1,
todoItems: [
{ text: 'draft blog post', done: true },
{ text: 'order business cards', done: false },
{ text: 'water plants', done: true },
{ text: 'bread', done: false }
]
},
{
type: 'torn',
title: 'Sprint 12',
content: 'Sprint tasks',
color: '#FFFDF9',
fontFamily: 'font-caveat',
width: 165,
height: 280,
rotation: -5,
todoItems: [
{ text: 'wireframes', done: false },
{ text: 'email client', done: false },
{ text: 'photograph sketches', done: true },
{ text: 'review PR', done: false },
{ text: 'draft blog post', done: true },
{ text: 'order business cards', done: true },
{ text: 'fix nav on mobile', done: false },
{ text: 'export assets', done: false }
]
},
{
type: 'graph',
title: 'User growth & metrics',
content: 'Q3 Analytics trajectory',
color: '#F0F2F5',
fontFamily: 'font-courier',
width: 200,
height: 200,
graphType: 'barchart',
rotation: 3
},
{
type: 'graph',
title: 'Architecture flowchart',
content: 'System wireframe blocks',
color: '#F8F6F0',
fontFamily: 'font-courier',
width: 220,
height: 190,
graphType: 'flowchart',
rotation: -2
},
{
type: 'graph',
title: 'Coordinate wireframe',
content: 'Grid layout specs',
color: '#FCFAF6',
fontFamily: 'font-courier',
width: 210,
height: 180,
graphType: 'curve',
rotation: 2
},
{
type: 'receipt',
title: 'MALL STORE',
subtitle: 'COFFEE & STATIONERY',
date: '10/24 15:34',
content: 'Receipt #9482',
color: '#F9F8F4',
fontFamily: 'font-mono',
width: 155,
height: 310,
rotation: 4,
receiptItems: [
{ name: 'PAPERBACK', price: '14.99' },
{ name: 'FLAT WHITE', price: '5.25' },
{ name: 'BAGEL', price: '4.50' },
{ name: 'WASHI TAPE', price: '3.75' },
{ name: 'SUBTOTAL', price: '28.49' },
{ name: 'TAX', price: '2.56' },
{ name: 'TOTAL', price: '$31.05' }
]
},
{
type: 'moodboard',
title: 'Q3 goals',
content: 'idea: paper-textured hero\ncoffee w/ Sam — 3pm\nTuesday: user interviews\nexplore SVG fractal noise filters',
color: '#FFF8E8',
fontFamily: 'font-indie',
width: 240,
height: 190,
rotation: -1.5,
stamp: 'APPROVED',
stampAngle: 12
},
{
type: 'sticky',
title: 'Moodboard',
content: 'remember:\nkerning on the\nhero typography',
color: '#FFD39B',
fontFamily: 'font-caveat',
width: 160,
height: 160,
rotation: 3
},
{
type: 'polaroid',
title: 'City Walk',
photoCaption: 'by Monday',
content: 'Downtown reflections',
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=600&q=80',
color: '#FFFFFF',
fontFamily: 'font-caveat',
width: 170,
height: 210,
rotation: -6
},
{
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',
color: '#FFFFFF',
fontFamily: 'font-indie',
width: 180,
height: 215,
rotation: 4
}
];
const WASHI_COLORS = [
'#E6C280', // kraft masking tape
'#8FB39A', // sage green washi
'#D98880', // coral pink washi
'#85C1E9', // sky blue washi
'#D7BDE2', // lavender washi
'#F7DC6F', // pale gold washi
'#48C9B0' // mint washi
];
const STICKY_COLORS = [
'#FFF9A6', // classic post-it yellow
'#FFDFD3', // pastel peach
'#E0D4FC', // lavender
'#BEE7F5', // pastel sky blue
'#C8F7C5', // soft mint
'#FFD6E0', // soft blush pink
'#FFE2CC' // warm cream
];
const FONTS_BY_MODE = {
handwritten: ['font-caveat', 'font-reenie', 'font-indie', 'font-shadows'],
typed: ['font-courier', 'font-special', 'font-mono'],
mixed: ['font-caveat', 'font-courier', 'font-reenie', 'font-special', 'font-indie', 'font-mono']
};
export function generateProceduralCards(
count: number,
canvasWidth: number,
canvasHeight: number,
attachChance: number = 0.55,
fontMode: 'mixed' | 'handwritten' | 'typed' = 'mixed'
): CardItem[] {
const cards: CardItem[] = [];
const paddingX = 80;
const paddingY = 90;
const usableWidth = Math.max(500, canvasWidth - paddingX * 2);
const usableHeight = Math.max(400, canvasHeight - paddingY * 2);
const availableFonts = FONTS_BY_MODE[fontMode];
for (let i = 0; i < count; i++) {
const preset = SAMPLE_PRESETS[i % SAMPLE_PRESETS.length];
// Position organically across desk with slight cluster jitter
const gridCols = Math.ceil(Math.sqrt(count * 1.5));
const gridRows = Math.ceil(count / gridCols);
const col = i % gridCols;
const row = Math.floor(i / gridCols);
const cellW = usableWidth / gridCols;
const cellH = usableHeight / gridRows;
// Base position with organic jitter
const baseX = paddingX + col * cellW + (Math.random() - 0.5) * cellW * 0.7;
const baseY = paddingY + row * cellH + (Math.random() - 0.5) * cellH * 0.7;
// Attachments (tape, clip, pin)
let attachType: CardItem['attachType'] = 'none';
if (Math.random() < attachChance) {
const rand = Math.random();
if (rand < 0.45) attachType = 'washi';
else if (rand < 0.75) attachType = 'pin';
else attachType = 'paperclip';
}
// Stamps
let stamp: StampType | undefined = preset.stamp;
if (!stamp && Math.random() < 0.15) {
const stamps: StampType[] = ['SAMPLE', 'DRAFT', 'PROOF', 'APPROVED'];
stamp = stamps[Math.floor(Math.random() * stamps.length)];
}
const rotation = (preset.rotation ?? 0) + (Math.random() * 10 - 5);
const chosenFont = availableFonts[Math.floor(Math.random() * availableFonts.length)];
cards.push({
id: `card-${i}-${Date.now().toString(36)}`,
type: preset.type || 'sticky',
title: preset.title,
subtitle: preset.subtitle,
content: preset.content || '',
date: preset.date,
author: preset.author,
imageUrl: preset.imageUrl,
photoCaption: preset.photoCaption,
color: preset.color || STICKY_COLORS[Math.floor(Math.random() * STICKY_COLORS.length)],
x: Math.max(paddingX, Math.min(canvasWidth - 250, baseX)),
y: Math.max(paddingY, Math.min(canvasHeight - 250, baseY)),
vx: 0,
vy: 0,
rotation: rotation,
width: preset.width || 180,
height: preset.height || 180,
zIndex: i + 1,
isPinned: false,
attachType: attachType,
attachColor: WASHI_COLORS[Math.floor(Math.random() * WASHI_COLORS.length)],
attachAngle: Math.floor(Math.random() * 20 - 10),
stamp: stamp,
stampAngle: Math.floor(Math.random() * 24 - 12),
fontFamily: chosenFont,
todoItems: preset.todoItems ? JSON.parse(JSON.stringify(preset.todoItems)) : undefined,
graphType: preset.graphType,
receiptItems: preset.receiptItems,
tagNumber: preset.tagNumber
});
}
return cards;
}
+184
View File
@@ -0,0 +1,184 @@
// Web Audio API Synthesizer for tactile paper & stationery sounds
let audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext | null {
if (typeof window === 'undefined') return null;
if (!audioCtx) {
const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (AudioContextClass) {
audioCtx = new AudioContextClass();
}
}
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume().catch(() => {});
}
return audioCtx;
}
export const soundEngine = {
// Paper sliding / rustle sound
rustle: (enabled = true, intensity = 1.0) => {
if (!enabled) return;
const ctx = getAudioContext();
if (!ctx) return;
try {
const bufferSize = ctx.sampleRate * 0.12; // 120ms
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.45));
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1400 + Math.random() * 400, ctx.currentTime);
filter.Q.setValueAtTime(1.8, ctx.currentTime);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.08 * intensity, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.12);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
noise.start();
} catch {
// Audio might be blocked before first interaction
}
},
// Paper landing / drop thud
drop: (enabled = true) => {
if (!enabled) return;
const ctx = getAudioContext();
if (!ctx) return;
try {
const now = ctx.currentTime;
// Low frequency soft thud
const osc = ctx.createOscillator();
const oscGain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(120, now);
osc.frequency.exponentialRampToValueAtTime(45, now + 0.08);
oscGain.gain.setValueAtTime(0.12, now);
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
osc.connect(oscGain);
oscGain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.09);
// Light paper flap noise
soundEngine.rustle(enabled, 0.6);
} catch {}
},
// Flick / throw whoosh
flick: (enabled = true) => {
if (!enabled) return;
const ctx = getAudioContext();
if (!ctx) return;
try {
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * 0.18;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.sin((i / bufferSize) * Math.PI);
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(800, now);
filter.frequency.exponentialRampToValueAtTime(1800, now + 0.08);
filter.frequency.exponentialRampToValueAtTime(600, now + 0.18);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.09, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
noise.start(now);
} catch {}
},
// Pin stick / tap sound
pin: (enabled = true) => {
if (!enabled) return;
const ctx = getAudioContext();
if (!ctx) return;
try {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(900, now);
osc.frequency.exponentialRampToValueAtTime(200, now + 0.05);
gain.gain.setValueAtTime(0.18, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.06);
} catch {}
},
// Tape peel / stick sound
tape: (enabled = true) => {
if (!enabled) return;
const ctx = getAudioContext();
if (!ctx) return;
try {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(3200, now);
osc.frequency.linearRampToValueAtTime(1600, now + 0.06);
gain.gain.setValueAtTime(0.04, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(2200, now);
osc.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.07);
} catch {}
},
// Shuffle multiple sheets rapidly
shuffle: (enabled = true) => {
if (!enabled) return;
for (let i = 0; i < 5; i++) {
setTimeout(() => {
soundEngine.rustle(enabled, 0.7 + Math.random() * 0.4);
}, i * 45);
}
}
};
+26
View File
@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
caveat: ['Caveat', 'cursive'],
reenie: ['"Reenie Beanie"', 'cursive'],
indie: ['"Indie Flower"', 'cursive'],
courier: ['"Courier Prime"', 'Courier', 'monospace'],
special: ['"Special Elite"', 'monospace'],
mono: ['"JetBrains Mono"', 'monospace'],
serif: ['"Playfair Display"', 'Georgia', 'serif'],
},
boxShadow: {
'paper': '0 2px 8px rgba(0,0,0,0.12), 0 8px 24px rgba(0,0,0,0.08)',
'lifted': '0 12px 32px rgba(0,0,0,0.22), 0 4px 12px rgba(0,0,0,0.12)',
'pin': '0 4px 8px rgba(0,0,0,0.3)',
}
},
},
plugins: [],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true
}
})