Implement persistent backend with Docker storage, photo uploads, and real-time SSE sync

This commit is contained in:
airent106 committed 2026-10-13 00:58:13 -06:00
1 parent 157fc83a47
commit 7f4fed3c8c
10 files changed
+1583 -75

No files matched your search

+21 -5
View File
@@ -10,14 +10,30 @@ COPY . ./
RUN npm run build
# Production stage
FROM nginx:alpine
FROM node:22-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=80
ENV DATA_DIR=/app/data
# Install production dependencies
COPY package*.json ./
RUN npm install --omit=dev
# Copy backend server code
COPY server/ ./server/
# Copy built frontend assets
COPY --from=builder /app/dist ./dist
# Create persistent storage directories
RUN mkdir -p /app/data/uploads
EXPOSE 80
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 wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1
CMD ["nginx", "-g", "daemon off;"]
CMD ["node", "server/index.js"]
+3 -1
View File
@@ -5,12 +5,14 @@ services:
dockerfile: Dockerfile
container_name: glimpse
restart: unless-stopped
volumes:
- /home/chrisf/docker/appdata/glimpse/data:/app/data
networks:
- t3_proxy
ports:
- "127.0.0.1:8476:80"
healthcheck:
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:80/ || exit 1"]
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
+1005 -8
View File
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -6,16 +6,23 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"start": "node server/index.js"
},
"dependencies": {
"clsx": "^2.1.1",
"cors": "^2.8.6",
"express": "^5.3.0",
"lucide-react": "^1.16.0",
"multer": "^2.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.2"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/multer": "^2.3.0",
"@types/node": "^22.13.9",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
+214
View File
@@ -0,0 +1,214 @@
import express from 'express';
import cors from 'cors';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 80;
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data');
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads');
const DESK_FILE = path.join(DATA_DIR, 'desk.json');
// Ensure storage directories exist
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
// Middleware
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Configure Multer for file/camera photo uploads
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, UPLOADS_DIR);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
const cleanExt = ['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext) ? ext : '.jpg';
const uniqueName = `glimpse_${Date.now()}_${Math.random().toString(36).substring(2, 8)}${cleanExt}`;
cb(null, uniqueName);
}
});
const upload = multer({
storage,
limits: { fileSize: 25 * 1024 * 1024 } // 25 MB max photo size
});
// SSE Client Connection Pool
const sseClients = new Set();
function broadcastEvent(eventData) {
const payload = `data: ${JSON.stringify(eventData)}\n\n`;
for (const client of sseClients) {
try {
client.res.write(payload);
} catch {
sseClients.delete(client);
}
}
}
// Keep-alive heartbeat for SSE every 20 seconds
setInterval(() => {
for (const client of sseClients) {
try {
client.res.write(':keep-alive\n\n');
} catch {
sseClients.delete(client);
}
}
}, 20000);
// Helper to safely read desk state
function readDeskState() {
try {
if (fs.existsSync(DESK_FILE)) {
const raw = fs.readFileSync(DESK_FILE, 'utf-8');
return JSON.parse(raw);
}
} catch (err) {
console.error('Error reading desk state:', err);
}
return null;
}
// Helper to safely write desk state (atomic write via temp file)
function writeDeskState(state) {
const tempFile = `${DESK_FILE}.tmp.${Date.now()}`;
try {
fs.writeFileSync(tempFile, JSON.stringify(state, null, 2), 'utf-8');
fs.renameSync(tempFile, DESK_FILE);
return true;
} catch (err) {
console.error('Error writing desk state:', err);
try {
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
} catch {
// ignore
}
return false;
}
}
// --- API Endpoints ---
// Healthcheck
app.get('/api/health', (_req, res) => {
res.json({
status: 'healthy',
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
});
// SSE Stream for Real-time Cross-Device Sync
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // Disable Traefik/Nginx buffering
res.flushHeaders?.();
const client = { id: Math.random().toString(36).substring(2), res };
sseClients.add(client);
res.write(`data: ${JSON.stringify({ type: 'connected', clientId: client.id })}\n\n`);
req.on('close', () => {
sseClients.delete(client);
});
});
// Get current desk state
app.get('/api/desk', (_req, res) => {
const desk = readDeskState();
if (desk) {
res.json({ success: true, desk });
} else {
res.json({ success: true, desk: null, initialized: false });
}
});
// Save desk state
app.post('/api/desk', (req, res) => {
const { cards, props, strokes, settings, originClientId } = req.body;
if (!Array.isArray(cards)) {
return res.status(400).json({ success: false, error: 'Invalid desk state: cards required' });
}
const newState = {
cards,
props: Array.isArray(props) ? props : [],
strokes: Array.isArray(strokes) ? strokes : [],
settings: settings || {},
updatedAt: Date.now()
};
const saved = writeDeskState(newState);
if (!saved) {
return res.status(500).json({ success: false, error: 'Failed to persist desk state' });
}
// Broadcast to all other devices in real-time
broadcastEvent({
type: 'desk_updated',
desk: newState,
originClientId,
timestamp: newState.updatedAt
});
res.json({ success: true, updatedAt: newState.updatedAt });
});
// Upload photo/file endpoint
app.post('/api/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ success: false, error: 'No file uploaded' });
}
const publicUrl = `/uploads/${req.file.filename}`;
res.json({
success: true,
url: publicUrl,
filename: req.file.filename,
size: req.file.size
});
});
// Serve uploaded photos/media
app.use('/uploads', express.static(UPLOADS_DIR, {
maxAge: '7d',
immutable: true
}));
// Serve static frontend from dist
const distPath = path.resolve(__dirname, '../dist');
if (fs.existsSync(distPath)) {
app.use(express.static(distPath));
// SPA Fallback: serve index.html for unknown routes
app.get('*', (req, res) => {
if (req.path.startsWith('/api') || req.path.startsWith('/uploads')) {
return res.status(404).json({ error: 'Not found' });
}
res.sendFile(path.join(distPath, 'index.html'));
});
}
// Start Server
app.listen(PORT, '0.0.0.0', () => {
console.log(`[Glimpse Backend] Server listening on http://0.0.0.0:${PORT}`);
console.log(`[Glimpse Backend] Storage path: ${DATA_DIR}`);
});
+218 -39
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { CardItem, DeskProp, DeskSettings, InkStroke } from './types';
import { CardItem, DeskProp, DeskSettings, InkStroke, SyncStatus } from './types';
import { generateProceduralCards, SAMPLE_PRESETS } from './utils/proceduralCards';
import { soundEngine } from './utils/sound';
import { TopBar } from './components/TopBar';
@@ -50,6 +50,12 @@ export const App: React.FC = () => {
const [editingCard, setEditingCard] = useState<CardItem | null>(null);
const [isPortfolioOpen, setIsPortfolioOpen] = useState(false);
// Cross-device sync & backend persistence state
const [syncStatus, setSyncStatus] = useState<SyncStatus>('loading');
const clientIdRef = useRef<string>(Math.random().toString(36).substring(2));
const isLoadedRef = useRef<boolean>(false);
const saveTimeoutRef = useRef<number | null>(null);
// Dragging & Physics references
const draggingIdRef = useRef<string | null>(null);
const dragTypeRef = useRef<'card' | 'prop'>('card');
@@ -57,26 +63,161 @@ export const App: React.FC = () => {
const pointerHistoryRef = useRef<{ x: number; y: number; time: number }[]>([]);
const [draggingCardId, setDraggingCardId] = useState<string | null>(null);
// Initialize cards on mount
useEffect(() => {
const w = typeof window !== 'undefined' ? window.innerWidth : 1400;
const h = typeof window !== 'undefined' ? window.innerHeight : 900;
const isMobile = w < 768;
const initialCount = isMobile ? 9 : 22;
// Save Desk State to Server
const saveToServer = useCallback(
(
currentCards: CardItem[],
currentProps: DeskProp[],
currentStrokes: InkStroke[],
currentSettings: DeskSettings,
immediate = false
) => {
if (!isLoadedRef.current) return;
if (isMobile) {
setSettings((s) => ({ ...s, cardCount: initialCount }));
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
const doSave = () => {
setSyncStatus('syncing');
fetch('/api/desk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cards: currentCards,
props: currentProps,
strokes: currentStrokes,
settings: {
deskTexture: currentSettings.deskTexture,
lightAngle: currentSettings.lightAngle,
grain: currentSettings.grain,
layoutMode: currentSettings.layoutMode
},
originClientId: clientIdRef.current
})
})
.then((r) => r.json())
.then((res) => {
if (res.success) {
setSyncStatus('synced');
} else {
setSyncStatus('offline');
}
})
.catch(() => setSyncStatus('offline'));
};
if (immediate) {
doSave();
} else {
saveTimeoutRef.current = window.setTimeout(doSave, 700);
}
},
[]
);
// Initialize and load persistent desk on mount
useEffect(() => {
fetch('/api/desk')
.then((res) => res.json())
.then((data) => {
if (data.success && data.desk && Array.isArray(data.desk.cards) && data.desk.cards.length > 0) {
setCards(data.desk.cards);
if (Array.isArray(data.desk.props) && data.desk.props.length > 0) {
setPropsList(data.desk.props);
}
if (Array.isArray(data.desk.strokes)) {
setStrokes(data.desk.strokes);
}
if (data.desk.settings) {
setSettings((s) => ({
...s,
deskTexture: data.desk.settings.deskTexture ?? s.deskTexture,
lightAngle: data.desk.settings.lightAngle ?? s.lightAngle,
grain: data.desk.settings.grain ?? s.grain,
layoutMode: data.desk.settings.layoutMode ?? s.layoutMode
}));
}
const maxZ = Math.max(15, ...data.desk.cards.map((c: CardItem) => c.zIndex || 1));
setMaxZIndex(maxZ + 5);
setSyncStatus('synced');
isLoadedRef.current = true;
} else {
// Initialize default seed cards
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;
const initialCards = generateProceduralCards(
initialCount,
w,
h,
settings.attachChance,
settings.fontMode
);
setCards(initialCards);
setMaxZIndex(initialCards.length + 20);
setSyncStatus('synced');
isLoadedRef.current = true;
saveToServer(initialCards, INITIAL_PROPS, [], settings, true);
}
})
.catch((err) => {
console.warn('Backend server offline, using local procedural cards:', err);
const w = typeof window !== 'undefined' ? window.innerWidth : 1400;
const h = typeof window !== 'undefined' ? window.innerHeight : 900;
const initialCards = generateProceduralCards(9, w, h, settings.attachChance, settings.fontMode);
setCards(initialCards);
setSyncStatus('offline');
isLoadedRef.current = true;
});
}, [saveToServer]);
// Real-Time Cross-Device SSE Listener
useEffect(() => {
let es: EventSource | null = null;
try {
es = new EventSource('/api/events');
es.onmessage = (e) => {
try {
const payload = JSON.parse(e.data);
if (payload.type === 'desk_updated' && payload.originClientId !== clientIdRef.current) {
if (payload.desk) {
if (Array.isArray(payload.desk.cards)) {
setCards(payload.desk.cards);
const maxZ = Math.max(15, ...payload.desk.cards.map((c: CardItem) => c.zIndex || 1));
setMaxZIndex((prev) => Math.max(prev, maxZ + 2));
}
if (Array.isArray(payload.desk.props)) setPropsList(payload.desk.props);
if (Array.isArray(payload.desk.strokes)) setStrokes(payload.desk.strokes);
if (payload.desk.settings) {
setSettings((prev) => ({
...prev,
deskTexture: payload.desk.settings.deskTexture ?? prev.deskTexture,
lightAngle: payload.desk.settings.lightAngle ?? prev.lightAngle,
grain: payload.desk.settings.grain ?? prev.grain,
layoutMode: payload.desk.settings.layoutMode ?? prev.layoutMode
}));
}
setSyncStatus('synced');
}
}
} catch (err) {
console.error('Error parsing SSE event:', err);
}
};
es.onerror = () => {
// EventSource will auto-reconnect
};
} catch {
// ignore
}
const initialCards = generateProceduralCards(
initialCount,
w,
h,
settings.attachChance,
settings.fontMode
);
setCards(initialCards);
setMaxZIndex(initialCards.length + 20);
return () => {
es?.close();
};
}, []);
// Update card count when slider changes
@@ -133,7 +274,7 @@ export const App: React.FC = () => {
setCards((prev) => {
const shuffledOrder = prev.map((_, i) => i).sort(() => Math.random() - 0.5);
return prev.map((card, i) => {
const next = prev.map((card, i) => {
const slot = shuffledOrder[i];
const col = slot % cols;
const row = Math.floor(slot / cols);
@@ -154,8 +295,10 @@ export const App: React.FC = () => {
zIndex: i + 1
};
});
saveToServer(next, propsList, strokes, settings, false);
return next;
});
}, [cards.length, settings.zoom, settings.soundEnabled]);
}, [cards.length, settings.zoom, settings.soundEnabled, saveToServer, propsList, strokes, settings]);
// Arrange in Grid mode (clean non-overlapping Pinterest/moodboard tiles)
const arrangeGrid = useCallback(() => {
@@ -175,8 +318,8 @@ export const App: React.FC = () => {
const cellW = Math.floor((availableW - (cols - 1) * gap) / cols);
const rowH = isMobile ? 185 : 215;
setCards((prev) =>
prev.map((card, idx) => {
setCards((prev) => {
const next = prev.map((card, idx) => {
const col = idx % cols;
const row = Math.floor(idx / cols);
@@ -190,9 +333,11 @@ export const App: React.FC = () => {
vx: 0,
vy: 0
};
})
);
}, [settings.zoom, settings.soundEnabled]);
});
saveToServer(next, propsList, strokes, settings, false);
return next;
});
}, [settings.zoom, settings.soundEnabled, saveToServer, propsList, strokes, settings]);
// Trigger re-layout when layoutMode or zoom changes
useEffect(() => {
@@ -383,8 +528,8 @@ export const App: React.FC = () => {
soundEngine.drop(settings.soundEnabled);
}
setCards((prev) =>
prev.map((c) =>
setCards((prev) => {
const next = prev.map((c) =>
c.id === id
? {
...c,
@@ -392,10 +537,13 @@ export const App: React.FC = () => {
vy: Math.max(-25, Math.min(25, vy))
}
: c
)
);
);
saveToServer(next, propsList, strokes, settings, false);
return next;
});
} else {
soundEngine.drop(settings.soundEnabled);
saveToServer(cards, propsList, strokes, settings, false);
}
};
@@ -570,6 +718,7 @@ export const App: React.FC = () => {
onAddCard={() => setIsAddOpen(true)}
onOpenPortfolio={() => setIsPortfolioOpen(true)}
totalCards={visibleCards.length}
syncStatus={syncStatus}
/>
{/* 3. Scalable Desk Workspace (Cards, Props & Pen) */}
@@ -590,8 +739,8 @@ export const App: React.FC = () => {
onPointerDown={handleCardPointerDown}
onDoubleClick={(c) => setEditingCard(c)}
onToggleTodo={(cardId, idx) => {
setCards((prev) =>
prev.map((c) => {
setCards((prev) => {
const next = prev.map((c) => {
if (c.id !== cardId || !c.todoItems) return c;
const updatedTodos = [...c.todoItems];
updatedTodos[idx] = {
@@ -599,8 +748,10 @@ export const App: React.FC = () => {
done: !updatedTodos[idx].done
};
return { ...c, todoItems: updatedTodos };
})
);
});
saveToServer(next, propsList, strokes, settings, true);
return next;
});
}}
/>
))}
@@ -618,8 +769,17 @@ export const App: React.FC = () => {
<PenLayer
active={settings.penMode}
strokes={strokes}
onAddStroke={(s) => setStrokes((prev) => [...prev, s])}
onClear={() => setStrokes([])}
onAddStroke={(s) => {
setStrokes((prev) => {
const next = [...prev, s];
saveToServer(cards, propsList, next, settings, false);
return next;
});
}}
onClear={() => {
setStrokes([]);
saveToServer(cards, propsList, [], settings, true);
}}
/>
</div>
@@ -644,6 +804,7 @@ export const App: React.FC = () => {
penActive={settings.penMode}
isGrid={settings.layoutMode === 'grid'}
zoomLevel={settings.zoom}
syncStatus={syncStatus}
/>
{/* 7. Modals */}
@@ -674,7 +835,11 @@ export const App: React.FC = () => {
fontFamily: newCard.fontFamily || 'font-caveat'
};
setMaxZIndex(maxZIndex + 1);
setCards((prev) => [...prev, created]);
setCards((prev) => {
const next = [...prev, created];
saveToServer(next, propsList, strokes, settings, true);
return next;
});
soundEngine.drop(settings.soundEnabled);
}}
/>
@@ -684,11 +849,19 @@ export const App: React.FC = () => {
isOpen={!!editingCard}
onClose={() => setEditingCard(null)}
onSave={(updated) => {
setCards((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
setCards((prev) => {
const next = prev.map((c) => (c.id === updated.id ? updated : c));
saveToServer(next, propsList, strokes, settings, true);
return next;
});
soundEngine.rustle(settings.soundEnabled);
}}
onDelete={(id) => {
setCards((prev) => prev.filter((c) => c.id !== id));
setCards((prev) => {
const next = prev.filter((c) => c.id !== id);
saveToServer(next, propsList, strokes, settings, true);
return next;
});
soundEngine.rustle(settings.soundEnabled);
}}
/>
@@ -698,9 +871,15 @@ export const App: React.FC = () => {
onClose={() => setIsPortfolioOpen(false)}
cards={cards}
settings={settings}
onLoadPreset={handleLoadPreset}
onLoadPreset={(name) => {
handleLoadPreset(name);
setTimeout(() => {
saveToServer(cards, propsList, strokes, settings, true);
}, 100);
}}
onImportJson={(importedCards) => {
setCards(importedCards);
saveToServer(importedCards, propsList, strokes, settings, true);
soundEngine.shuffle(settings.soundEnabled);
}}
/>
+49 -13
View File
@@ -17,10 +17,13 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
const [attachType, setAttachType] = useState<AttachType>('washi');
const [fontFamily, setFontFamily] = useState('font-caveat');
const [isUploading, setIsUploading] = useState(false);
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isUploading) return;
onAdd({
type,
title: title.trim() || undefined,
@@ -39,6 +42,41 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
setImageUrl('');
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Immediate preview
const reader = new FileReader();
reader.onload = (ev) => {
if (typeof ev.target?.result === 'string') {
setImageUrl(ev.target.result);
}
};
reader.readAsDataURL(file);
// Upload to server
setIsUploading(true);
const formData = new FormData();
formData.append('file', file);
fetch('/api/upload', {
method: 'POST',
body: formData
})
.then((res) => res.json())
.then((data) => {
setIsUploading(false);
if (data.success && data.url) {
setImageUrl(data.url);
}
})
.catch((err) => {
console.warn('Server upload not reachable, using local image data:', err);
setIsUploading(false);
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-[#1C1A17] border border-neutral-700 w-full max-w-md rounded-xl p-5 text-neutral-200 shadow-2xl">
@@ -100,25 +138,23 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
{/* Image Upload or URL for Polaroid */}
{type === 'polaroid' && (
<div className="space-y-2">
<label className="block text-neutral-400">Photo / Snapshot</label>
<div className="flex justify-between items-center text-neutral-400">
<span>Photo / Snapshot</span>
{isUploading && (
<span className="text-[10px] text-amber-400 font-mono animate-pulse">
Uploading to server...
</span>
)}
</div>
<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>
<span>📸 {isUploading ? 'Uploading...' : '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);
}}
onChange={handleFileUpload}
className="hidden"
disabled={isUploading}
/>
</label>
</div>
+16 -3
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { Shuffle, Grid, LayoutGrid, PenTool, Plus, ZoomIn } from 'lucide-react';
import { SyncStatus } from '../types';
interface BottomBarProps {
onShuffle?: () => void;
@@ -10,6 +11,7 @@ interface BottomBarProps {
penActive?: boolean;
isGrid?: boolean;
zoomLevel?: number;
syncStatus?: SyncStatus;
}
export const BottomBar: React.FC<BottomBarProps> = ({
@@ -20,17 +22,28 @@ export const BottomBar: React.FC<BottomBarProps> = ({
onCycleZoom,
penActive = false,
isGrid = false,
zoomLevel = 1.0
zoomLevel = 1.0,
syncStatus = 'synced'
}) => {
return (
<>
{/* 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
className={`w-1.5 h-1.5 rounded-full inline-block ${
syncStatus === 'synced'
? 'bg-emerald-400'
: syncStatus === 'syncing'
? 'bg-amber-400 animate-pulse'
: 'bg-neutral-500'
}`}
/>
<span className="capitalize">{syncStatus}</span>
</span>
<span className="text-neutral-600">·</span>
<span>drag anything</span>
<span className="text-neutral-600">·</span>
<span>flick to throw</span>
<span className="text-neutral-600">·</span>
<span>double-click to edit</span>
+47 -5
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { DeskSettings, DeskTexture, FontMode, CardType } from '../types';
import { Volume2, VolumeX, Plus, Sparkles, SlidersHorizontal, X } from 'lucide-react';
import { DeskSettings, DeskTexture, FontMode, CardType, SyncStatus } from '../types';
import { Volume2, VolumeX, Plus, Sparkles, SlidersHorizontal, X, Cloud, RefreshCw, CloudOff } from 'lucide-react';
interface TopBarProps {
settings: DeskSettings;
@@ -10,6 +10,7 @@ interface TopBarProps {
onAddCard: () => void;
onOpenPortfolio: () => void;
totalCards: number;
syncStatus?: SyncStatus;
}
export const TopBar: React.FC<TopBarProps> = ({
@@ -19,15 +20,16 @@ export const TopBar: React.FC<TopBarProps> = ({
onReplay,
onAddCard,
onOpenPortfolio,
totalCards
totalCards,
syncStatus = 'synced'
}) => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
<>
<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">
{/* 1. Left: Brand Title, Subtitle & Sync Pill */}
<div className="flex items-center gap-2 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">
@@ -39,6 +41,46 @@ export const TopBar: React.FC<TopBarProps> = ({
procedural paper · visual desk
</span>
</div>
{/* Cross-Device Sync Status Pill */}
<div className="flex items-center">
{syncStatus === 'synced' && (
<span
className="flex items-center gap-1 text-[10px] text-emerald-400 font-mono px-1.5 py-0.5 bg-emerald-950/40 border border-emerald-800/60 rounded-full"
title="Synced live across devices"
>
<Cloud size={11} className="text-emerald-400" />
<span className="hidden sm:inline">Synced</span>
</span>
)}
{syncStatus === 'syncing' && (
<span
className="flex items-center gap-1 text-[10px] text-amber-300 font-mono px-1.5 py-0.5 bg-amber-950/40 border border-amber-800/60 rounded-full"
title="Saving changes to server..."
>
<RefreshCw size={10} className="text-amber-400 animate-spin" />
<span className="hidden sm:inline">Syncing...</span>
</span>
)}
{syncStatus === 'loading' && (
<span
className="flex items-center gap-1 text-[10px] text-amber-300 font-mono px-1.5 py-0.5 bg-neutral-900 border border-neutral-700 rounded-full"
title="Loading desk from server..."
>
<RefreshCw size={10} className="text-amber-400 animate-spin" />
<span className="hidden sm:inline">Loading...</span>
</span>
)}
{syncStatus === 'offline' && (
<span
className="flex items-center gap-1 text-[10px] text-neutral-400 font-mono px-1.5 py-0.5 bg-neutral-900 border border-neutral-700 rounded-full"
title="Local / Offline mode"
>
<CloudOff size={11} className="text-neutral-500" />
<span className="hidden sm:inline">Local</span>
</span>
)}
</div>
</div>
{/* 2. Primary Actions: Shuffle, Scatter/Grid (Always Visible) */}
+2
View File
@@ -90,3 +90,5 @@ export interface DeskSettings {
activeFilter: 'all' | CardType;
zoom: number; // 0.4 - 1.5 zoom level
}
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'loading';