diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 573a791..7671dde 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -2,6 +2,16 @@ import { db } from "./db.ts"; export const TOKEN_TTL_DAYS = parseInt(process.env.TOKEN_TTL_DAYS ?? "30"); +export const issueToken = async (userId: number): Promise => { + const token = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 86_400_000) + .toISOString() + .replace("T", " ") + .slice(0, 19); + await db`INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (${userId}, ${token}, ${expiresAt})`; + return token; +}; + export const validateToken = async (token: string): Promise => { const [row] = await db` SELECT user_id FROM auth_tokens diff --git a/backend/src/login.ts b/backend/src/login.ts new file mode 100644 index 0000000..60217b6 --- /dev/null +++ b/backend/src/login.ts @@ -0,0 +1,18 @@ +import { db } from "./db.ts"; +import { issueToken } from "./auth.ts"; + +const [username, password] = process.argv.slice(2); + +if (!username || !password) { + console.error("Usage: bun run login "); + process.exit(1); +} + +const [user] = await db`SELECT id, password_hash FROM users WHERE username = ${username}`; + +if (!user || !await Bun.password.verify(password, user.password_hash as string)) { + console.error("Invalid credentials"); + process.exit(1); +} + +console.log(await issueToken(user.id as number)); diff --git a/backend/src/routes/login.ts b/backend/src/routes/login.ts index 92dc4f6..9c85f48 100644 --- a/backend/src/routes/login.ts +++ b/backend/src/routes/login.ts @@ -1,5 +1,5 @@ import { db } from "../db.ts"; -import { TOKEN_TTL_DAYS } from "../auth.ts"; +import { issueToken } from "../auth.ts"; export const handleLogin = async (req: Request): Promise => { if (req.method !== "POST") { @@ -30,16 +30,6 @@ export const handleLogin = async (req: Request): Promise => { return Response.json({ error: "Invalid credentials" }, { status: 401 }); } - const token = crypto.randomUUID(); - const expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 86_400_000) - .toISOString() - .replace("T", " ") - .slice(0, 19); - - await db` - INSERT INTO auth_tokens (user_id, token, expires_at) - VALUES (${user.id}, ${token}, ${expiresAt}) - `; - + const token = await issueToken(user.id as number); return Response.json({ token }); }; diff --git a/build/assets/global.css b/build/assets/global.css index 3d816c7..00fda5e 100644 --- a/build/assets/global.css +++ b/build/assets/global.css @@ -7,6 +7,8 @@ --border: #3e3d32; --accent: #f92672; --accent-alt: #a6e22e; + --danger: #c4265e; + --danger-alt: #f92672; --text: #f8f8f2; --text-muted: #75715e; --text-dim: #cfcfc2; diff --git a/package.json b/package.json index 9ed0f77..2ff401c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "backend": "bun backend/src/index.ts", "backend:dev": "bun --hot backend/src/index.ts", "register": "bun backend/src/register.ts", + "login": "bun backend/src/login.ts", "docker:build": "docker build -t git.pabloader.ru/pabloid/tsgames:latest backend/", "docker:push": "docker push git.pabloader.ru/pabloid/tsgames:latest", "docker:update": "docker service update --force --image git.pabloader.ru/pabloid/tsgames:latest tsgames_backend", diff --git a/src/common/assets/login.module.css b/src/common/assets/login.module.css new file mode 100644 index 0000000..ef1c0aa --- /dev/null +++ b/src/common/assets/login.module.css @@ -0,0 +1,4 @@ +.error { + color: var(--danger); + font-size: 13px; +} diff --git a/src/common/assets/ui.module.css b/src/common/assets/ui.module.css index 80593a6..7e74455 100644 --- a/src/common/assets/ui.module.css +++ b/src/common/assets/ui.module.css @@ -58,6 +58,7 @@ button { background: transparent; transition: all var(--transition); color: var(--text); + width: fit-content; } .buttonPrimary { diff --git a/src/common/components/LoginForm.tsx b/src/common/components/LoginForm.tsx new file mode 100644 index 0000000..3128efd --- /dev/null +++ b/src/common/components/LoginForm.tsx @@ -0,0 +1,65 @@ +import { useState } from "preact/hooks"; +import clsx from "clsx"; + +import { login } from "@common/storage"; +import { useInputState } from "@common/hooks/useInputState"; +import ui from "../assets/ui.module.css"; +import styles from "../assets/login.module.css"; + +interface IProps { + onLogin?: () => void; + class?: string; + className?: string; +} + +export const LoginForm = ({ onLogin, ['class']: cls, className }: IProps) => { + const [username, handleUsername] = useInputState(); + const [password, handlePassword] = useInputState(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await login(username, password); + onLogin?.(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + +
+
+ + +
+ {error && {error}} + +
+ ); +}; diff --git a/src/common/hooks/useRemote.ts b/src/common/hooks/useRemote.ts index 81897a6..12879d1 100644 --- a/src/common/hooks/useRemote.ts +++ b/src/common/hooks/useRemote.ts @@ -1,10 +1,12 @@ import { useEffect, useReducer, useState, type Dispatch, type Reducer, type StateUpdater } from "preact/hooks"; import { loadObject, saveObject } from "@common/storage"; +import { storedStateKey, storedReducerKey } from "@common/hooks/useStored"; -declare const GAME: string; +export const remoteStateKey = (key: string) => `useRemoteState.${GAME}.${key}`; +export const remoteReducerKey = (key: string) => `useRemoteReducer.${GAME}.${key}`; export const useRemoteState = (key: string, initialValue: T) => { - const storedKey = `useRemoteState.${GAME}.${key}`; + const storedKey = remoteStateKey(key); const [value, setValue] = useState(initialValue); @@ -34,10 +36,35 @@ const wrapReducer = (reducer: Reducer): Reducer(key: string, reducer: Reducer, initialValue: T) => { - const storedKey = `useRemoteReducer.${GAME}.${key}`; +/** Copies a useStoredState value into the useRemoteState slot and syncs it remotely. */ +export const importStoredState = (key: string): boolean => { + const raw = localStorage.getItem(storedStateKey(key)); + if (!raw) return false; + saveObject(remoteStateKey(key), JSON.parse(raw) as T); + return true; +}; - const [state, dispatch] = useReducer(wrapReducer(reducer), initialValue); +/** Copies a useStoredReducer value into the useRemoteReducer slot and syncs it remotely. */ +export const importStoredReducer = (key: string): boolean => { + const raw = localStorage.getItem(storedReducerKey(key)); + if (!raw) return false; + saveObject(remoteReducerKey(key), JSON.parse(raw) as T); + return true; +}; + +export const useRemoteReducer = (key: string, reducer: Reducer, initialValue: T) => { + const storedKey = remoteReducerKey(key); + + const getInitialValue = (): T => { + try { + const storedValue = localStorage.getItem(storedKey); + return storedValue ? JSON.parse(storedValue) : initialValue; + } catch { + return initialValue; + } + }; + + const [state, dispatch] = useReducer(wrapReducer(reducer), undefined, getInitialValue); useEffect(() => { loadObject(storedKey, initialValue).then((loaded) => { diff --git a/src/common/hooks/useStored.ts b/src/common/hooks/useStored.ts index ad7fafc..e071996 100644 --- a/src/common/hooks/useStored.ts +++ b/src/common/hooks/useStored.ts @@ -1,9 +1,10 @@ import { useEffect, useReducer, useState, type Dispatch, type Reducer, type StateUpdater } from "preact/hooks"; -declare const GAME: string; +export const storedStateKey = (key: string) => `useStoredState.${GAME}.${key}`; +export const storedReducerKey = (key: string) => `useStoredReducer.${GAME}.${key}`; export const useStoredState = (key: string, initialValue: T) => { - const storedKey = `useStoredState.${GAME}.${key}`; + const storedKey = storedStateKey(key); const [value, setValue] = useState(() => { try { @@ -22,7 +23,7 @@ export const useStoredState = (key: string, initialValue: T) => { }; export const useStoredReducer = (key: string, reducer: Reducer, initialValue: T) => { - const storedKey = `useStoredReducer.${GAME}.${key}`; + const storedKey = storedReducerKey(key); const getInitialValue = (): T => { try { @@ -40,4 +41,4 @@ export const useStoredReducer = (key: string, reducer: Reducer, init }, [storedKey, state]); return [state, dispatch] as [T, Dispatch]; -}; \ No newline at end of file +}; diff --git a/src/common/storage.ts b/src/common/storage.ts index 6b6196f..677c0ba 100644 --- a/src/common/storage.ts +++ b/src/common/storage.ts @@ -1,62 +1,93 @@ -const saveThrottleDelay = 2000; +const SAVE_THROTTLE_MS = 2000; +const TOKEN_KEY = '__storage_token__'; +const BASE_URL = '/storage/api'; + const pendingSaves = new Map>(); +// ─── Auth ──────────────────────────────────────────────────── + +let token: string | null = localStorage.getItem(TOKEN_KEY); + +export const isLoggedIn = () => token !== null; + +export const login = async (username: string, password: string): Promise => { + const res = await fetch(`${BASE_URL}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error ?? 'Login failed'); + token = json.token; + localStorage.setItem(TOKEN_KEY, json.token); +}; + +export const logout = () => { + token = null; + localStorage.removeItem(TOKEN_KEY); +}; + +// ─── Storage ───────────────────────────────────────────────── + export const loadObject = async (key: string, defaultObject: T): Promise => { let localObject: Partial = {}; - try { const json = localStorage.getItem(key); - if (json) { - localObject = JSON.parse(json); - } + if (json) localObject = JSON.parse(json); } catch { } let remoteObject: Partial = {}; - try { - // TODO loading from a remote source - const compressedData = new Blob([]); - const decompressedData = await decompressBlob(compressedData); - remoteObject = JSON.parse(await decompressedData.text()); - } catch { } + if (token) { + try { + const res = await fetch(`${BASE_URL}/storage/${key}`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (res.ok) remoteObject = await res.json(); + } catch { } + } return { ...defaultObject, ...localObject, ...remoteObject }; } 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 { localStorage.setItem(key, saveData); } catch { } - try { - const compressedData = await compressBlob(saveData); - // TODO saving to remote storage - void compressedData; - } catch { + if (token) { + const existing = pendingSaves.get(key); + if (existing) clearTimeout(existing); + pendingSaves.set(key, setTimeout(() => { + pendingSaves.delete(key); + saveRemote(key, saveData); + }, SAVE_THROTTLE_MS)); } -} +}; + +const saveRemote = async (key: string, saveData: string) => { + try { + await fetch(`${BASE_URL}/storage/${key}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: saveData, + }); + } catch { } +}; + +// ─── Compression utils ─────────────────────────────────────── export const compressBlob = async (blob: Blob | string): Promise => { - if (typeof blob === 'string') { - blob = new Blob([blob]); - } + if (typeof blob === 'string') blob = new Blob([blob]); const cs = new CompressionStream("gzip"); - const compressedStream = blob.stream().pipeThrough(cs); - return await new Response(compressedStream).blob(); + return await new Response(blob.stream().pipeThrough(cs)).blob(); } export const decompressBlob = async (blob: Blob): Promise => { const ds = new DecompressionStream("gzip"); - const decompressedStream = blob.stream().pipeThrough(ds); - return await new Response(decompressedStream).blob(); + return await new Response(blob.stream().pipeThrough(ds)).blob(); } diff --git a/src/games/index/index.tsx b/src/games/index/index.tsx index 68df85b..585e265 100644 --- a/src/games/index/index.tsx +++ b/src/games/index/index.tsx @@ -2,8 +2,6 @@ import { render } from "preact"; import styles from "./assets/game.module.css"; import type { Game } from "../../../build/isGame"; -declare const GAMES: Game[]; - function GameList({ games }: { games: Game[] }) { return (
diff --git a/src/games/storywriter/assets/settings-modal.module.css b/src/games/storywriter/assets/settings-modal.module.css index 03c5112..b37ecd8 100644 --- a/src/games/storywriter/assets/settings-modal.module.css +++ b/src/games/storywriter/assets/settings-modal.module.css @@ -71,10 +71,6 @@ min-height: unset; } -.button { - composes: button from '@common/assets/ui.module.css'; -} - .buttonSecondary { composes: buttonSecondary from '@common/assets/ui.module.css'; } @@ -84,6 +80,10 @@ color: var(--accent-text); } +.buttonDanger { + composes: buttonDanger from '@common/assets/ui.module.css'; +} + .inputRow { composes: inputRow from '@common/assets/ui.module.css'; } diff --git a/src/games/storywriter/components/settings-modal.tsx b/src/games/storywriter/components/settings-modal.tsx index d277be8..f889f44 100644 --- a/src/games/storywriter/components/settings-modal.tsx +++ b/src/games/storywriter/components/settings-modal.tsx @@ -10,13 +10,14 @@ import { SamplingSettings } from "./settings/sampling"; import { SystemInstructionSettings } from "./settings/system-instruction"; import { ImageSettings } from "./settings/image"; import { UserSettings } from "./settings/user"; +import { LoginSettings } from "./settings/login"; interface Props { open: boolean; onClose: () => void; } -type Tab = "banned-tokens" | "system-instruction" | "chat-system-instruction" | "continue-prompt" | "connection" | "user" | "sampling" | "image"; +type Tab = "banned-tokens" | "system-instruction" | "chat-system-instruction" | "continue-prompt" | "connection" | "user" | "sampling" | "image" | "login"; const TABS: { id: Tab; label: string }[] = [ { id: "connection", label: "Connection" }, @@ -27,6 +28,7 @@ const TABS: { id: Tab; label: string }[] = [ { id: "system-instruction", label: "System Instruction" }, { id: "continue-prompt", label: "Continue Prompt" }, { id: "chat-system-instruction", label: "Chat System Instruction" }, + { id: "login", label: "Login" }, ]; export const SettingsModal = ({ open, onClose }: Props) => { @@ -60,6 +62,7 @@ export const SettingsModal = ({ open, onClose }: Props) => { {activeTab === "connection" && } {activeTab === "sampling" && } {activeTab === "image" && } + {activeTab === "login" && } ); }; diff --git a/src/games/storywriter/components/settings/connection.tsx b/src/games/storywriter/components/settings/connection.tsx index b2ddb64..c8f0924 100644 --- a/src/games/storywriter/components/settings/connection.tsx +++ b/src/games/storywriter/components/settings/connection.tsx @@ -4,7 +4,7 @@ import { useInputState } from "@common/hooks/useInputState"; import { useUpdate } from "@common/hooks/useUpdate"; import { fuzzyMatch } from "@common/utils"; import clsx from "clsx"; -import { useMemo, useRef } from "preact/hooks"; +import { useEffect, useMemo, useRef } from "preact/hooks"; import styles from "../../assets/settings-modal.module.css"; import { useAppState } from "../../contexts/state"; import LLM from "../../utils/llm"; diff --git a/src/games/storywriter/components/settings/image.tsx b/src/games/storywriter/components/settings/image.tsx index 0cd2036..6721875 100644 --- a/src/games/storywriter/components/settings/image.tsx +++ b/src/games/storywriter/components/settings/image.tsx @@ -114,7 +114,7 @@ export const ImageSettings = () => { />
-
diff --git a/src/games/storywriter/components/settings/login.tsx b/src/games/storywriter/components/settings/login.tsx new file mode 100644 index 0000000..39e808f --- /dev/null +++ b/src/games/storywriter/components/settings/login.tsx @@ -0,0 +1,36 @@ +import { useState } from "preact/hooks"; +import { LoginForm } from "@common/components/LoginForm"; +import { isLoggedIn, logout } from "@common/storage"; +import { importStoredReducer } from "@common/hooks/useRemote"; +import { STATE_KEY } from "../../contexts/state"; +import styles from "../../assets/settings-modal.module.css"; + +export const LoginSettings = () => { + const [loggedIn, setLoggedIn] = useState(isLoggedIn); + const [imported, setImported] = useState(false); + + const handleLogin = () => setLoggedIn(true); + + const handleLogout = () => { + logout(); + setLoggedIn(false); + }; + + const handleImport = () => { + if (importStoredReducer(STATE_KEY)) setImported(true); + }; + + if (loggedIn) { + return ( +
+ Logged in. Save data is synced to the server. + + +
+ ); + } + + return ; +}; diff --git a/src/games/storywriter/components/settings/sampling.tsx b/src/games/storywriter/components/settings/sampling.tsx index a40ef6d..2162b17 100644 --- a/src/games/storywriter/components/settings/sampling.tsx +++ b/src/games/storywriter/components/settings/sampling.tsx @@ -14,12 +14,12 @@ interface ParamConfig { } const PARAMS: ParamConfig[] = [ - { key: "temperature", label: "Temperature", min: 0, max: 4, step: 0.01 }, - { key: "top_p", label: "Top P", min: 0, max: 1, step: 0.01 }, - { key: "top_k", label: "Top K", min: 0, max: 200, step: 1 }, - { key: "min_p", label: "Min P", min: 0, max: 1, step: 0.01 }, - { key: "repetition_penalty", label: "Repetition Penalty", min: 1, max: 3, step: 0.01 }, - { key: "frequency_penalty", label: "Frequency Penalty", min: -2, max: 2, step: 0.01 }, + { key: "temperature", label: "Temperature", min: 0, max: 4, step: 0.01 }, + { key: "top_p", label: "Top P", min: 0, max: 1, step: 0.01 }, + { key: "top_k", label: "Top K", min: 0, max: 200, step: 1 }, + { key: "min_p", label: "Min P", min: 0, max: 1, step: 0.01 }, + { key: "repetition_penalty", label: "Repetition Penalty", min: 1, max: 3, step: 0.01 }, + { key: "frequency_penalty", label: "Frequency Penalty", min: -2, max: 2, step: 0.01 }, ]; function ParamRow({ config, initialValue, onChange }: { @@ -83,7 +83,7 @@ export const SamplingSettings = () => { /> ))}
-
diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index 708287d..ac055a2 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -1,4 +1,4 @@ -import { useStoredReducer } from "@common/hooks/useStored"; +import { useRemoteReducer } from "@common/hooks/useRemote"; import { createContext } from "preact"; import { useContext, useMemo } from "preact/hooks"; import Chapters from "../utils/chapters"; @@ -695,8 +695,10 @@ export const useAppState = () => useContext(StateContext); // ─── Provider ──────────────────────────────────────────────────────────────── +export const STATE_KEY = 'state'; + export const StateContextProvider = ({ children }: { children?: any }) => { - const [state, dispatch] = useStoredReducer('state', reducer, DEFAULT_STATE); + const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE); const value = useMemo(() => { const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null; diff --git a/src/index.ts b/src/index.ts index 74efcd9..6cdda71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -declare const GAME: string; - async function main() { const { default: runGame }: { default: RunGame } = await import(`./games/${GAME}`); await runGame(); diff --git a/src/types.d.ts b/src/types.d.ts index c0a79c3..a3c809e 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -3,6 +3,9 @@ type Rect = [number, number, number, number]; type RunGame = () => Promise; +declare const GAME: string; +declare const GAMES: Game[]; + declare namespace WebAssembly { type tc = 'i32' | 'i64' | 'f32' | 'f64'; export class Function {