Add desk lock mode, web bookmark card type, sound default, and buried visual effects

This commit is contained in:
airent106 committed 2026-10-13 09:53:26 -06:00
1 parent 15fbe1db88
commit 19eb968f86
7 files changed
+493 -187

No files matched your search

+74
View File
@@ -187,6 +187,80 @@ app.post('/api/upload', upload.single('file'), (req, res) => {
});
});
// Bookmark web page metadata preview extractor
app.get('/api/bookmark-meta', async (req, res) => {
const targetUrl = req.query.url;
if (!targetUrl || typeof targetUrl !== 'string') {
return res.status(400).json({ success: false, error: 'url parameter required' });
}
let validUrl;
try {
validUrl = new URL(targetUrl.startsWith('http') ? targetUrl : `https://${targetUrl}`);
} catch {
return res.status(400).json({ success: false, error: 'Invalid URL format' });
}
const hostname = validUrl.hostname;
const favicon = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=128`;
const mshotsScreenshot = `https://s0.wp.com/mshots/v1/${encodeURIComponent(validUrl.href)}?w=600`;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
const response = await fetch(validUrl.href, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
});
clearTimeout(timeout);
const html = await response.text();
// Extract title
const ogTitleMatch = html.match(/<meta\s+property=["']og:title["']\s+content=["'](.*?)["']/i) ||
html.match(/<meta\s+content=["'](.*?)["']\s+property=["']og:title["']/i);
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
const title = ogTitleMatch?.[1] || titleMatch?.[1] || hostname;
// Extract description
const ogDescMatch = html.match(/<meta\s+property=["']og:description["']\s+content=["'](.*?)["']/i) ||
html.match(/<meta\s+name=["']description["']\s+content=["'](.*?)["']/i);
const description = ogDescMatch?.[1] || '';
// Extract image
const ogImageMatch = html.match(/<meta\s+property=["']og:image["']\s+content=["'](.*?)["']/i) ||
html.match(/<meta\s+content=["'](.*?)["']\s+property=["']og:image["']/i);
let image = ogImageMatch?.[1] || mshotsScreenshot;
if (image && image.startsWith('/')) {
image = `${validUrl.origin}${image}`;
}
res.json({
success: true,
url: validUrl.href,
hostname,
title: title.trim(),
description: description.trim(),
image,
favicon
});
} catch {
res.json({
success: true,
url: validUrl.href,
hostname,
title: hostname,
description: '',
image: mshotsScreenshot,
favicon
});
}
});
// Serve uploaded photos/media
app.use('/uploads', express.static(UPLOADS_DIR, {
maxAge: '7d',
+13 -4
View File
@@ -35,7 +35,8 @@ export const App: React.FC = () => {
showProps: true,
enableMotion: true,
penMode: false,
soundEnabled: false,
soundEnabled: true,
isLocked: false,
activeFilter: 'all',
zoom: typeof window !== 'undefined' && window.innerWidth < 768 ? 0.65 : 1.0
});
@@ -350,7 +351,7 @@ export const App: React.FC = () => {
// Physics animation loop on requestAnimationFrame
useEffect(() => {
if (!settings.enableMotion) return;
if (!settings.enableMotion || settings.isLocked) return;
let animId: number;
@@ -412,7 +413,7 @@ export const App: React.FC = () => {
// Pointer Down on a Card
const handleCardPointerDown = (e: React.PointerEvent, card: CardItem) => {
if (settings.penMode) return;
if (settings.penMode || settings.isLocked) return;
e.stopPropagation();
const zoom = settings.zoom || 1.0;
@@ -442,7 +443,7 @@ export const App: React.FC = () => {
// Pointer Down on a Prop
const handlePropPointerDown = (e: React.PointerEvent, prop: DeskProp) => {
if (settings.penMode) return;
if (settings.penMode || settings.isLocked) return;
e.stopPropagation();
const zoom = settings.zoom || 1.0;
@@ -565,6 +566,9 @@ export const App: React.FC = () => {
} else if (e.key === 'p' || e.key === 'P') {
e.preventDefault();
setSettings((prev) => ({ ...prev, penMode: !prev.penMode }));
} else if (e.key === 'l' || e.key === 'L') {
e.preventDefault();
setSettings((prev) => ({ ...prev, isLocked: !prev.isLocked }));
} else if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
setIsAddOpen(true);
@@ -736,6 +740,7 @@ export const App: React.FC = () => {
card={card}
lightAngle={settings.lightAngle}
isDragging={draggingCardId === card.id}
isLocked={settings.isLocked}
onPointerDown={handleCardPointerDown}
onDoubleClick={(c) => setEditingCard(c)}
onToggleTodo={(cardId, idx) => {
@@ -793,6 +798,7 @@ export const App: React.FC = () => {
}))
}
onTogglePen={() => setSettings((prev) => ({ ...prev, penMode: !prev.penMode }))}
onToggleLock={() => setSettings((prev) => ({ ...prev, isLocked: !prev.isLocked }))}
onAddCard={() => setIsAddOpen(true)}
onCycleZoom={() =>
setSettings((prev) => {
@@ -805,6 +811,7 @@ export const App: React.FC = () => {
isGrid={settings.layoutMode === 'grid'}
zoomLevel={settings.zoom}
syncStatus={syncStatus}
isLocked={settings.isLocked}
/>
{/* 7. Modals */}
@@ -820,6 +827,8 @@ export const App: React.FC = () => {
title: newCard.title,
content: newCard.content || '',
imageUrl: newCard.imageUrl,
url: newCard.url,
faviconUrl: newCard.faviconUrl,
photoCaption: newCard.photoCaption,
color: newCard.color || '#FFF9A6',
x: w / 2 - 90 + (Math.random() - 0.5) * 60,
+126 -9
View File
@@ -13,33 +13,77 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [bookmarkUrl, setBookmarkUrl] = useState('');
const [faviconUrl, setFaviconUrl] = useState('');
const [color, setColor] = useState('#FFF9A6');
const [attachType, setAttachType] = useState<AttachType>('washi');
const [fontFamily, setFontFamily] = useState('font-caveat');
const [isUploading, setIsUploading] = useState(false);
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
if (!isOpen) return null;
const handleFetchBookmarkMeta = async (rawUrl: string) => {
if (!rawUrl.trim()) return;
setIsFetchingMeta(true);
try {
const res = await fetch(`/api/bookmark-meta?url=${encodeURIComponent(rawUrl.trim())}`);
const data = await res.json();
if (data.success) {
if (!title && data.title) setTitle(data.title);
if (!content && data.description) setContent(data.description);
if (data.image) setImageUrl(data.image);
if (data.favicon) setFaviconUrl(data.favicon);
}
} catch {
try {
const parsed = new URL(rawUrl.startsWith('http') ? rawUrl : `https://${rawUrl}`);
if (!title) setTitle(parsed.hostname);
setImageUrl(`https://s0.wp.com/mshots/v1/${encodeURIComponent(parsed.href)}?w=600`);
setFaviconUrl(`https://www.google.com/s2/favicons?domain=${encodeURIComponent(parsed.hostname)}&sz=128`);
} catch {
// ignore
}
} finally {
setIsFetchingMeta(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isUploading) return;
if (isUploading || isFetchingMeta) return;
const formattedBookmarkUrl = bookmarkUrl.trim()
? (bookmarkUrl.trim().startsWith('http') ? bookmarkUrl.trim() : `https://${bookmarkUrl.trim()}`)
: undefined;
const effectiveImageUrl = type === 'polaroid'
? (imageUrl.trim() || 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=600&q=80')
: type === 'bookmark'
? (imageUrl.trim() || (formattedBookmarkUrl ? `https://s0.wp.com/mshots/v1/${encodeURIComponent(formattedBookmarkUrl)}?w=600` : undefined))
: undefined;
onAdd({
type,
title: title.trim() || undefined,
title: title.trim() || (type === 'bookmark' && formattedBookmarkUrl ? new URL(formattedBookmarkUrl).hostname : 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,
imageUrl: effectiveImageUrl,
url: type === 'bookmark' ? formattedBookmarkUrl : undefined,
faviconUrl: type === 'bookmark' ? (faviconUrl || (formattedBookmarkUrl ? `https://www.google.com/s2/favicons?domain=${encodeURIComponent(new URL(formattedBookmarkUrl).hostname)}&sz=128` : undefined)) : undefined,
photoCaption: type === 'polaroid' ? (title.trim() || content.trim()) : undefined,
color,
attachType,
color: type === 'bookmark' ? '#FAF8F5' : color,
attachType: type === 'bookmark' ? 'washi' : attachType,
fontFamily,
width: type === 'polaroid' ? 175 : type === 'tag' ? 140 : 180,
height: type === 'polaroid' ? 220 : type === 'tag' ? 220 : 180
width: type === 'polaroid' ? 175 : type === 'tag' ? 140 : type === 'bookmark' ? 210 : 180,
height: type === 'polaroid' ? 220 : type === 'tag' ? 220 : type === 'bookmark' ? 250 : 180
});
onClose();
setTitle('');
setContent('');
setImageUrl('');
setBookmarkUrl('');
setFaviconUrl('');
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -94,7 +138,7 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
<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) => (
{(['sticky', 'polaroid', 'bookmark', 'tag', 'torn', 'business', 'moodboard'] as CardType[]).map((t) => (
<button
type="button"
key={t}
@@ -111,6 +155,59 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
</div>
</div>
{/* Bookmark URL input & preview fetcher */}
{type === 'bookmark' && (
<div className="space-y-2 p-3 bg-neutral-900/80 rounded-lg border border-neutral-800">
<div className="flex justify-between items-center text-neutral-400">
<span className="font-semibold text-white">Web Bookmark Address</span>
{isFetchingMeta && (
<span className="text-[10px] text-amber-400 font-mono animate-pulse">
Fetching preview...
</span>
)}
</div>
<div className="flex gap-2">
<input
type="text"
value={bookmarkUrl}
onChange={(e) => setBookmarkUrl(e.target.value)}
onBlur={() => {
if (bookmarkUrl.trim()) handleFetchBookmarkMeta(bookmarkUrl.trim());
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (bookmarkUrl.trim()) handleFetchBookmarkMeta(bookmarkUrl.trim());
}
}}
placeholder="e.g. https://news.ycombinator.com"
className="flex-1 bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500 font-mono text-[11px]"
/>
<button
type="button"
onClick={() => handleFetchBookmarkMeta(bookmarkUrl.trim())}
disabled={isFetchingMeta || !bookmarkUrl.trim()}
className="px-3 py-1.5 rounded bg-amber-500 hover:bg-amber-400 active:scale-95 text-neutral-950 font-bold transition disabled:opacity-50 shrink-0"
>
{isFetchingMeta ? '...' : 'Preview'}
</button>
</div>
{/* Bookmark thumbnail */}
{imageUrl && (
<div className="flex items-center gap-2.5 mt-2 bg-neutral-950/70 p-2 rounded border border-neutral-800">
<div className="w-16 aspect-[16/10] rounded border border-neutral-700 overflow-hidden relative shrink-0 bg-neutral-900">
<img src={imageUrl} alt="Thumbnail" className="w-full h-full object-cover" />
</div>
<div className="flex flex-col text-[11px] overflow-hidden min-w-0">
<span className="text-emerald-400 font-medium">✓ Snapshot Ready</span>
<span className="truncate text-neutral-400 font-mono text-[10px]">{imageUrl}</span>
</div>
</div>
)}
</div>
)}
{/* Title */}
<div>
<label className="block text-neutral-400 mb-1">Title / Header</label>
@@ -262,6 +359,7 @@ export const EditCardModal: React.FC<EditCardModalProps> = ({
const [content, setContent] = useState(card.content || '');
const [color, setColor] = useState(card.color || '#FFF9A6');
const [imageUrl, setImageUrl] = useState(card.imageUrl || '');
const [url, setUrl] = useState(card.url || '');
const [isUploading, setIsUploading] = useState(false);
const handleEditFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -298,12 +396,17 @@ export const EditCardModal: React.FC<EditCardModalProps> = ({
};
const handleSave = () => {
const formattedUrl = url.trim()
? (url.trim().startsWith('http') ? url.trim() : `https://${url.trim()}`)
: undefined;
onSave({
...card,
title: title.trim() || undefined,
content: content.trim(),
color,
imageUrl: card.type === 'polaroid' ? (imageUrl.trim() || undefined) : card.imageUrl,
url: card.type === 'bookmark' ? formattedUrl : card.url,
imageUrl: (card.type === 'polaroid' || card.type === 'bookmark') ? (imageUrl.trim() || undefined) : card.imageUrl,
photoCaption: card.type === 'polaroid' ? (title.trim() || content.trim()) : undefined
});
onClose();
@@ -344,6 +447,20 @@ export const EditCardModal: React.FC<EditCardModalProps> = ({
/>
</div>
{/* Bookmark URL field */}
{card.type === 'bookmark' && (
<div className="space-y-1">
<label className="block text-neutral-400 mb-1">Bookmark URL</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500 font-mono text-[11px]"
/>
</div>
)}
{/* Photo management for Polaroid */}
{card.type === 'polaroid' && (
<div className="space-y-2">
+21 -17
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Shuffle, Grid, LayoutGrid, PenTool, Plus, ZoomIn } from 'lucide-react';
import { Shuffle, Grid, LayoutGrid, Plus, ZoomIn, Lock, Unlock } from 'lucide-react';
import { SyncStatus } from '../types';
interface BottomBarProps {
@@ -8,22 +8,24 @@ interface BottomBarProps {
onTogglePen?: () => void;
onAddCard?: () => void;
onCycleZoom?: () => void;
onToggleLock?: () => void;
penActive?: boolean;
isGrid?: boolean;
zoomLevel?: number;
syncStatus?: SyncStatus;
isLocked?: boolean;
}
export const BottomBar: React.FC<BottomBarProps> = ({
onShuffle,
onToggleLayout,
onTogglePen,
onAddCard,
onCycleZoom,
penActive = false,
onToggleLock,
isGrid = false,
zoomLevel = 1.0,
syncStatus = 'synced'
syncStatus = 'synced',
isLocked = false
}) => {
return (
<>
@@ -48,12 +50,12 @@ export const BottomBar: React.FC<BottomBarProps> = ({
<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">L</span> {isLocked ? 'unlock' : 'lock'}
<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
@@ -61,6 +63,18 @@ export const BottomBar: React.FC<BottomBarProps> = ({
{/* 2. Mobile: Thumb-friendly floating dock */}
<footer className="fixed bottom-3 left-1/2 -translate-x-1/2 z-40 px-3 py-1.5 bg-[#1C1A17]/95 text-neutral-300 rounded-full shadow-2xl border border-neutral-700/80 backdrop-blur-md flex md:hidden items-center gap-2 select-none">
<button
onClick={onToggleLock}
className={`p-2 rounded-full transition active:scale-95 ${
isLocked
? 'bg-amber-500 text-neutral-950 font-bold border border-amber-400 shadow-md'
: 'bg-neutral-800 text-neutral-300 hover:bg-neutral-700'
}`}
title={isLocked ? 'Desk Locked (Tap to Unlock)' : 'Desk Unlocked (Tap to Lock)'}
>
{isLocked ? <Lock size={15} /> : <Unlock size={15} />}
</button>
<button
onClick={onShuffle}
className="p-2 rounded-full bg-neutral-800 hover:bg-neutral-700 active:scale-95 text-neutral-200 transition"
@@ -89,20 +103,10 @@ export const BottomBar: React.FC<BottomBarProps> = ({
<button
onClick={onAddCard}
className="p-2 rounded-full bg-amber-500 hover:bg-amber-400 active:scale-95 text-neutral-950 font-bold transition shadow-md"
title="Add Note or Photo"
title="Add Note, Photo or Bookmark"
>
<Plus size={17} />
</button>
<button
onClick={onTogglePen}
className={`p-2 rounded-full transition active:scale-95 ${
penActive ? 'bg-amber-500/20 text-amber-300 border border-amber-500' : 'bg-neutral-800 text-neutral-200'
}`}
title="Draw / Pen"
>
<PenTool size={15} />
</button>
</footer>
</>
);
+132 -16
View File
@@ -2,12 +2,13 @@ import React, { useState } from 'react';
import { CardItem } from '../types';
import { getShadowStyle } from '../utils/lighting';
import { Fastener, Stamp, PhotoCorners } from './Fastener';
import { Pencil } from 'lucide-react';
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;
@@ -17,6 +18,7 @@ export const PaperCard: React.FC<PaperCardProps> = ({
card,
lightAngle,
isDragging,
isLocked = false,
onPointerDown,
onDoubleClick,
onToggleTodo
@@ -34,9 +36,9 @@ export const PaperCard: React.FC<PaperCardProps> = ({
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]' : ''
}`}
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`,
@@ -48,6 +50,12 @@ export const PaperCard: React.FC<PaperCardProps> = ({
}}
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)}
>
@@ -186,27 +194,36 @@ export const PaperCard: React.FC<PaperCardProps> = ({
{/* 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%)]"
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-1 mb-3 flex items-center justify-center">
<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-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 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-2 text-[10px] tracking-widest uppercase font-mono text-neutral-600">
<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>
@@ -415,6 +432,105 @@ export const PaperCard: React.FC<PaperCardProps> = ({
</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>
)}
</div>
</div>
);
+122 -140
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { DeskSettings, DeskTexture, FontMode, CardType, SyncStatus } from '../types';
import { Volume2, VolumeX, Plus, Sparkles, SlidersHorizontal, X, Cloud, RefreshCw, CloudOff } from 'lucide-react';
import { Volume2, VolumeX, Plus, Sparkles, SlidersHorizontal, X, Cloud, RefreshCw, CloudOff, Lock, Unlock } from 'lucide-react';
interface TopBarProps {
settings: DeskSettings;
@@ -29,16 +29,13 @@ export const TopBar: React.FC<TopBarProps> = ({
<>
<header className="fixed top-2.5 left-1/2 -translate-x-1/2 z-50 w-[96vw] 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 items-center justify-between gap-2 text-xs select-none">
{/* 1. Left: Brand Title, Subtitle & Sync Pill */}
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-2.5 shrink-0">
<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="font-bold text-white tracking-tight text-sm">
Glimpse
</span>
<span className="text-[10px] text-neutral-400 font-mono tracking-tight leading-none hidden sm:inline">
procedural paper · visual desk
visual paper workspace
</span>
</div>
@@ -119,17 +116,31 @@ export const TopBar: React.FC<TopBarProps> = ({
</button>
</div>
{/* Lock / Unlock Toggle */}
<button
onClick={() => onUpdateSettings({ isLocked: !settings.isLocked })}
className={`px-2.5 py-1 rounded transition flex items-center gap-1 shrink-0 ${
settings.isLocked
? 'bg-amber-500 text-neutral-950 font-bold shadow-sm'
: 'bg-neutral-800 text-neutral-300 hover:text-white border border-neutral-700/80'
}`}
title={settings.isLocked ? "Desk is locked (Cards cannot move. Click to unlock)" : "Lock Desk (Cards cannot be dragged)"}
>
{settings.isLocked ? <Lock size={13} className="text-neutral-950" /> : <Unlock size={13} />}
<span className="hidden sm:inline">{settings.isLocked ? 'Locked' : 'Lock'}</span>
</button>
{/* 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 photo"
title="Add a new custom note, photo, or web bookmark"
>
<Plus size={15} />
</button>
</div>
{/* 3. Desktop-Only Controls (hidden on small/medium screens) */}
{/* 3. Desktop-Only Controls (Cleaned & streamlined) */}
<div className="hidden xl:flex items-center gap-3">
{/* Typography Mode */}
<div className="flex items-center bg-neutral-800/90 rounded p-0.5 border border-neutral-700/80">
@@ -183,40 +194,6 @@ export const TopBar: React.FC<TopBarProps> = ({
</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>
{/* Light Angle */}
<div className="flex items-center gap-1.5" title="Directional light angle">
<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>
{/* Page Scale / Zoom */}
<div className="flex items-center gap-1 bg-neutral-800/90 rounded p-0.5 border border-neutral-700/80">
<span className="text-[10px] text-neutral-400 px-1 font-mono">Zoom:</span>
@@ -235,27 +212,6 @@ export const TopBar: React.FC<TopBarProps> = ({
))}
</div>
{/* Checkboxes: Props, Motion, Pen */}
<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.penMode}
onChange={(e) => onUpdateSettings({ penMode: e.target.checked })}
className="rounded accent-amber-500 cursor-pointer"
/>
<span>Pen</span>
</label>
{/* Sound Toggle */}
<button
onClick={() => onUpdateSettings({ soundEnabled: !settings.soundEnabled })}
@@ -264,6 +220,7 @@ export const TopBar: React.FC<TopBarProps> = ({
? 'bg-neutral-800 text-amber-400 border-amber-600/50'
: 'bg-neutral-800/60 text-neutral-400 border-neutral-700'
}`}
title={settings.soundEnabled ? "Mute tactile sound" : "Enable tactile sound"}
>
{settings.soundEnabled ? <Volume2 size={13} /> : <VolumeX size={13} />}
</button>
@@ -277,6 +234,7 @@ export const TopBar: React.FC<TopBarProps> = ({
<option value="all">Types: All</option>
<option value="sticky">Sticky Notes</option>
<option value="polaroid">Photos</option>
<option value="bookmark">Bookmarks</option>
<option value="tag">Tags</option>
<option value="torn">Letters</option>
<option value="business">Cards</option>
@@ -406,76 +364,37 @@ export const TopBar: React.FC<TopBarProps> = ({
/>
</div>
{/* Sliders: Density, Grain, Light */}
<div className="space-y-3 pt-1">
<div>
<div className="flex justify-between text-neutral-400 mb-1">
<span>Card Count</span>
<span className="font-mono text-neutral-200">{settings.cardCount} sheets</span>
</div>
<input
type="range"
min="6"
max="45"
value={settings.cardCount}
onChange={(e) => onUpdateSettings({ cardCount: Number(e.target.value) })}
className="w-full accent-amber-500 cursor-pointer h-2 bg-neutral-800 rounded-lg"
/>
</div>
<div>
<div className="flex justify-between text-neutral-400 mb-1">
<span>Paper Texture Grain</span>
<span className="font-mono text-neutral-200">{settings.grain.toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={settings.grain}
onChange={(e) => onUpdateSettings({ grain: Number(e.target.value) })}
className="w-full accent-amber-500 cursor-pointer h-2 bg-neutral-800 rounded-lg"
/>
</div>
<div>
<div className="flex justify-between text-neutral-400 mb-1">
<span>Lighting Angle</span>
<span className="font-mono text-amber-300">{settings.lightAngle}°</span>
</div>
<input
type="range"
min="0"
max="360"
step="5"
value={settings.lightAngle}
onChange={(e) => onUpdateSettings({ lightAngle: Number(e.target.value) })}
className="w-full accent-amber-400 cursor-pointer h-2 bg-neutral-800 rounded-lg"
/>
{/* Sliders: Density */}
<div className="pt-1">
<div className="flex justify-between text-neutral-400 mb-1">
<span>Card Count</span>
<span className="font-mono text-neutral-200">{settings.cardCount} sheets</span>
</div>
<input
type="range"
min="6"
max="45"
value={settings.cardCount}
onChange={(e) => onUpdateSettings({ cardCount: Number(e.target.value) })}
className="w-full accent-amber-500 cursor-pointer h-2 bg-neutral-800 rounded-lg"
/>
</div>
{/* Quick Toggles */}
{/* Quick Primary Actions */}
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-neutral-800">
<button
onClick={() => onUpdateSettings({ showProps: !settings.showProps })}
onClick={() => onUpdateSettings({ isLocked: !settings.isLocked })}
className={`py-2 px-3 rounded border text-left flex items-center justify-between ${
settings.showProps ? 'bg-amber-500/10 border-amber-500 text-amber-300' : 'bg-neutral-800 border-neutral-700 text-neutral-400'
settings.isLocked
? 'bg-amber-500/20 border-amber-500 text-amber-300 font-bold'
: 'bg-neutral-800 border-neutral-700 text-neutral-300'
}`}
>
<span>Desk Props</span>
<span className="font-mono text-[10px]">{settings.showProps ? 'ON' : 'OFF'}</span>
</button>
<button
onClick={() => onUpdateSettings({ penMode: !settings.penMode })}
className={`py-2 px-3 rounded border text-left flex items-center justify-between ${
settings.penMode ? 'bg-amber-500/10 border-amber-500 text-amber-300' : 'bg-neutral-800 border-neutral-700 text-neutral-400'
}`}
>
<span>Draw / Pen</span>
<span className="font-mono text-[10px]">{settings.penMode ? 'ACTIVE' : 'OFF'}</span>
<span className="flex items-center gap-1.5">
{settings.isLocked ? <Lock size={13} /> : <Unlock size={13} />}
<span>Lock Desk</span>
</span>
<span className="font-mono text-[10px]">{settings.isLocked ? 'LOCKED' : 'OPEN'}</span>
</button>
<button
@@ -487,22 +406,85 @@ export const TopBar: React.FC<TopBarProps> = ({
<span>Tactile Audio</span>
<span className="font-mono text-[10px]">{settings.soundEnabled ? 'ON' : 'OFF'}</span>
</button>
<button
onClick={() => {
setMobileMenuOpen(false);
onOpenPortfolio();
}}
className="py-2 px-3 rounded bg-amber-500/20 border border-amber-500 text-amber-300 font-bold text-center flex items-center justify-center gap-1.5"
>
<Sparkles size={14} />
<span>Presets</span>
</button>
</div>
{/* Buried Advanced Visual Effects (Lighting, Grain, Pen) */}
<details className="mt-2 border border-neutral-800 rounded-lg p-2.5 bg-neutral-900/50 text-neutral-400">
<summary className="cursor-pointer text-xs font-semibold text-neutral-400 hover:text-white select-none flex items-center justify-between">
<span>Advanced Visual Effects (Grain, Light, Pen)</span>
<span className="text-[10px] text-neutral-500 font-mono">Expand ▾</span>
</summary>
<div className="space-y-3 pt-3 mt-2 border-t border-neutral-800/80">
<div>
<div className="flex justify-between text-neutral-400 mb-1">
<span>Paper Texture Grain</span>
<span className="font-mono text-neutral-200">{settings.grain.toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={settings.grain}
onChange={(e) => onUpdateSettings({ grain: Number(e.target.value) })}
className="w-full accent-amber-500 cursor-pointer h-2 bg-neutral-800 rounded-lg"
/>
</div>
<div>
<div className="flex justify-between text-neutral-400 mb-1">
<span>Lighting Angle</span>
<span className="font-mono text-amber-300">{settings.lightAngle}°</span>
</div>
<input
type="range"
min="0"
max="360"
step="5"
value={settings.lightAngle}
onChange={(e) => onUpdateSettings({ lightAngle: Number(e.target.value) })}
className="w-full accent-amber-400 cursor-pointer h-2 bg-neutral-800 rounded-lg"
/>
</div>
<div className="grid grid-cols-2 gap-2 pt-1">
<button
onClick={() => onUpdateSettings({ showProps: !settings.showProps })}
className={`py-1.5 px-2.5 rounded border text-left flex items-center justify-between text-xs ${
settings.showProps ? 'bg-amber-500/10 border-amber-500 text-amber-300' : 'bg-neutral-800 border-neutral-700 text-neutral-400'
}`}
>
<span>Desk Props</span>
<span className="font-mono text-[10px]">{settings.showProps ? 'ON' : 'OFF'}</span>
</button>
<button
onClick={() => onUpdateSettings({ penMode: !settings.penMode })}
className={`py-1.5 px-2.5 rounded border text-left flex items-center justify-between text-xs ${
settings.penMode ? 'bg-amber-500/10 border-amber-500 text-amber-300' : 'bg-neutral-800 border-neutral-700 text-neutral-400'
}`}
>
<span>Draw / Pen</span>
<span className="font-mono text-[10px]">{settings.penMode ? 'ACTIVE' : 'OFF'}</span>
</button>
</div>
</div>
</details>
<button
onClick={() => {
setMobileMenuOpen(false);
onOpenPortfolio();
}}
className="w-full py-2 px-3 rounded bg-amber-500/20 border border-amber-500 text-amber-300 font-bold text-center flex items-center justify-center gap-1.5"
>
<Sparkles size={14} />
<span>Presets & Themes</span>
</button>
<button
onClick={() => setMobileMenuOpen(false)}
className="w-full py-2.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-neutral-950 font-bold text-sm mt-3"
className="w-full py-2.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-neutral-950 font-bold text-sm mt-2"
>
Done
</button>
+5 -1
View File
@@ -6,7 +6,8 @@ export type CardType =
| 'business'
| 'receipt'
| 'graph'
| 'moodboard';
| 'moodboard'
| 'bookmark';
export type AttachType = 'none' | 'washi' | 'paperclip' | 'pin';
@@ -33,6 +34,8 @@ export interface CardItem {
author?: string;
imageUrl?: string;
photoCaption?: string;
url?: string;
faviconUrl?: string;
color?: string; // pastel hex or theme
x: number;
y: number;
@@ -89,6 +92,7 @@ export interface DeskSettings {
soundEnabled: boolean;
activeFilter: 'all' | CardType;
zoom: number; // 0.4 - 1.5 zoom level
isLocked: boolean; // lock desk so cards cannot be dragged
}
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'loading';