Files
glimpse/src/components/PaperCard.tsx
T

633 lines
28 KiB
TypeScript

import React, { useState } from 'react';
import { CardItem } from '../types';
import { getShadowStyle } from '../utils/lighting';
import { Fastener, Stamp, PhotoCorners } from './Fastener';
import { Pencil, Globe, ExternalLink } from 'lucide-react';
interface PaperCardProps {
card: CardItem;
lightAngle: number;
isDragging: boolean;
isLocked?: 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,
isLocked = false,
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 select-none transition-transform will-change-transform ${
isLocked ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'
} ${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)}
onClick={(e) => {
if (isLocked && card.type === 'bookmark' && card.url) {
e.stopPropagation();
window.open(card.url.startsWith('http') ? card.url : `https://${card.url}`, '_blank', 'noopener,noreferrer');
}
}}
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]" />
{/* Quick Edit button for Mobile & Hover */}
<button
onClick={(e) => {
e.stopPropagation();
onDoubleClick(card);
}}
className={`absolute top-1.5 right-1.5 z-30 p-1 rounded-full bg-black/5 hover:bg-black/15 text-neutral-700 transition ${
isHovered ? 'opacity-90' : 'opacity-0 md:opacity-0 max-md:opacity-60'
}`}
title="Edit note"
>
<Pencil size={11} />
</button>
{/* 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-3.5 pb-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-0.5 mb-2 flex items-center justify-center shrink-0">
<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-1 px-1">
<div className="text-[10px] font-mono font-bold tracking-widest text-neutral-700/80 uppercase">
{card.tagNumber || card.subtitle || '#TAG'}
</div>
{card.title && (
<div className={`text-sm font-bold tracking-wide uppercase text-neutral-900 ${card.fontFamily}`}>
{card.title}
</div>
)}
{card.content && (
<div className={`text-xs text-neutral-900/90 leading-snug whitespace-pre-line py-1.5 border-y border-amber-900/20 my-1 ${card.fontFamily}`}>
{card.content}
</div>
)}
{card.date && (
<div className="text-[10px] font-mono text-neutral-700">
{card.date}
</div>
)}
</div>
<div className="w-full text-center border-t border-amber-900/20 pt-1.5 text-[9px] tracking-widest uppercase font-mono text-neutral-700 shrink-0">
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>
)}
{/* 9. BOOKMARK / WEB PREVIEW CARD */}
{card.type === 'bookmark' && (
<div className="p-3 bg-[#FAF8F5] text-neutral-900 flex flex-col justify-between h-full rounded border border-neutral-300/80 shadow-xs">
{/* Header: Favicon + Domain + External Link */}
<div className="flex items-center justify-between gap-1.5 pb-1.5 mb-1.5 border-b border-neutral-200/90">
<div className="flex items-center gap-1.5 overflow-hidden">
{card.faviconUrl ? (
<img src={card.faviconUrl} alt="" className="w-3.5 h-3.5 rounded-xs shrink-0" />
) : (
<Globe size={13} className="text-neutral-500 shrink-0" />
)}
<span className="font-mono text-[10px] text-neutral-600 truncate font-semibold">
{card.url ? (() => {
try {
return new URL(card.url.startsWith('http') ? card.url : `https://${card.url}`).hostname.replace('www.', '');
} catch {
return 'link';
}
})() : 'bookmark'}
</span>
</div>
{card.url && (
<a
href={card.url.startsWith('http') ? card.url : `https://${card.url}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-1 rounded bg-neutral-200/60 hover:bg-amber-400 hover:text-black text-neutral-600 transition shrink-0"
title="Open link in new window"
>
<ExternalLink size={11} />
</a>
)}
</div>
{/* Thumbnail Preview Image */}
<div
className="w-full aspect-[16/10] bg-neutral-200 rounded overflow-hidden relative shadow-inner mb-2 cursor-pointer group"
onClick={(e) => {
if (card.url) {
e.stopPropagation();
window.open(card.url.startsWith('http') ? card.url : `https://${card.url}`, '_blank', 'noopener,noreferrer');
}
}}
>
{card.imageUrl ? (
<img
src={card.imageUrl}
alt={card.title || 'Web preview'}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
loading="lazy"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-neutral-400 gap-1 bg-neutral-100">
<Globe size={18} className="text-neutral-400" />
<span className="text-[10px] font-mono">Web Snapshot</span>
</div>
)}
{/* Hover visit pill */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/15 transition flex items-center justify-center">
<span className="opacity-0 group-hover:opacity-100 bg-black/80 text-white text-[10px] font-mono px-2 py-0.5 rounded shadow transition flex items-center gap-1">
<ExternalLink size={10} /> Visit
</span>
</div>
</div>
{/* Title & Description */}
<div className="flex-1 flex flex-col justify-between">
<div>
<h4
className="font-bold text-xs leading-snug line-clamp-2 text-neutral-900 cursor-pointer hover:text-amber-600 transition"
onClick={(e) => {
if (card.url) {
e.stopPropagation();
window.open(card.url.startsWith('http') ? card.url : `https://${card.url}`, '_blank', 'noopener,noreferrer');
}
}}
>
{card.title || card.url || 'Web Bookmark'}
</h4>
{card.content && (
<p className="text-[11px] text-neutral-600 line-clamp-2 mt-1 font-sans">
{card.content}
</p>
)}
</div>
{card.url && (
<div className="mt-2 pt-1.5 border-t border-neutral-200/80 flex items-center justify-between text-[10px] text-neutral-500 font-mono">
<span className="truncate max-w-[130px]">{card.url}</span>
<span className="text-amber-600 font-bold flex items-center gap-0.5">
↗
</span>
</div>
)}
</div>
</div>
)}
{/* 10. INTERACTIVE CHECKLIST CARD */}
{card.type === 'checklist' && (
<div className="p-4 flex flex-col justify-between h-full bg-[#FCFAF2] text-neutral-900 border border-neutral-300/80">
<div>
<div className="flex justify-between items-center pb-2 mb-2 border-b border-neutral-300">
<span className="font-bold text-sm tracking-tight">{card.title || 'To-Do List'}</span>
{card.todoItems && card.todoItems.length > 0 && (
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-neutral-200 text-neutral-700">
{card.todoItems.filter((t) => t.done).length}/{card.todoItems.length}
</span>
)}
</div>
{card.todoItems && card.todoItems.length > 0 ? (
<div className={`space-y-1.5 py-1 ${card.fontFamily || 'font-caveat'} text-base`}>
{card.todoItems.map((item, idx) => (
<div
key={idx}
className="flex items-center gap-2 cursor-pointer group"
onClick={(e) => {
e.stopPropagation();
onToggleTodo?.(card.id, idx);
}}
>
<input
type="checkbox"
checked={item.done}
readOnly
className="cursor-pointer accent-neutral-900 rounded-sm w-3.5 h-3.5 pointer-events-none"
/>
<span className={`${item.done ? 'line-through text-neutral-400' : 'text-neutral-800'}`}>
{item.text}
</span>
</div>
))}
</div>
) : (
<div className="text-xs text-neutral-500 italic py-2">
{card.content || 'No tasks listed'}
</div>
)}
</div>
<div className="pt-2 border-t border-neutral-200 flex justify-between text-[10px] font-mono text-neutral-500">
<span>CHECKLIST</span>
<span>{card.date || 'PENDING'}</span>
</div>
</div>
)}
{/* 11. VINTAGE TICKET / BOARDING PASS */}
{card.type === 'ticket' && (
<div
className="p-3.5 flex flex-col justify-between bg-[#F4EDE2] text-neutral-900 font-mono text-xs border border-amber-900/30"
style={{
clipPath: 'polygon(0% 0%, 100% 0%, 100% 40%, 96% 45%, 96% 55%, 100% 60%, 100% 100%, 0% 100%, 0% 60%, 4% 55%, 4% 45%, 0% 40%)'
}}
>
<div className="flex justify-between items-center pb-1.5 border-b border-amber-900/30">
<span className="text-[10px] font-black tracking-widest text-amber-900 uppercase">
{card.subtitle || 'PASS // TICKET'}
</span>
<span className="text-[10px] text-neutral-600">
NO. {card.tagNumber || '78429'}
</span>
</div>
<div className="my-auto py-2 text-center">
<div className="text-sm font-bold tracking-wider text-neutral-900 uppercase">
{card.title || 'BOARDING PASS'}
</div>
<div className="text-[11px] text-neutral-700 mt-1 whitespace-pre-line">
{card.content || 'ADMIT ONE · GENERAL ACCESS'}
</div>
{card.date && (
<div className="text-[10px] text-amber-900 mt-1.5 font-bold">
{card.date}
</div>
)}
</div>
{/* Perforated barcode tear stub */}
<div className="pt-2 border-t border-dashed border-amber-900/40 flex flex-col items-center">
<div className="h-4 w-28 flex items-stretch gap-[1.5px] justify-center opacity-70">
{[1, 2, 1, 3, 1, 2, 1, 4, 1, 2, 3, 1, 2, 1, 3].map((w, i) => (
<div key={i} className="bg-neutral-900" style={{ width: `${w}px` }} />
))}
</div>
<span className="text-[8px] tracking-widest text-neutral-500 mt-0.5">
★ NON-TRANSFERABLE ★
</span>
</div>
</div>
)}
</div>
</div>
);
};