Enhance mobile experience: responsive bottom dock, camera upload, drawer, and adaptive card scaling; fix IPv6 localhost healthcheck
This commit is contained in:
1 parent
eb3787dd16
commit
7d9ce809a8
9 files changed
+531
-277
No files matched your search
+2
-2
@@ -17,7 +17,7 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost:80/ || exit 1
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://127.0.0.1:80/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
+3
-3
@@ -10,11 +10,11 @@ services:
|
||||
ports:
|
||||
- "127.0.0.1:8476:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:80/ || exit 1"]
|
||||
interval: 30s
|
||||
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:80/ || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
start_period: 5s
|
||||
|
||||
networks:
|
||||
t3_proxy:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
|
||||
+22
-3
@@ -60,8 +60,15 @@ export const App: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const w = typeof window !== 'undefined' ? window.innerWidth : 1400;
|
||||
const h = typeof window !== 'undefined' ? window.innerHeight : 900;
|
||||
const isMobile = w < 768;
|
||||
const initialCount = isMobile ? 9 : 22;
|
||||
|
||||
if (isMobile) {
|
||||
setSettings((s) => ({ ...s, cardCount: initialCount }));
|
||||
}
|
||||
|
||||
const initialCards = generateProceduralCards(
|
||||
settings.cardCount,
|
||||
initialCount,
|
||||
w,
|
||||
h,
|
||||
settings.attachChance,
|
||||
@@ -575,8 +582,20 @@ export const App: React.FC = () => {
|
||||
onClear={() => setStrokes([])}
|
||||
/>
|
||||
|
||||
{/* 6. Bottom Shortcut Bar */}
|
||||
<BottomBar />
|
||||
{/* 6. Bottom Shortcut Bar / Mobile Thumb Dock */}
|
||||
<BottomBar
|
||||
onShuffle={handleShuffle}
|
||||
onToggleLayout={() =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
layoutMode: prev.layoutMode === 'scatter' ? 'grid' : 'scatter'
|
||||
}))
|
||||
}
|
||||
onTogglePen={() => setSettings((prev) => ({ ...prev, penMode: !prev.penMode }))}
|
||||
onAddCard={() => setIsAddOpen(true)}
|
||||
penActive={settings.penMode}
|
||||
isGrid={settings.layoutMode === 'grid'}
|
||||
/>
|
||||
|
||||
{/* 7. Modals */}
|
||||
<AddCardModal
|
||||
|
||||
@@ -97,17 +97,43 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image URL for Polaroid */}
|
||||
{/* Image Upload or URL for Polaroid */}
|
||||
{type === 'polaroid' && (
|
||||
<div>
|
||||
<label className="block text-neutral-400 mb-1">Image URL (Optional)</label>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-neutral-400">Photo / Snapshot</label>
|
||||
<div className="flex gap-2">
|
||||
<label className="flex-1 flex items-center justify-center gap-1.5 py-1.5 px-3 bg-neutral-800 border border-neutral-700 hover:border-amber-500 rounded cursor-pointer text-amber-300 font-medium transition text-center">
|
||||
<span>📸 Camera / Photos</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
if (typeof ev.target?.result === 'string') {
|
||||
setImageUrl(ev.target.result);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
placeholder="https://images.unsplash.com/..."
|
||||
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2.5 py-1.5 text-neutral-100 focus:outline-none focus:border-amber-500"
|
||||
placeholder="Or paste an image URL (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 text-[11px]"
|
||||
/>
|
||||
{imageUrl && (
|
||||
<div className="w-16 h-16 rounded border border-neutral-700 overflow-hidden relative mt-1">
|
||||
<img src={imageUrl} alt="Preview" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,26 +1,83 @@
|
||||
import React from 'react';
|
||||
import { Shuffle, Grid, LayoutGrid, PenTool, Plus } from 'lucide-react';
|
||||
|
||||
export const BottomBar: React.FC = () => {
|
||||
interface BottomBarProps {
|
||||
onShuffle?: () => void;
|
||||
onToggleLayout?: () => void;
|
||||
onTogglePen?: () => void;
|
||||
onAddCard?: () => void;
|
||||
penActive?: boolean;
|
||||
isGrid?: boolean;
|
||||
}
|
||||
|
||||
export const BottomBar: React.FC<BottomBarProps> = ({
|
||||
onShuffle,
|
||||
onToggleLayout,
|
||||
onTogglePen,
|
||||
onAddCard,
|
||||
penActive = false,
|
||||
isGrid = false
|
||||
}) => {
|
||||
return (
|
||||
<footer className="fixed bottom-2 left-1/2 -translate-x-1/2 z-40 px-3.5 py-1 bg-black/60 text-neutral-400 rounded-full text-[11px] font-mono backdrop-blur-sm pointer-events-none select-none border border-white/5 flex items-center gap-2 shadow-lg">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block" />
|
||||
<span>drag anything</span>
|
||||
</span>
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span>flick to throw</span>
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span>double-click to edit</span>
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span className="text-neutral-300 font-semibold">S</span> shuffle
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span className="text-neutral-300 font-semibold">G</span> grid
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span className="text-neutral-300 font-semibold">P</span> pen
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span className="text-neutral-300 font-semibold">N</span> new
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span className="text-neutral-300 font-semibold">Space</span> scatter
|
||||
</footer>
|
||||
<>
|
||||
{/* 1. Desktop: Keyboard hints */}
|
||||
<footer className="fixed bottom-2 left-1/2 -translate-x-1/2 z-40 px-3.5 py-1 bg-black/60 text-neutral-400 rounded-full text-[11px] font-mono backdrop-blur-sm pointer-events-none select-none border border-white/5 hidden md:flex items-center gap-2 shadow-lg">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block" />
|
||||
<span>drag anything</span>
|
||||
</span>
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span>flick to throw</span>
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span>double-click to edit</span>
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span className="text-neutral-300 font-semibold">S</span> shuffle
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span className="text-neutral-300 font-semibold">G</span> grid
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span className="text-neutral-300 font-semibold">P</span> pen
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span className="text-neutral-300 font-semibold">N</span> new
|
||||
<span className="text-neutral-600">·</span>
|
||||
<span className="text-neutral-300 font-semibold">Space</span> scatter
|
||||
</footer>
|
||||
|
||||
{/* 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-3 select-none">
|
||||
<button
|
||||
onClick={onShuffle}
|
||||
className="p-2 rounded-full bg-neutral-800 hover:bg-neutral-700 active:scale-95 text-neutral-200 transition"
|
||||
title="Shuffle Desk"
|
||||
>
|
||||
<Shuffle size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onToggleLayout}
|
||||
className="p-2 rounded-full bg-neutral-800 hover:bg-neutral-700 active:scale-95 text-neutral-200 transition"
|
||||
title="Toggle Grid / Scatter"
|
||||
>
|
||||
{isGrid ? <LayoutGrid size={16} className="text-amber-400" /> : <Grid size={16} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onAddCard}
|
||||
className="p-2.5 rounded-full bg-amber-500 hover:bg-amber-400 active:scale-95 text-neutral-950 font-bold transition shadow-md"
|
||||
title="Add Note"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</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={16} />
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
interface PaperCardProps {
|
||||
card: CardItem;
|
||||
@@ -70,6 +71,20 @@ export const PaperCard: React.FC<PaperCardProps> = ({
|
||||
{/* 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
|
||||
|
||||
+371
-237
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { DeskSettings, DeskTexture, FontMode, CardType } from '../types';
|
||||
import { Volume2, VolumeX, Plus, Sparkles } from 'lucide-react';
|
||||
import { Volume2, VolumeX, Plus, Sparkles, SlidersHorizontal, X } from 'lucide-react';
|
||||
|
||||
interface TopBarProps {
|
||||
settings: DeskSettings;
|
||||
@@ -21,264 +21,398 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
onOpenPortfolio,
|
||||
totalCards
|
||||
}) => {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="fixed top-2.5 left-1/2 -translate-x-1/2 z-50 w-[98vw] max-w-[1440px] px-3.5 py-1.5 bg-[#1C1A17]/95 text-neutral-300 rounded-xl shadow-2xl border border-neutral-700/60 backdrop-blur-md flex flex-wrap items-center justify-between gap-y-2 text-xs select-none">
|
||||
{/* 1. Left: Brand Title & Subtitle */}
|
||||
<div className="flex items-center gap-2.5 mr-1">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-white tracking-tight text-sm">
|
||||
Glimpse
|
||||
<>
|
||||
<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 */}
|
||||
<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="text-[10px] text-neutral-400 font-mono tracking-tight leading-none hidden sm:inline">
|
||||
procedural paper · visual desk
|
||||
</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
</div>
|
||||
<span className="text-[10px] text-neutral-400 font-mono tracking-tight leading-none">
|
||||
procedural paper · visual desk
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Primary Actions: Shuffle, Scatter/Grid */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onShuffle}
|
||||
className="px-3 py-1 rounded bg-[#ECA136] hover:bg-[#F2B04D] active:scale-95 text-neutral-950 font-bold transition shadow-sm flex items-center gap-1"
|
||||
title="Press 'S' to shuffle"
|
||||
>
|
||||
Shuffle
|
||||
</button>
|
||||
|
||||
{/* Scatter / Grid Toggle */}
|
||||
<div className="flex items-center bg-neutral-800/90 rounded p-0.5 border border-neutral-700/80">
|
||||
<button
|
||||
onClick={() => onUpdateSettings({ layoutMode: 'scatter' })}
|
||||
className={`px-2 py-0.5 rounded transition ${
|
||||
settings.layoutMode === 'scatter'
|
||||
? 'bg-neutral-600 text-white font-medium shadow-xs'
|
||||
: 'text-neutral-400 hover:text-neutral-200'
|
||||
}`}
|
||||
title="Press 'G' to toggle"
|
||||
>
|
||||
Scatter
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onUpdateSettings({ layoutMode: 'grid' })}
|
||||
className={`px-2 py-0.5 rounded transition ${
|
||||
settings.layoutMode === 'grid'
|
||||
? 'bg-neutral-600 text-white font-medium shadow-xs'
|
||||
: 'text-neutral-400 hover:text-neutral-200'
|
||||
}`}
|
||||
title="Press 'G' to toggle"
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Typography Mode: Mixed, Handwritten, Typed */}
|
||||
<div className="flex items-center bg-neutral-800/90 rounded p-0.5 border border-neutral-700/80">
|
||||
{(['mixed', 'handwritten', 'typed'] as FontMode[]).map((mode) => (
|
||||
{/* 2. Primary Actions: Shuffle, Scatter/Grid (Always Visible) */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={onShuffle}
|
||||
className="px-2.5 py-1 rounded bg-[#ECA136] hover:bg-[#F2B04D] active:scale-95 text-neutral-950 font-bold transition shadow-sm flex items-center gap-1 shrink-0"
|
||||
title="Press 'S' to shuffle"
|
||||
>
|
||||
Shuffle
|
||||
</button>
|
||||
|
||||
{/* Scatter / Grid Toggle */}
|
||||
<div className="flex items-center bg-neutral-800/90 rounded p-0.5 border border-neutral-700/80">
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => onUpdateSettings({ fontMode: mode })}
|
||||
className={`px-2 py-0.5 rounded capitalize transition ${
|
||||
settings.fontMode === mode
|
||||
onClick={() => onUpdateSettings({ layoutMode: 'scatter' })}
|
||||
className={`px-2 py-0.5 rounded transition ${
|
||||
settings.layoutMode === 'scatter'
|
||||
? 'bg-neutral-600 text-white font-medium shadow-xs'
|
||||
: 'text-neutral-400 hover:text-neutral-200'
|
||||
}`}
|
||||
title="Scatter layout"
|
||||
>
|
||||
{mode}
|
||||
Scatter
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onUpdateSettings({ layoutMode: 'grid' })}
|
||||
className={`px-2 py-0.5 rounded transition ${
|
||||
settings.layoutMode === 'grid'
|
||||
? 'bg-neutral-600 text-white font-medium shadow-xs'
|
||||
: 'text-neutral-400 hover:text-neutral-200'
|
||||
}`}
|
||||
title="Grid layout"
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 3. Desk Texture Selector */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-neutral-400">Desk:</span>
|
||||
<select
|
||||
value={settings.deskTexture}
|
||||
onChange={(e) => onUpdateSettings({ deskTexture: e.target.value as DeskTexture })}
|
||||
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-neutral-200 focus:outline-none focus:border-amber-500 cursor-pointer text-xs"
|
||||
>
|
||||
<option value="oak">Oak desk</option>
|
||||
<option value="walnut">Walnut</option>
|
||||
<option value="cutting-mat">Cutting mat</option>
|
||||
<option value="corkboard">Corkboard</option>
|
||||
<option value="blueprint">Blueprint</option>
|
||||
<option value="dark-slate">Dark slate</option>
|
||||
<option value="marble">Marble</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 4. Sliders: Cards, Grain, Attach, Light */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Cards Count */}
|
||||
<div className="flex items-center gap-1.5" title="Number of paper sheets on desk">
|
||||
<span className="text-neutral-400">Cards</span>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="80"
|
||||
step="1"
|
||||
value={settings.cardCount}
|
||||
onChange={(e) => onUpdateSettings({ cardCount: Number(e.target.value) })}
|
||||
className="w-14 accent-amber-500 cursor-pointer h-1.5 bg-neutral-700 rounded-lg appearance-none"
|
||||
/>
|
||||
<span className="font-mono text-[11px] text-neutral-300 w-5">
|
||||
{settings.cardCount}
|
||||
</span>
|
||||
{/* 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"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</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>
|
||||
{/* 3. Desktop-Only Controls (hidden on small/medium screens) */}
|
||||
<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">
|
||||
{(['mixed', 'handwritten', 'typed'] as FontMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => onUpdateSettings({ fontMode: mode })}
|
||||
className={`px-2 py-0.5 rounded capitalize transition ${
|
||||
settings.fontMode === mode
|
||||
? 'bg-neutral-600 text-white font-medium shadow-xs'
|
||||
: 'text-neutral-400 hover:text-neutral-200'
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desk Texture Selector */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-neutral-400">Desk:</span>
|
||||
<select
|
||||
value={settings.deskTexture}
|
||||
onChange={(e) => onUpdateSettings({ deskTexture: e.target.value as DeskTexture })}
|
||||
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-neutral-200 focus:outline-none focus:border-amber-500 cursor-pointer text-xs"
|
||||
>
|
||||
<option value="oak">Oak desk</option>
|
||||
<option value="walnut">Walnut</option>
|
||||
<option value="cutting-mat">Cutting mat</option>
|
||||
<option value="corkboard">Corkboard</option>
|
||||
<option value="blueprint">Blueprint</option>
|
||||
<option value="dark-slate">Dark slate</option>
|
||||
<option value="marble">Marble</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Cards Count */}
|
||||
<div className="flex items-center gap-1.5" title="Number of paper sheets on desk">
|
||||
<span className="text-neutral-400">Cards</span>
|
||||
<input
|
||||
type="range"
|
||||
min="8"
|
||||
max="65"
|
||||
step="1"
|
||||
value={settings.cardCount}
|
||||
onChange={(e) => onUpdateSettings({ cardCount: Number(e.target.value) })}
|
||||
className="w-14 accent-amber-500 cursor-pointer h-1.5 bg-neutral-700 rounded-lg appearance-none"
|
||||
/>
|
||||
<span className="font-mono text-[11px] text-neutral-300 w-5">
|
||||
{settings.cardCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Grain */}
|
||||
<div className="flex items-center gap-1.5" title="Paper texture grain density">
|
||||
<span className="text-neutral-400">Grain</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={settings.grain}
|
||||
onChange={(e) => onUpdateSettings({ grain: Number(e.target.value) })}
|
||||
className="w-14 accent-amber-500 cursor-pointer h-1.5 bg-neutral-700 rounded-lg appearance-none"
|
||||
/>
|
||||
<span className="font-mono text-[11px] text-neutral-300 w-7">
|
||||
{settings.grain.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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 })}
|
||||
className={`px-2 py-0.5 rounded border transition flex items-center gap-1 ${
|
||||
settings.soundEnabled
|
||||
? 'bg-neutral-800 text-amber-400 border-amber-600/50'
|
||||
: 'bg-neutral-800/60 text-neutral-400 border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{settings.soundEnabled ? <Volume2 size={13} /> : <VolumeX size={13} />}
|
||||
</button>
|
||||
|
||||
{/* Card Type Filter */}
|
||||
<select
|
||||
value={settings.activeFilter}
|
||||
onChange={(e) => onUpdateSettings({ activeFilter: e.target.value as DeskSettings['activeFilter'] })}
|
||||
className="bg-neutral-800 border border-neutral-700 rounded px-1.5 py-0.5 text-neutral-300 text-[11px] cursor-pointer"
|
||||
>
|
||||
<option value="all">Types: All</option>
|
||||
<option value="sticky">Sticky Notes</option>
|
||||
<option value="polaroid">Photos</option>
|
||||
<option value="tag">Tags</option>
|
||||
<option value="torn">Letters</option>
|
||||
<option value="business">Cards</option>
|
||||
<option value="receipt">Receipts</option>
|
||||
<option value="graph">Sketches</option>
|
||||
</select>
|
||||
|
||||
{/* Portfolio */}
|
||||
<button
|
||||
onClick={onOpenPortfolio}
|
||||
className="px-2 py-0.5 rounded bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 border border-amber-500/40 transition flex items-center gap-1 font-medium"
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
<span>Presets</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Attach Chance */}
|
||||
<div className="flex items-center gap-1.5" title="Attachment frequency (washi tape, pins, clips)">
|
||||
<span className="text-neutral-400">Attach</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={settings.attachChance}
|
||||
onChange={(e) => onUpdateSettings({ attachChance: Number(e.target.value) })}
|
||||
className="w-14 accent-amber-500 cursor-pointer h-1.5 bg-neutral-700 rounded-lg appearance-none"
|
||||
/>
|
||||
<span className="font-mono text-[11px] text-neutral-300 w-7">
|
||||
{Math.round(settings.attachChance * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Light Direction Dial */}
|
||||
<div className="flex items-center gap-1.5" title="Directional desk lamp angle (shadow rotation)">
|
||||
<span className="text-neutral-400">Light</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
step="5"
|
||||
value={settings.lightAngle}
|
||||
onChange={(e) => onUpdateSettings({ lightAngle: Number(e.target.value) })}
|
||||
className="w-14 accent-amber-400 cursor-pointer h-1.5 bg-neutral-700 rounded-lg appearance-none"
|
||||
/>
|
||||
<span className="font-mono text-[11px] text-amber-300 w-8">
|
||||
{settings.lightAngle}°
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. Checkboxes: Props, Motion, Pen */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<label className="flex items-center gap-1 cursor-pointer text-neutral-300 hover:text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showProps}
|
||||
onChange={(e) => onUpdateSettings({ showProps: e.target.checked })}
|
||||
className="rounded accent-amber-500 cursor-pointer"
|
||||
/>
|
||||
<span>Props</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1 cursor-pointer text-neutral-300 hover:text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableMotion}
|
||||
onChange={(e) => onUpdateSettings({ enableMotion: e.target.checked })}
|
||||
className="rounded accent-amber-500 cursor-pointer"
|
||||
/>
|
||||
<span>Motion</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1 cursor-pointer text-neutral-300 hover:text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.penMode}
|
||||
onChange={(e) => onUpdateSettings({ penMode: e.target.checked })}
|
||||
className="rounded accent-amber-500 cursor-pointer"
|
||||
/>
|
||||
<span>Pen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 6. Action Buttons & Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 4. Mobile Settings Trigger (always accessible on mobile & tablet) */}
|
||||
<button
|
||||
onClick={onReplay}
|
||||
className="px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white border border-neutral-700/80 transition"
|
||||
title="Replay entry animations"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="xl:hidden p-1.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-300 border border-neutral-700 transition flex items-center gap-1"
|
||||
title="Open Desk Settings"
|
||||
>
|
||||
Replay
|
||||
<SlidersHorizontal size={14} />
|
||||
<span className="text-[11px] hidden sm:inline">Settings</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Sound Toggle */}
|
||||
<button
|
||||
onClick={() => onUpdateSettings({ soundEnabled: !settings.soundEnabled })}
|
||||
className={`px-2 py-0.5 rounded border transition flex items-center gap-1 ${
|
||||
settings.soundEnabled
|
||||
? 'bg-neutral-800 text-amber-400 border-amber-600/50'
|
||||
: 'bg-neutral-800/60 text-neutral-400 border-neutral-700'
|
||||
}`}
|
||||
title="Toggle paper & stationery audio effects"
|
||||
>
|
||||
{settings.soundEnabled ? <Volume2 size={13} /> : <VolumeX size={13} />}
|
||||
<span>{settings.soundEnabled ? 'Sound on' : 'Sound off'}</span>
|
||||
</button>
|
||||
{/* 5. Mobile Drawer / Bottom Sheet */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm p-0 sm:p-4 select-none">
|
||||
<div className="bg-[#1C1A17] border-t sm:border border-neutral-700 w-full sm:max-w-md rounded-t-2xl sm:rounded-2xl p-5 text-neutral-200 shadow-2xl max-h-[85vh] overflow-y-auto space-y-4 text-xs animate-in slide-in-from-bottom duration-200">
|
||||
<div className="flex justify-between items-center pb-2 border-b border-neutral-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal size={16} className="text-amber-400" />
|
||||
<h2 className="text-sm font-bold text-white">Desk Controls & Tools</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="text-neutral-400 hover:text-white p-1"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card Type Filter */}
|
||||
<select
|
||||
value={settings.activeFilter}
|
||||
onChange={(e) => onUpdateSettings({ activeFilter: e.target.value as DeskSettings['activeFilter'] })}
|
||||
className="bg-neutral-800 border border-neutral-700 rounded px-1.5 py-0.5 text-neutral-300 focus:outline-none text-[11px] cursor-pointer"
|
||||
>
|
||||
<option value="all">Types: All</option>
|
||||
<option value="sticky">Sticky Notes</option>
|
||||
<option value="polaroid">Photos</option>
|
||||
<option value="tag">Tags</option>
|
||||
<option value="torn">Letters</option>
|
||||
<option value="business">Cards</option>
|
||||
<option value="receipt">Receipts</option>
|
||||
<option value="graph">Sketches</option>
|
||||
<option value="moodboard">Moodboards</option>
|
||||
</select>
|
||||
{/* Desk Surface Texture */}
|
||||
<div>
|
||||
<label className="block text-neutral-400 mb-1 font-semibold">Desk Surface</label>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{[
|
||||
{ id: 'oak', label: 'Oak Desk' },
|
||||
{ id: 'walnut', label: 'Walnut' },
|
||||
{ id: 'cutting-mat', label: 'Mat' },
|
||||
{ id: 'corkboard', label: 'Cork' },
|
||||
{ id: 'blueprint', label: 'Blueprint' },
|
||||
{ id: 'dark-slate', label: 'Slate' },
|
||||
{ id: 'marble', label: 'Marble' }
|
||||
].map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => onUpdateSettings({ deskTexture: d.id as DeskTexture })}
|
||||
className={`py-1.5 px-2 rounded border text-center transition ${
|
||||
settings.deskTexture === d.id
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500 font-bold'
|
||||
: 'bg-neutral-800 text-neutral-400 border-neutral-700 hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add New Card (+) Button */}
|
||||
<button
|
||||
onClick={onAddCard}
|
||||
className="p-1 rounded bg-neutral-800 hover:bg-amber-500 hover:text-neutral-950 text-neutral-300 border border-neutral-700 transition"
|
||||
title="Add a new custom note or card"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
{/* Typography */}
|
||||
<div>
|
||||
<label className="block text-neutral-400 mb-1 font-semibold">Typography</label>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{(['mixed', 'handwritten', 'typed'] as FontMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => onUpdateSettings({ fontMode: mode })}
|
||||
className={`py-1.5 rounded capitalize border text-center transition ${
|
||||
settings.fontMode === mode
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500 font-bold'
|
||||
: 'bg-neutral-800 text-neutral-400 border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Portfolio Mode / Presets Modal Button */}
|
||||
<button
|
||||
onClick={onOpenPortfolio}
|
||||
className="px-2 py-0.5 rounded bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 border border-amber-500/40 transition flex items-center gap-1 font-medium"
|
||||
title="Portfolio & Preset Modes"
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
<span>Portfolio</span>
|
||||
</button>
|
||||
{/* 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>
|
||||
|
||||
{/* Stats readout like in original */}
|
||||
<span className="font-mono text-[10px] text-neutral-400 pl-1 border-l border-neutral-700/80 hidden xl:inline">
|
||||
{totalCards}/{totalCards} sheets
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<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>
|
||||
|
||||
{/* Quick Toggles */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-neutral-800">
|
||||
<button
|
||||
onClick={() => onUpdateSettings({ showProps: !settings.showProps })}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onUpdateSettings({ soundEnabled: !settings.soundEnabled })}
|
||||
className={`py-2 px-3 rounded border text-left flex items-center justify-between ${
|
||||
settings.soundEnabled ? 'bg-amber-500/10 border-amber-500 text-amber-300' : 'bg-neutral-800 border-neutral-700 text-neutral-400'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -286,10 +286,12 @@ export function generateProceduralCards(
|
||||
fontMode: 'mixed' | 'handwritten' | 'typed' = 'mixed'
|
||||
): CardItem[] {
|
||||
const cards: CardItem[] = [];
|
||||
const paddingX = 80;
|
||||
const paddingY = 90;
|
||||
const usableWidth = Math.max(500, canvasWidth - paddingX * 2);
|
||||
const usableHeight = Math.max(400, canvasHeight - paddingY * 2);
|
||||
const isMobile = canvasWidth < 768;
|
||||
const scale = isMobile ? Math.min(1, Math.max(0.75, canvasWidth / 500)) : 1.0;
|
||||
const paddingX = isMobile ? 25 : 80;
|
||||
const paddingY = isMobile ? 70 : 90;
|
||||
const usableWidth = Math.max(260, canvasWidth - paddingX * 2);
|
||||
const usableHeight = Math.max(380, canvasHeight - paddingY * 2);
|
||||
|
||||
const availableFonts = FONTS_BY_MODE[fontMode];
|
||||
|
||||
@@ -345,8 +347,8 @@ export function generateProceduralCards(
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
rotation: rotation,
|
||||
width: preset.width || 180,
|
||||
height: preset.height || 180,
|
||||
width: Math.round((preset.width || 180) * scale),
|
||||
height: Math.round((preset.height || 180) * scale),
|
||||
zIndex: i + 1,
|
||||
isPinned: false,
|
||||
attachType: attachType,
|
||||
|
||||
Reference in new issue
Block a user