Initial commit: Play site

This commit is contained in:
The Howling Whispers 2026-06-30 16:57:28 +02:00
commit c3b10b45df
43 changed files with 4832 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules/
build/
.svelte-kit/
data/
*.db
.env
.env.local
.vite/
dist/
.tmp/
*.log
.DS_Store

2266
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "play",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 2027 --host 127.0.0.1",
"build": "vite build",
"preview": "node build/index.js",
"start": "node build/index.js"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.20.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
},
"dependencies": {
"better-sqlite3": "^11.5.0"
}
}

17
run.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
# Wrapper for the Play SvelteKit process.
set -e
DIR="/var/www/play"
PORT="${PORT:-2028}"
HOST="${HOST:-127.0.0.1}"
ORIGIN="${ORIGIN:-https://play.thehowlingwhispers.com}"
export PORT HOST ORIGIN
if [ -f "$DIR/.env" ]; then
set -a
# shellcheck disable=SC1090,SC1091
source "$DIR/.env"
set +a
fi
exec node "$DIR/build/index.js"

268
scripts/netcup-dns.sh Executable file
View File

@ -0,0 +1,268 @@
#!/bin/bash
# /var/www/rp/scripts/netcup-dns.sh
# Reusable Netcup DNS tool for thehowlingwhispers.com (and any Netcup-managed zone).
#
# Credentials: /root/.netcup-creds (chmod 600, NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD)
# Audit log: /var/log/netcup-dns.log
#
# Usage:
# netcup-dns.sh list <domain> # list all records
# netcup-dns.sh add <domain> <host> <type> <value> # add a record (idempotent)
# netcup-dns.sh delete <domain> <recordId> # delete a record
# netcup-dns.sh --dry-run add <domain> ... # preview without API calls
# netcup-dns.sh --yes add <domain> ... # skip confirmation prompt
# netcup-dns.sh --json list ... # raw JSON output
#
# Examples:
# netcup-dns.sh list thehowlingwhispers.com
# netcup-dns.sh add thehowlingwhispers.com rp A 159.195.194.180
# netcup-dns.sh add thehowlingwhispers.com sandbox CNAME rp.thehowlingwhispers.com
# netcup-dns.sh add thehowlingwhispers.com @ TXT "v=spf1 -all"
# netcup-dns.sh --dry-run add thehowlingwhispers.com blog A 159.195.194.180
# netcup-dns.sh --json list thehowlingwhispers.com
set -euo pipefail
# ─── config ────────────────────────────────────────────────────
CREDS_FILE="${NETCUP_CREDS_FILE:-/root/.netcup-creds}"
LOG_FILE="${NETCUP_DNS_LOG:-/var/log/netcup-dns.log}"
API_BASE="https://ccp.netcup.net/api/v1"
JQ_FORMAT='@json' # not used; kept for clarity
# ─── colors ───────────────────────────────────────────────────
if [ -t 1 ]; then
C_RED=$'\e[31m'; C_GRN=$'\e[32m'; C_YEL=$'\e[33m'; C_DIM=$'\e[2m'; C_RST=$'\e[0m'
else
C_RED=''; C_GRN=''; C_YEL=''; C_DIM=''; C_RST=''
fi
ok() { printf '%s✓%s %s\n' "$C_GRN" "$C_RST" "$*"; }
warn() { printf '%s!%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; }
err() { printf '%s✗%s %s\n' "$C_RED" "$C_RST" "$*" >&2; }
# ─── flag parsing ─────────────────────────────────────────────
DRY_RUN=false
ASSUME_YES=false
JSON_OUT=false
SUBCMD=""
ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--yes|-y) ASSUME_YES=true; shift ;;
--json) JSON_OUT=true; shift ;;
--help|-h)
sed -n '2,28p' "$0"
exit 0
;;
list|add|delete)
SUBCMD="$1"; shift; ARGS=("$@"); break ;;
*)
err "Unknown flag or command: $1"; exit 2 ;;
esac
done
# ─── log helper ───────────────────────────────────────────────
log() {
printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" >> "$LOG_FILE"
}
# ─── load creds (chmod 600 enforced) ────────────────────────
if [ ! -f "$CREDS_FILE" ]; then
err "Credentials file not found: $CREDS_FILE"
err "Create it with NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD (chmod 600)"
exit 1
fi
PERMS=$(stat -c '%a' "$CREDS_FILE" 2>/dev/null || echo "")
if [ "$PERMS" != "600" ] && [ "$PERMS" != "400" ]; then
err "Credentials file must be chmod 600 or 400 (currently: $PERMS). Refusing to run."
exit 1
fi
# shellcheck disable=SC1090
source "$CREDS_FILE"
if [ -z "${NETCUP_CUSTOMER:-}" ] || [ -z "${NETCUP_API_KEY:-}" ] || [ -z "${NETCUP_API_PASSWORD:-}" ]; then
err "Missing one of NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD in $CREDS_FILE"
exit 1
fi
# ─── API helpers ─────────────────────────────────────────────
# Netcup requires X-Api-Key + X-Customer-Number headers, plus
# X-Session-Id for authenticated calls. Login returns sessionId.
SESSION_ID=""
SESSION_LOGGED_IN_AT=0
SESSION_TTL=240 # refresh session every 4 minutes
login() {
local now
now=$(date +%s)
if [ -n "$SESSION_ID" ] && [ $((now - SESSION_LOGGED_IN_AT)) -lt $SESSION_TTL ]; then
return 0
fi
local response http_code body
response=$(curl -sS -w '\n%{http_code}' -X POST \
-H "X-Api-Key: ${NETCUP_API_KEY}" \
-H "X-Customer-Number: ${NETCUP_CUSTOMER}" \
-H "Content-Type: application/json" \
"${API_BASE}/login")
http_code=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$http_code" != "200" ]; then
err "Login failed (HTTP $http_code): $body"
log "ERROR" "login failed: HTTP $http_code"
exit 1
fi
SESSION_ID=$(printf '%s' "$body" | jq -r '.sessionId')
SESSION_LOGGED_IN_AT=$now
log "INFO" "login OK (session refreshed)"
}
api() {
local method=$1; shift
local path=$1; shift
local body=${1:-}
login
local args=(-sS -w '\n%{http_code}' -X "$method" -H "X-Session-Id: $SESSION_ID" -H "Content-Type: application/json")
if [ -n "$body" ]; then
args+=(-d "$body")
fi
curl "${args[@]}" "${API_BASE}${path}"
}
# ─── subdomain ──────────────────────────────────────────────
require_domain() {
if [ -z "${1:-}" ]; then err "Domain required"; exit 1; fi
echo "$1"
}
require_arg() {
if [ -z "${1:-}" ]; then err "$2 required"; exit 1; fi
echo "$1"
}
# ─── subcommands ─────────────────────────────────────────────
cmd_list() {
local domain
domain=$(require_domain "${ARGS[0]:-}")
local response http_code body
response=$(api GET "/dns/${domain}")
http_code=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$http_code" != "200" ]; then
err "List failed (HTTP $http_code): $body"
log "ERROR" "list $domain failed: HTTP $http_code"
exit 1
fi
if $JSON_OUT; then
printf '%s\n' "$body"
else
printf '%s%-8s %-30s %-25s %s%s\n' "$C_DIM" "TYPE" "HOST" "VALUE" "TTL" "$C_RST"
printf '%s\n' "$body" | jq -r '.[] | [.type, .hostname, (.value|tostring|.[0:25]), .ttl] | @tsv' | \
while IFS=$'\t' read -r t h v ttl; do
printf '%-8s %-30s %-25s %s\n' "$t" "$h" "$v" "$ttl"
done
fi
log "INFO" "list $domain (HTTP $http_code, $(printf '%s' "$body" | jq 'length') records)"
}
cmd_add() {
local domain host type value
domain=$(require_domain "${ARGS[0]:-}")
host=$(require_arg "${ARGS[1]:-}" "host")
type=$(require_arg "${ARGS[2]:-}" "type")
value=$(require_arg "${ARGS[3]:-}" "value")
# Idempotency check: list first
local list_body
list_body=$(api GET "/dns/${domain}" | sed '$d')
local existing
existing=$(printf '%s' "$list_body" | jq -r --arg h "$host" --arg t "$type" --arg v "$value" \
'.[] | select(.hostname == $h and .type == $t and (.value|tostring) == $v) | .id')
if [ -n "$existing" ] && [ "$existing" != "null" ]; then
ok "Already exists: $domain $host $type $value (recordId=$existing) — no-op"
log "INFO" "add $domain $host $type $value — no-op (already exists, id=$existing)"
return 0
fi
warn "About to add: $domain $host $type $value"
if ! $ASSUME_YES && [ -t 0 ]; then
read -r -p "Continue? [y/N] " reply
case "$reply" in
y|Y|yes|YES) ;;
*) err "Aborted."; exit 1 ;;
esac
fi
if $DRY_RUN; then
ok "[dry-run] would POST $host $type $value to $domain"
log "INFO" "add $domain $host $type $value — DRY RUN"
return 0
fi
# Netcup payload format
local payload
payload=$(jq -n --arg h "$host" --arg t "$type" --arg v "$value" \
'{hostname:$h, type:$t, value:$v, ttl:3600}')
local response http_code body
response=$(api POST "/dns/${domain}" "$payload")
http_code=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$http_code" != "200" ] && [ "$http_code" != "201" ]; then
err "Add failed (HTTP $http_code): $body"
log "ERROR" "add $domain $host $type $value failed: HTTP $http_code"
exit 1
fi
local new_id
new_id=$(printf '%s' "$body" | jq -r '.id // .recordId // empty')
ok "Added: $domain $host $type $value (recordId=$new_id)"
log "INFO" "add $domain $host $type $value OK (id=$new_id)"
}
cmd_delete() {
local domain record_id
domain=$(require_domain "${ARGS[0]:-}")
record_id=$(require_arg "${ARGS[1]:-}" "recordId")
warn "About to delete: $domain recordId=$record_id"
if ! $ASSUME_YES && [ -t 0 ]; then
read -r -p "Continue? [y/N] " reply
case "$reply" in
y|Y|yes|YES) ;;
*) err "Aborted."; exit 1 ;;
esac
fi
if $DRY_RUN; then
ok "[dry-run] would DELETE recordId=$record_id from $domain"
log "INFO" "delete $domain $record_id — DRY RUN"
return 0
fi
local response http_code body
response=$(api DELETE "/dns/${domain}/${record_id}")
http_code=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$http_code" != "200" ] && [ "$http_code" != "204" ]; then
err "Delete failed (HTTP $http_code): $body"
log "ERROR" "delete $domain $record_id failed: HTTP $http_code"
exit 1
fi
ok "Deleted: $domain recordId=$record_id"
log "INFO" "delete $domain $record_id OK"
}
# ─── dispatch ────────────────────────────────────────────────
case "$SUBCMD" in
list) cmd_list ;;
add) cmd_add ;;
delete) cmd_delete ;;
"")
err "Subcommand required (list|add|delete). Try --help."
sed -n '2,28p' "$0" >&2
exit 2
;;
*)
err "Unknown subcommand: $SUBCMD"
exit 2
;;
esac

