Initial commit of Glimpse: Procedural paper desk and moodboard workspace
This commit is contained in:
1 parent
eb0ad24141
commit
eb3787dd16
29 files changed
+6333
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
+23
@@ -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;"]
|
||||||
@@ -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`.
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Generated
+2856
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
+643
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user