Initial commit: Play site
This commit is contained in:
commit
c3b10b45df
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
2266
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
17
run.sh
Executable 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
268
scripts/netcup-dns.sh
Executable 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
57
src/app.css
Normal 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
12
src/app.d.ts
vendored
Normal 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
15
src/app.html
Normal 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
43
src/hooks.server.ts
Normal 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);
|
||||
};
|
||||
37
src/lib/components/Badge.svelte
Normal file
37
src/lib/components/Badge.svelte
Normal 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>
|
||||
69
src/lib/components/Button.svelte
Normal file
69
src/lib/components/Button.svelte
Normal 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>
|
||||
47
src/lib/components/EmptyState.svelte
Normal file
47
src/lib/components/EmptyState.svelte
Normal 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>
|
||||
65
src/lib/components/PageHeader.svelte
Normal file
65
src/lib/components/PageHeader.svelte
Normal 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>
|
||||
18
src/lib/components/Panel.svelte
Normal file
18
src/lib/components/Panel.svelte
Normal 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>
|
||||
161
src/lib/design/editorial.css
Normal file
161
src/lib/design/editorial.css
Normal 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); }
|
||||
18
src/lib/server/auth/index.ts
Normal file
18
src/lib/server/auth/index.ts
Normal 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';
|
||||
51
src/lib/server/auth/local.ts
Normal file
51
src/lib/server/auth/local.ts
Normal 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
|
||||
};
|
||||
}
|
||||
125
src/lib/server/auth/providers/discord.ts
Normal file
125
src/lib/server/auth/providers/discord.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
115
src/lib/server/auth/session.ts
Normal file
115
src/lib/server/auth/session.ts
Normal 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;
|
||||
68
src/lib/server/db/index.ts
Normal file
68
src/lib/server/db/index.ts
Normal 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
38
src/routes/+error.svelte
Normal 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
307
src/routes/+layout.svelte
Normal 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
96
src/routes/+page.svelte
Normal 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>
|
||||
6
src/routes/api/auth/local-enabled/+server.ts
Normal file
6
src/routes/api/auth/local-enabled/+server.ts
Normal 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() });
|
||||
};
|
||||
13
src/routes/api/auth/logout/+server.ts
Normal file
13
src/routes/api/auth/logout/+server.ts
Normal 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');
|
||||
};
|
||||
11
src/routes/api/auth/me/+server.ts
Normal file
11
src/routes/api/auth/me/+server.ts
Normal 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 });
|
||||
};
|
||||
35
src/routes/api/profile/+server.ts
Normal file
35
src/routes/api/profile/+server.ts
Normal 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 } });
|
||||
};
|
||||
14
src/routes/auth/discord/+server.ts
Normal file
14
src/routes/auth/discord/+server.ts
Normal 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);
|
||||
};
|
||||
64
src/routes/auth/discord/callback/+server.ts
Normal file
64
src/routes/auth/discord/callback/+server.ts
Normal 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)}`);
|
||||
};
|
||||
47
src/routes/auth/local/+server.ts
Normal file
47
src/routes/auth/local/+server.ts
Normal 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)}`);
|
||||
};
|
||||
21
src/routes/characters/+page.svelte
Normal file
21
src/routes/characters/+page.svelte
Normal 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>
|
||||
198
src/routes/loading/+page.svelte
Normal file
198
src/routes/loading/+page.svelte
Normal 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>
|
||||
5
src/routes/login/+page.server.ts
Normal file
5
src/routes/login/+page.server.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { isLocalLoginEnabled } from '$lib/server/auth/local';
|
||||
|
||||
export const load = () => {
|
||||
return { localLoginEnabled: isLocalLoginEnabled() };
|
||||
};
|
||||
260
src/routes/login/+page.svelte
Normal file
260
src/routes/login/+page.svelte
Normal 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>
|
||||
106
src/routes/profile/+page.svelte
Normal file
106
src/routes/profile/+page.svelte
Normal 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>
|
||||
21
src/routes/rooms/+page.svelte
Normal file
21
src/routes/rooms/+page.svelte
Normal 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>
|
||||
57
src/routes/settings/+page.svelte
Normal file
57
src/routes/settings/+page.svelte
Normal 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
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 B |
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
4
static/favicon.svg
Normal file
4
static/favicon.svg
Normal 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
16
svelte.config.js
Normal 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
14
tsconfig.json
Normal 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
11
vite.config.ts
Normal 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
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user