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
+1583
-75
No files matched your search
+21
-5
@@ -10,14 +10,30 @@ COPY . ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM nginx:alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
WORKDIR /app
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
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
|
EXPOSE 80
|
||||||
|
|
||||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=5s --retries=3 \
|
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
|
dockerfile: Dockerfile
|
||||||
container_name: glimpse
|
container_name: glimpse
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /home/chrisf/docker/appdata/glimpse/data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- t3_proxy
|
- t3_proxy
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8476:80"
|
- "127.0.0.1:8476:80"
|
||||||
healthcheck:
|
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
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
Generated
+1005
-8
File diff suppressed because it is too large.
Load diff
+8
-1
@@ -6,16 +6,23 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"start": "node server/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"express": "^5.3.0",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"multer": "^2.4.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^3.0.2"
|
"tailwind-merge": "^3.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/multer": "^2.3.0",
|
||||||
"@types/node": "^22.13.9",
|
"@types/node": "^22.13.9",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@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}`);
|
||||||
|
});
|
||||||
+218
-39
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
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 { generateProceduralCards, SAMPLE_PRESETS } from './utils/proceduralCards';
|
||||||
import { soundEngine } from './utils/sound';
|
import { soundEngine } from './utils/sound';
|
||||||
import { TopBar } from './components/TopBar';
|
import { TopBar } from './components/TopBar';
|
||||||
@@ -50,6 +50,12 @@ export const App: React.FC = () => {
|
|||||||
const [editingCard, setEditingCard] = useState<CardItem | null>(null);
|
const [editingCard, setEditingCard] = useState<CardItem | null>(null);
|
||||||
const [isPortfolioOpen, setIsPortfolioOpen] = useState(false);
|
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
|
// Dragging & Physics references
|
||||||
const draggingIdRef = useRef<string | null>(null);
|
const draggingIdRef = useRef<string | null>(null);
|
||||||
const dragTypeRef = useRef<'card' | 'prop'>('card');
|
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 pointerHistoryRef = useRef<{ x: number; y: number; time: number }[]>([]);
|
||||||
const [draggingCardId, setDraggingCardId] = useState<string | null>(null);
|
const [draggingCardId, setDraggingCardId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Initialize cards on mount
|
// Save Desk State to Server
|
||||||
useEffect(() => {
|
const saveToServer = useCallback(
|
||||||
const w = typeof window !== 'undefined' ? window.innerWidth : 1400;
|
(
|
||||||
const h = typeof window !== 'undefined' ? window.innerHeight : 900;
|
currentCards: CardItem[],
|
||||||
const isMobile = w < 768;
|
currentProps: DeskProp[],
|
||||||
const initialCount = isMobile ? 9 : 22;
|
currentStrokes: InkStroke[],
|
||||||
|
currentSettings: DeskSettings,
|
||||||
|
immediate = false
|
||||||
|
) => {
|
||||||
|
if (!isLoadedRef.current) return;
|
||||||
|
|
||||||
if (isMobile) {
|
if (saveTimeoutRef.current) {
|
||||||
setSettings((s) => ({ ...s, cardCount: initialCount }));
|
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(
|
return () => {
|
||||||
initialCount,
|
es?.close();
|
||||||
w,
|
};
|
||||||
h,
|
|
||||||
settings.attachChance,
|
|
||||||
settings.fontMode
|
|
||||||
);
|
|
||||||
setCards(initialCards);
|
|
||||||
setMaxZIndex(initialCards.length + 20);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update card count when slider changes
|
// Update card count when slider changes
|
||||||
@@ -133,7 +274,7 @@ export const App: React.FC = () => {
|
|||||||
setCards((prev) => {
|
setCards((prev) => {
|
||||||
const shuffledOrder = prev.map((_, i) => i).sort(() => Math.random() - 0.5);
|
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 slot = shuffledOrder[i];
|
||||||
const col = slot % cols;
|
const col = slot % cols;
|
||||||
const row = Math.floor(slot / cols);
|
const row = Math.floor(slot / cols);
|
||||||
@@ -154,8 +295,10 @@ export const App: React.FC = () => {
|
|||||||
zIndex: i + 1
|
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)
|
// Arrange in Grid mode (clean non-overlapping Pinterest/moodboard tiles)
|
||||||
const arrangeGrid = useCallback(() => {
|
const arrangeGrid = useCallback(() => {
|
||||||
@@ -175,8 +318,8 @@ export const App: React.FC = () => {
|
|||||||
const cellW = Math.floor((availableW - (cols - 1) * gap) / cols);
|
const cellW = Math.floor((availableW - (cols - 1) * gap) / cols);
|
||||||
const rowH = isMobile ? 185 : 215;
|
const rowH = isMobile ? 185 : 215;
|
||||||
|
|
||||||
setCards((prev) =>
|
setCards((prev) => {
|
||||||
prev.map((card, idx) => {
|
const next = prev.map((card, idx) => {
|
||||||
const col = idx % cols;
|
const col = idx % cols;
|
||||||
const row = Math.floor(idx / cols);
|
const row = Math.floor(idx / cols);
|
||||||
|
|
||||||
@@ -190,9 +333,11 @@ export const App: React.FC = () => {
|
|||||||
vx: 0,
|
vx: 0,
|
||||||
vy: 0
|
vy: 0
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
);
|
saveToServer(next, propsList, strokes, settings, false);
|
||||||
}, [settings.zoom, settings.soundEnabled]);
|
return next;
|
||||||
|
});
|
||||||
|
}, [settings.zoom, settings.soundEnabled, saveToServer, propsList, strokes, settings]);
|
||||||
|
|
||||||
// Trigger re-layout when layoutMode or zoom changes
|
// Trigger re-layout when layoutMode or zoom changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -383,8 +528,8 @@ export const App: React.FC = () => {
|
|||||||
soundEngine.drop(settings.soundEnabled);
|
soundEngine.drop(settings.soundEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCards((prev) =>
|
setCards((prev) => {
|
||||||
prev.map((c) =>
|
const next = prev.map((c) =>
|
||||||
c.id === id
|
c.id === id
|
||||||
? {
|
? {
|
||||||
...c,
|
...c,
|
||||||
@@ -392,10 +537,13 @@ export const App: React.FC = () => {
|
|||||||
vy: Math.max(-25, Math.min(25, vy))
|
vy: Math.max(-25, Math.min(25, vy))
|
||||||
}
|
}
|
||||||
: c
|
: c
|
||||||
)
|
);
|
||||||
);
|
saveToServer(next, propsList, strokes, settings, false);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
soundEngine.drop(settings.soundEnabled);
|
soundEngine.drop(settings.soundEnabled);
|
||||||
|
saveToServer(cards, propsList, strokes, settings, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -570,6 +718,7 @@ export const App: React.FC = () => {
|
|||||||
onAddCard={() => setIsAddOpen(true)}
|
onAddCard={() => setIsAddOpen(true)}
|
||||||
onOpenPortfolio={() => setIsPortfolioOpen(true)}
|
onOpenPortfolio={() => setIsPortfolioOpen(true)}
|
||||||
totalCards={visibleCards.length}
|
totalCards={visibleCards.length}
|
||||||
|
syncStatus={syncStatus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 3. Scalable Desk Workspace (Cards, Props & Pen) */}
|
{/* 3. Scalable Desk Workspace (Cards, Props & Pen) */}
|
||||||
@@ -590,8 +739,8 @@ export const App: React.FC = () => {
|
|||||||
onPointerDown={handleCardPointerDown}
|
onPointerDown={handleCardPointerDown}
|
||||||
onDoubleClick={(c) => setEditingCard(c)}
|
onDoubleClick={(c) => setEditingCard(c)}
|
||||||
onToggleTodo={(cardId, idx) => {
|
onToggleTodo={(cardId, idx) => {
|
||||||
setCards((prev) =>
|
setCards((prev) => {
|
||||||
prev.map((c) => {
|
const next = prev.map((c) => {
|
||||||
if (c.id !== cardId || !c.todoItems) return c;
|
if (c.id !== cardId || !c.todoItems) return c;
|
||||||
const updatedTodos = [...c.todoItems];
|
const updatedTodos = [...c.todoItems];
|
||||||
updatedTodos[idx] = {
|
updatedTodos[idx] = {
|
||||||
@@ -599,8 +748,10 @@ export const App: React.FC = () => {
|
|||||||
done: !updatedTodos[idx].done
|
done: !updatedTodos[idx].done
|
||||||
};
|
};
|
||||||
return { ...c, todoItems: updatedTodos };
|
return { ...c, todoItems: updatedTodos };
|
||||||
})
|
});
|
||||||
);
|
saveToServer(next, propsList, strokes, settings, true);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -618,8 +769,17 @@ export const App: React.FC = () => {
|
|||||||
<PenLayer
|
<PenLayer
|
||||||
active={settings.penMode}
|
active={settings.penMode}
|
||||||
strokes={strokes}
|
strokes={strokes}
|
||||||
onAddStroke={(s) => setStrokes((prev) => [...prev, s])}
|
onAddStroke={(s) => {
|
||||||
onClear={() => setStrokes([])}
|
setStrokes((prev) => {
|
||||||
|
const next = [...prev, s];
|
||||||
|
saveToServer(cards, propsList, next, settings, false);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setStrokes([]);
|
||||||
|
saveToServer(cards, propsList, [], settings, true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -644,6 +804,7 @@ export const App: React.FC = () => {
|
|||||||
penActive={settings.penMode}
|
penActive={settings.penMode}
|
||||||
isGrid={settings.layoutMode === 'grid'}
|
isGrid={settings.layoutMode === 'grid'}
|
||||||
zoomLevel={settings.zoom}
|
zoomLevel={settings.zoom}
|
||||||
|
syncStatus={syncStatus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 7. Modals */}
|
{/* 7. Modals */}
|
||||||
@@ -674,7 +835,11 @@ export const App: React.FC = () => {
|
|||||||
fontFamily: newCard.fontFamily || 'font-caveat'
|
fontFamily: newCard.fontFamily || 'font-caveat'
|
||||||
};
|
};
|
||||||
setMaxZIndex(maxZIndex + 1);
|
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);
|
soundEngine.drop(settings.soundEnabled);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -684,11 +849,19 @@ export const App: React.FC = () => {
|
|||||||
isOpen={!!editingCard}
|
isOpen={!!editingCard}
|
||||||
onClose={() => setEditingCard(null)}
|
onClose={() => setEditingCard(null)}
|
||||||
onSave={(updated) => {
|
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);
|
soundEngine.rustle(settings.soundEnabled);
|
||||||
}}
|
}}
|
||||||
onDelete={(id) => {
|
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);
|
soundEngine.rustle(settings.soundEnabled);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -698,9 +871,15 @@ export const App: React.FC = () => {
|
|||||||
onClose={() => setIsPortfolioOpen(false)}
|
onClose={() => setIsPortfolioOpen(false)}
|
||||||
cards={cards}
|
cards={cards}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onLoadPreset={handleLoadPreset}
|
onLoadPreset={(name) => {
|
||||||
|
handleLoadPreset(name);
|
||||||
|
setTimeout(() => {
|
||||||
|
saveToServer(cards, propsList, strokes, settings, true);
|
||||||
|
}, 100);
|
||||||
|
}}
|
||||||
onImportJson={(importedCards) => {
|
onImportJson={(importedCards) => {
|
||||||
setCards(importedCards);
|
setCards(importedCards);
|
||||||
|
saveToServer(importedCards, propsList, strokes, settings, true);
|
||||||
soundEngine.shuffle(settings.soundEnabled);
|
soundEngine.shuffle(settings.soundEnabled);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
|
|||||||
const [attachType, setAttachType] = useState<AttachType>('washi');
|
const [attachType, setAttachType] = useState<AttachType>('washi');
|
||||||
const [fontFamily, setFontFamily] = useState('font-caveat');
|
const [fontFamily, setFontFamily] = useState('font-caveat');
|
||||||
|
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (isUploading) return;
|
||||||
onAdd({
|
onAdd({
|
||||||
type,
|
type,
|
||||||
title: title.trim() || undefined,
|
title: title.trim() || undefined,
|
||||||
@@ -39,6 +42,41 @@ export const AddCardModal: React.FC<AddCardModalProps> = ({ isOpen, onClose, onA
|
|||||||
setImageUrl('');
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<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">
|
<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 */}
|
{/* Image Upload or URL for Polaroid */}
|
||||||
{type === 'polaroid' && (
|
{type === 'polaroid' && (
|
||||||
<div className="space-y-2">
|
<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">
|
<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">
|
<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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={(e) => {
|
onChange={handleFileUpload}
|
||||||
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"
|
className="hidden"
|
||||||
|
disabled={isUploading}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Shuffle, Grid, LayoutGrid, PenTool, Plus, ZoomIn } from 'lucide-react';
|
import { Shuffle, Grid, LayoutGrid, PenTool, Plus, ZoomIn } from 'lucide-react';
|
||||||
|
import { SyncStatus } from '../types';
|
||||||
|
|
||||||
interface BottomBarProps {
|
interface BottomBarProps {
|
||||||
onShuffle?: () => void;
|
onShuffle?: () => void;
|
||||||
@@ -10,6 +11,7 @@ interface BottomBarProps {
|
|||||||
penActive?: boolean;
|
penActive?: boolean;
|
||||||
isGrid?: boolean;
|
isGrid?: boolean;
|
||||||
zoomLevel?: number;
|
zoomLevel?: number;
|
||||||
|
syncStatus?: SyncStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BottomBar: React.FC<BottomBarProps> = ({
|
export const BottomBar: React.FC<BottomBarProps> = ({
|
||||||
@@ -20,17 +22,28 @@ export const BottomBar: React.FC<BottomBarProps> = ({
|
|||||||
onCycleZoom,
|
onCycleZoom,
|
||||||
penActive = false,
|
penActive = false,
|
||||||
isGrid = false,
|
isGrid = false,
|
||||||
zoomLevel = 1.0
|
zoomLevel = 1.0,
|
||||||
|
syncStatus = 'synced'
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 1. Desktop: Keyboard hints */}
|
{/* 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">
|
<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="flex items-center gap-1.5">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block" />
|
<span
|
||||||
<span>drag anything</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>
|
||||||
<span className="text-neutral-600">·</span>
|
<span className="text-neutral-600">·</span>
|
||||||
|
<span>drag anything</span>
|
||||||
|
<span className="text-neutral-600">·</span>
|
||||||
<span>flick to throw</span>
|
<span>flick to throw</span>
|
||||||
<span className="text-neutral-600">·</span>
|
<span className="text-neutral-600">·</span>
|
||||||
<span>double-click to edit</span>
|
<span>double-click to edit</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { DeskSettings, DeskTexture, FontMode, CardType } from '../types';
|
import { DeskSettings, DeskTexture, FontMode, CardType, SyncStatus } from '../types';
|
||||||
import { Volume2, VolumeX, Plus, Sparkles, SlidersHorizontal, X } from 'lucide-react';
|
import { Volume2, VolumeX, Plus, Sparkles, SlidersHorizontal, X, Cloud, RefreshCw, CloudOff } from 'lucide-react';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
settings: DeskSettings;
|
settings: DeskSettings;
|
||||||
@@ -10,6 +10,7 @@ interface TopBarProps {
|
|||||||
onAddCard: () => void;
|
onAddCard: () => void;
|
||||||
onOpenPortfolio: () => void;
|
onOpenPortfolio: () => void;
|
||||||
totalCards: number;
|
totalCards: number;
|
||||||
|
syncStatus?: SyncStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopBar: React.FC<TopBarProps> = ({
|
export const TopBar: React.FC<TopBarProps> = ({
|
||||||
@@ -19,15 +20,16 @@ export const TopBar: React.FC<TopBarProps> = ({
|
|||||||
onReplay,
|
onReplay,
|
||||||
onAddCard,
|
onAddCard,
|
||||||
onOpenPortfolio,
|
onOpenPortfolio,
|
||||||
totalCards
|
totalCards,
|
||||||
|
syncStatus = 'synced'
|
||||||
}) => {
|
}) => {
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
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">
|
<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 */}
|
{/* 1. Left: Brand Title, Subtitle & Sync Pill */}
|
||||||
<div className="flex items-center gap-2.5 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-bold text-white tracking-tight text-sm">
|
<span className="font-bold text-white tracking-tight text-sm">
|
||||||
@@ -39,6 +41,46 @@ export const TopBar: React.FC<TopBarProps> = ({
|
|||||||
procedural paper · visual desk
|
procedural paper · visual desk
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 2. Primary Actions: Shuffle, Scatter/Grid (Always Visible) */}
|
{/* 2. Primary Actions: Shuffle, Scatter/Grid (Always Visible) */}
|
||||||
|
|||||||
@@ -90,3 +90,5 @@ export interface DeskSettings {
|
|||||||
activeFilter: 'all' | CardType;
|
activeFilter: 'all' | CardType;
|
||||||
zoom: number; // 0.4 - 1.5 zoom level
|
zoom: number; // 0.4 - 1.5 zoom level
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'loading';
|
||||||
Reference in new issue
Block a user