feat: implement dual-state system for temporary layout sessions

- Add temporaryStateAtom to track in-memory sessions
- Update useGrimoire to handle conditional state management
- Previews and Direct links now use temporary state, preserving dashboard
- Add Apply and Discard logic to persist or revert temporary sessions
This commit is contained in:
Alejandro Gómez
2025-12-20 21:24:29 +01:00
parent 3fcfd42b9a
commit cef6da87f4
3 changed files with 102 additions and 69 deletions

View File

@@ -23,10 +23,16 @@ import { SpellbookEvent } from "@/types/spell";
import { toast } from "sonner";
import { Button } from "./ui/button";
const PREVIEW_BACKUP_KEY = "grimoire-preview-backup";
export default function Home() {
const { state, updateLayout, removeWindow, loadSpellbook } = useGrimoire();
const {
state,
updateLayout,
removeWindow,
switchToTemporary,
applyTemporaryToPersistent,
discardTemporary,
isTemporary
} = useGrimoire();
const [commandLauncherOpen, setCommandLauncherOpen] = useState(false);
const { actor, identifier } = useParams();
const navigate = useNavigate();
@@ -35,6 +41,7 @@ export default function Home() {
// Preview state
const [resolvedPubkey, setResolvedPubkey] = useState<string | null>(null);
const isPreviewPath = location.pathname.startsWith("/preview/");
const isDirectPath = actor && identifier && !isPreviewPath;
const [hasLoadedSpellbook, setHasLoadedSpellbook] = useState(false);
// 1. Resolve actor to pubkey
@@ -42,6 +49,8 @@ export default function Home() {
if (!actor) {
setResolvedPubkey(null);
setHasLoadedSpellbook(false);
// If we were in temporary mode and navigated back to /, discard
if (isTemporary) discardTemporary();
return;
}
@@ -62,7 +71,7 @@ export default function Home() {
};
resolve();
}, [actor]);
}, [actor, isTemporary, discardTemporary]);
// 2. Fetch the spellbook event
const pointer = useMemo(() => {
@@ -82,58 +91,35 @@ export default function Home() {
try {
const parsed = parseSpellbook(spellbookEvent as SpellbookEvent);
// Use the new temporary state system
switchToTemporary(parsed);
setHasLoadedSpellbook(true);
if (isPreviewPath) {
// In preview mode, save current state to sessionStorage for recovery
if (!sessionStorage.getItem(PREVIEW_BACKUP_KEY)) {
sessionStorage.setItem(PREVIEW_BACKUP_KEY, JSON.stringify(state));
}
loadSpellbook(parsed);
setHasLoadedSpellbook(true);
toast.info(`Previewing layout: ${parsed.title}`, {
description: "You are in preview mode. Apply to keep this layout or discard to return.",
description: "You are in a temporary session. Apply to keep this layout permanently.",
});
} else if (isDirectPath) {
toast.success(`Loaded temporary layout: ${parsed.title}`, {
description: "Visit / to return to your permanent dashboard, or click Apply Layout.",
});
} else {
// Direct mode: Just load it immediately
loadSpellbook(parsed);
setHasLoadedSpellbook(true);
// Update URL to home after loading to avoid re-loading on refresh if they start modifying
navigate("/", { replace: true });
toast.success(`Loaded layout: ${parsed.title}`);
}
} catch (e) {
console.error("Failed to parse spellbook:", e);
toast.error("Failed to load spellbook");
}
}
}, [spellbookEvent, hasLoadedSpellbook, isPreviewPath]);
}, [spellbookEvent, hasLoadedSpellbook, isPreviewPath, isDirectPath, switchToTemporary]);
const handleApplyLayout = () => {
sessionStorage.removeItem(PREVIEW_BACKUP_KEY);
applyTemporaryToPersistent();
navigate("/", { replace: true });
toast.success("Layout applied permanently");
toast.success("Layout applied to your dashboard permanently");
};
const handleDiscardPreview = () => {
const backup = sessionStorage.getItem(PREVIEW_BACKUP_KEY);
if (backup) {
try {
JSON.parse(backup);
// We need a way to restore the whole state.
// For now, the easiest way to "restore" a persisted state from sessionStorage
// is to clear our local storage and reload, or manually call setters.
// But loadSpellbook already overwrote it in localStorage via Jotai.
// Let's try to overwrite localStorage directly and reload for a clean restore
localStorage.setItem("grimoire-state", backup);
sessionStorage.removeItem(PREVIEW_BACKUP_KEY);
window.location.href = "/";
return;
} catch (e) {
console.error("Failed to restore backup:", e);
}
}
navigate("/");
discardTemporary();
navigate("/", { replace: true });
};
// Sync active account and fetch relay lists
@@ -206,11 +192,13 @@ export default function Home() {
/>
<GlobalAuthPrompt />
<main className="h-screen w-screen flex flex-col bg-background text-foreground">
{isPreviewPath && (
<div className="bg-accent text-accent-foreground px-4 py-1.5 flex items-center justify-between text-sm font-medium animate-in slide-in-from-top duration-300">
{isTemporary && (
<div className="bg-accent text-accent-foreground px-4 py-1.5 flex items-center justify-between text-sm font-medium animate-in slide-in-from-top duration-300 shadow-md z-50">
<div className="flex items-center gap-2">
<BookHeart className="size-4" />
<span>Preview Mode: {spellbookEvent?.tags.find(t => t[0] === 'title')?.[1] || 'Spellbook'}</span>
<span>
{isPreviewPath ? "Preview Mode" : "Temporary Layout"}: {spellbookEvent?.tags.find(t => t[0] === 'title')?.[1] || 'Spellbook'}
</span>
</div>
<div className="flex items-center gap-2">
<Button

View File

@@ -184,9 +184,7 @@ export function SpellbookDropdown() {
>
<Save className="size-3.5 mr-2 text-muted-foreground" />
<div className="flex flex-col min-w-0">
<span className="font-medium text-sm">
Update "{activeSpellbook.title}"
</span>
<span className="font-medium text-sm">Update</span>
<span className="text-[10px] text-muted-foreground">
Save current state to this spellbook
</span>
@@ -197,7 +195,7 @@ export function SpellbookDropdown() {
className={cn(itemClass, "text-xs opacity-70")}
>
<X className="size-3.5 mr-2 text-muted-foreground" />
Stop Tracking Layout
Deselect
</DropdownMenuItem>
<DropdownMenuSeparator />
</>

View File

@@ -1,5 +1,5 @@
import { useEffect, useCallback } from "react";
import { useAtom } from "jotai";
import { atom, useAtom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import {
GrimoireState,
@@ -117,28 +117,51 @@ const storage = createJSONStorage<GrimoireState>(() => ({
},
}));
// Persistence Atom with custom storage
// Persistence Atom with custom storage (The Dashboard)
export const grimoireStateAtom = atomWithStorage<GrimoireState>(
"grimoire_v6",
initialState,
storage,
);
// Temporary Atom for Previews/Sessions (In-memory only)
export const temporaryStateAtom = atom<GrimoireState | null>(null);
// The Hook
export const useGrimoire = () => {
const [state, setState] = useAtom(grimoireStateAtom);
const [persistentState, setPersistentState] = useAtom(grimoireStateAtom);
const [tempState, setTempState] = useAtom(temporaryStateAtom);
// Decide which state we are using
// If tempState is set, we are in a temporary session (Preview or Direct Link)
const isTemporary = tempState !== null;
const state = isTemporary ? tempState : persistentState;
const setState = useCallback((
updater: (prev: GrimoireState) => GrimoireState
) => {
if (isTemporary) {
setTempState(prev => {
const current = prev || persistentState;
return updater(current);
});
} else {
setPersistentState(updater);
}
}, [isTemporary, setTempState, setPersistentState, persistentState]);
const browserLocale = useLocale();
// Initialize locale from browser if not set (moved to useEffect to avoid race condition)
useEffect(() => {
if (!state.locale) {
setState((prev) => ({ ...prev, locale: browserLocale }));
setState((prev: GrimoireState) => ({ ...prev, locale: browserLocale }));
}
}, [state.locale, browserLocale, setState]);
// Wrap all callbacks in useCallback for stable references
const createWorkspace = useCallback(() => {
setState((prev) => {
setState((prev: GrimoireState) => {
const nextNumber = Logic.findLowestAvailableWorkspaceNumber(
prev.workspaces,
);
@@ -148,7 +171,7 @@ export const useGrimoire = () => {
const createWorkspaceWithNumber = useCallback(
(number: number) => {
setState((prev) => {
setState((prev: GrimoireState) => {
// Check if we're leaving an empty workspace and should auto-remove it
const currentWorkspace = prev.workspaces[prev.activeWorkspaceId];
const shouldDeleteCurrent =
@@ -180,7 +203,7 @@ export const useGrimoire = () => {
customTitle?: string,
spellId?: string,
) =>
setState((prev) =>
setState((prev: GrimoireState) =>
Logic.addWindow(prev, {
appId,
props,
@@ -201,31 +224,31 @@ export const useGrimoire = () => {
"props" | "title" | "customTitle" | "commandString" | "appId"
>
>,
) => setState((prev) => Logic.updateWindow(prev, windowId, updates)),
) => setState((prev: GrimoireState) => Logic.updateWindow(prev, windowId, updates)),
[setState],
);
const removeWindow = useCallback(
(id: string) => setState((prev) => Logic.removeWindow(prev, id)),
(id: string) => setState((prev: GrimoireState) => Logic.removeWindow(prev, id)),
[setState],
);
const moveWindowToWorkspace = useCallback(
(windowId: string, targetWorkspaceId: string) =>
setState((prev) =>
setState((prev: GrimoireState) =>
Logic.moveWindowToWorkspace(prev, windowId, targetWorkspaceId),
),
[setState],
);
const updateLayout = useCallback(
(layout: any) => setState((prev) => Logic.updateLayout(prev, layout)),
(layout: any) => setState((prev: GrimoireState) => Logic.updateLayout(prev, layout)),
[setState],
);
const setActiveWorkspace = useCallback(
(id: string) =>
setState((prev) => {
setState((prev: GrimoireState) => {
// Validate target workspace exists
if (!prev.workspaces[id]) {
console.warn(`Cannot switch to non-existent workspace: ${id}`);
@@ -261,58 +284,79 @@ export const useGrimoire = () => {
const setActiveAccount = useCallback(
(pubkey: string | undefined) =>
setState((prev) => Logic.setActiveAccount(prev, pubkey)),
setState((prev: GrimoireState) => Logic.setActiveAccount(prev, pubkey)),
[setState],
);
const setActiveAccountRelays = useCallback(
(relays: RelayInfo[]) =>
setState((prev) => Logic.setActiveAccountRelays(prev, relays)),
setState((prev: GrimoireState) => Logic.setActiveAccountRelays(prev, relays)),
[setState],
);
const updateLayoutConfig = useCallback(
(layoutConfig: Partial<LayoutConfig>) =>
setState((prev) => Logic.updateLayoutConfig(prev, layoutConfig)),
setState((prev: GrimoireState) => Logic.updateLayoutConfig(prev, layoutConfig)),
[setState],
);
const applyPresetLayout = useCallback(
(preset: any) => setState((prev) => Logic.applyPresetLayout(prev, preset)),
(preset: any) => setState((prev: GrimoireState) => Logic.applyPresetLayout(prev, preset)),
[setState],
);
const updateWorkspaceLabel = useCallback(
(workspaceId: string, label: string | undefined) =>
setState((prev) => Logic.updateWorkspaceLabel(prev, workspaceId, label)),
setState((prev: GrimoireState) => Logic.updateWorkspaceLabel(prev, workspaceId, label)),
[setState],
);
const reorderWorkspaces = useCallback(
(orderedIds: string[]) =>
setState((prev) => Logic.reorderWorkspaces(prev, orderedIds)),
setState((prev: GrimoireState) => Logic.reorderWorkspaces(prev, orderedIds)),
[setState],
);
const setCompactModeKinds = useCallback(
(kinds: number[]) =>
setState((prev) => Logic.setCompactModeKinds(prev, kinds)),
setState((prev: GrimoireState) => Logic.setCompactModeKinds(prev, kinds)),
[setState],
);
const loadSpellbook = useCallback(
(spellbook: ParsedSpellbook) =>
setState((prev) => SpellbookManager.loadSpellbook(prev, spellbook)),
setState((prev: GrimoireState) => SpellbookManager.loadSpellbook(prev, spellbook)),
[setState],
);
const clearActiveSpellbook = useCallback(
() => setState((prev) => Logic.clearActiveSpellbook(prev)),
() => setState((prev: GrimoireState) => Logic.clearActiveSpellbook(prev)),
[setState],
);
const switchToTemporary = useCallback((spellbook?: ParsedSpellbook) => {
setTempState(prev => {
const current = prev || persistentState;
return spellbook
? SpellbookManager.loadSpellbook(current, spellbook)
: { ...current };
});
}, [persistentState, setTempState]);
const applyTemporaryToPersistent = useCallback(() => {
if (tempState) {
setPersistentState(tempState);
setTempState(null);
}
}, [tempState, setPersistentState, setTempState]);
const discardTemporary = useCallback(() => {
setTempState(null);
}, [setTempState]);
return {
state,
isTemporary,
locale: state.locale || browserLocale,
activeWorkspace: state.workspaces[state.activeWorkspaceId],
createWorkspace,
@@ -332,5 +376,8 @@ export const useGrimoire = () => {
setCompactModeKinds,
loadSpellbook,
clearActiveSpellbook,
switchToTemporary,
applyTemporaryToPersistent,
discardTemporary,
};
};