From 1973b4dc83cc270a7f8b42b76e4b6792f1b261ab Mon Sep 17 00:00:00 2001 From: Pabloader Date: Sat, 21 Mar 2026 15:48:49 +0000 Subject: [PATCH] Settings Modal --- src/common/hooks/useAsyncState.ts | 20 +++ src/common/hooks/useStoredState.ts | 41 ++++++ src/common/hooks/useUpdate.ts | 9 ++ .../assets/settings-modal.module.css | 53 +++++++ .../storywriter/components/settings-modal.tsx | 134 +++++++++++++++++- src/games/storywriter/contexts/state.tsx | 31 +++- 6 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 src/common/hooks/useAsyncState.ts create mode 100644 src/common/hooks/useStoredState.ts create mode 100644 src/common/hooks/useUpdate.ts diff --git a/src/common/hooks/useAsyncState.ts b/src/common/hooks/useAsyncState.ts new file mode 100644 index 0000000..7ec12a6 --- /dev/null +++ b/src/common/hooks/useAsyncState.ts @@ -0,0 +1,20 @@ +import { useEffect, useState, type Dispatch, type StateUpdater } from "preact/hooks"; +import { useUpdate } from "./useUpdate"; + +type Query = (...args: Args) => (Promise | T); +type Return = [T | undefined, Dispatch>, () => void]; + +export const useAsyncState = (query: Query, ...args: Args): Return => { + const [data, setData] = useState(); + const [update, request] = useUpdate(); + + useEffect(() => void Promise.resolve(query?.(...args)) + ?.then(d => setData(d)) + .catch(() => setData(undefined)), [query, ...args, update]); + + return [data, setData, request]; +}; + +export const useQuery = (query: Query, ...args: Args): T | undefined => ( + useAsyncState(query, ...args)[0] +); diff --git a/src/common/hooks/useStoredState.ts b/src/common/hooks/useStoredState.ts new file mode 100644 index 0000000..7a40b7e --- /dev/null +++ b/src/common/hooks/useStoredState.ts @@ -0,0 +1,41 @@ +import { useEffect, useReducer, useState, type Dispatch, type Reducer, type StateUpdater } from "preact/hooks"; + +export const useStoredState = (key: string, initialValue: T) => { + const storedKey = `useStoredState.${key}`; + + const [value, setValue] = useState(() => { + try { + const storedValue = localStorage.getItem(storedKey); + return storedValue ? JSON.parse(storedValue) : initialValue; + } catch { + return initialValue; + } + }); + + useEffect(() => { + localStorage.setItem(storedKey, JSON.stringify(value)); + }, [storedKey, value]); + + return [value, setValue] as [T, Dispatch>]; +}; + +export const useStoredReducer = (key: string, reducer: Reducer, initialValue: T) => { + const storedKey = `useStoredReducer.${key}`; + + const getInitialValue = (): T => { + try { + const storedValue = localStorage.getItem(storedKey); + return storedValue ? JSON.parse(storedValue) : initialValue; + } catch { + return initialValue; + } + }; + + const [state, dispatch] = useReducer(reducer, undefined, getInitialValue); + + useEffect(() => { + localStorage.setItem(storedKey, JSON.stringify(state)); + }, [storedKey, state]); + + return [state, dispatch] as [T, Dispatch]; +}; \ No newline at end of file diff --git a/src/common/hooks/useUpdate.ts b/src/common/hooks/useUpdate.ts new file mode 100644 index 0000000..eda34b8 --- /dev/null +++ b/src/common/hooks/useUpdate.ts @@ -0,0 +1,9 @@ +import { useCallback, useMemo, useState } from "preact/hooks"; + +export const useUpdate = (): [unknown, () => void] => { + const [value, setValue] = useState(0); + + const update = useCallback(() => setValue(v => v + 1), [setValue]); + + return useMemo(() => [value, update], [value, update]); +}; diff --git a/src/games/storywriter/assets/settings-modal.module.css b/src/games/storywriter/assets/settings-modal.module.css index d3310ae..dd660ab 100644 --- a/src/games/storywriter/assets/settings-modal.module.css +++ b/src/games/storywriter/assets/settings-modal.module.css @@ -61,3 +61,56 @@ overflow-y: auto; padding: 20px; } + +.form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.formGroup { + display: flex; + flex-direction: column; +} + +.label { + display: block; + margin-bottom: 4px; + font-weight: bold; +} + +.input, +.select { + width: 100%; + padding: 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} + +.footer { + padding: 16px 20px; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.button { + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; +} + +.buttonSecondary { + border: 1px solid var(--border); + background: transparent; + color: var(--text); +} + +.buttonPrimary { + border: none; + background: var(--accent); + color: var(--accent-text); +} diff --git a/src/games/storywriter/components/settings-modal.tsx b/src/games/storywriter/components/settings-modal.tsx index 0977136..ab50d0f 100644 --- a/src/games/storywriter/components/settings-modal.tsx +++ b/src/games/storywriter/components/settings-modal.tsx @@ -1,10 +1,74 @@ -import styles from '../assets/settings-modal.module.css'; +import { useMemo, useRef } from "preact/hooks"; + +import { useQuery } from "@common/hooks/useAsyncState"; +import { useInputState } from "@common/hooks/useInputState"; +import { useUpdate } from "@common/hooks/useUpdate"; + +import { useAppState } from "../contexts/state"; +import LLM from "../utils/llm"; +import styles from "../assets/settings-modal.module.css"; interface Props { onClose: () => void; } export const SettingsModal = ({ onClose }: Props) => { + const { connection, model, dispatch } = useAppState(); + const [url, setUrl] = useInputState(connection?.url ?? ""); + const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? ""); + const [selectedModel, setSelectedModel] = useInputState(model ?? ""); + const [update, triggerFetch] = useUpdate(); + + const urlRef = useRef(url); + const apiKeyRef = useRef(apiKey); + + urlRef.current = url; + apiKeyRef.current = apiKey; + + const connectionToFetch = useMemo(() => { + const currentUrl = urlRef.current; + const currentApiKey = apiKeyRef.current; + if (!currentUrl || !currentApiKey) return null; + return { url: currentUrl, apiKey: currentApiKey }; + }, [update]); + + const fetchModels = useMemo(() => async (conn: LLM.Connection | null) => { + if (!conn) return []; + const r = await LLM.getModels(conn); + return r.data; + }, []); + + const modelsData = useQuery(fetchModels, connectionToFetch); + + const isLoadingModels = connectionToFetch != null && modelsData == undefined; + const models = modelsData ?? []; + + const handleBlur = () => { + if (url && apiKey) { + triggerFetch(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && url && apiKey) { + triggerFetch(); + } + }; + + const handleConfirm = () => { + dispatch({ + type: 'SET_CONNECTION', + connection: connectionToFetch, + }); + dispatch({ + type: 'SET_MODEL', + model: selectedModel || null, + }); + onClose(); + }; + + const connectionToTest = url && apiKey ? { url, apiKey } : null; + return (
e.stopPropagation()}> @@ -15,7 +79,73 @@ export const SettingsModal = ({ onClose }: Props) => {
-

Settings plceholder

+
+
+ + +
+
+ + +
+
+ + {connectionToTest ? ( + isLoadingModels ? ( +

Loading models...

+ ) : models.length > 0 ? ( + + ) : ( +

No models available

+ ) + ) : ( +

Enter connection details to load models

+ )} +
+
+
+
+ +
diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index 98d38d8..f3988d9 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -1,6 +1,9 @@ import { createContext } from "preact"; import { useContext, useMemo, useReducer } from "preact/hooks"; +import LLM from "../utils/llm"; +import { useStoredReducer } from "@common/hooks/useStoredState"; + // ─── Types ──────────────────────────────────────────────────────────────────── export interface ChatMessage { @@ -21,6 +24,8 @@ export interface Story { interface IState { stories: Story[]; currentStoryId: string | null; + connection: LLM.Connection | null; + model: string | null; } // ─── Actions ───────────────────────────────────────────────────────────────── @@ -32,13 +37,17 @@ type Action = | { type: 'DELETE_STORY'; id: string } | { type: 'SELECT_STORY'; id: string } | { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage } - | { type: 'CLEAR_CHAT'; storyId: string }; + | { type: 'CLEAR_CHAT'; storyId: string } + | { type: 'SET_CONNECTION'; connection: LLM.Connection | null } + | { type: 'SET_MODEL'; model: string | null }; // ─── Initial State ─────────────────────────────────────────────────────────── const DEFAULT_STATE: IState = { stories: [], currentStoryId: null, + connection: null, + model: null, }; // ─── Reducer ───────────────────────────────────────────────────────────────── @@ -105,8 +114,18 @@ function reducer(state: IState, action: Action): IState { ), }; } - default: - return state; + case 'SET_CONNECTION': { + return { + ...state, + connection: action.connection, + }; + } + case 'SET_MODEL': { + return { + ...state, + model: action.model, + }; + } } } @@ -115,6 +134,8 @@ function reducer(state: IState, action: Action): IState { interface IStateContext { stories: Story[]; currentStory: Story | null; + connection: LLM.Connection | null; + model: string | null; dispatch: (action: Action) => void; } @@ -125,11 +146,13 @@ export const useAppState = () => useContext(StateContext); // ─── Provider ──────────────────────────────────────────────────────────────── export const StateContextProvider = ({ children }: { children?: any }) => { - const [state, dispatch] = useReducer(reducer, DEFAULT_STATE); + const [state, dispatch] = useStoredReducer('storywriter.state', reducer, DEFAULT_STATE); const value = useMemo(() => ({ stories: state.stories, currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null, + connection: state.connection, + model: state.model, dispatch, }), [state]);