diff --git a/.gitignore b/.gitignore index d70bb9c..85dc671 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules dist +.gradle .DS_Store *.local diff --git a/IWantToHeal2-Thor-v1.0.2.apk b/IWantToHeal2-Thor-v1.0.2.apk new file mode 100644 index 0000000..5406ec2 Binary files /dev/null and b/IWantToHeal2-Thor-v1.0.2.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index f55e654..9357a9e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.phenomrom.iwanttoheal2" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 3 - versionName "1.0.1" + versionCode 10002 + versionName "1.0.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/docs/truenas-deploy.md b/docs/truenas-deploy.md index db78d3e..d738557 100644 --- a/docs/truenas-deploy.md +++ b/docs/truenas-deploy.md @@ -26,26 +26,8 @@ set -e cd /Users/warren/Documents/action-mode -export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" -export PATH="$JAVA_HOME/bin:$PATH" - VERSION="1.0.0" - -CURRENT_CODE=$(grep -E 'versionCode [0-9]+' android/app/build.gradle | awk '{print $2}') -NEXT_CODE=$((CURRENT_CODE + 1)) - -perl -0pi -e "s/versionCode\s+\d+/versionCode $NEXT_CODE/" android/app/build.gradle -perl -0pi -e "s/versionName\s+\"[^\"]+\"/versionName \"$VERSION\"/" android/app/build.gradle - -npm ci -VITE_API_BASE_URL="https://iwanttoheal.phenomrom.com" npm run android:sync - -cd android -./gradlew clean assembleDebug -cd .. - -cp android/app/build/outputs/apk/debug/app-debug.apk "IWantToHeal2-Thor-v$VERSION.apk" -ls -lh "IWantToHeal2-Thor-v$VERSION.apk" +scripts/build-thor-apk.sh "$VERSION" ``` ## Step 2: Web/Server Build Check diff --git a/eslint.config.js b/eslint.config.js index 1a00096..5e335df 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' export default tseslint.config( - { ignores: ['dist'] }, + { ignores: ['dist', 'android/**/build/**', 'android/app/src/main/assets/**'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], diff --git a/scripts/build-thor-apk.sh b/scripts/build-thor-apk.sh new file mode 100755 index 0000000..af0847c --- /dev/null +++ b/scripts/build-thor-apk.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +VERSION="${1:-${VERSION:-1.0.2}}" + +if [[ ! "$VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then + echo "VERSION must look like 1.0.2" >&2 + exit 1 +fi + +IFS=. read -r VERSION_MAJOR VERSION_MINOR VERSION_PATCH <<< "$VERSION" +VERSION_CODE=$((VERSION_MAJOR * 10000 + VERSION_MINOR * 100 + VERSION_PATCH)) + +export JAVA_HOME="${JAVA_HOME:-/Applications/Android Studio.app/Contents/jbr/Contents/Home}" +export PATH="$JAVA_HOME/bin:$PATH" +export GRADLE_USER_HOME="${GRADLE_USER_HOME:-$ROOT_DIR/.gradle}" + +if [[ ! -x "$JAVA_HOME/bin/java" ]]; then + echo "Java not found at $JAVA_HOME/bin/java" >&2 + echo "Set JAVA_HOME to Android Studio bundled JBR." >&2 + exit 1 +fi + +perl -0pi -e "s/versionCode\s+\d+/versionCode $VERSION_CODE/" android/app/build.gradle +perl -0pi -e "s/versionName\s+\"[^\"]+\"/versionName \"$VERSION\"/" android/app/build.gradle + +npm ci +VITE_API_BASE_URL="${VITE_API_BASE_URL:-https://iwanttoheal.phenomrom.com}" npm run android:sync + +( + cd android + ./gradlew --no-daemon --stacktrace clean assembleDebug +) + +APK="IWantToHeal2-Thor-v$VERSION.apk" +cp android/app/build/outputs/apk/debug/app-debug.apk "$APK" +ls -lh "$APK" diff --git a/server/db-init.mjs b/server/db-init.mjs index ba95a64..e13afcd 100644 --- a/server/db-init.mjs +++ b/server/db-init.mjs @@ -12,12 +12,32 @@ try { id uuid primary key default gen_random_uuid(), auth_issuer text not null, auth_subject text not null, + username text, + password_hash text, + password_salt text, display_name text not null default 'Healer', created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (auth_issuer, auth_subject) ); + alter table app_users add column if not exists username text; + alter table app_users add column if not exists password_hash text; + alter table app_users add column if not exists password_salt text; + + create unique index if not exists app_users_username_lower_idx + on app_users (lower(username)) + where username is not null; + + create table if not exists sessions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references app_users(id) on delete cascade, + token_hash text not null unique, + expires_at timestamptz not null, + created_ip text not null, + created_at timestamptz not null default now() + ); + create table if not exists save_slots ( id uuid primary key default gen_random_uuid(), user_id uuid not null references app_users(id) on delete cascade, @@ -64,6 +84,8 @@ try { ); create index if not exists save_slots_user_id_idx on save_slots(user_id); + create index if not exists sessions_token_hash_idx on sessions(token_hash); + create index if not exists sessions_expires_at_idx on sessions(expires_at); create index if not exists pvp_queue_mode_status_idx on pvp_queue(mode, status, queued_at); create index if not exists pvp_matches_player_one_idx on pvp_matches(player_one_id); create index if not exists pvp_matches_player_two_idx on pvp_matches(player_two_id); diff --git a/server/index.mjs b/server/index.mjs index 83f0862..9bbf5d7 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -1,5 +1,11 @@ import { createReadStream, existsSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' +import { + createHash, + randomBytes, + scryptSync, + timingSafeEqual, +} from 'node:crypto' import { createServer } from 'node:http' import { extname, join, normalize } from 'node:path' import { fileURLToPath } from 'node:url' @@ -11,6 +17,8 @@ const distDir = join(rootDir, 'dist') const host = process.env.HOST ?? '0.0.0.0' const port = Number(process.env.PORT ?? 4173) const pool = createPool() +const sessionCookieName = 'iwanttoheal2_session' +const sessionLifetimeSeconds = 60 * 60 * 24 * 30 const corsOrigins = new Set( (process.env.CORS_ORIGINS ?? '') @@ -49,8 +57,15 @@ const server = createServer(async (request, response) => { await serveStatic(response, url.pathname) } catch (error) { - console.error(error) - sendJson(response, 500, { error: 'internal_error' }) + const status = Number(error?.status) || (request.url?.startsWith('/api/') ? 400 : 500) + if (status >= 500) console.error(error) + sendJson(response, status, { + error: status >= 500 + ? 'internal_error' + : error instanceof Error + ? error.message + : 'Unable to process request.', + }) } }) @@ -70,6 +85,8 @@ function applyCors(request, response) { } async function handleApi(request, response, url) { + if (await handleAuthApi(request, response, url)) return + if (request.method === 'GET' && url.pathname === '/api/health') { const db = await pool.query('select now() as now') sendJson(response, 200, { ok: true, database: 'ok', now: db.rows[0].now }) @@ -154,7 +171,49 @@ async function handleApi(request, response, url) { sendJson(response, 404, { error: 'not_found' }) } +async function handleAuthApi(request, response, url) { + if (!url.pathname.startsWith('/api/auth/')) return false + + if (request.method === 'POST' && url.pathname === '/api/auth/register') { + const body = await readJson(request) + const result = await registerAccount(request, body) + sendJson(response, 201, result, { 'Set-Cookie': sessionCookie(result.token, request) }) + return true + } + + if (request.method === 'POST' && url.pathname === '/api/auth/login') { + const body = await readJson(request) + const result = await loginAccount(request, body) + sendJson(response, 200, result, { 'Set-Cookie': sessionCookie(result.token, request) }) + return true + } + + if (request.method === 'GET' && url.pathname === '/api/auth/session') { + const session = await currentSession(request) + sendJson(response, 200, { + account: session ? accountPayload(session) : null, + profile: null, + }) + return true + } + + if (request.method === 'POST' && url.pathname === '/api/auth/logout') { + const token = requestSessionToken(request) + if (token) { + await pool.query('delete from sessions where token_hash = $1', [tokenHash(token)]) + } + sendJson(response, 200, { ok: true }, { 'Set-Cookie': sessionCookie('', request, 0) }) + return true + } + + sendJson(response, 404, { error: 'not_found' }) + return true +} + async function requireUser(request, response) { + const session = await currentSession(request) + if (session) return session + if (process.env.TRUST_AUTH_HEADERS !== '1') { sendJson(response, 401, { error: 'auth_not_configured' }) return null @@ -188,15 +247,186 @@ async function requireUser(request, response) { return result.rows[0] } -async function readJson(request) { +function normalizeUsername(value) { + const username = String(value ?? '').trim() + if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) { + throw new Error('Username must be 3-20 letters, numbers, or underscores.') + } + return username +} + +function normalizeDisplayName(value, fallback) { + const name = String(value ?? fallback).trim() + if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) { + throw new Error('Character name must be 2-20 characters and start with a letter.') + } + return name +} + +function validatePassword(value) { + const password = String(value ?? '') + if (password.length < 10 || password.length > 128) { + throw new Error('Password must be 10-128 characters.') + } + return password +} + +function passwordDigest(password, salt) { + return scryptSync(password, salt, 64).toString('hex') +} + +function verifyPassword(password, user) { + const actual = Buffer.from(passwordDigest(password, user.password_salt), 'hex') + const expected = Buffer.from(user.password_hash, 'hex') + return actual.length === expected.length && timingSafeEqual(actual, expected) +} + +function tokenHash(token) { + return createHash('sha256').update(token).digest('hex') +} + +function parseCookies(request) { + return Object.fromEntries( + String(request.headers.cookie ?? '') + .split(';') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const separator = part.indexOf('=') + return separator < 0 + ? [part, ''] + : [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))] + }), + ) +} + +function bearerToken(request) { + const authorization = request.headers.authorization + if (typeof authorization !== 'string') return '' + const match = authorization.match(/^Bearer\s+(.+)$/i) + return match ? match[1].trim() : '' +} + +function requestSessionToken(request) { + return bearerToken(request) || parseCookies(request)[sessionCookieName] || '' +} + +function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) { + const secure = request.headers['x-forwarded-proto'] === 'https' + || Boolean(request.socket.encrypted) + || process.env.COOKIE_SECURE === '1' + return [ + `${sessionCookieName}=${encodeURIComponent(token)}`, + 'HttpOnly', + 'Path=/', + 'SameSite=Lax', + `Max-Age=${maxAge}`, + secure ? 'Secure' : '', + ].filter(Boolean).join('; ') +} + +function requestIp(request) { + if (process.env.TRUST_PROXY === '1') { + const forwarded = request.headers['x-forwarded-for'] + if (typeof forwarded === 'string') return forwarded.split(',')[0].trim() + } + return request.socket.remoteAddress ?? 'unknown' +} + +async function createSession(userId, ip) { + const token = randomBytes(32).toString('base64url') + await pool.query( + ` + insert into sessions (user_id, token_hash, expires_at, created_ip) + values ($1, $2, now() + interval '30 days', $3) + `, + [userId, tokenHash(token), ip], + ) + return token +} + +async function currentSession(request) { + const token = requestSessionToken(request) + if (!token) return null + await pool.query('delete from sessions where expires_at <= now()') + const result = await pool.query( + ` + select app_users.id, app_users.username, app_users.display_name, app_users.auth_issuer, app_users.auth_subject + from sessions + join app_users on app_users.id = sessions.user_id + where sessions.token_hash = $1 + and sessions.expires_at > now() + `, + [tokenHash(token)], + ) + return result.rows[0] ?? null +} + +function accountPayload(user) { + return { + id: user.id, + username: user.username ?? user.auth_subject, + displayName: user.display_name, + } +} + +async function registerAccount(request, payload) { + const username = normalizeUsername(payload?.username) + const normalizedSubject = username.toLowerCase() + const password = validatePassword(payload?.password) + const displayName = normalizeDisplayName(payload?.characterName, username) + const existing = await pool.query( + 'select id from app_users where username is not null and lower(username) = lower($1)', + [username], + ) + if (existing.rows[0]) throw new Error('That username is already taken.') + + const salt = randomBytes(16).toString('hex') + const result = await pool.query( + ` + insert into app_users (auth_issuer, auth_subject, username, password_hash, password_salt, display_name) + values ('local', $1, $2, $3, $4, $5) + returning id, username, display_name, auth_issuer, auth_subject + `, + [normalizedSubject, username, passwordDigest(password, salt), salt, displayName], + ) + const user = result.rows[0] + const token = await createSession(user.id, requestIp(request)) + return { account: accountPayload(user), profile: null, token } +} + +async function loginAccount(request, payload) { + const username = normalizeUsername(payload?.username) + const password = String(payload?.password ?? '') + const result = await pool.query( + ` + select id, username, password_hash, password_salt, display_name, auth_issuer, auth_subject + from app_users + where username is not null + and lower(username) = lower($1) + `, + [username], + ) + const user = result.rows[0] + if (!user || !user.password_hash || !user.password_salt || !verifyPassword(password, user)) { + throw new Error('Invalid username or password.') + } + const token = await createSession(user.id, requestIp(request)) + return { account: accountPayload(user), profile: null, token } +} + +async function readJson(request, maxSize = 512 * 1024) { const chunks = [] + let size = 0 for await (const chunk of request) chunks.push(chunk) + for (const chunk of chunks) size += chunk.length + if (size > maxSize) throw new Error('Request body is too large.') if (chunks.length === 0) return null return JSON.parse(Buffer.concat(chunks).toString('utf8')) } -function sendJson(response, status, payload) { - response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }) +function sendJson(response, status, payload, headers = {}) { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers }) response.end(JSON.stringify(payload)) } diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..980fb0a --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react' +import { + hydrateLocalSaveFromServer, + loadSession, + localCloudSave, + logoutAccount, + pushCloudSave, + type AuthSession, +} from './actionApi' +import type { ActionMechanicConfig } from './actionBoss/actionEncounterConfig' +import type { ActionCharacter } from './actionMode' +import { ActionModeScreen } from './components/ActionModeScreen' +import { AuthScreen } from './components/AuthScreen' + +export function App() { + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [message, setMessage] = useState('') + const [screenKey, setScreenKey] = useState(0) + + useEffect(() => { + let cancelled = false + async function start() { + try { + const nextSession = await loadSession() + if (!nextSession.account) { + if (!cancelled) setSession(nextSession) + return + } + const syncStatus = await hydrateLocalSaveFromServer() + if (!cancelled) { + setSession(nextSession) + setScreenKey((current) => current + 1) + setMessage(syncStatus === 'loaded' ? 'Server save loaded.' : '') + } + } catch { + if (!cancelled) setMessage('Server unavailable. Sign in when it comes back online.') + } finally { + if (!cancelled) setLoading(false) + } + } + start() + return () => { + cancelled = true + } + }, []) + + async function handleAuthenticated(nextSession: AuthSession) { + if (nextSession.account) { + const syncStatus = await hydrateLocalSaveFromServer() + setMessage(syncStatus === 'loaded' ? 'Server save loaded.' : 'Local save uploaded.') + setScreenKey((current) => current + 1) + } + setSession(nextSession) + } + + async function handleLogout() { + await logoutAccount() + setSession({ account: null }) + setMessage('Signed out.') + } + + function syncCharacter(character: ActionCharacter) { + if (!session?.account) return + pushCloudSave({ ...localCloudSave(), character }).catch(() => null) + } + + function syncMechanics(mechanics: ActionMechanicConfig) { + if (!session?.account) return + pushCloudSave({ ...localCloudSave(), mechanics }).catch(() => null) + } + + if (loading) { + return ( +
+
+
+

Loading

+

Connecting

+

Checking server session.

+
+
+
+ ) + } + + if (!session?.account) { + return + } + + return ( + + ) +} diff --git a/src/actionApi.ts b/src/actionApi.ts new file mode 100644 index 0000000..bb83d30 --- /dev/null +++ b/src/actionApi.ts @@ -0,0 +1,152 @@ +import { + ACTION_SAVE_KEY, + loadActionCharacter, + saveActionCharacter, + type ActionCharacter, +} from './actionMode' +import { + ACTION_MECHANIC_CONFIG_KEY, + loadActionMechanicConfig, + saveActionMechanicConfig, + type ActionMechanicConfig, +} from './actionBoss/actionEncounterConfig' + +const AUTH_TOKEN_KEY = 'i-want-to-heal-2:auth-token:v1' + +export type AuthAccount = { + id: string + username: string + displayName: string +} + +export type AuthSession = { + account: AuthAccount | null + profile?: null + token?: string +} + +export type ActionCloudSave = { + character?: ActionCharacter + mechanics?: ActionMechanicConfig + updatedAt?: string +} + +type SavedSlotResponse = { + save: null | { + save_json?: ActionCloudSave + saveJson?: ActionCloudSave + } +} + +function configuredBaseUrl(value: string | undefined) { + return value ? value.replace(/\/+$/, '') : '' +} + +function apiBaseUrl() { + if (typeof window !== 'undefined') { + const runtimeBase = (window as Window & { CAPACITOR_API_BASE_URL?: string }).CAPACITOR_API_BASE_URL + if (runtimeBase) return configuredBaseUrl(runtimeBase) + } + return configuredBaseUrl(import.meta.env.VITE_API_BASE_URL) +} + +function readAuthToken() { + return window.localStorage.getItem(AUTH_TOKEN_KEY) ?? '' +} + +function writeAuthToken(token: string) { + window.localStorage.setItem(AUTH_TOKEN_KEY, token) +} + +export function clearAuthToken() { + window.localStorage.removeItem(AUTH_TOKEN_KEY) +} + +export async function requestActionApiJson(path: string, init: RequestInit = {}): Promise { + const baseUrl = apiBaseUrl() + const headers = new Headers(init.headers) + const token = readAuthToken() + if (token && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`) + const response = await fetch(baseUrl ? `${baseUrl}${path}` : path, { + ...init, + credentials: 'include', + headers, + }) + const body = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.') + return body as T +} + +export async function loadSession() { + const session = await requestActionApiJson('/api/auth/session') + if (session.token) writeAuthToken(session.token) + return session +} + +export async function loginAccount(username: string, password: string) { + const session = await requestActionApiJson('/api/auth/login', { + body: JSON.stringify({ username, password }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + if (session.token) writeAuthToken(session.token) + return session +} + +export async function registerAccount(username: string, password: string, characterName: string) { + const session = await requestActionApiJson('/api/auth/register', { + body: JSON.stringify({ username, password, characterName }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + if (session.token) writeAuthToken(session.token) + return session +} + +export async function logoutAccount() { + await requestActionApiJson('/api/auth/logout', { method: 'POST' }).catch(() => null) + clearAuthToken() +} + +export function localCloudSave(): ActionCloudSave { + return { + character: loadActionCharacter(), + mechanics: loadActionMechanicConfig(), + updatedAt: new Date().toISOString(), + } +} + +export function applyCloudSave(save: ActionCloudSave) { + if (save.character) saveActionCharacter(save.character) + if (save.mechanics) saveActionMechanicConfig(save.mechanics) +} + +export async function loadCloudSave() { + const result = await requestActionApiJson('/api/save/default') + return result.save?.save_json ?? result.save?.saveJson ?? null +} + +export async function pushCloudSave(save: ActionCloudSave = localCloudSave()) { + return requestActionApiJson('/api/save/default', { + body: JSON.stringify({ + clientUpdatedAt: save.updatedAt ?? new Date().toISOString(), + save, + saveVersion: 1, + }), + headers: { 'Content-Type': 'application/json' }, + method: 'PUT', + }) +} + +export async function hydrateLocalSaveFromServer() { + const remote = await loadCloudSave() + if (remote) { + applyCloudSave(remote) + return 'loaded' + } + if (window.localStorage.getItem(ACTION_SAVE_KEY) || window.localStorage.getItem(ACTION_MECHANIC_CONFIG_KEY)) { + await pushCloudSave() + return 'uploaded' + } + return 'empty' +} diff --git a/src/actionMode.ts b/src/actionMode.ts index 9d55d3a..073c318 100644 --- a/src/actionMode.ts +++ b/src/actionMode.ts @@ -128,7 +128,7 @@ export const ACTION_DIFFICULTY_TIERS: ActionDifficultyTier[] = [ }, ] -const ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v2' +export const ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v2' const LEGACY_ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v1' const MAX_ACTION_LEVEL = 25 export const BULLDROME_UPGRADE_COST = 5 diff --git a/src/components/ActionModeScreen.tsx b/src/components/ActionModeScreen.tsx index a4ac7c4..beb370b 100644 --- a/src/components/ActionModeScreen.tsx +++ b/src/components/ActionModeScreen.tsx @@ -31,9 +31,15 @@ import { type ActionRunReward, } from '../actionMode' import { BulldromeBossSlice } from './BulldromeBossSlice' +import type { AuthAccount } from '../actionApi' type ActionModeScreenProps = { + account?: AuthAccount + onCharacterSaved?: (character: ActionCharacter) => void onBack?: () => void + onLogout?: () => void + onMechanicsSaved?: (config: ActionMechanicConfig) => void + serverMessage?: string } type ActionHubTab = 'dungeons' | 'raids' | 'pvp' | 'roguelike' | 'customize' | 'settings' @@ -54,7 +60,14 @@ const ACTION_HUB_ITEMS: Array<{ { id: 'settings', label: 'Settings', glyph: 'S', description: 'Tune action mode controls.' }, ] -export function ActionModeScreen({ onBack }: ActionModeScreenProps) { +export function ActionModeScreen({ + account, + onBack, + onCharacterSaved, + onLogout, + onMechanicsSaved, + serverMessage = '', +}: ActionModeScreenProps) { const [character, setCharacter] = useState(() => loadActionCharacter()) const [activeDifficulty, setActiveDifficulty] = useState(null) const [activeDungeonId, setActiveDungeonId] = useState('bulldrome') @@ -67,7 +80,8 @@ export function ActionModeScreen({ onBack }: ActionModeScreenProps) { useEffect(() => { saveActionCharacter(character) - }, [character]) + onCharacterSaved?.(character) + }, [character, onCharacterSaved]) if (activeDifficulty) { return ( @@ -121,12 +135,18 @@ export function ActionModeScreen({ onBack }: ActionModeScreenProps) {
{character.name} + {account && @{account.username}} Healer Level {character.level}
+ {onLogout && ( + + )} {(activeScreen !== 'menu' || onBack) && ( + +
+ +
+ + {mode === 'register' && ( + + )} + + +
+ +

+ {message || serverMessage || ( + mode === 'register' + ? 'Creates an account and uploads the current local save if one exists.' + : 'Sign in to load your server save.' + )} +

+ + + + ) +} diff --git a/src/main.tsx b/src/main.tsx index 4542b2a..8661d46 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { ActionModeScreen } from './components/ActionModeScreen' +import { App } from './App' import './styles.css' createRoot(document.getElementById('root')!).render( - + , ) diff --git a/src/styles.css b/src/styles.css index 59d5822..cd84a30 100644 --- a/src/styles.css +++ b/src/styles.css @@ -197,6 +197,122 @@ h2 { position: relative; } +.auth-shell { + align-items: center; + background: + radial-gradient(circle at 20% 0%, rgba(229, 185, 95, 0.08), transparent 26%), + linear-gradient(180deg, #0d0f15 0%, #07080b 100%); + display: flex; + min-height: 100dvh; + overflow: hidden; + padding: 24px; +} + +.auth-panel { + display: grid; + gap: 18px; + grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr); + margin: 0 auto; + max-width: 980px; + width: 100%; +} + +.auth-panel.single { + grid-template-columns: minmax(0, 520px); + justify-content: center; +} + +.auth-brand, +.auth-card { + background: var(--panel); + border: 3px solid #0c0d11; + box-shadow: 7px 7px 0 #08090c; + min-width: 0; + outline: 2px solid var(--edge); + padding: 24px; +} + +.auth-brand { + display: grid; + align-content: center; + gap: 16px; + min-height: 360px; +} + +.auth-brand h1 { + font-size: clamp(20px, 3.4vw, 34px); +} + +.auth-brand > p:last-child { + color: var(--muted); + font-size: 22px; + line-height: 1.2; +} + +.auth-tabs { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.auth-tabs button { + background: #17181e; + border: 2px solid #090a0d; + color: var(--muted); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + min-height: 42px; + outline: 2px solid #3e3d47; +} + +.auth-tabs button.selected { + color: var(--gold); + outline-color: var(--gold); +} + +.auth-card form { + display: grid; + gap: 14px; + margin-top: 18px; +} + +.auth-card label { + color: var(--muted); + display: grid; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + gap: 8px; + text-transform: uppercase; +} + +.auth-card input { + background: #111217; + border: 2px solid #090a0d; + color: var(--ink); + font: inherit; + font-size: 16px; + outline: 2px solid #3e3d47; + padding: 10px; + width: 100%; +} + +.auth-card input:focus { + outline-color: var(--gold); +} + +.auth-message { + color: var(--muted); + font-size: 16px; + line-height: 1.2; + margin-top: 16px; + min-height: 38px; +} + +.auth-message.error { + color: #ff8190; +} + .action-run-reward-backdrop { align-items: center; background: rgba(5, 6, 9, 0.78); @@ -1617,6 +1733,61 @@ h2 { width: 100%; } + .auth-shell { + padding: 10px; + } + + .auth-panel { + gap: 10px; + grid-template-columns: minmax(0, 0.95fr) minmax(260px, 1.05fr); + max-width: none; + } + + .auth-brand, + .auth-card { + box-shadow: none; + padding: 14px; + } + + .auth-brand { + gap: 10px; + min-height: 0; + } + + .auth-brand h1 { + font-size: 18px; + } + + .auth-brand > p:last-child, + .auth-message { + font-size: 14px; + } + + .auth-card form { + gap: 10px; + margin-top: 12px; + } + + .auth-tabs button { + font-size: 6px; + min-height: 34px; + } + + .auth-card label { + font-size: 6px; + gap: 6px; + } + + .auth-card input { + font-size: 12px; + padding: 7px; + } + + .auth-message { + margin-top: 10px; + min-height: 30px; + } + .content-screen, .message-panel { box-shadow: none; @@ -2005,6 +2176,14 @@ h2 { padding: 8px; } + .auth-panel { + grid-template-columns: minmax(0, 1fr); + } + + .auth-brand { + display: none; + } + .screen-heading { align-items: start; }