Save and persist desk settings independently per workspace

This commit is contained in:
airent106 committed 2026-10-13 11:53:06 -06:00
1 parent 842c4754a9
commit 760ba2ff53
1 file changed
+191 -124
+191 -124
View File
@@ -76,7 +76,7 @@ export const App: React.FC = () => {
}
const currentBoard = targetBoard || currentSettings.deskTexture || 'oak';
const updatedBoards = targetBoards || {
const updatedBoards: BoardsMap = targetBoards || {
...boardsRef.current,
[currentBoard]: {
cards: currentCards,
@@ -85,6 +85,14 @@ export const App: React.FC = () => {
settings: currentSettings
}
};
if (!targetBoards) {
updatedBoards[currentBoard] = {
cards: currentCards,
props: currentProps,
strokes: currentStrokes,
settings: currentSettings
};
}
boardsRef.current = updatedBoards;
const doSave = () => {
@@ -98,13 +106,7 @@ export const App: React.FC = () => {
strokes: currentStrokes,
activeBoard: currentBoard,
boards: updatedBoards,
settings: {
deskTexture: currentBoard,
lightAngle: currentSettings.lightAngle,
grain: currentSettings.grain,
layoutMode: currentSettings.layoutMode,
showEvidenceStrings: currentSettings.showEvidenceStrings
},
settings: currentSettings,
originClientId: clientIdRef.current
})
})
@@ -161,13 +163,23 @@ export const App: React.FC = () => {
if (currentBoardData.props) setPropsList(currentBoardData.props);
if (currentBoardData.strokes) setStrokes(currentBoardData.strokes);
const boardSettings = currentBoardData.settings || desk.settings || {};
const isMobile = window.innerWidth < 768;
setSettings((s) => ({
...s,
...boardSettings,
deskTexture: activeBoard,
lightAngle: desk.settings?.lightAngle ?? s.lightAngle,
grain: desk.settings?.grain ?? s.grain,
layoutMode: desk.settings?.layoutMode ?? s.layoutMode,
showEvidenceStrings: activeBoard === 'evidence-board' ? true : (desk.settings?.showEvidenceStrings ?? false)
zoom: boardSettings.zoom ?? (isMobile ? 0.65 : 1.0),
layoutMode: boardSettings.layoutMode ?? 'scatter',
isLocked: boardSettings.isLocked ?? false,
fontMode: boardSettings.fontMode ?? (activeBoard === 'blueprint' ? 'typed' : 'mixed'),
lightAngle: boardSettings.lightAngle ?? (activeBoard === 'blueprint' ? 270 : 225),
grain: boardSettings.grain ?? s.grain,
showEvidenceStrings: boardSettings.showEvidenceStrings ?? (activeBoard === 'evidence-board'),
showProps: boardSettings.showProps ?? true,
cardCount: boardSettings.cardCount ?? (currentBoardData.cards ? currentBoardData.cards.length : 22),
activeFilter: boardSettings.activeFilter ?? 'all',
soundEnabled: desk.settings?.soundEnabled ?? s.soundEnabled
}));
const maxZ = Math.max(15, ...(currentBoardData.cards || []).map((c: CardItem) => c.zIndex || 1));
@@ -190,7 +202,7 @@ export const App: React.FC = () => {
);
setCards(initialCards);
const initialBoards: BoardsMap = {
oak: { cards: initialCards, props: INITIAL_PROPS, strokes: [] }
oak: { cards: initialCards, props: INITIAL_PROPS, strokes: [], settings }
};
boardsRef.current = initialBoards;
setMaxZIndex(initialCards.length + 20);
@@ -232,14 +244,23 @@ export const App: React.FC = () => {
}
if (Array.isArray(payload.desk.props)) setPropsList(payload.desk.props);
if (Array.isArray(payload.desk.strokes)) setStrokes(payload.desk.strokes);
if (payload.desk.settings) {
const boardSettings = (payload.desk.boards && payload.desk.boards[activeBoard]?.settings) || payload.desk.settings;
if (boardSettings) {
setSettings((prev) => ({
...prev,
...boardSettings,
deskTexture: activeBoard,
lightAngle: payload.desk.settings.lightAngle ?? prev.lightAngle,
grain: payload.desk.settings.grain ?? prev.grain,
layoutMode: payload.desk.settings.layoutMode ?? prev.layoutMode,
showEvidenceStrings: payload.desk.settings.showEvidenceStrings ?? prev.showEvidenceStrings
zoom: boardSettings.zoom ?? prev.zoom,
layoutMode: boardSettings.layoutMode ?? prev.layoutMode,
isLocked: boardSettings.isLocked ?? prev.isLocked,
fontMode: boardSettings.fontMode ?? prev.fontMode,
lightAngle: boardSettings.lightAngle ?? prev.lightAngle,
grain: boardSettings.grain ?? prev.grain,
showEvidenceStrings: boardSettings.showEvidenceStrings ?? prev.showEvidenceStrings,
showProps: boardSettings.showProps ?? prev.showProps,
cardCount: boardSettings.cardCount ?? prev.cardCount,
activeFilter: boardSettings.activeFilter ?? prev.activeFilter,
soundEnabled: prev.soundEnabled
}));
}
setSyncStatus('synced');
@@ -262,68 +283,101 @@ export const App: React.FC = () => {
}, []);
// Update desk settings or switch unique workspace board
const updateSettings = (newSettings: Partial<DeskSettings>) => {
setSettings((prev) => {
const updated = { ...prev, ...newSettings };
const updateSettings = useCallback(
(newSettingsInput: Partial<DeskSettings> | ((prev: DeskSettings) => Partial<DeskSettings>)) => {
setSettings((prev) => {
const newSettings = typeof newSettingsInput === 'function' ? newSettingsInput(prev) : newSettingsInput;
const updated = { ...prev, ...newSettings };
// 1. Switch between 8 unique workspace boards
if (newSettings.deskTexture !== undefined && newSettings.deskTexture !== prev.deskTexture) {
const prevDesk = prev.deskTexture;
const nextDesk = newSettings.deskTexture;
// 1. Switch between 8 unique workspace boards
if (newSettings.deskTexture !== undefined && newSettings.deskTexture !== prev.deskTexture) {
const prevDesk = prev.deskTexture;
const nextDesk = newSettings.deskTexture;
// Snapshot current board state
const updatedBoards = {
...boardsRef.current,
[prevDesk]: {
cards,
props: propsList,
strokes,
settings: prev
// Snapshot current board state with its active settings
const updatedBoards: BoardsMap = {
...boardsRef.current,
[prevDesk]: {
cards,
props: propsList,
strokes,
settings: prev
}
};
// Load or initialize target board state
let nextBoardData = updatedBoards[nextDesk];
if (!nextBoardData || !Array.isArray(nextBoardData.cards) || nextBoardData.cards.length === 0) {
if (nextDesk === 'oak') {
const w = window.innerWidth;
const h = window.innerHeight;
const seed = generateProceduralCards(w < 768 ? 9 : 20, w, h, prev.attachChance, prev.fontMode);
nextBoardData = {
cards: seed,
props: INITIAL_PROPS,
strokes: [],
settings: { deskTexture: 'oak', zoom: w < 768 ? 0.65 : 1.0, lightAngle: 225, grain: 0.7 }
};
} else {
nextBoardData = getDefaultBoardData(nextDesk, window.innerWidth, window.innerHeight);
}
updatedBoards[nextDesk] = nextBoardData;
}
};
// Load or initialize target board state
let nextBoardData = updatedBoards[nextDesk];
if (!nextBoardData || !Array.isArray(nextBoardData.cards) || nextBoardData.cards.length === 0) {
if (nextDesk === 'oak') {
const w = window.innerWidth;
const h = window.innerHeight;
const seed = generateProceduralCards(w < 768 ? 9 : 20, w, h, updated.attachChance, updated.fontMode);
nextBoardData = { cards: seed, props: INITIAL_PROPS, strokes: [] };
} else {
nextBoardData = getDefaultBoardData(nextDesk, window.innerWidth, window.innerHeight);
}
updatedBoards[nextDesk] = nextBoardData;
// Restore target workspace's own saved settings
const targetSavedSettings = nextBoardData.settings || {};
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const nextSettings: DeskSettings = {
...prev,
...targetSavedSettings,
...newSettings,
deskTexture: nextDesk,
zoom: newSettings.zoom ?? targetSavedSettings.zoom ?? (isMobile ? 0.65 : 1.0),
layoutMode: newSettings.layoutMode ?? targetSavedSettings.layoutMode ?? 'scatter',
isLocked: newSettings.isLocked ?? targetSavedSettings.isLocked ?? false,
fontMode: newSettings.fontMode ?? targetSavedSettings.fontMode ?? (nextDesk === 'blueprint' ? 'typed' : 'mixed'),
lightAngle: newSettings.lightAngle ?? targetSavedSettings.lightAngle ?? (nextDesk === 'blueprint' ? 270 : 225),
grain: newSettings.grain ?? targetSavedSettings.grain ?? prev.grain,
showEvidenceStrings: newSettings.showEvidenceStrings ?? targetSavedSettings.showEvidenceStrings ?? (nextDesk === 'evidence-board'),
showProps: newSettings.showProps ?? targetSavedSettings.showProps ?? true,
cardCount: newSettings.cardCount ?? targetSavedSettings.cardCount ?? (nextBoardData.cards ? nextBoardData.cards.length : prev.cardCount),
activeFilter: newSettings.activeFilter ?? targetSavedSettings.activeFilter ?? 'all',
// Preserve user global audio preference:
soundEnabled: prev.soundEnabled
};
updatedBoards[nextDesk] = {
cards: nextBoardData.cards || [],
props: nextBoardData.props || INITIAL_PROPS,
strokes: nextBoardData.strokes || [],
settings: nextSettings
};
boardsRef.current = updatedBoards;
setCards(nextBoardData.cards || []);
setPropsList(nextBoardData.props || INITIAL_PROPS);
setStrokes(nextBoardData.strokes || []);
saveToServer(
nextBoardData.cards || [],
nextBoardData.props || INITIAL_PROPS,
nextBoardData.strokes || [],
nextSettings,
true,
nextDesk,
updatedBoards
);
soundEngine.rustle(nextSettings.soundEnabled);
return nextSettings;
}
boardsRef.current = updatedBoards;
setCards(nextBoardData.cards || []);
setPropsList(nextBoardData.props || INITIAL_PROPS);
setStrokes(nextBoardData.strokes || []);
const nextEvidenceStrings = nextDesk === 'evidence-board' ? true : (newSettings.showEvidenceStrings ?? false);
updated.deskTexture = nextDesk;
updated.showEvidenceStrings = nextEvidenceStrings;
saveToServer(
nextBoardData.cards || [],
nextBoardData.props || INITIAL_PROPS,
nextBoardData.strokes || [],
updated,
true,
nextDesk,
updatedBoards
);
soundEngine.rustle(updated.soundEnabled);
}
// 2. Real-time typography mode update across desk cards
if (newSettings.fontMode !== undefined && newSettings.fontMode !== prev.fontMode) {
const mode = newSettings.fontMode;
setCards((prevCards) => {
const next = prevCards.map((c) => {
// 2. Real-time typography mode update across desk cards
let nextCards = cards;
if (newSettings.fontMode !== undefined && newSettings.fontMode !== prev.fontMode) {
const mode = newSettings.fontMode;
nextCards = cards.map((c) => {
const fonts = mode === 'handwritten'
? ['font-caveat', 'font-reenie', 'font-indie', 'font-shadows']
: mode === 'typed'
@@ -332,40 +386,55 @@ export const App: React.FC = () => {
const chosen = fonts[Math.floor(Math.random() * fonts.length)];
return { ...c, fontFamily: chosen };
});
saveToServer(next, propsList, strokes, updated, false);
return next;
});
}
// 3. Card density adjustment
if (newSettings.cardCount !== undefined && newSettings.cardCount !== prev.cardCount) {
const w = window.innerWidth;
const h = window.innerHeight;
const diff = newSettings.cardCount - cards.length;
if (diff > 0) {
const added = generateProceduralCards(diff, w, h, updated.attachChance, updated.fontMode);
setCards((c) => [...c, ...added]);
} else if (diff < 0) {
setCards((c) => c.slice(0, newSettings.cardCount));
setCards(nextCards);
}
}
// 4. Attach chance adjustment
if (newSettings.attachChance !== undefined && newSettings.attachChance !== prev.attachChance) {
setCards((c) =>
c.map((card) => {
// 3. Card density adjustment
if (newSettings.cardCount !== undefined && newSettings.cardCount !== prev.cardCount) {
const w = window.innerWidth;
const h = window.innerHeight;
const diff = newSettings.cardCount - nextCards.length;
if (diff > 0) {
const added = generateProceduralCards(diff, w, h, updated.attachChance, updated.fontMode);
nextCards = [...nextCards, ...added];
setCards(nextCards);
} else if (diff < 0) {
nextCards = nextCards.slice(0, newSettings.cardCount);
setCards(nextCards);
}
}
// 4. Attach chance adjustment
if (newSettings.attachChance !== undefined && newSettings.attachChance !== prev.attachChance) {
nextCards = nextCards.map((card) => {
if (Math.random() < (newSettings.attachChance ?? 0.55)) {
const types: CardItem['attachType'][] = ['washi', 'pin', 'paperclip'];
return { ...card, attachType: types[Math.floor(Math.random() * types.length)] };
}
return { ...card, attachType: 'none' };
})
);
}
});
setCards(nextCards);
}
return updated;
});
};
// 5. Snapshot and persist current workspace's updated settings
const currentDesk = prev.deskTexture;
const updatedBoards: BoardsMap = {
...boardsRef.current,
[currentDesk]: {
cards: nextCards,
props: propsList,
strokes,
settings: updated
}
};
boardsRef.current = updatedBoards;
saveToServer(nextCards, propsList, strokes, updated, false, currentDesk, updatedBoards);
return updated;
});
},
[cards, propsList, strokes, saveToServer]
);
// Re-shuffle cards (stratified positioning so cards never clump or run over each other)
const handleShuffle = useCallback(() => {
@@ -453,14 +522,17 @@ export const App: React.FC = () => {
});
}, [settings.zoom, settings.soundEnabled, saveToServer, propsList, strokes, settings]);
// Trigger re-layout when layoutMode or zoom changes
// Trigger re-layout when layoutMode changes to grid
const prevLayoutModeRef = useRef<string>(settings.layoutMode);
useEffect(() => {
if (settings.layoutMode === 'grid') {
arrangeGrid();
} else {
handleShuffle();
if (!isLoadedRef.current) return;
if (prevLayoutModeRef.current !== settings.layoutMode) {
prevLayoutModeRef.current = settings.layoutMode;
if (settings.layoutMode === 'grid') {
arrangeGrid();
}
}
}, [settings.layoutMode, settings.zoom]);
}, [settings.layoutMode, arrangeGrid]);
// Physics animation loop on requestAnimationFrame
useEffect(() => {
@@ -672,16 +744,15 @@ export const App: React.FC = () => {
handleShuffle();
} else if (e.key === 'g' || e.key === 'G') {
e.preventDefault();
setSettings((prev) => ({
...prev,
updateSettings((prev) => ({
layoutMode: prev.layoutMode === 'scatter' ? 'grid' : 'scatter'
}));
} else if (e.key === 'p' || e.key === 'P') {
e.preventDefault();
setSettings((prev) => ({ ...prev, penMode: !prev.penMode }));
updateSettings((prev) => ({ penMode: !prev.penMode }));
} else if (e.key === 'l' || e.key === 'L') {
e.preventDefault();
setSettings((prev) => ({ ...prev, isLocked: !prev.isLocked }));
updateSettings((prev) => ({ isLocked: !prev.isLocked }));
} else if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
setIsAddOpen(true);
@@ -693,7 +764,7 @@ export const App: React.FC = () => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleShuffle]);
}, [handleShuffle, updateSettings]);
// Load Presets
const handleLoadPreset = (presetName: string) => {
@@ -703,7 +774,7 @@ export const App: React.FC = () => {
if (presetName === 'original') {
const generated = generateProceduralCards(20, w, h, 0.6, 'mixed');
setCards(generated);
setSettings((s) => ({ ...s, deskTexture: 'oak', lightAngle: 225, grain: 0.7 }));
updateSettings({ deskTexture: 'oak', lightAngle: 225, grain: 0.7 });
} else if (presetName === 'designer') {
const designerCards: CardItem[] = SAMPLE_PRESETS.slice(0, 14).map((p, i) => ({
id: `design-${i}`,
@@ -725,7 +796,7 @@ export const App: React.FC = () => {
todoItems: p.todoItems
}));
setCards(designerCards);
setSettings((s) => ({ ...s, deskTexture: 'cutting-mat', lightAngle: 180 }));
updateSettings({ deskTexture: 'cutting-mat', lightAngle: 180 });
} else if (presetName === 'creative-coder') {
const coderCards: CardItem[] = [
{
@@ -784,7 +855,7 @@ export const App: React.FC = () => {
}
];
setCards(coderCards);
setSettings((s) => ({ ...s, deskTexture: 'blueprint', lightAngle: 270 }));
updateSettings({ deskTexture: 'blueprint', lightAngle: 270 });
} else if (presetName === 'photojournal') {
const photoCards = SAMPLE_PRESETS.filter((p) => p.type === 'polaroid').map((p, i) => ({
id: `photo-${i}`,
@@ -806,7 +877,7 @@ export const App: React.FC = () => {
fontFamily: 'font-caveat'
}));
setCards(photoCards);
setSettings((s) => ({ ...s, deskTexture: 'walnut', lightAngle: 225 }));
updateSettings({ deskTexture: 'walnut', lightAngle: 225 });
}
soundEngine.shuffle(settings.soundEnabled);
};
@@ -963,20 +1034,19 @@ export const App: React.FC = () => {
<BottomBar
onShuffle={handleShuffle}
onToggleLayout={() =>
setSettings((prev) => ({
...prev,
updateSettings((prev) => ({
layoutMode: prev.layoutMode === 'scatter' ? 'grid' : 'scatter'
}))
}
onTogglePen={() => setSettings((prev) => ({ ...prev, penMode: !prev.penMode }))}
onToggleLock={() => setSettings((prev) => ({ ...prev, isLocked: !prev.isLocked }))}
onTogglePen={() => updateSettings((prev) => ({ penMode: !prev.penMode }))}
onToggleLock={() => updateSettings((prev) => ({ isLocked: !prev.isLocked }))}
onOpenSettings={() => setIsSettingsOpen(true)}
onAddCard={() => setIsAddOpen(true)}
onCycleZoom={() =>
setSettings((prev) => {
updateSettings((prev) => {
const z = prev.zoom;
const nextZoom = z <= 0.55 ? 0.75 : z <= 0.8 ? 1.0 : 0.5;
return { ...prev, zoom: nextZoom };
return { zoom: nextZoom };
})
}
penActive={settings.penMode}
@@ -1062,9 +1132,6 @@ export const App: React.FC = () => {
settings={settings}
onLoadPreset={(name) => {
handleLoadPreset(name);
setTimeout(() => {
saveToServer(cards, propsList, strokes, settings, true);
}, 100);
}}
onImportJson={(importedCards) => {
setCards(importedCards);