57
src/app.css Normal file
View File

@ -0,0 +1,57 @@
/* Editorial Phase 0 placeholder styles.
The full Editorial design system ships in Phase 1.
This file gives the hello-world enough typography to look intentional. */
:root {
--bg-void: #050312;
--bg-panel: #16213e;
--text-main: #e0e0e0;
--text-muted: #a0a0b0;
--accent-blush: #e0709a;
--accent-neon: #bc13fe;
--border-glow: #d4adfc33;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
background: var(--bg-void);
color: var(--text-main);
font-family: 'Georgia', 'Times New Roman', 'Iowan Old Style', serif;
font-size: 17px;
line-height: 1.7;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: var(--accent-blush);
text-decoration: none;
border-bottom: 1px solid rgba(224, 112, 154, 0.4);
transition: border-color 0.15s, color 0.15s;
}
a:hover {
color: #ffb3d9;
border-bottom-color: var(--accent-blush);
}
h1, h2, h3, h4 {
font-family: 'Georgia', 'Times New Roman', serif;
color: #fff;
font-weight: 600;
letter-spacing: -0.01em;
}
code {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 0.9em;
background: rgba(255, 255, 255, 0.05);
padding: 1px 6px;
border-radius: 3px;
}

12
src/app.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

15
src/app.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Project RP</title>
%sveltekit.head%
</head>
<body>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

43
src/hooks.server.ts Normal file
View File

@ -0,0 +1,43 @@
import { redirect, type Handle } from '@sveltejs/kit';
import { getSessionUser, SESSION_COOKIE } from '$lib/server/auth';
const PUBLIC_PATHS = new Set<string>([
'/login',
'/auth/discord',
'/auth/discord/callback',
'/auth/local',
'/api/auth/me',
'/api/auth/local-enabled',
'/api/health'
]);
const PUBLIC_PREFIXES = ['/auth/discord/callback', '/_app/', '/favicon'];
function isPublic(pathname: string): boolean {
if (PUBLIC_PATHS.has(pathname)) return true;
return PUBLIC_PREFIXES.some((p) => pathname === p || pathname.startsWith(p));
}
export const handle: Handle = async ({ event, resolve }) => {
const { url, cookies } = event;
const cookie = cookies.get(SESSION_COOKIE);
const user = getSessionUser(cookie);
// Expose the user to all routes via event.locals
(event.locals as any).user = user;
if (!user && !isPublic(url.pathname)) {
// API routes: 401 JSON
if (url.pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'authentication required' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
// Page routes: redirect to /login
const next = url.pathname + url.search;
throw redirect(302, `/login?next=${encodeURIComponent(next)}`);
}
return resolve(event);
};

View File

@ -0,0 +1,37 @@
<script lang="ts">
export let tone: 'default' | 'success' | 'warning' | 'accent' = 'default';
</script>
<span class="badge {tone}">
<slot />
</span>
<style>
.badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 3px;
background: var(--bg-elevated);
color: var(--text-muted);
border: 1px solid var(--border-strong);
}
.badge.success {
background: rgba(127, 191, 127, 0.12);
color: var(--success);
border-color: rgba(127, 191, 127, 0.3);
}
.badge.warning {
background: rgba(232, 197, 71, 0.12);
color: var(--warning);
border-color: rgba(232, 197, 71, 0.3);
}
.badge.accent {
background: var(--accent-blush-soft);
color: var(--accent-blush);
border-color: rgba(224, 112, 154, 0.3);
}
</style>

View File

@ -0,0 +1,69 @@
<script lang="ts">
export let variant: 'primary' | 'secondary' | 'ghost' = 'primary';
export let href: string | undefined = undefined;
export let type: 'button' | 'submit' = 'button';
export let disabled = false;
export let small = false;
</script>
{#if href}
<a class="btn {variant} {small ? 'small' : ''}" {href} role="button">
<slot />
</a>
{:else}
<button class="btn {variant} {small ? 'small' : ''}" {type} {disabled} on:click>
<slot />
</button>
{/if}
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px 18px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
text-decoration: none;
transition: all 0.15s;
line-height: 1;
font-family: inherit;
}
.btn.small { padding: 5px 10px; font-size: var(--text-xs); }
.btn.primary {
background: var(--accent-blush);
color: var(--text-on-accent);
}
.btn.primary:hover:not(:disabled) {
background: #ec8aae;
}
.btn.secondary {
background: var(--bg-elevated);
border-color: var(--border-strong);
color: var(--text-main);
}
.btn.secondary:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent-blush);
}
.btn.ghost {
background: transparent;
color: var(--text-soft);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text-main);
background: var(--bg-elevated);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>

View File

