1
0
Fork 0

Settings Modal

This commit is contained in:
Pabloader 2026-03-21 15:48:49 +00:00
parent 3fd2b60a10
commit 1973b4dc83
6 changed files with 282 additions and 6 deletions

View File

@ -0,0 +1,20 @@
import { useEffect, useState, type Dispatch, type StateUpdater } from "preact/hooks";
import { useUpdate } from "./useUpdate";
type Query<T, Args extends any[]> = (...args: Args) => (Promise<T> | T);
type Return<T> = [T | undefined, Dispatch<StateUpdater<T | undefined>>, () => void];
export const useAsyncState = <T, Args extends any[]>(query: Query<T, Args>, ...args: Args): Return<T> => {
const [data, setData] = useState<T>();
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 = <T, Args extends any[]>(query: Query<T, Args>, ...args: Args): T | undefined => (
useAsyncState(query, ...args)[0]
);

View File

@ -0,0 +1,41 @@
import { useEffect, useReducer, useState, type Dispatch, type Reducer, type StateUpdater } from "preact/hooks";
export const useStoredState = <T>(key: string, initialValue: T) => {
const storedKey = `useStoredState.${key}`;
const [value, setValue] = useState<T>(() => {
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<StateUpdater<T>>];
};
export const useStoredReducer = <T, A>(key: string, reducer: Reducer<T, A>, 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<A>];
};

View File

@ -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]);
};

View File

@ -61,3 +61,56 @@
overflow-y: auto; overflow-y: auto;
padding: 20px; 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);
}

View File

@ -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 { interface Props {
onClose: () => void; onClose: () => void;
} }
export const SettingsModal = ({ onClose }: Props) => { 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<LLM.Connection | null>(() => {
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 ( return (
<div class={styles.overlay} onClick={onClose}> <div class={styles.overlay} onClick={onClose}>
<div class={styles.modal} onClick={(e) => e.stopPropagation()}> <div class={styles.modal} onClick={(e) => e.stopPropagation()}>
@ -15,7 +79,73 @@ export const SettingsModal = ({ onClose }: Props) => {
</button> </button>
</div> </div>
<div class={styles.content}> <div class={styles.content}>
<p>Settings plceholder</p> <div class={styles.form} autocomplete="off">
<div class={styles.formGroup}>
<label class={styles.label}>
API URL
</label>
<input
type="text"
value={url}
onInput={setUrl}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder="http://localhost:1234"
class={styles.input}
autocomplete="off"
name="api-url-random"
/>
</div>
<div class={styles.formGroup}>
<label class={styles.label}>
API Key
</label>
<input
type="password"
value={apiKey}
onInput={setApiKey}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder="your-api-key"
class={styles.input}
autocomplete="new-password"
name="api-key-random"
/>
</div>
<div class={styles.formGroup}>
<label class={styles.label}>
Model
</label>
{connectionToTest ? (
isLoadingModels ? (
<p>Loading models...</p>
) : models.length > 0 ? (
<select
value={selectedModel}
onChange={setSelectedModel}
class={styles.select}
>
<option value="">Select a model</option>
{models.map(m => (
<option key={m.id} value={m.id}>{m.id}</option>
))}
</select>
) : (
<p>No models available</p>
)
) : (
<p>Enter connection details to load models</p>
)}
</div>
</div>
</div>
<div class={styles.footer}>
<button onClick={onClose} class={`${styles.button} ${styles.buttonSecondary}`}>
Cancel
</button>
<button onClick={handleConfirm} class={`${styles.button} ${styles.buttonPrimary}`}>
Confirm
</button>
</div> </div>
</div> </div>
</div> </div>

View File

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