From ba3b500063ee28e77d13877288c79b0d6ffeb141 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Mon, 30 Mar 2026 11:40:27 +0000 Subject: [PATCH] Remote storage for storywriter --- src/common/hooks/useRemote.ts | 51 +++++++++++++++++++ .../hooks/{useStoredState.ts => useStored.ts} | 0 src/common/storage.ts | 14 ++++- src/games/storywriter/contexts/state.tsx | 6 +-- 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 src/common/hooks/useRemote.ts rename src/common/hooks/{useStoredState.ts => useStored.ts} (100%) diff --git a/src/common/hooks/useRemote.ts b/src/common/hooks/useRemote.ts new file mode 100644 index 0000000..b2ca43f --- /dev/null +++ b/src/common/hooks/useRemote.ts @@ -0,0 +1,51 @@ +import { useEffect, useReducer, useState, type Dispatch, type Reducer, type StateUpdater } from "preact/hooks"; +import { loadObject, saveObject } from "@common/storage"; + +export const useRemoteState = (key: string, initialValue: T) => { + const storedKey = `useRemoteState.${key}`; + + const [value, setValue] = useState(initialValue); + + useEffect(() => { + loadObject(storedKey, initialValue).then(setValue); + }, [storedKey]); + + useEffect(() => { + saveObject(storedKey, value); + }, [storedKey, value]); + + return [value, setValue] as [T, Dispatch>]; +}; + +const hydrate = Symbol('hydrate'); + +type HydrateAction = { type: typeof hydrate; payload: T }; + +const isHydrateAction = (action: unknown): action is HydrateAction => + typeof action === 'object' && action !== null && 'type' in action && action.type === hydrate; + +const wrapReducer = (reducer: Reducer): Reducer> => + (state, action) => { + if (isHydrateAction(action)) { + return action.payload; + } + return reducer(state, action); + }; + +export const useRemoteReducer = (key: string, reducer: Reducer, initialValue: T) => { + const storedKey = `useRemoteReducer.${key}`; + + const [state, dispatch] = useReducer(wrapReducer(reducer), initialValue); + + useEffect(() => { + loadObject(storedKey, initialValue).then((loaded) => { + dispatch({ type: hydrate, payload: loaded }); + }); + }, [storedKey]); + + useEffect(() => { + saveObject(storedKey, state); + }, [storedKey, state]); + + return [state, dispatch] as [T, Dispatch]; +}; diff --git a/src/common/hooks/useStoredState.ts b/src/common/hooks/useStored.ts similarity index 100% rename from src/common/hooks/useStoredState.ts rename to src/common/hooks/useStored.ts diff --git a/src/common/storage.ts b/src/common/storage.ts index da246da..29e02be 100644 --- a/src/common/storage.ts +++ b/src/common/storage.ts @@ -1,5 +1,8 @@ const API_KEY = 'awoorwa32'; +const saveThrottleDelay = 2000; +const pendingSaves = new Map>(); + export const loadObject = async (key: string, defaultObject: T): Promise => { let localObject: Partial = {}; @@ -23,7 +26,16 @@ export const loadObject = async (key: string, defaultObject: T): Promise = return { ...defaultObject, ...localObject, ...remoteObject }; } -export const saveObject = async (key: string, obj: T) => { +export const saveObject = (key: string, obj: T) => { + const existing = pendingSaves.get(key); + if (existing) clearTimeout(existing); + pendingSaves.set(key, setTimeout(() => { + pendingSaves.delete(key); + doSaveObject(key, obj); + }, saveThrottleDelay)); +}; + +const doSaveObject = async (key: string, obj: T) => { const saveData = JSON.stringify(obj); try { diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index b7040fc..debd7dc 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -1,9 +1,9 @@ import { createContext } from "preact"; -import { useContext, useMemo, useReducer } from "preact/hooks"; +import { useContext, useMemo } from "preact/hooks"; import LLM from "../utils/llm"; import Chapters from "../utils/chapters"; -import { useStoredReducer } from "@common/hooks/useStoredState"; +import { useRemoteReducer } from "@common/hooks/useRemote"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -561,7 +561,7 @@ export const useAppState = () => useContext(StateContext); // ─── Provider ──────────────────────────────────────────────────────────────── export const StateContextProvider = ({ children }: { children?: any }) => { - const [state, dispatch] = useStoredReducer('storywriter.state', reducer, DEFAULT_STATE); + const [state, dispatch] = useRemoteReducer('storywriter.state', reducer, DEFAULT_STATE); const value = useMemo(() => ({ stories: state.stories,