@ -0,0 +1,47 @@
<script lang="ts">
export let icon: string | undefined = undefined;
export let title: string;
export let description: string | undefined = undefined;
</script>
<div class="empty">
{#if icon}
<div class="icon" aria-hidden="true">{icon}</div>
{/if}
<h3 class="title">{title}</h3>
{#if description}
<p class="description">{description}</p>
{/if}
<div class="cta">
<slot />
</div>
</div>
<style>
.empty {
text-align: center;
padding: var(--space-8) var(--space-4);
}
.icon {
font-size: 40px;
line-height: 1;
margin-bottom: var(--space-3);
opacity: 0.55;
}
.title {
font-size: var(--text-lg);
color: var(--text-main);
margin-bottom: var(--space-2);
}
.description {
color: var(--text-muted);
font-size: var(--text-sm);
max-width: 380px;
margin: 0 auto var(--space-5);
}
.cta {
display: flex;
justify-content: center;
gap: var(--space-2);
}
</style>

View File

@ -0,0 +1,65 @@
<script lang="ts">
export let title: string;
export let kicker: string | undefined = undefined;
export let icon: string | undefined = undefined;
</script>
<header class="page-header">
{#if kicker}
<p class="kicker">{kicker}</p>
{/if}
<div class="title-row">
<div class="title-block">
{#if icon}
<span class="icon" aria-hidden="true">{icon}</span>
{/if}
<h1>{title}</h1>
</div>
<div class="actions">
<slot name="actions" />
</div>
</div>
<div class="meta">
<slot name="meta" />
</div>
</header>
<style>
.page-header {
margin-bottom: var(--space-8);
padding-bottom: var(--space-6);
border-bottom: 1px solid var(--border-faint);
}
.kicker {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: var(--space-2);
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.title-block {
display: flex;
align-items: baseline;
gap: var(--space-3);
}
.icon {
font-size: var(--text-2xl);
line-height: 1;
}
.actions {
display: flex;
gap: var(--space-2);
}
.meta {
margin-top: var(--space-2);
font-size: var(--text-sm);
color: var(--text-muted);
}
</style>

View File

@ -0,0 +1,18 @@
<script lang="ts">
export let padded = true;
</script>
<section class="panel" class:padded>
<slot />
</section>
<style>
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-faint);
border-radius: var(--radius-lg);
}
.panel.padded {
padding: var(--space-6);
}
</style>

View File

@ -0,0 +1,161 @@
/* Editorial the design system for rp / sandbox / play.
Typography-led, dark background, blush-violet accent. No glassmorphic, no glow. */
:root {
/* Surface */
--bg-void: #050312;
--bg-deep: #0a0518;
--bg-panel: #14101f;
--bg-elevated: #1c1730;
--bg-hover: #251f3d;
/* Border */
--border-faint: rgba(255, 255, 255, 0.06);
--border-glow: #d4adfc33;
--border-strong: rgba(255, 255, 255, 0.12);
/* Text */
--text-main: #e8e6ed;
--text-soft: #c8c5d0;
--text-muted: #8a8693;
--text-faint: #5a5663;
--text-on-accent: #0a0518;
/* Accent (blush-violet, used sparingly) */
--accent-blush: #e0709a;
--accent-blush-soft: rgba(224, 112, 154, 0.15);
--accent-blush-faint: rgba(224, 112, 154, 0.06);
--accent-neon: #bc13fe;
--accent-gold: #e8c547;
/* Semantic */
--success: #7fbf7f;
--warning: #e8c547;
--danger: #d96a7a;
/* Type */
--font-serif: 'Iowan Old Style', 'Georgia', 'Times New Roman', serif;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace;
/* Type scale (modular, ratio 1.25) */
--text-xs: 12px;
--text-sm: 13px;
--text-base: 15px;
--text-md: 17px;
--text-lg: 20px;
--text-xl: 26px;
--text-2xl: 34px;
--text-3xl: 44px;
--text-4xl: 56px;
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
/* Layout */
--topbar-height: 56px;
--sidenav-width: 240px;
--footer-height: 44px;
--content-max-width: 920px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
background: var(--bg-void);
color: var(--text-main);
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4 {
font-family: var(--font-serif);
font-weight: 600;
color: #fff;
letter-spacing: -0.01em;
line-height: 1.2;
}
h1 { font-size: var(--text-3xl); margin-bottom: var(--space-3); }
h2 { font-size: var(--text-2xl); margin-bottom: var(--space-3); }
h3 { font-size: var(--text-xl); margin-bottom: var(--space-2); }
h4 { font-size: var(--text-lg); margin-bottom: var(--space-2); }
p { color: var(--text-soft); margin-bottom: var(--space-3); }
a {
color: var(--accent-blush);
text-decoration: none;
border-bottom: 1px solid var(--accent-blush-soft);
transition: border-color 0.15s;
}
a:hover {
border-bottom-color: var(--accent-blush);
}
code {
font-family: var(--font-mono);
font-size: 0.88em;
background: rgba(255, 255, 255, 0.05);
padding: 1px 5px;
border-radius: var(--radius-sm);
color: var(--text-main);
}
hr {
border: none;
border-top: 1px solid var(--border-faint);
margin: var(--space-6) 0;
}
button, input, select, textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
}
input, textarea, select {
background: var(--bg-deep);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
padding: 8px 12px;
width: 100%;
transition: border-color 0.15s;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent-blush);
}
label {
display: block;
font-size: var(--text-sm);
color: var(--text-muted);
margin-bottom: var(--space-1);
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.18); }

View File

@ -0,0 +1,18 @@
// /var/www/rp/src/lib/server/auth/index.ts
// Re-exports for convenience.
export {
getSessionUser,
createSession,
destroySession,
upsertUser,
SESSION_COOKIE,
SESSION_TTL_DURATION,
type SessionUser
} from './session';
export {
buildAuthorizeUrl,
exchangeCodeForUser,
isUserAllowed
} from './providers/discord';

View File

@ -0,0 +1,51 @@
// /var/www/rp/src/lib/server/auth/local.ts
// Local password login — fallback for when Discord OAuth is unavailable.
// Reads LOCAL_ADMIN_PASSWORD from env. If unset, the local login is disabled.
//
// The session created here is identical to a Discord session: same cookie,
// same DB row, same user record. So the user has full app access either way.
//
// SECURITY: this is a development convenience, not a public auth flow.
// Leave LOCAL_ADMIN_PASSWORD unset in production deployments.
import { timingSafeEqual } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { getConfig } from '../db';
const LOCAL_USER_ID = 'local-admin';
export interface LocalUser {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
email: string | null;
display_name: string | null;
}
export function isLocalLoginEnabled(): boolean {
return !!env.LOCAL_ADMIN_PASSWORD && env.LOCAL_ADMIN_PASSWORD.length > 0;
}
export function verifyLocalPassword(submitted: string): LocalUser | null {
if (!isLocalLoginEnabled()) return null;
const expected = env.LOCAL_ADMIN_PASSWORD!;
if (submitted.length !== expected.length) {
// Even on a length mismatch, do a constant-time-ish compare to avoid leaking length
timingSafeEqual(Buffer.from(submitted), Buffer.from(submitted));
return null;
}
const ok = timingSafeEqual(Buffer.from(submitted), Buffer.from(expected));
if (!ok) return null;
// Read the local user's display name from the config table if previously saved
const displayName = getConfig('local_admin_display_name');
return {
id: LOCAL_USER_ID,
username: 'local-admin',
global_name: 'Local Admin',
avatar: null,
email: null,
display_name: displayName ?? null
};
}

View File

@ -0,0 +1,125 @@
// /var/www/rp/src/lib/server/auth/providers/discord.ts
// Discord OAuth2 — "identify" scope only.
// Used for rp / sandbox / play subdomain login.
import { randomBytes } from 'node:crypto';
import { getConfig, setConfig } from '../../db';
const DISCORD_API = 'https://discord.com/api';
interface DiscordTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}
interface DiscordUser {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
email: string | null;
verified: boolean;
}
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
function getRedirectUri(): string {
return requireEnv('DISCORD_REDIRECT_URI');
}
function getClientId(): string {
return requireEnv('DISCORD_CLIENT_ID');
}
function getClientSecret(): string {
return requireEnv('DISCORD_CLIENT_SECRET');
}
const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function storeState(state: string, redirectAfter: string): void {
setConfig(`discord_oauth_state:${state}`, JSON.stringify({ redirect: redirectAfter, created_at: Date.now() }));
}
function consumeState(state: string): { redirect: string } | null {
const raw = getConfig(`discord_oauth_state:${state}`);
if (!raw) return null;
setConfig(`discord_oauth_state:${state}`, ''); // one-shot
try {
const parsed = JSON.parse(raw) as { redirect: string; created_at: number };
if (Date.now() - parsed.created_at > STATE_TTL_MS) return null;
return { redirect: parsed.redirect };
} catch {
return null;
}
}
export function buildAuthorizeUrl(redirectAfter: string): string {
const state = randomBytes(24).toString('hex');
storeState(state, redirectAfter);
const params = new URLSearchParams({
client_id: getClientId(),
redirect_uri: getRedirectUri(),
response_type: 'code',
scope: 'identify',
state,
prompt: 'none'
});
return `${DISCORD_API}/oauth2/authorize?${params.toString()}`;
}
export async function exchangeCodeForUser(
code: string,
state: string
): Promise<{ user: DiscordUser; redirect: string }> {
const stateData = consumeState(state);
if (!stateData) {
throw new Error('Invalid or expired OAuth state. Please try logging in again.');
}
const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: getClientId(),
client_secret: getClientSecret(),
grant_type: 'authorization_code',
code,
redirect_uri: getRedirectUri()
}).toString()
});
if (!tokenRes.ok) {
const text = await tokenRes.text();
throw new Error(`Discord token exchange failed (${tokenRes.status}): ${text}`);
}
const token = (await tokenRes.json()) as DiscordTokenResponse;
const userRes = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bearer ${token.access_token}` }
});
if (!userRes.ok) {
const text = await userRes.text();
throw new Error(`Discord user fetch failed (${userRes.status}): ${text}`);
}
const user = (await userRes.json()) as DiscordUser;
return { user, redirect: stateData.redirect || '/' };
}
export function isUserAllowed(discordId: string): boolean {
const raw = getConfig('allowed_discord_ids');
if (!raw) return false;
try {
const ids = JSON.parse(raw) as string[];
return ids.includes(discordId);
} catch {
return false;
}
}

View File

@ -0,0 +1,115 @@
// /var/www/rp/src/lib/server/auth/session.ts
// Signed cookie sessions. SESSION_SECRET must be set in .env.
// Session ID is a random 32-byte hex string. The session row in the DB
// carries the user_id and expires_at. The cookie carries only the session ID.
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { db } from '../db';
const SESSION_TTL_DAYS = 30;
const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
const COOKIE_NAME = 'thw_play_session';
function getSessionSecret(): string {
const s = process.env.SESSION_SECRET;
if (!s || s.length < 32) {
throw new Error('SESSION_SECRET must be set in .env (32+ chars)');
}
return s;
}
function sign(value: string): string {
return createHmac('sha256', getSessionSecret()).update(value).digest('hex');
}
function makeCookieValue(sessionId: string): string {
return `${sessionId}.${sign(sessionId)}`;
}
function verifyCookieValue(cookieValue: string): string | null {
const dotIdx = cookieValue.lastIndexOf('.');
if (dotIdx < 1) return null;
const sessionId = cookieValue.slice(0, dotIdx);
const providedSig = cookieValue.slice(dotIdx + 1);
const expectedSig = sign(sessionId);
if (providedSig.length !== expectedSig.length) return null;
try {
if (!timingSafeEqual(Buffer.from(providedSig, 'hex'), Buffer.from(expectedSig, 'hex'))) {
return null;
}
} catch {
return null;
}
return sessionId;
}
export interface SessionUser {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
email: string | null;
display_name: string | null;
created_at?: string;
last_login_at?: string;
}
export function createSession(userId: string): { id: string; cookie: string; expiresAt: Date } {
const id = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run(
id,
userId,
expiresAt.toISOString()
);
return { id, cookie: makeCookieValue(id), expiresAt };
}
export function destroySession(sessionId: string): void {
db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
}
export function getSessionUser(cookieValue: string | undefined | null): SessionUser | null {
if (!cookieValue) return null;
const sessionId = verifyCookieValue(cookieValue);
if (!sessionId) return null;
const row = db
.prepare(
`SELECT u.id, u.username, u.global_name, u.avatar, u.email, u.display_name,
u.created_at, u.last_login_at, s.expires_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > datetime('now')`
)
.get(sessionId) as
| (SessionUser & { expires_at: string })
| undefined;
if (!row) return null;
return {
id: row.id,
username: row.username,
global_name: row.global_name,
avatar: row.avatar,
email: row.email,
display_name: row.display_name ?? null,
created_at: row.created_at,
last_login_at: row.last_login_at
};
}
export function upsertUser(user: SessionUser): void {
db.prepare(
`INSERT INTO users (id, username, global_name, avatar, email, display_name, last_login_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
username = excluded.username,
global_name = excluded.global_name,
avatar = excluded.avatar,
email = excluded.email,
display_name = excluded.display_name,
last_login_at = datetime('now')`
).run(user.id, user.username, user.global_name, user.avatar, user.email, user.display_name ?? null);
}
export const SESSION_COOKIE = COOKIE_NAME;
export const SESSION_TTL_DURATION = SESSION_TTL_DAYS * 24 * 60 * 60;

View File

@ -0,0 +1,68 @@
// /var/www/rp/src/lib/server/db/index.ts
// Per-subdomain SQLite connection.
// One DB per subdomain (rp.db, sandbox.db, play.db) — separate from the main site's srh.db.
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const DB_PATH = process.env.DATABASE_PATH || './data/app.db';
mkdirSync(dirname(DB_PATH), { recursive: true });
export const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// Schema: run on every boot, idempotent
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
global_name TEXT,
avatar TEXT,
email TEXT,
display_name TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Idempotent migration: add display_name column if it doesn't exist
-- (SQLite has no IF NOT EXISTS for ADD COLUMN before 3.35; we use try/catch via the wrapper)
PRAGMA table_info(users);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
// Apply the display_name column migration. The CREATE TABLE above already
// includes display_name for fresh DBs, but for existing DBs from Phase 0
// we need to ALTER. SQLite raises a "duplicate column" error if it exists,
// which we catch and ignore.
try {
db.exec('ALTER TABLE users ADD COLUMN display_name TEXT');
} catch {
// Column already exists — fine.
}
export function getConfig(key: string): string | null {
const row = db.prepare('SELECT value FROM config WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value ?? null;
}
export function setConfig(key: string, value: string): void {
db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)').run(key, value);
}

38
src/routes/+error.svelte Normal file
View File

@ -0,0 +1,38 @@
<script lang="ts">
import { page } from '$app/stores';
import Button from '$lib/components/Button.svelte';
</script>
<div class="error-page">
<div class="error-block">
<p class="kicker">{$page.status} · Error</p>
<h1>{$page.status === 404 ? 'Page not found' : 'Something went wrong'}</h1>
<p class="lede">
{$page.error?.message || 'The page you requested does not exist or could not be loaded.'}
</p>
<div class="actions">
<Button href="/" variant="primary">Go home</Button>
</div>
</div>
</div>
<style>
.error-page {
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-block { max-width: 540px; text-align: center; }
.kicker {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: var(--space-3);
}
h1 { font-size: var(--text-3xl); margin-bottom: var(--space-4); }
.lede { color: var(--text-muted); margin-bottom: var(--space-6); }
.actions { display: flex; justify-content: center; }
</style>

307
src/routes/+layout.svelte Normal file
View File

@ -0,0 +1,307 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
$: pathname = $page.url.pathname;
$: isChromeLess = pathname === '/login' || pathname === '/loading';
$: isActive = (p: string) => pathname === p || (p !== '/' && pathname.startsWith(p));
let user: {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
display_name: string | null;
} | null = null;
let sidenavOpen = false;
let userMenuOpen = false;
onMount(async () => {
try {
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
const data = await res.json();
user = data.user;
} catch {}
});
async function logout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
window.location.href = '/login';
}
</script>
{#if isChromeLess}
<!-- /login and /loading: render only the slot, no chrome (clean auth surface) -->
<slot />
{:else}
<div class="shell">
<header class="topbar">
<div class="topbar-left">
<button class="hamburger" type="button" on:click={() => (sidenavOpen = !sidenavOpen)} aria-label="Toggle navigation">
<span></span><span></span><span></span>
</button>
<a class="brand" href="/">
<span class="brand-main">TheHowlingWhispers</span>
<span class="brand-sub">· play</span>
</a>
</div>
<div class="topbar-right">
{#if user}
<button class="avatar-btn" type="button" on:click={() => (userMenuOpen = !userMenuOpen)} aria-label="Account menu">
{#if user.avatar}
<img class="avatar" src="https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}.png?size=64" alt="" />
{:else}
<span class="avatar fallback">{(user.display_name || user.username || '?').charAt(0).toUpperCase()}</span>
{/if}
</button>
{#if userMenuOpen}
<div class="user-menu" on:click|self={() => (userMenuOpen = false)} role="menu">
<div class="user-menu-head">
<div class="user-menu-name">{user.display_name || user.global_name || user.username}</div>
<div class="user-menu-meta">@{user.username}</div>
</div>
<a class="user-menu-item" href="/profile" on:click={() => (userMenuOpen = false)}>Profile</a>
<a class="user-menu-item" href="/settings" on:click={() => (userMenuOpen = false)}>Settings</a>
<button class="user-menu-item danger" type="button" on:click={logout}>Sign out</button>
</div>
{/if}
{/if}
</div>
</header>
<div class="body">
<aside class="sidenav" class:open={sidenavOpen}>
<nav>
<a class="nav-item" class:active={isActive('/')} href="/" on:click={() => (sidenavOpen = false)}>Dashboard</a>
<a class="nav-item" class:active={isActive('/characters')} href="/characters" on:click={() => (sidenavOpen = false)}>Characters</a>
<a class="nav-item" class:active={isActive('/rooms')} href="/rooms" on:click={() => (sidenavOpen = false)}>Rooms</a>
<a class="nav-item" class:active={isActive('/profile')} href="/profile" on:click={() => (sidenavOpen = false)}>Profile</a>
<a class="nav-item" class:active={isActive('/settings')} href="/settings" on:click={() => (sidenavOpen = false)}>Settings</a>
</nav>
</aside>
<main class="content" on:click={() => { if (sidenavOpen) sidenavOpen = false; if (userMenuOpen) userMenuOpen = false; }}>
<slot />
</main>
</div>
<footer class="footer">
<span>TheHowlingWhispers · <code>play.thehowlingwhispers.com</code></span>
</footer>
</div>
{/if}
<style>
/* Skip the global editorial.css import on the login page so the login
CSS uses its own dark backdrop. On the login page, the chrome CSS
below isn't used, but the import keeps the typography scale consistent. */
@import url('$lib/design/editorial.css');
.shell {
display: grid;
grid-template-rows: var(--topbar-height) 1fr var(--footer-height);
height: 100vh;
}
/* ── topbar ─────────────────────────────────────── */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-6);
background: var(--bg-deep);
border-bottom: 1px solid var(--border-faint);
z-index: 10;
}
.topbar-left {
display: flex;
align-items: center;
gap: var(--space-3);
}
.hamburger {
display: none;
background: none;
border: none;
flex-direction: column;
gap: 3px;
padding: 8px;
cursor: pointer;
}
.hamburger span {
display: block;
width: 18px;
height: 2px;
background: var(--text-soft);
border-radius: 1px;
}
.brand {
text-decoration: none;
border-bottom: none;
color: inherit;
display: flex;
align-items: baseline;
gap: 6px;
}
.brand-main {
font-family: var(--font-serif);
font-size: var(--text-md);
font-weight: 600;
color: #fff;
}
.brand-sub {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: 0.5px;
}
.topbar-right {
position: relative;
display: flex;
align-items: center;
}
.avatar-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
line-height: 0;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: block;
}
.avatar.fallback {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent-blush);
color: var(--text-on-accent);
font-weight: 700;
font-size: 14px;
}
.user-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 220px;
background: var(--bg-panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
padding: var(--space-1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 50;
}
.user-menu-head {
padding: var(--space-3);
border-bottom: 1px solid var(--border-faint);
margin-bottom: var(--space-1);
}
.user-menu-name {
font-weight: 600;
color: var(--text-main);
font-size: var(--text-sm);
}
.user-menu-meta {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-muted);
}
.user-menu-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
color: var(--text-soft);
font-size: var(--text-sm);
cursor: pointer;
text-decoration: none;
}
.user-menu-item:hover {
background: var(--bg-elevated);
color: var(--text-main);
}
.user-menu-item.danger { color: var(--danger); }
.user-menu-item.danger:hover { background: rgba(217, 106, 122, 0.12); }
/* ── body / sidenav / content ───────────────────── */
.body {
display: grid;
grid-template-columns: var(--sidenav-width) 1fr;
overflow: hidden;
}
.sidenav {
background: var(--bg-deep);
border-right: 1px solid var(--border-faint);
padding: var(--space-6) var(--space-3);
overflow-y: auto;
}
.sidenav nav { display: flex; flex-direction: column; gap: 2px; }
.nav-item {
padding: 8px 12px;
border-radius: var(--radius-sm);
color: var(--text-soft);
text-decoration: none;
font-size: var(--text-sm);
font-weight: 500;
border-left: 2px solid transparent;
}
.nav-item:hover {
background: var(--bg-elevated);
color: var(--text-main);
}
.nav-item.active {
color: #fff;
background: var(--bg-elevated);
border-left-color: var(--accent-blush);
}
.content {
overflow-y: auto;
padding: var(--space-8) var(--space-8);
}
.content > :global(*) { max-width: var(--content-max-width); }
/* ── footer ─────────────────────────────────────── */
.footer {
background: var(--bg-deep);
border-top: 1px solid var(--border-faint);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-faint);
font-family: var(--font-serif);
font-style: italic;
font-size: var(--text-sm);
}
.footer code {
font-style: normal;
font-family: var(--font-mono);
}
/* ── mobile ─────────────────────────────────────── */
@media (max-width: 800px) {
.hamburger { display: flex; }
.body { grid-template-columns: 1fr; }
.sidenav {
position: fixed;
top: var(--topbar-height);
bottom: var(--footer-height);
left: 0;
width: 260px;
transform: translateX(-100%);
transition: transform 0.2s;
z-index: 20;
}
.sidenav.open { transform: translateX(0); }
.content { padding: var(--space-6) var(--space-4); }
}
</style>

96
src/routes/+page.svelte Normal file
View File

@ -0,0 +1,96 @@
<script lang="ts">
import { onMount } from 'svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Panel from '$lib/components/Panel.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Button from '$lib/components/Button.svelte';
let user: {
id: string;
username: string;
global_name: string | null;
display_name: string | null;
avatar: string | null;
created_at: string;
last_login_at: string;
} | null = null;
onMount(async () => {
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
const data = await res.json();
user = data.user;
});
function joinedAgo(iso: string): string {
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
if (days === 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 30) return `${days} days ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`;
}
</script>
{#if user}
<PageHeader
title={`Welcome back, ${user.display_name || user.global_name || user.username}`}
kicker="Dashboard"
>
<div slot="actions">
<Button href="/profile" variant="secondary" small>View profile</Button>
</div>
</PageHeader>
<div class="grid">
<Panel>
<EmptyState
icon="🎭"
title="No characters yet"
description="Create your first character to start chatting. Soulforge will walk you through it."
>
<Button href="/characters" variant="primary" small>Create your first character</Button>
</EmptyState>
</Panel>
<Panel>
<EmptyState
icon="💬"
title="No rooms yet"
description="Create a room and invite a character. Or join a public one."
>
<Button href="/rooms" variant="primary" small>Browse or create a room</Button>
</EmptyState>
</Panel>
<Panel>
<EmptyState
icon="📜"
title="No activity yet"
description="Your first message, character creation, and room activity will appear here."
/>
</Panel>
</div>
<div class="meta">
<p>
Member since {joinedAgo(user.created_at)} · last sign-in {joinedAgo(user.last_login_at)} ·
Discord ID <code>{user.id}</code>
</p>
</div>
{:else}
<p>Loading…</p>
{/if}
<style>
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-4);
}
.meta {
margin-top: var(--space-8);
color: var(--text-faint);
font-size: var(--text-sm);
}
.meta code { font-size: 11px; }
</style>

View File

@ -0,0 +1,6 @@
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async () => {
const { isLocalLoginEnabled } = await import('$lib/server/auth/local');
return json({ enabled: isLocalLoginEnabled() });
};

View File

@ -0,0 +1,13 @@
import { redirect, type RequestHandler } from '@sveltejs/kit';
export const POST: RequestHandler = async ({ cookies }) => {
const { destroySession, SESSION_COOKIE } = await import('$lib/server/auth');
const cookie = cookies.get(SESSION_COOKIE);
if (cookie) {
const dotIdx = cookie.lastIndexOf('.');
const sessionId = dotIdx > 0 ? cookie.slice(0, dotIdx) : cookie;
destroySession(sessionId);
}
cookies.delete(SESSION_COOKIE, { path: '/' });
throw redirect(302, '/login');
};

View File

@ -0,0 +1,11 @@
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ cookies }) => {
const { getSessionUser, SESSION_COOKIE } = await import('$lib/server/auth');
const cookie = cookies.get(SESSION_COOKIE);
const user = getSessionUser(cookie);
if (!user) {
return json({ user: null }, { status: 200 });
}
return json({ user });
};

View File

@ -0,0 +1,35 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getSessionUser, SESSION_COOKIE, upsertUser } from '$lib/server/auth';
export const GET: RequestHandler = async ({ cookies }) => {
const user = getSessionUser(cookies.get(SESSION_COOKIE));
return json({ user });
};
export const PUT: RequestHandler = async ({ cookies, request }) => {
const user = getSessionUser(cookies.get(SESSION_COOKIE));
if (!user) return json({ error: 'authentication required' }, { status: 401 });
let payload: { display_name?: string };
try {
payload = await request.json();
} catch {
return json({ error: 'invalid json' }, { status: 400 });
}
const newName = (payload.display_name ?? '').toString().trim();
if (newName.length > 80) {
return json({ error: 'display_name must be ≤80 chars' }, { status: 400 });
}
upsertUser({
id: user.id,
username: user.username,
global_name: user.global_name,
avatar: user.avatar,
email: user.email,
display_name: newName || null
} as any);
return json({ user: { ...user, display_name: newName || null } });
};

View File

@ -0,0 +1,14 @@
import { redirect, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ url }) => {
// The redirect target after a successful login.
// Default to root; can be overridden via ?next= for deep links.
const next = url.searchParams.get('next');
const safeNext = next && next.startsWith('/') && !next.startsWith('//') ? next : '/';
const stateRedirect = encodeURIComponent(safeNext);
// Lazy import to avoid loading the OAuth module unless we need it.
const { buildAuthorizeUrl } = await import('$lib/server/auth');
const authorizeUrl = buildAuthorizeUrl(stateRedirect);
throw redirect(302, authorizeUrl);
};

View File

@ -0,0 +1,64 @@
import { redirect, type RequestHandler } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const GET: RequestHandler = async ({ url, cookies }) => {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error) {
throw redirect(302, `/login?error=${encodeURIComponent(error)}`);
}
if (!code || !state) {
throw redirect(302, '/login?error=missing_code_or_state');
}
const { exchangeCodeForUser, isUserAllowed, createSession, upsertUser, SESSION_COOKIE, SESSION_TTL_DURATION } = await import('$lib/server/auth');
let user, redirectTo;
try {
const result = await exchangeCodeForUser(code, state);
user = result.user;
redirectTo = result.redirect;
} catch (e) {
const msg = e instanceof Error ? e.message : 'unknown error';
throw redirect(302, `/login?error=${encodeURIComponent(msg)}`);
}
// Allowlist check — the user must be in ALLOWED_DISCORD_IDS to proceed
if (!isUserAllowed(user.id)) {
throw redirect(302, `/login?error=not_on_allowlist&discord_id=${encodeURIComponent(user.id)}`);
}
// Upsert the user and create a session
upsertUser({
id: user.id,
username: user.username,
global_name: user.global_name,
avatar: user.avatar,
email: user.email,
display_name: null
});
const session = createSession(user.id);
// Set the session cookie via SvelteKit's cookies API.
// SvelteKit's redirect() takes (status, location) — it does NOT accept headers.
// (The previous `throw redirect(302, loc, { 'set-cookie': ... })` silently
// dropped the cookie, which is why users got bounced back to /login after auth.)
// secure: true is conditional on the request scheme — allow http://127.0.0.1
// testing to set the cookie, force https:// in production.
const isHttps = env.ORIGIN?.startsWith('https://') ?? false;
cookies.set(SESSION_COOKIE, session.cookie, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: isHttps,
maxAge: SESSION_TTL_DURATION
});
// Route through /loading so the user sees a brief "Verifying your session…" buffer
// before landing on the destination. This replaces the previous direct-to-destination
// redirect, which showed a brief "Loading…" flash on the home page while the
// client-side fetch resolved.
throw redirect(302, `/loading?next=${encodeURIComponent(redirectTo)}`);
};

View File

@ -0,0 +1,47 @@
// /var/www/rp/src/routes/auth/local/+server.ts
// Local password login endpoint. POST /auth/local with form-encoded `password`.
// On success, sets the same session cookie as Discord and redirects to next or /.
import { redirect, type RequestHandler } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const POST: RequestHandler = async ({ request, cookies, url }) => {
const { isLocalLoginEnabled, verifyLocalPassword } = await import('$lib/server/auth/local');
const { createSession, upsertUser, SESSION_COOKIE, SESSION_TTL_DURATION } = await import('$lib/server/auth');
if (!isLocalLoginEnabled()) {
throw redirect(302, '/login?error=local_login_disabled');
}
const formData = await request.formData();
const password = (formData.get('password') ?? '').toString();
const user = verifyLocalPassword(password);
if (!user) {
throw redirect(302, '/login?error=local_login_invalid');
}
upsertUser({
id: user.id,
username: user.username,
global_name: user.global_name,
avatar: user.avatar,
email: user.email,
display_name: user.display_name
});
const session = createSession(user.id);
const isHttps = env.ORIGIN?.startsWith('https://') ?? false;
cookies.set(SESSION_COOKIE, session.cookie, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: isHttps,
maxAge: SESSION_TTL_DURATION
});
const next = url.searchParams.get('next');
const safeNext = next && next.startsWith('/') && !next.startsWith('//') ? next : '/';
// Route through /loading — same buffer as the Discord flow.
throw redirect(302, `/loading?next=${encodeURIComponent(safeNext)}`);
};

View File

@ -0,0 +1,21 @@
<script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte';
import Panel from '$lib/components/Panel.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Button from '$lib/components/Button.svelte';
import Badge from '$lib/components/Badge.svelte';
</script>
<PageHeader title="Your Characters" kicker="Soulforge">
<Badge slot="meta" tone="warning">Coming soon</Badge>
</PageHeader>
<Panel>
<EmptyState
icon="🎭"
title="Soulforge ships in a later phase"
description="The streamlined character creator — 9 sections, live age-gate, auto-save, and the Codex AI assistant. The deprecated legacy editor was dropped from the new site in v4.5.3."
>
<Button variant="secondary" small disabled>Create your first character (coming soon)</Button>
</EmptyState>
</Panel>

View File

@ -0,0 +1,198 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
let status: 'verifying' | 'success' | 'error' = 'verifying';
let user: { username: string; global_name: string | null; display_name: string | null; avatar: string | null; id: string } | null = null;
let errorMsg = '';
// Where to go after success
$: nextParam = $page.url.searchParams.get('next');
$: safeNext = nextParam && nextParam.startsWith('/') && !nextParam.startsWith('//') ? nextParam : '/';
onMount(async () => {
try {
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
const data = await res.json();
if (data.user) {
user = data.user;
// Brief "✓ Signed in" pause so the user sees the transition
setTimeout(() => {
status = 'success';
// Then navigate after another short beat
setTimeout(() => {
window.location.href = safeNext;
}, 350);
}, 250);
} else {
// No session — bounce to login with an error
errorMsg = 'Session not found. Please sign in again.';
status = 'error';
setTimeout(() => {
window.location.href = '/login?error=session_expired';
}, 1500);
}
} catch (e) {
errorMsg = e instanceof Error ? e.message : 'Network error';
status = 'error';
setTimeout(() => {
window.location.href = '/login?error=loading_failed';
}, 1500);
}
});
</script>
<svelte:head>
<title>Verifying — TheHowlingWhispers</title>
</svelte:head>
<main class="buffer">
<article class="card">
<p class="kicker">TheHowlingWhispers</p>
<div class="spinner-wrap">
{#if status === 'verifying'}
<div class="spinner" aria-hidden="true"></div>
{:else if status === 'success'}
<div class="check" aria-hidden="true"></div>
{:else}
<div class="xmark" aria-hidden="true"></div>
{/if}
</div>
<h1>
{#if status === 'verifying'}
Verifying your session…
{:else if status === 'success'}
Signed in{#if user} as {user.display_name || user.global_name || user.username}{/if}
{:else}
Could not verify session
{/if}
</h1>
{#if user}
<p class="who">
{#if user.avatar}
<img class="avatar" src="https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}.png?size=64" alt="" />
{:else}
<span class="avatar fallback">{(user.display_name || user.global_name || user.username).charAt(0).toUpperCase()}</span>
{/if}
<span class="who-name">{user.display_name || user.global_name || user.username}</span>
</p>
{/if}
{#if status === 'error'}
<p class="error">{errorMsg}</p>
{/if}
</article>
</main>
<style>
.buffer {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
background: var(--bg-void);
}
.card {
max-width: 420px;
width: 100%;
text-align: center;
}
.kicker {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent-blush);
margin-bottom: 32px;
}
h1 {
font-size: 22px;
font-weight: 600;
color: #fff;
margin-bottom: 16px;
}
.spinner-wrap {
margin: 0 auto 28px;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--accent-blush);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.check, .xmark {
width: 56px;
height: 56px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
animation: pop 0.3s ease;
}
.check {
background: rgba(127, 191, 127, 0.18);
border: 2px solid rgba(127, 191, 127, 0.5);
color: #b8e6c0;
}
.xmark {
background: rgba(217, 106, 122, 0.18);
border: 2px solid rgba(217, 106, 122, 0.5);
color: #ffb3c0;
}
@keyframes pop {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.who {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text-soft);
font-size: 14px;
margin: 0;
padding: 8px 14px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-strong);
border-radius: 100px;
}
.avatar {
width: 22px;
height: 22px;
border-radius: 50%;
}
.avatar.fallback {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: var(--accent-blush);
color: var(--text-on-accent);
font-weight: 700;
font-size: 11px;
}
.who-name { color: var(--text-main); font-weight: 500; }
.error {
color: #ffb3c0;
font-size: 13px;
margin-top: 12px;
}
</style>

View File

@ -0,0 +1,5 @@
import { isLocalLoginEnabled } from '$lib/server/auth/local';
export const load = () => {
return { localLoginEnabled: isLocalLoginEnabled() };
};

View File

@ -0,0 +1,260 @@
<script lang="ts">
import { page } from '$app/stores';
$: errorParam = $page.url.searchParams.get('error');
$: errorMessage = (() => {
if (!errorParam) return null;
if (errorParam === 'not_on_allowlist') return "Your Discord account is not on the allowlist for this site. Ask the owner to add your Discord ID.";
if (errorParam.startsWith('not_on_allowlist')) {
const id = errorParam.split('=')[1];
return `Your Discord account (ID ${id}) is not on the allowlist. Ask the owner to add it.`;
}
if (errorParam === 'missing_code_or_state') return 'The OAuth callback was missing required parameters. Please try logging in again.';
if (errorParam === 'local_login_invalid') return 'Wrong password. Try again.';
if (errorParam === 'local_login_disabled') return 'Local login is not enabled. Set LOCAL_ADMIN_PASSWORD in .env.';
if (errorParam === 'invalid_request' || errorParam.includes('redirect')) return 'Discord rejected the redirect URI. Check the Discord developer portal.';
return `OAuth error: ${errorParam}`;
})();
// Server-rendered prop from +page.server.ts: is local password login enabled?
export let data;
$: localLoginEnabled = data?.localLoginEnabled ?? false;
</script>
<svelte:head>
<title>Sign in — TheHowlingWhispers</title>
</svelte:head>
<main class="login">
<article class="card">
<p class="kicker">TheHowlingWhispers</p>
<h1>Sign in</h1>
<p class="lede">
This site is locked away from normal users. Sign in to continue.
</p>
{#if errorMessage}
<div class="error" role="alert">
<strong>Sign-in failed.</strong> {errorMessage}
</div>
{/if}
<div class="providers">
<a class="provider discord" href="/auth/discord">
<span class="provider-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.058a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</span>
<span class="provider-label">Continue with Discord</span>
</a>
<button class="provider google" disabled type="button" aria-disabled="true" title="Coming soon">
<span class="provider-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M21.35 11.1H12v3.8h5.35c-.5 2.5-2.7 4.3-5.35 4.3-3.25 0-5.9-2.65-5.9-5.9s2.65-5.9 5.9-5.9c1.45 0 2.75.5 3.8 1.4l2.7-2.7C16.95 4.5 14.65 3.5 12 3.5 7.3 3.5 3.5 7.3 3.5 12s3.8 8.5 8.5 8.5c4.9 0 8.2-3.45 8.2-8.3 0-.55-.05-1.1-.15-1.6z"/></svg>
</span>
<span class="provider-label">Continue with Google</span>
<span class="provider-badge">coming soon</span>
</button>
<button class="provider email" disabled type="button" aria-disabled="true" title="Coming soon">
<span class="provider-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z"/></svg>
</span>
<span class="provider-label">Continue with Email</span>
<span class="provider-badge">coming soon</span>
</button>
</div>
{#if localLoginEnabled}
<div class="divider"><span>or</span></div>
<form class="local-form" method="POST" action="/auth/local">
<label for="local-password">Local password (dev only)</label>
<input
id="local-password"
name="password"
type="password"
placeholder="Enter local admin password"
autocomplete="current-password"
required
/>
<button type="submit" class="local-submit">Sign in with local password</button>
</form>
{/if}
<p class="hint">
Access is restricted to a small allowlist. If you don't have access, you'll see
"Not on the allowlist" after signing in.
</p>
</article>
</main>
<style>
.login {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 80px 24px;
}
.card {
max-width: 440px;
width: 100%;
background: rgba(20, 15, 35, 0.6);
border: 1px solid var(--border-glow);
border-radius: 16px;
padding: 40px;
backdrop-filter: blur(20px);
}
.kicker {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent-blush);
margin-bottom: 12px;
}
h1 {
font-size: 36px;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
margin-bottom: 8px;
color: #fff;
}
.lede {
font-size: 15px;
line-height: 1.5;
color: var(--text-muted);
margin-bottom: 28px;
}
.providers {
display: flex;
flex-direction: column;
gap: 10px;
}
.provider {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
text-decoration: none;
border: 1px solid var(--border-glow);
transition: all 0.15s;
background: rgba(0, 0, 0, 0.3);
color: #fff;
cursor: pointer;
position: relative;
}
.provider.discord {
background: rgba(88, 101, 242, 0.15);
border-color: rgba(88, 101, 242, 0.4);
}
.provider.discord:hover {
background: rgba(88, 101, 242, 0.3);
border-color: rgba(88, 101, 242, 0.7);
transform: translateY(-1px);
}
.provider.google,
.provider.email {
opacity: 0.45;
cursor: not-allowed;
}
.provider-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
}
.provider-label {
flex: 1;
text-align: left;
}
.provider-badge {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
background: rgba(0, 0, 0, 0.4);
padding: 2px 6px;
border-radius: 3px;
}
.hint {
margin-top: 20px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
}
.error {
background: rgba(233, 69, 96, 0.12);
border: 1px solid rgba(233, 69, 96, 0.4);
color: #ffb3c0;
padding: 12px 16px;
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
margin-bottom: 20px;
}
.error strong {
color: #fff;
display: block;
margin-bottom: 2px;
}
.divider {
text-align: center;
margin: 24px 0 16px;
position: relative;
color: var(--text-faint);
font-size: 11px;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.divider::before,
.divider::after {
content: '';
position: absolute;
top: 50%;
width: calc(50% - 20px);
height: 1px;
background: var(--border-faint);
}
.divider::before { left: 0; }
.divider::after { right: 0; }
.divider span { background: rgba(20, 15, 35, 0.9); padding: 0 10px; position: relative; z-index: 1; }
.local-form { display: flex; flex-direction: column; gap: var(--space-2); }
.local-form input {
background: var(--bg-deep);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
padding: 10px 12px;
color: var(--text-main);
font-size: var(--text-sm);
}
.local-form input:focus {
outline: none;
border-color: var(--accent-blush);
}
.local-submit {
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
color: var(--text-main);
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.local-submit:hover {
background: var(--bg-hover);
border-color: var(--accent-blush);
}
</style>

View File

@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Panel from '$lib/components/Panel.svelte';
import Button from '$lib/components/Button.svelte';
let user: any = null;
let displayName = '';
let saving = false;
let saveMessage = '';
onMount(async () => {
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
const data = await res.json();
user = data.user;
if (user) displayName = user.display_name || user.global_name || user.username;
});
async function save() {
if (!user) return;
saving = true;
saveMessage = '';
try {
const res = await fetch('/api/profile', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: displayName })
});
const data = await res.json();
if (data.error) {
saveMessage = `Error: ${data.error}`;
} else {
saveMessage = 'Saved.';
if (data.user) user = data.user;
}
} catch (e) {
saveMessage = `Network error: ${e instanceof Error ? e.message : 'unknown'}`;
} finally {
saving = false;
}
}
</script>
<PageHeader title="Profile" kicker="Account" />
{#if user}
<div class="grid">
<Panel>
<div class="user-card">
{#if user.avatar}
<img class="avatar" src="https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}.png?size=128" alt="" />
{:else}
<div class="avatar fallback">{(user.global_name || user.username || '?').charAt(0).toUpperCase()}</div>
{/if}
<div class="info">
<h2 class="name">{user.global_name || user.username}</h2>
<p class="meta">@{user.username}</p>
<p class="meta">Discord ID <code>{user.id}</code></p>
</div>
</div>
</Panel>
<Panel>
<h3>Display name</h3>
<p class="hint">How you'd like to be addressed across the platform. Falls back to your Discord global name if blank.</p>
<form on:submit|preventDefault={save}>
<label for="display-name">Display name</label>
<input id="display-name" type="text" bind:value={displayName} maxlength="80" />
<div class="row">
<Button type="submit" variant="primary" small disabled={saving}>
{saving ? 'Saving…' : 'Save changes'}
</Button>
{#if saveMessage}
<span class="message">{saveMessage}</span>
{/if}
</div>
</form>
</Panel>
</div>
{:else}
<p>Loading…</p>
{/if}
<style>
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
.user-card { display: flex; align-items: center; gap: var(--space-4); }
.avatar {
width: 72px; height: 72px; border-radius: 50%;
background: var(--accent-blush); flex-shrink: 0;
}
.avatar.fallback {
display: inline-flex; align-items: center; justify-content: center;
color: var(--text-on-accent); font-weight: 700; font-size: 32px;
}
.name { font-size: var(--text-xl); margin-bottom: 4px; }
.meta { color: var(--text-muted); font-size: var(--text-sm); margin-bottom: 2px; }
.meta code { font-size: 11px; }
.hint { color: var(--text-muted); font-size: var(--text-sm); margin-bottom: var(--space-3); }
form { display: flex; flex-direction: column; gap: var(--space-2); }
.row { display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-2); }
.message { font-size: var(--text-sm); color: var(--text-muted); }
</style>

View File

@ -0,0 +1,21 @@
<script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte';
import Panel from '$lib/components/Panel.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Button from '$lib/components/Button.svelte';
import Badge from '$lib/components/Badge.svelte';
</script>
<PageHeader title="Your Rooms" kicker="Conversations">
<Badge slot="meta" tone="warning">Coming soon</Badge>
</PageHeader>
<Panel>
<EmptyState
icon="💬"
title="The chat experience ships in a later phase"
description="Aether body stats, character mood, RP/stage direction, SSE streaming — all the things that make a conversation more than a chatbot. The chat sidebar from the main site (XP, reputation, alarm, dialogue colors) lives here."
>
<Button variant="secondary" small disabled>Create a new room (coming soon)</Button>
</EmptyState>
</Panel>

View File

@ -0,0 +1,57 @@
<script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte';
import Panel from '$lib/components/Panel.svelte';
import Button from '$lib/components/Button.svelte';
import Badge from '$lib/components/Badge.svelte';
async function logout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
window.location.href = '/login';
}
</script>
<PageHeader title="Settings" kicker="Account" />
<div class="grid">
<Panel>
<div class="row">
<h3>Account</h3>
<Badge>Discord OAuth</Badge>
</div>
<p class="row-line">You sign in with Discord. Account-linking (email/password) ships in a later phase.</p>
</Panel>
<Panel>
<div class="row">
<h3>Appearance</h3>
<Badge>Editorial</Badge>
</div>
<p class="row-line">The Editorial design system is locked. A 3-design vote was scoped out in Phase 0 — re-introducing it would ship the other two (Velvet Refined, Neon Noir) as alternates.</p>
</Panel>
<Panel>
<h3>Session</h3>
<p class="row-line">Sign out of this device. To sign out of all devices, clear the cookies in your browser.</p>
<div class="actions">
<Button variant="secondary" small on:click={logout}>Sign out of this device</Button>
</div>
</Panel>
<Panel>
<h3>Danger zone</h3>
<p class="row-line">Account deletion is disabled in this phase. Each subdomain keeps its own user table; deleting an account here only removes the row in this subdomain's database.</p>
<div class="actions">
<Button variant="ghost" small disabled>Delete account (coming soon)</Button>
</div>
</Panel>
</div>
<style>
.grid { display: flex; flex-direction: column; gap: var(--space-4); max-width: 720px; }
.row {
display: flex; align-items: center; justify-content: space-between;
gap: var(--space-3); margin-bottom: var(--space-2);
}
.row-line { color: var(--text-muted); font-size: var(--text-sm); margin: 0; }
.actions { display: flex; gap: var(--space-2); margin-top: var(--space-3); }
</style>

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 B

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

4
static/favicon.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#050312"/>
<circle cx="16" cy="16" r="12" fill="#e0709a"/>
</svg>

After

Width:  |  Height:  |  Size: 173 B

16
svelte.config.js Normal file
View File

@ -0,0 +1,16 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
precompress: false
}),
csrf: { checkOrigin: false }
}
};
export default config;

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

11
vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 2028,
host: '127.0.0.1',
strictPort: true
}
});