Add desk lock mode, web bookmark card type, sound default, and buried visual effects
This commit is contained in:
1 parent
15fbe1db88
commit
19eb968f86
7 files changed
+493
-187
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
Reference in new issue
Block a user