Implement persistent backend with Docker storage, photo uploads, and real-time SSE sync
This commit is contained in:
1 parent
157fc83a47
commit
7f4fed3c8c
10 files changed
+1570
-62
No files matched your search
+21
-5
@@ -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
@@ -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
|
||||
|
||||
Generated
+1005
-8
File diff suppressed because it is too large.
Load diff
+8
-1
@@ -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
@@ -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}`);
|
||||
});
|
||||
+205
-26
@@ -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,17 +63,92 @@ 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
|
||||
// Save Desk State to Server
|
||||
const saveToServer = useCallback(
|
||||
(
|
||||
currentCards: CardItem[],
|
||||
currentProps: DeskProp[],
|
||||
currentStrokes: InkStroke[],
|
||||
currentSettings: DeskSettings,
|
||||
immediate = false
|
||||
) => {
|
||||
if (!isLoadedRef.current) return;
|
||||
|
||||
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;
|
||||
|
||||
if (isMobile) {
|
||||
setSettings((s) => ({ ...s, cardCount: initialCount }));
|
||||
}
|
||||
|
||||
const initialCards = generateProceduralCards(
|
||||
initialCount,
|
||||
w,
|
||||
@@ -77,6 +158,66 @@ export const App: React.FC = () => {
|
||||
);
|
||||
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
|
||||
}
|
||||
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user