MVP
This commit is contained in:
commit
00874e0c97
|
|
@ -0,0 +1,7 @@
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.git/
|
||||||
|
.agents/
|
||||||
|
.codex/
|
||||||
|
.aws/
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
FROM python:3.14-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
PORT=8000 \
|
||||||
|
PREFERENCES_DB=/app/data/preferences.sqlite3
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN useradd --system --uid 10001 --create-home app && mkdir /app/data && chown app:app /app/data
|
||||||
|
COPY --chown=app:app server.py /app/server.py
|
||||||
|
COPY --chown=app:app static /app/static
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["python", "server.py"]
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Bravo Team Forever Interest
|
||||||
|
|
||||||
|
A small single-page app for collecting class and role rankings and content interests from a group. It uses Python's standard library and SQLite; there are no package dependencies. Every username uses the shared password `galnik` by default.
|
||||||
|
|
||||||
|
## Run with Docker
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:8000>. Preferences are stored in the named Docker volume `preferences_data`, so they survive container rebuilds. To use a different shared password, set `SHARED_PASSWORD` in a local `.env` file or in the environment before starting Compose.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://127.0.0.1:8000>. The SQLite database is created at `data/preferences.sqlite3`. You can change this with `PREFERENCES_DB`, and change the listening address or port with `HOST` and `PORT`.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- Existing login usernames are suggested as you type, before entering the shared password. The password is still required to sign in. A similar new username requires an explicit choice to create it. Login username matching ignores case.
|
||||||
|
- Rank the WoW Forever classes and roles you want to express a preference for. Drag a row, use its arrow buttons, or focus the drag handle and press the arrow keys. Select × to move a category to **No preference**, or Restore to rank it again. You can exclude every category. For Content, choose Yes, Maybe, or No for each of the seven activities. Save each property separately.
|
||||||
|
- Open **Group preferences** to see everyone's saved choices. Class and Role can display columns by rank or category. Excluded choices appear as blank cells and sort as the lowest preference; people who have not submitted that property appear last. Content always displays category columns. Select a column to sort, search by display name or login username, or refresh to load recent changes. When the names differ, the login username appears below the display name.
|
||||||
|
- Open **Account** to change your login username or display name independently. Display names can be shared; login usernames must be unique. Your preferences and active sessions remain tied to your account when you rename it. You can also permanently delete your account, preferences, and sessions by typing your display name to confirm.
|
||||||
|
|
||||||
|
This version uses a new database schema and does not migrate earlier preproduction data. Wipe the old database or Docker volume before starting it with existing data.
|
||||||
|
|
||||||
|
The shared password does not verify who owns a username: anyone who knows it can sign in using another person's username. If this is exposed beyond a trusted group, serve it over HTTPS and set `COOKIE_SECURE=1`.
|
||||||
|
|
||||||
|
This rollout only collects and displays preferences. Assignment and optimization are planned for a later rollout.
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
services:
|
||||||
|
preferences:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
SHARED_PASSWORD: ${SHARED_PASSWORD:-galnik}
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-0}
|
||||||
|
volumes:
|
||||||
|
- preferences_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
preferences_data:
|
||||||
|
|
@ -0,0 +1,474 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Small, dependency-free server for the character preference SPA."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
STATIC = ROOT / "static"
|
||||||
|
DB_PATH = Path(os.environ.get("PREFERENCES_DB", ROOT / "data" / "preferences.sqlite3"))
|
||||||
|
SESSION_DAYS = 30
|
||||||
|
PROPERTIES = [
|
||||||
|
{
|
||||||
|
"id": "class",
|
||||||
|
"group": "WoW Forever Character",
|
||||||
|
"name": "Class",
|
||||||
|
"categories": [
|
||||||
|
"Druid", "Hunter", "Mage", "Paladin", "Priest",
|
||||||
|
"Rogue", "Shaman", "Warlock", "Warrior",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "role",
|
||||||
|
"group": "WoW Forever Character",
|
||||||
|
"name": "Role",
|
||||||
|
"categories": ["Tank", "Healer", "DPS"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "content",
|
||||||
|
"group": "WoW Forever Character",
|
||||||
|
"name": "Content",
|
||||||
|
"categories": [
|
||||||
|
"Group Questing", "Open World PvP", "Instanced PvP",
|
||||||
|
"5-Man Dungeons", "10-Man Raids", "20-Man Raids", "40-Man Raids",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
PROPERTY_BY_ID = {item["id"]: item for item in PROPERTIES}
|
||||||
|
CONTENT_CHOICES = {"Yes", "Maybe", "No"}
|
||||||
|
def preference_payload(property_id: str, value_json: str, updated_at: str) -> dict:
|
||||||
|
field = "choices" if property_id == "content" else "ranking"
|
||||||
|
value = json.loads(value_json)
|
||||||
|
return {field: value, "updatedAt": updated_at}
|
||||||
|
|
||||||
|
|
||||||
|
def login_key_for(login_username: str) -> str:
|
||||||
|
"""Keep login lookup case-insensitive while retaining chosen capitalization."""
|
||||||
|
return login_username.strip().casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def valid_account_name(value: str) -> bool:
|
||||||
|
return 1 <= len(value) <= 32 and all(ord(char) >= 32 and ord(char) != 127 for char in value)
|
||||||
|
|
||||||
|
|
||||||
|
def user_payload(row: sqlite3.Row) -> dict:
|
||||||
|
return {
|
||||||
|
"userId": row["user_id"],
|
||||||
|
"loginUsername": row["login_username"],
|
||||||
|
"displayName": row["display_name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def password_matches(password: str) -> bool:
|
||||||
|
expected = os.environ.get("SHARED_PASSWORD", "galnik")
|
||||||
|
return hmac.compare_digest(password.encode("utf-8"), expected.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_usernames(query: str, connection: sqlite3.Connection) -> list[str]:
|
||||||
|
key = login_key_for(query)
|
||||||
|
if len(key) < 2:
|
||||||
|
return []
|
||||||
|
matches = []
|
||||||
|
for row in connection.execute("SELECT login_key, login_username FROM users"):
|
||||||
|
candidate = row["login_key"]
|
||||||
|
similarity = SequenceMatcher(None, key, candidate).ratio()
|
||||||
|
if candidate == key or candidate.startswith(key) or key.startswith(candidate) or similarity >= 0.64:
|
||||||
|
score = (candidate == key, candidate.startswith(key), similarity)
|
||||||
|
matches.append((score, row["login_username"]))
|
||||||
|
matches.sort(key=lambda match: (match[0], match[1].casefold()), reverse=True)
|
||||||
|
return [username for _, username in matches[:5]]
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def db() -> Iterator[sqlite3.Connection]:
|
||||||
|
connection = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
try:
|
||||||
|
with connection:
|
||||||
|
yield connection
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with db() as connection:
|
||||||
|
existing = connection.execute("PRAGMA table_info(users)").fetchall()
|
||||||
|
if existing and "user_id" not in {row["name"] for row in existing}:
|
||||||
|
raise RuntimeError("Existing database uses the old schema. Wipe the preproduction database before starting this version.")
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
login_username TEXT NOT NULL,
|
||||||
|
login_key TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS preferences (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
property_id TEXT NOT NULL,
|
||||||
|
preference_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, property_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "Preferences/1.0"
|
||||||
|
|
||||||
|
def _json(self, status: int, payload: dict) -> None:
|
||||||
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def _body(self) -> dict | None:
|
||||||
|
if self.headers.get("Content-Type", "").split(";")[0].strip() != "application/json":
|
||||||
|
self._json(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, {"error": "Send JSON."})
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length < 1 or length > 8192:
|
||||||
|
raise ValueError
|
||||||
|
value = json.loads(self.rfile.read(length))
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError
|
||||||
|
return value
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
self._json(HTTPStatus.BAD_REQUEST, {"error": "Invalid request body."})
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _same_site_write(self) -> bool:
|
||||||
|
# JSON plus this header prevents simple cross-site form submissions.
|
||||||
|
if self.headers.get("X-Requested-With") != "PreferencesSPA":
|
||||||
|
self._json(HTTPStatus.FORBIDDEN, {"error": "Invalid request."})
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _session(self) -> sqlite3.Row | None:
|
||||||
|
cookie = SimpleCookie()
|
||||||
|
try:
|
||||||
|
cookie.load(self.headers.get("Cookie", ""))
|
||||||
|
token = cookie["session"].value
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return None
|
||||||
|
token_hash = hashlib.sha256(token.encode("ascii", "ignore")).hexdigest()
|
||||||
|
with db() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""SELECT u.user_id, u.login_username, u.display_name FROM sessions s
|
||||||
|
JOIN users u ON u.user_id = s.user_id
|
||||||
|
WHERE s.token_hash = ? AND s.expires_at > ?""",
|
||||||
|
(token_hash, now().isoformat()),
|
||||||
|
).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _require_session(self) -> sqlite3.Row | None:
|
||||||
|
session = self._session()
|
||||||
|
if not session:
|
||||||
|
self._json(HTTPStatus.UNAUTHORIZED, {"error": "Please sign in."})
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _serve_static(self, path: str) -> None:
|
||||||
|
files = {
|
||||||
|
"/": ("index.html", "text/html; charset=utf-8"),
|
||||||
|
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
||||||
|
"/styles.css": ("styles.css", "text/css; charset=utf-8"),
|
||||||
|
"/icon.webp": ("icon.webp", "image/webp"),
|
||||||
|
}
|
||||||
|
if path not in files:
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
filename, content_type = files[path]
|
||||||
|
data = (STATIC / filename).read_bytes()
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
if path == "/api/bootstrap":
|
||||||
|
session = self._session()
|
||||||
|
if not session:
|
||||||
|
self._json(HTTPStatus.OK, {"user": None, "properties": PROPERTIES})
|
||||||
|
return
|
||||||
|
user_id = session["user_id"]
|
||||||
|
with db() as connection:
|
||||||
|
own_rows = connection.execute(
|
||||||
|
"SELECT property_id, preference_json, updated_at FROM preferences WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
people_rows = connection.execute(
|
||||||
|
"""SELECT u.user_id, u.login_username, u.display_name,
|
||||||
|
p.property_id, p.preference_json, p.updated_at
|
||||||
|
FROM users u LEFT JOIN preferences p ON p.user_id = u.user_id
|
||||||
|
ORDER BY u.display_name COLLATE NOCASE, u.login_username COLLATE NOCASE"""
|
||||||
|
).fetchall()
|
||||||
|
own = {
|
||||||
|
row["property_id"]: preference_payload(row["property_id"], row["preference_json"], row["updated_at"])
|
||||||
|
for row in own_rows
|
||||||
|
}
|
||||||
|
people: dict[int, dict] = {}
|
||||||
|
for row in people_rows:
|
||||||
|
person = people.setdefault(row["user_id"], {**user_payload(row), "preferences": {}})
|
||||||
|
if row["property_id"]:
|
||||||
|
person["preferences"][row["property_id"]] = preference_payload(
|
||||||
|
row["property_id"], row["preference_json"], row["updated_at"]
|
||||||
|
)
|
||||||
|
self._json(HTTPStatus.OK, {
|
||||||
|
"user": user_payload(session),
|
||||||
|
"properties": PROPERTIES,
|
||||||
|
"preferences": own,
|
||||||
|
"people": list(people.values()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
if path.startswith("/api/"):
|
||||||
|
self._json(HTTPStatus.NOT_FOUND, {"error": "Not found."})
|
||||||
|
return
|
||||||
|
self._serve_static(path)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
if not self._same_site_write():
|
||||||
|
return
|
||||||
|
if path == "/api/username-suggestions":
|
||||||
|
body = self._body()
|
||||||
|
if body is None:
|
||||||
|
return
|
||||||
|
query = body.get("loginUsername")
|
||||||
|
if not isinstance(query, str):
|
||||||
|
self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a username."})
|
||||||
|
return
|
||||||
|
if len(query) > 32:
|
||||||
|
self._json(HTTPStatus.BAD_REQUEST, {"error": "Username must be at most 32 characters."})
|
||||||
|
return
|
||||||
|
with db() as connection:
|
||||||
|
suggestions = suggested_usernames(query, connection)
|
||||||
|
self._json(HTTPStatus.OK, {"suggestions": suggestions})
|
||||||
|
return
|
||||||
|
if path == "/api/login":
|
||||||
|
body = self._body()
|
||||||
|
if body is None:
|
||||||
|
return
|
||||||
|
username = body.get("loginUsername")
|
||||||
|
password = body.get("password")
|
||||||
|
if not isinstance(username, str) or not isinstance(password, str):
|
||||||
|
self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a username and password."})
|
||||||
|
return
|
||||||
|
username = username.strip()
|
||||||
|
if not valid_account_name(username):
|
||||||
|
self._json(HTTPStatus.BAD_REQUEST, {"error": "Username must be 1–32 printable characters."})
|
||||||
|
return
|
||||||
|
if not password_matches(password):
|
||||||
|
self._json(HTTPStatus.UNAUTHORIZED, {"error": "Incorrect password."})
|
||||||
|
return
|
||||||
|
key = login_key_for(username)
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = hashlib.sha256(token.encode("ascii")).hexdigest()
|
||||||
|
expiry = now() + timedelta(days=SESSION_DAYS)
|
||||||
|
with db() as connection:
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT user_id FROM users WHERE login_key = ?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
if not existing and body.get("createNew") is not True:
|
||||||
|
suggestions = suggested_usernames(username, connection)
|
||||||
|
if suggestions:
|
||||||
|
self._json(HTTPStatus.CONFLICT, {
|
||||||
|
"error": "Similar usernames already exist. Choose one or confirm a new account.",
|
||||||
|
"suggestions": suggestions,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO users (login_username, login_key, display_name, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(username, key, username, now().isoformat()),
|
||||||
|
)
|
||||||
|
user = connection.execute(
|
||||||
|
"SELECT user_id, login_username, display_name FROM users WHERE login_key = ?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
|
||||||
|
(token_hash, user["user_id"], expiry.isoformat()),
|
||||||
|
)
|
||||||
|
connection.execute("DELETE FROM sessions WHERE expires_at <= ?", (now().isoformat(),))
|
||||||
|
data = json.dumps({"user": user_payload(user)}).encode("utf-8")
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
secure = "; Secure" if os.environ.get("COOKIE_SECURE") == "1" else ""
|
||||||
|
self.send_header("Set-Cookie", f"session={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age={SESSION_DAYS * 86400}{secure}")
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
return
|
||||||
|
if path == "/api/logout":
|
||||||
|
cookie = SimpleCookie()
|
||||||
|
try:
|
||||||
|
cookie.load(self.headers.get("Cookie", ""))
|
||||||
|
token = cookie["session"].value
|
||||||
|
token_hash = hashlib.sha256(token.encode("ascii", "ignore")).hexdigest()
|
||||||
|
with db() as connection:
|
||||||
|
connection.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,))
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
pass
|
||||||
|
self.send_response(HTTPStatus.NO_CONTENT)
|
||||||
|
self.send_header("Set-Cookie", "session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
self._json(HTTPStatus.NOT_FOUND, {"error": "Not found."})
|
||||||
|
|
||||||
|
def do_DELETE(self) -> None:
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
if not self._same_site_write():
|
||||||
|
return
|
||||||
|
if path != "/api/me":
|
||||||
|
self._json(HTTPStatus.NOT_FOUND, {"error": "Not found."})
|
||||||
|
return
|
||||||
|
session = self._require_session()
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
body = self._body()
|
||||||
|
if body is None:
|
||||||
|
return
|
||||||
|
if body.get("confirmDisplayName") != session["display_name"]:
|
||||||
|
self._json(HTTPStatus.BAD_REQUEST, {"error": "Type your display name exactly to confirm deletion."})
|
||||||
|
return
|
||||||
|
with db() as connection:
|
||||||
|
connection.execute("DELETE FROM users WHERE user_id = ?", (session["user_id"],))
|
||||||
|
self.send_response(HTTPStatus.NO_CONTENT)
|
||||||
|
self.send_header("Set-Cookie", "session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_PUT(self) -> None:
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
if not self._same_site_write():
|
||||||
|
return
|
||||||
|
session = self._require_session()
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
if path == "/api/me":
|
||||||
|
body = self._body()
|
||||||
|
if body is None:
|
||||||
|
return
|
||||||
|
login_username = body.get("loginUsername")
|
||||||
|
display_name = body.get("displayName")
|
||||||
|
if not isinstance(login_username, str) or not isinstance(display_name, str):
|
||||||
|
self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a login username and display name."})
|
||||||
|
return
|
||||||
|
login_username = login_username.strip()
|
||||||
|
display_name = display_name.strip()
|
||||||
|
if not valid_account_name(login_username) or not valid_account_name(display_name):
|
||||||
|
self._json(HTTPStatus.BAD_REQUEST, {"error": "Each name must be 1–32 printable characters."})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with db() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""UPDATE users SET login_username = ?, login_key = ?, display_name = ?
|
||||||
|
WHERE user_id = ?""",
|
||||||
|
(login_username, login_key_for(login_username), display_name, session["user_id"]),
|
||||||
|
)
|
||||||
|
user = connection.execute(
|
||||||
|
"SELECT user_id, login_username, display_name FROM users WHERE user_id = ?",
|
||||||
|
(session["user_id"],),
|
||||||
|
).fetchone()
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
self._json(HTTPStatus.CONFLICT, {"error": "That login username is already in use."})
|
||||||
|
return
|
||||||
|
if not user:
|
||||||
|
self._json(HTTPStatus.UNAUTHORIZED, {"error": "Please sign in."})
|
||||||
|
return
|
||||||
|
self._json(HTTPStatus.OK, {"user": user_payload(user)})
|
||||||
|
return
|
||||||
|
if not path.startswith("/api/preferences/"):
|
||||||
|
self._json(HTTPStatus.NOT_FOUND, {"error": "Not found."})
|
||||||
|
return
|
||||||
|
property_id = path.removeprefix("/api/preferences/")
|
||||||
|
property_ = PROPERTY_BY_ID.get(property_id)
|
||||||
|
if not property_:
|
||||||
|
self._json(HTTPStatus.NOT_FOUND, {"error": "Unknown property."})
|
||||||
|
return
|
||||||
|
body = self._body()
|
||||||
|
if body is None:
|
||||||
|
return
|
||||||
|
categories = property_["categories"]
|
||||||
|
if property_id == "content":
|
||||||
|
value = body.get("choices")
|
||||||
|
if not isinstance(value, dict) or set(value) != set(categories) or any(
|
||||||
|
not isinstance(choice, str) or choice not in CONTENT_CHOICES for choice in value.values()
|
||||||
|
):
|
||||||
|
self._json(HTTPStatus.BAD_REQUEST, {"error": "Choose Yes, Maybe, or No for every content category."})
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
value = body.get("ranking")
|
||||||
|
if (
|
||||||
|
not isinstance(value, list)
|
||||||
|
or any(not isinstance(item, str) for item in value)
|
||||||
|
or len(value) != len(set(value))
|
||||||
|
or not set(value).issubset(categories)
|
||||||
|
):
|
||||||
|
self._json(HTTPStatus.BAD_REQUEST, {"error": "Rank each chosen category once; leave excluded categories out."})
|
||||||
|
return
|
||||||
|
updated_at = now().isoformat()
|
||||||
|
with db() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO preferences (user_id, property_id, preference_json, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, property_id) DO UPDATE SET
|
||||||
|
preference_json = excluded.preference_json, updated_at = excluded.updated_at""",
|
||||||
|
(session["user_id"], property_id, json.dumps(value), updated_at),
|
||||||
|
)
|
||||||
|
self._json(HTTPStatus.OK, preference_payload(property_id, json.dumps(value), updated_at))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
init_db()
|
||||||
|
host = os.environ.get("HOST", "127.0.0.1")
|
||||||
|
port = int(os.environ.get("PORT", "8000"))
|
||||||
|
server = ThreadingHTTPServer((host, port), Handler)
|
||||||
|
print(f"Character preferences running at http://{host}:{port}", flush=True)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,554 @@
|
||||||
|
const state = {
|
||||||
|
data: null,
|
||||||
|
view: "mine",
|
||||||
|
propertyId: "class",
|
||||||
|
drafts: {},
|
||||||
|
sort: { key: "displayName", direction: 1 },
|
||||||
|
groupMode: "rank",
|
||||||
|
search: "",
|
||||||
|
busy: false,
|
||||||
|
notice: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = document.querySelector("#app");
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, (character) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
|
})[character]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(options.body ? { "Content-Type": "application/json", "X-Requested-With": "PreferencesSPA" } : {}),
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const payload = response.status === 204 ? null : await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(payload?.error || "Something went wrong. Please try again.");
|
||||||
|
error.status = response.status;
|
||||||
|
error.suggestions = payload?.suggestions || [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(preserveDrafts = false) {
|
||||||
|
const data = await api("/api/bootstrap");
|
||||||
|
state.data = data;
|
||||||
|
if (!preserveDrafts) {
|
||||||
|
state.drafts = {};
|
||||||
|
for (const property of data.properties) {
|
||||||
|
state.drafts[property.id] = property.id === "content"
|
||||||
|
? { ...(data.preferences?.[property.id]?.choices || {}) }
|
||||||
|
: [...(data.preferences?.[property.id]?.ranking || property.categories)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function property() {
|
||||||
|
return state.data.properties.find((item) => item.id === state.propertyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyTabs() {
|
||||||
|
return `<div class="property-tabs" role="tablist" aria-label="Character property">
|
||||||
|
${state.data.properties.map((item) => `<button type="button" class="property-tab ${item.id === state.propertyId ? "selected" : ""}" role="tab" aria-selected="${item.id === state.propertyId}" data-property="${item.id}">${escapeHtml(item.name)}</button>`).join("")}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shell(content) {
|
||||||
|
const username = state.data.user.displayName;
|
||||||
|
app.innerHTML = `<div class="site-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand"><div class="brand-text"><strong>Bravo Team</strong><small>Forever Interest</small></div></div>
|
||||||
|
<div class="topbar-right"><span class="account">Signed in as <strong>${escapeHtml(username)}</strong></span><button class="text-button" id="logout" type="button">Sign out</button></div>
|
||||||
|
</header>
|
||||||
|
<main class="main">
|
||||||
|
<div class="page-heading"><div><h1>Share your preferences</h1><p>Rank your class and role choices, and tell the group which content you would play. You can update your choices any time.</p></div><div class="heading-sigil circle-icon" aria-hidden="true"><img src="/icon.webp" alt=""></div></div>
|
||||||
|
<nav class="main-nav" aria-label="Main navigation">
|
||||||
|
<button type="button" class="nav-item ${state.view === "mine" ? "active" : ""}" data-view="mine"><span class="nav-icon">◇</span> My priorities</button>
|
||||||
|
<button type="button" class="nav-item ${state.view === "group" ? "active" : ""}" data-view="group"><span class="nav-icon">▦</span> Group preferences <span class="nav-count">${state.data.people.length}</span></button>
|
||||||
|
<button type="button" class="nav-item ${state.view === "account" ? "active" : ""}" data-view="account"><span class="nav-icon">○</span> Account</button>
|
||||||
|
</nav>
|
||||||
|
${state.notice ? `<div class="notice" role="status">${escapeHtml(state.notice)}</div>` : ""}
|
||||||
|
${content}
|
||||||
|
</main>
|
||||||
|
<footer class="footer">Bravo Team <span>·</span> Forever Interest</footer>
|
||||||
|
</div>`;
|
||||||
|
app.querySelector("#logout").addEventListener("click", logout);
|
||||||
|
app.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
state.view = button.dataset.view;
|
||||||
|
state.notice = "";
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
app.querySelectorAll("[data-property]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
state.propertyId = button.dataset.property;
|
||||||
|
state.sort = { key: "displayName", direction: 1 };
|
||||||
|
state.notice = "";
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogin(error = "", success = "") {
|
||||||
|
app.innerHTML = `<div class="login-page">
|
||||||
|
<div class="login-glow"></div>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-emblem circle-icon" aria-hidden="true"><img src="/icon.webp" alt=""></div>
|
||||||
|
<h1>Bravo Team<br><em>Forever Interest</em></h1>
|
||||||
|
<p class="login-intro">Share your preferences for classes, roles, and content; the guild can see everyone’s choices in one place.</p>
|
||||||
|
<form id="login-form">
|
||||||
|
<label for="username">LOGIN USERNAME</label>
|
||||||
|
<input id="username" name="username" type="text" maxlength="32" autocomplete="username" placeholder="Choose a login username" required>
|
||||||
|
<div class="username-suggestions" id="username-suggestions" aria-live="polite"></div>
|
||||||
|
<label for="password">GROUP PASSWORD</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" placeholder="Enter the shared password" required>
|
||||||
|
<div class="form-error" role="alert">${escapeHtml(error)}</div>
|
||||||
|
${success ? `<div class="form-success" role="status">${escapeHtml(success)}</div>` : ""}
|
||||||
|
<button class="primary-button full" type="submit">Enter the roster <span>→</span></button>
|
||||||
|
</form>
|
||||||
|
<div class="login-footnote">Use the same username each time to update your preferences.</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-bottom">Bravo Team <span>·</span> Forever Interest</div>
|
||||||
|
</div>`;
|
||||||
|
const form = app.querySelector("#login-form");
|
||||||
|
const nameField = form.elements.namedItem("username");
|
||||||
|
const passwordField = form.elements.namedItem("password");
|
||||||
|
const suggestionBox = form.querySelector("#username-suggestions");
|
||||||
|
let requestId = 0;
|
||||||
|
let timer;
|
||||||
|
let createNewName = null;
|
||||||
|
|
||||||
|
function showSuggestions(names, confirmNewName = null) {
|
||||||
|
if (!names.length) {
|
||||||
|
suggestionBox.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suggestionBox.innerHTML = `<p>Existing usernames — is one of these yours?</p>
|
||||||
|
<div class="suggestion-list">${names.map((name) => `<button type="button" class="suggestion-item" data-suggested-name="${escapeHtml(name)}">${escapeHtml(name)}</button>`).join("")}</div>
|
||||||
|
${confirmNewName ? `<button type="button" class="create-anyway">Create “${escapeHtml(confirmNewName)}” anyway</button>` : ""}`;
|
||||||
|
suggestionBox.querySelectorAll("[data-suggested-name]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
requestId += 1;
|
||||||
|
clearTimeout(timer);
|
||||||
|
nameField.value = button.dataset.suggestedName;
|
||||||
|
createNewName = null;
|
||||||
|
suggestionBox.innerHTML = "";
|
||||||
|
form.querySelector(".form-error").textContent = "";
|
||||||
|
nameField.focus();
|
||||||
|
}));
|
||||||
|
suggestionBox.querySelector(".create-anyway")?.addEventListener("click", () => {
|
||||||
|
createNewName = nameField.value.trim();
|
||||||
|
form.requestSubmit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSuggestions() {
|
||||||
|
createNewName = null;
|
||||||
|
requestId += 1;
|
||||||
|
clearTimeout(timer);
|
||||||
|
suggestionBox.innerHTML = "";
|
||||||
|
const username = nameField.value.trim();
|
||||||
|
if (username.length < 2) return;
|
||||||
|
const currentRequest = requestId;
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await api("/api/username-suggestions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ loginUsername: username }),
|
||||||
|
});
|
||||||
|
if (currentRequest === requestId) showSuggestions(result.suggestions);
|
||||||
|
} catch {
|
||||||
|
if (currentRequest === requestId) suggestionBox.innerHTML = "";
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
nameField.addEventListener("input", updateSuggestions);
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const button = form.querySelector('button[type="submit"]');
|
||||||
|
button.disabled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
requestId += 1;
|
||||||
|
const username = nameField.value.trim();
|
||||||
|
try {
|
||||||
|
await api("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
loginUsername: username,
|
||||||
|
password: passwordField.value,
|
||||||
|
createNew: createNewName === username,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
form.querySelector(".form-error").textContent = error.message;
|
||||||
|
if (error.status === 409) showSuggestions(error.suggestions, username);
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditor() {
|
||||||
|
const item = property();
|
||||||
|
if (item.id === "content") {
|
||||||
|
renderContentEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const draft = state.drafts[item.id];
|
||||||
|
const saved = state.data.preferences?.[item.id]?.ranking;
|
||||||
|
const excluded = item.categories.filter((category) => !draft.includes(category));
|
||||||
|
const changed = !!saved && (draft.length !== saved.length || draft.some((value, index) => value !== saved[index]));
|
||||||
|
const status = !saved ? "Not submitted" : changed ? "Unsaved changes" : "Saved";
|
||||||
|
const cards = draft.map((category, index) => `<li class="rank-card" data-index="${index}">
|
||||||
|
<span class="rank-number">${String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<span class="category-name">${escapeHtml(category)}</span>
|
||||||
|
<div class="rank-actions">
|
||||||
|
<button type="button" class="move-button" data-move="up" data-index="${index}" aria-label="Move ${escapeHtml(category)} up" ${index === 0 ? "disabled" : ""}>↑</button>
|
||||||
|
<button type="button" class="move-button" data-move="down" data-index="${index}" aria-label="Move ${escapeHtml(category)} down" ${index === draft.length - 1 ? "disabled" : ""}>↓</button>
|
||||||
|
<button type="button" class="drag-handle" data-index="${index}" aria-label="Drag ${escapeHtml(category)} to reorder; use arrow keys when focused" title="Drag to reorder">⠿</button>
|
||||||
|
<button type="button" class="exclude-button" data-exclude="${escapeHtml(category)}" aria-label="Exclude ${escapeHtml(category)} from your ranking" title="No preference for ${escapeHtml(category)}">×</button>
|
||||||
|
</div>
|
||||||
|
</li>`).join("");
|
||||||
|
const excludedCards = excluded.map((category) => `<li class="excluded-card"><span class="category-name">${escapeHtml(category)}</span><span class="excluded-tag">NO PREFERENCE</span><button type="button" class="restore-button" data-restore="${escapeHtml(category)}" aria-label="Restore ${escapeHtml(category)} to your ranking">Restore</button></li>`).join("");
|
||||||
|
shell(`<section class="content-card editor-card">
|
||||||
|
<div class="card-topline"><span class="section-kicker">01 / YOUR RANKING</span><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div>
|
||||||
|
<div class="section-heading"><div><h2>Rank your ${escapeHtml(item.name.toLowerCase())} choices</h2><p>Put your favorites at the top. Select × for any choice you do not want to rank; you can restore it below.</p></div><div class="count-badge">${draft.length} RANKED</div></div>
|
||||||
|
${propertyTabs()}
|
||||||
|
<div class="ranking-layout"><div class="ranking-labels"><span>MOST PREFERRED</span><span>DRAG TO ORDER</span></div><ol class="rank-list" id="rank-list">${cards}</ol>${draft.length ? `<div class="least-label">LOWEST RANKED</div>` : `<p class="rank-empty">No ranked choices. Restore a category below to rank it.</p>`}
|
||||||
|
<div class="excluded-section"><div class="excluded-heading"><strong>No preference</strong><span>${excluded.length} EXCLUDED</span></div><p>Excluded choices have no rank. They appear blank in group tables and sort as the lowest preference.</p>${excluded.length ? `<ul class="excluded-list">${excludedCards}</ul>` : `<p class="excluded-empty">No categories excluded.</p>`}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer"><span>${saved ? `Last saved ${formatDate(state.data.preferences[item.id].updatedAt)}` : "Save to share your ranking with the group."}</span><button type="button" class="primary-button" id="save" ${state.busy ? "disabled" : ""}>${state.busy ? "Saving…" : saved ? "Update ranking" : "Save ranking"} <span>→</span></button></div>
|
||||||
|
</section>`);
|
||||||
|
app.querySelectorAll("[data-move]").forEach((button) => button.addEventListener("click", () => move(Number(button.dataset.index), button.dataset.move === "up" ? -1 : 1)));
|
||||||
|
app.querySelectorAll("[data-exclude]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
state.drafts[item.id] = draft.filter((category) => category !== button.dataset.exclude);
|
||||||
|
state.notice = "";
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
app.querySelectorAll("[data-restore]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
state.drafts[item.id].push(button.dataset.restore);
|
||||||
|
state.notice = "";
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
app.querySelectorAll(".drag-handle").forEach((handle) => {
|
||||||
|
handle.addEventListener("pointerdown", startDrag);
|
||||||
|
handle.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
move(Number(handle.dataset.index), event.key === "ArrowUp" ? -1 : 1, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.querySelector("#save").addEventListener("click", savePreference);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContentEditor() {
|
||||||
|
const item = property();
|
||||||
|
const draft = state.drafts.content;
|
||||||
|
const saved = state.data.preferences?.content?.choices;
|
||||||
|
const answered = item.categories.filter((category) => ["Yes", "Maybe", "No"].includes(draft[category])).length;
|
||||||
|
const changed = !!saved && item.categories.some((category) => draft[category] !== saved[category]);
|
||||||
|
const status = !saved ? "Not submitted" : changed ? "Unsaved changes" : "Saved";
|
||||||
|
const rows = item.categories.map((category) => `<div class="content-row">
|
||||||
|
<span class="content-name">${escapeHtml(category)}</span>
|
||||||
|
<div class="content-options" role="group" aria-label="${escapeHtml(category)}">
|
||||||
|
${["Yes", "Maybe", "No"].map((choice) => `<button type="button" class="content-option ${draft[category] === choice ? `selected ${choice.toLowerCase()}` : ""}" aria-pressed="${draft[category] === choice}" data-content-category="${escapeHtml(category)}" data-content-choice="${choice}">${choice}</button>`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>`).join("");
|
||||||
|
shell(`<section class="content-card editor-card">
|
||||||
|
<div class="card-topline"><span class="section-kicker">01 / YOUR CONTENT</span><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div>
|
||||||
|
<div class="section-heading"><div><h2>Choose your content interests</h2><p>Select Yes, Maybe, or No for each activity.</p></div><div class="count-badge">${answered} / ${item.categories.length} ANSWERED</div></div>
|
||||||
|
${propertyTabs()}
|
||||||
|
<div class="content-list">${rows}</div>
|
||||||
|
<div class="card-footer"><span>${saved ? `Last saved ${formatDate(state.data.preferences.content.updatedAt)}` : `${answered} of ${item.categories.length} answered. Choose one response for each activity.`}</span><button type="button" class="primary-button" id="save" ${state.busy || answered !== item.categories.length ? "disabled" : ""}>${state.busy ? "Saving…" : saved ? "Update content" : "Save content"} <span>→</span></button></div>
|
||||||
|
</section>`);
|
||||||
|
app.querySelectorAll("[data-content-category]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
state.drafts.content[button.dataset.contentCategory] = button.dataset.contentChoice;
|
||||||
|
state.notice = "";
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
app.querySelector("#save").addEventListener("click", savePreference);
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(index, delta, refocus = false) {
|
||||||
|
const list = state.drafts[state.propertyId];
|
||||||
|
const next = index + delta;
|
||||||
|
if (next < 0 || next >= list.length) return;
|
||||||
|
[list[index], list[next]] = [list[next], list[index]];
|
||||||
|
state.notice = "";
|
||||||
|
render();
|
||||||
|
if (refocus) app.querySelector(`.drag-handle[data-index="${next}"]`)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(event) {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const handle = event.currentTarget;
|
||||||
|
const row = handle.closest(".rank-card");
|
||||||
|
const list = app.querySelector("#rank-list");
|
||||||
|
const from = Number(handle.dataset.index);
|
||||||
|
const startY = event.clientY;
|
||||||
|
row.classList.add("dragging");
|
||||||
|
handle.setPointerCapture(event.pointerId);
|
||||||
|
const onMove = (moveEvent) => {
|
||||||
|
const delta = moveEvent.clientY - startY;
|
||||||
|
row.style.transform = `translateY(${delta}px)`;
|
||||||
|
};
|
||||||
|
const finish = (endEvent) => {
|
||||||
|
handle.removeEventListener("pointermove", onMove);
|
||||||
|
handle.removeEventListener("pointerup", finish);
|
||||||
|
handle.removeEventListener("pointercancel", cancel);
|
||||||
|
const others = [...list.children].filter((child) => child !== row);
|
||||||
|
let to = others.findIndex((child) => endEvent.clientY < child.getBoundingClientRect().top + child.getBoundingClientRect().height / 2);
|
||||||
|
if (to < 0) to = others.length;
|
||||||
|
row.style.transform = "";
|
||||||
|
row.classList.remove("dragging");
|
||||||
|
if (to !== from) {
|
||||||
|
const draft = state.drafts[state.propertyId];
|
||||||
|
const [value] = draft.splice(from, 1);
|
||||||
|
draft.splice(to, 0, value);
|
||||||
|
state.notice = "";
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cancel = () => {
|
||||||
|
handle.removeEventListener("pointermove", onMove);
|
||||||
|
handle.removeEventListener("pointerup", finish);
|
||||||
|
handle.removeEventListener("pointercancel", cancel);
|
||||||
|
row.style.transform = "";
|
||||||
|
row.classList.remove("dragging");
|
||||||
|
};
|
||||||
|
handle.addEventListener("pointermove", onMove);
|
||||||
|
handle.addEventListener("pointerup", finish);
|
||||||
|
handle.addEventListener("pointercancel", cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePreference() {
|
||||||
|
if (state.busy) return;
|
||||||
|
const propertyId = state.propertyId;
|
||||||
|
const item = property();
|
||||||
|
if (propertyId === "content" && item.categories.some((category) => !["Yes", "Maybe", "No"].includes(state.drafts.content[category]))) return;
|
||||||
|
state.busy = true;
|
||||||
|
render();
|
||||||
|
try {
|
||||||
|
await api(`/api/preferences/${propertyId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(propertyId === "content"
|
||||||
|
? { choices: state.drafts.content }
|
||||||
|
: { ranking: state.drafts[propertyId] }),
|
||||||
|
});
|
||||||
|
await load(true);
|
||||||
|
state.notice = `${item.name} preferences saved.`;
|
||||||
|
} catch (error) {
|
||||||
|
state.notice = error.message;
|
||||||
|
} finally {
|
||||||
|
state.busy = false;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return "—";
|
||||||
|
return new Date(value).toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAccount() {
|
||||||
|
const { loginUsername, displayName } = state.data.user;
|
||||||
|
shell(`<section class="content-card account-card">
|
||||||
|
<div class="card-topline"><span class="section-kicker">03 / YOUR ACCOUNT</span></div>
|
||||||
|
<div class="section-heading"><div><h2>Account settings</h2><p>Change the name you sign in with or the name shown to the group.</p></div></div>
|
||||||
|
<form class="account-details" id="account-form">
|
||||||
|
<div class="account-fields"><div><label for="login-username">LOGIN USERNAME</label><input id="login-username" name="loginUsername" type="text" maxlength="32" autocomplete="username" value="${escapeHtml(loginUsername)}" required><p>Unique, matched without regard to capitalization.</p></div>
|
||||||
|
<div><label for="display-name">DISPLAY NAME</label><input id="display-name" name="displayName" type="text" maxlength="32" value="${escapeHtml(displayName)}" required><p>Shown in the group tables. Display names may be shared.</p></div></div>
|
||||||
|
<div class="account-save"><div class="form-error" role="alert"></div><button class="primary-button" type="submit">Save account changes <span>→</span></button></div>
|
||||||
|
</form>
|
||||||
|
<div class="danger-zone"><div><span class="danger-kicker">DELETE ACCOUNT</span><h3>Remove yourself from the roster</h3><p>This permanently deletes your account, all saved preferences, and all active sessions. Type <strong>${escapeHtml(displayName)}</strong> exactly to confirm.</p></div>
|
||||||
|
<form id="delete-form"><label for="confirm-display-name">YOUR DISPLAY NAME</label><input id="confirm-display-name" type="text" autocomplete="off" placeholder="Type ${escapeHtml(displayName)}" required><div class="form-error" id="delete-error" role="alert"></div><button class="delete-button" type="submit" disabled>Delete my account</button></form>
|
||||||
|
</div>
|
||||||
|
</section>`);
|
||||||
|
const accountForm = app.querySelector("#account-form");
|
||||||
|
accountForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const button = accountForm.querySelector('button[type="submit"]');
|
||||||
|
button.disabled = true;
|
||||||
|
accountForm.querySelector(".form-error").textContent = "";
|
||||||
|
try {
|
||||||
|
await api("/api/me", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
loginUsername: accountForm.elements.namedItem("loginUsername").value,
|
||||||
|
displayName: accountForm.elements.namedItem("displayName").value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await load(true);
|
||||||
|
state.notice = "Account changes saved.";
|
||||||
|
render();
|
||||||
|
} catch (error) {
|
||||||
|
accountForm.querySelector(".form-error").textContent = error.message;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const form = app.querySelector("#delete-form");
|
||||||
|
const input = form.querySelector("#confirm-display-name");
|
||||||
|
const button = form.querySelector('button[type="submit"]');
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
button.disabled = input.value !== displayName;
|
||||||
|
form.querySelector("#delete-error").textContent = "";
|
||||||
|
});
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (input.value !== displayName) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await api("/api/me", { method: "DELETE", body: JSON.stringify({ confirmDisplayName: input.value }) });
|
||||||
|
state.data = null;
|
||||||
|
state.drafts = {};
|
||||||
|
state.view = "mine";
|
||||||
|
state.notice = "";
|
||||||
|
renderLogin("", "Your account and preferences were deleted.");
|
||||||
|
} catch (error) {
|
||||||
|
form.querySelector("#delete-error").textContent = error.message;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinal(number) {
|
||||||
|
const suffix = number % 100 >= 11 && number % 100 <= 13 ? "th" : ({ 1: "st", 2: "nd", 3: "rd" }[number % 10] || "th");
|
||||||
|
return `${number}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPeople(people, propertyId) {
|
||||||
|
const { key, direction } = state.sort;
|
||||||
|
return [...people].sort((a, b) => {
|
||||||
|
const value = (person) => {
|
||||||
|
if (key === "displayName") return person.displayName;
|
||||||
|
const preference = person.preferences[propertyId];
|
||||||
|
if (key === "updated") return preference?.updatedAt;
|
||||||
|
if (key.startsWith("category:")) {
|
||||||
|
if (propertyId === "content") {
|
||||||
|
return { Yes: 1, Maybe: 2, No: 3 }[preference?.choices[key.slice(9)]] ?? null;
|
||||||
|
}
|
||||||
|
if (!preference) return null;
|
||||||
|
const index = preference?.ranking.indexOf(key.slice(9));
|
||||||
|
return index < 0 ? Number.MAX_SAFE_INTEGER : index + 1;
|
||||||
|
}
|
||||||
|
return preference?.ranking[Number(key.slice(4))];
|
||||||
|
};
|
||||||
|
const aValue = value(a);
|
||||||
|
const bValue = value(b);
|
||||||
|
if (aValue == null && bValue != null) return 1;
|
||||||
|
if (bValue == null && aValue != null) return -1;
|
||||||
|
if (aValue == null && bValue == null && !!a.preferences[propertyId] !== !!b.preferences[propertyId]) {
|
||||||
|
return a.preferences[propertyId] ? -1 : 1;
|
||||||
|
}
|
||||||
|
const comparison = typeof aValue === "number" && typeof bValue === "number"
|
||||||
|
? aValue - bValue
|
||||||
|
: String(aValue || "").localeCompare(String(bValue || ""), undefined, { sensitivity: "base" });
|
||||||
|
return comparison * direction
|
||||||
|
|| a.loginUsername.localeCompare(b.loginUsername, undefined, { sensitivity: "base" })
|
||||||
|
|| a.userId - b.userId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortHeader(key, label) {
|
||||||
|
const active = state.sort.key === key;
|
||||||
|
return `<button type="button" class="sort-button ${active ? "active" : ""}" data-sort="${key}">${label}<span aria-hidden="true">${active ? (state.sort.direction === 1 ? "↑" : "↓") : "↕"}</span></button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroup() {
|
||||||
|
const item = property();
|
||||||
|
const isContent = item.id === "content";
|
||||||
|
const query = state.search.trim().toLocaleLowerCase();
|
||||||
|
const people = sortPeople(state.data.people.filter((person) =>
|
||||||
|
person.displayName.toLocaleLowerCase().includes(query) || person.loginUsername.toLocaleLowerCase().includes(query)
|
||||||
|
), item.id);
|
||||||
|
const submitted = state.data.people.filter((person) => person.preferences[item.id]).length;
|
||||||
|
const columns = !isContent && state.groupMode === "rank"
|
||||||
|
? item.categories.map((_, index) => ({ key: `rank${index}`, label: ordinal(index + 1), index }))
|
||||||
|
: item.categories.map((category) => ({ key: `category:${category}`, label: category, category }));
|
||||||
|
const headers = columns.map((column) => `<th scope="col" aria-sort="${state.sort.key === column.key ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader(column.key, escapeHtml(column.label))}</th>`).join("");
|
||||||
|
const rows = people.map((person) => {
|
||||||
|
const preference = person.preferences[item.id];
|
||||||
|
const ranks = columns.map((column) => {
|
||||||
|
if (!preference) return `<td><span class="table-dash">—</span></td>`;
|
||||||
|
if (isContent) {
|
||||||
|
const choice = preference.choices[column.category];
|
||||||
|
return `<td><span class="content-table-choice ${choice.toLowerCase()}">${choice}</span></td>`;
|
||||||
|
}
|
||||||
|
const rank = column.category ? preference.ranking.indexOf(column.category) + 1 : column.index + 1;
|
||||||
|
if (column.category && rank === 0) return `<td class="excluded-cell" aria-label="No preference"></td>`;
|
||||||
|
if (!column.category && !preference.ranking[column.index]) return `<td class="excluded-cell" aria-label="No ranked choice"></td>`;
|
||||||
|
const value = column.category ? ordinal(rank) : preference.ranking[column.index];
|
||||||
|
return `<td><span class="table-choice ${rank === 1 ? "first" : ""}">${escapeHtml(value)}</span></td>`;
|
||||||
|
}).join("");
|
||||||
|
return `<tr><th scope="row"><div class="person-cell"><span class="person-name">${escapeHtml(person.displayName)}${person.userId === state.data.user.userId ? `<small>YOU</small>` : ""}</span>${person.loginUsername !== person.displayName ? `<span class="person-login">@${escapeHtml(person.loginUsername)}</span>` : ""}</div></th>${ranks}<td class="date-cell">${preference ? formatDate(preference.updatedAt) : `<span class="pending">Pending</span>`}</td></tr>`;
|
||||||
|
}).join("");
|
||||||
|
shell(`<section class="content-card group-card">
|
||||||
|
<div class="card-topline"><span class="section-kicker">02 / THE GROUP</span><span class="group-total">${submitted} OF ${state.data.people.length} ${isContent ? "RESPONDED" : "RANKED"}</span></div>
|
||||||
|
<div class="section-heading"><div><h2>Everyone’s preferences</h2><p>Explore the group’s choices. Select any column heading to sort.</p></div></div>
|
||||||
|
${propertyTabs()}
|
||||||
|
<div class="table-tools"><div class="table-description"><strong>${escapeHtml(item.name)} ${isContent ? "preferences" : "rankings"}</strong><span>${people.length} ${people.length === 1 ? "person" : "people"} shown</span></div><div class="table-actions">${isContent ? "" : `<div class="view-toggle" role="group" aria-label="Table columns"><button type="button" data-group-mode="rank" aria-pressed="${state.groupMode === "rank"}" class="${state.groupMode === "rank" ? "selected" : ""}">By rank</button><button type="button" data-group-mode="category" aria-pressed="${state.groupMode === "category"}" class="${state.groupMode === "category" ? "selected" : ""}">By category</button></div>`}<label class="search-box"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="Find a person" value="${escapeHtml(state.search)}" aria-label="Find a person"></label><button type="button" class="secondary-button" id="refresh">Refresh</button></div></div>
|
||||||
|
<div class="table-scroll"><table><thead><tr><th scope="col" aria-sort="${state.sort.key === "displayName" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("displayName", "PERSON")}</th>${headers}<th scope="col" aria-sort="${state.sort.key === "updated" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("updated", "UPDATED")}</th></tr></thead><tbody>${rows || `<tr><td class="empty-table" colspan="${item.categories.length + 2}">No people match that search.</td></tr>`}</tbody></table></div>
|
||||||
|
<div class="table-footer">${isContent ? "Only saved preferences appear here. A dash means the person has not completed this property yet." : "Only saved preferences appear here. A blank cell means no preference was ranked; a dash means the person has not submitted this property."}</div>
|
||||||
|
</section>`);
|
||||||
|
app.querySelectorAll("[data-group-mode]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
state.groupMode = button.dataset.groupMode;
|
||||||
|
state.sort = { key: "displayName", direction: 1 };
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
app.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
const key = button.dataset.sort;
|
||||||
|
state.sort = { key, direction: state.sort.key === key ? -state.sort.direction : 1 };
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
app.querySelector("#search").addEventListener("input", (event) => {
|
||||||
|
const position = event.target.selectionStart;
|
||||||
|
state.search = event.target.value;
|
||||||
|
render();
|
||||||
|
const input = app.querySelector("#search");
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(position, position);
|
||||||
|
});
|
||||||
|
app.querySelector("#refresh").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await load(true);
|
||||||
|
state.notice = "Group preferences refreshed.";
|
||||||
|
} catch (error) {
|
||||||
|
state.notice = error.message;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await api("/api/logout", { method: "POST", body: "{}" });
|
||||||
|
} finally {
|
||||||
|
state.data = null;
|
||||||
|
state.drafts = {};
|
||||||
|
state.notice = "";
|
||||||
|
renderLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!state.data?.user) {
|
||||||
|
renderLogin();
|
||||||
|
} else if (state.view === "group") {
|
||||||
|
renderGroup();
|
||||||
|
} else if (state.view === "account") {
|
||||||
|
renderAccount();
|
||||||
|
} else {
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load().catch((error) => {
|
||||||
|
app.innerHTML = `<div class="fatal"><h1>Could not load preferences</h1><p>${escapeHtml(error.message)}</p><button onclick="location.reload()">Try again</button></div>`;
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#0c1624">
|
||||||
|
<title>WoW Forever · Character Preferences</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"><div class="loading">Loading preferences…</div></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: 'DM Sans', Arial, sans-serif;
|
||||||
|
color: #e8e9e9;
|
||||||
|
background: #0c1624;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-width: 320px; }
|
||||||
|
button, input { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:disabled { cursor: not-allowed; opacity: .55; }
|
||||||
|
button:focus-visible, input:focus-visible { outline: 2px solid #e6bd69; outline-offset: 3px; }
|
||||||
|
|
||||||
|
.loading, .fatal { min-height: 100vh; display: grid; place-content: center; text-align: center; }
|
||||||
|
.loading, .fatal p, .fatal button { font-size: 24px; }
|
||||||
|
.fatal button { justify-self: center; padding: 10px 20px; }
|
||||||
|
.site-shell { min-height: 100vh; background: radial-gradient(circle at 75% -10%, rgba(52, 81, 102, .16), transparent 31%), #0c1624; }
|
||||||
|
.topbar { min-height: 76px; border-bottom: 1px solid #263543; display: flex; justify-content: space-between; align-items: center; padding: 0 max(5vw, calc((100vw - 1200px) / 2)); background: rgba(12, 22, 36, .9); }
|
||||||
|
.brand { display: flex; align-items: center; }
|
||||||
|
.brand-text { display: grid; gap: 2px; }
|
||||||
|
.brand-text strong { font-family: Georgia, serif; color: #f4e3b8; font-size: 16px; letter-spacing: .13em; }
|
||||||
|
.brand-text small { color: #87919c; font-size: 8px; letter-spacing: .25em; font-weight: 700; }
|
||||||
|
.topbar-right { display: flex; align-items: center; gap: 26px; }
|
||||||
|
.account { color: #9daab5; font-size: 12px; }
|
||||||
|
.account strong { color: #e6e5de; font-weight: 600; }
|
||||||
|
.text-button { color: #dcbf83; border: 0; background: transparent; font-size: 12px; padding: 9px 0; }
|
||||||
|
.text-button:hover { color: #fff0c7; }
|
||||||
|
.main { width: min(1200px, 90vw); margin: 0 auto; padding: 61px 0 75px; }
|
||||||
|
.eyebrow { display: flex; align-items: center; gap: 10px; color: #cda763; font-size: 15px; font-weight: 700; letter-spacing: .27em; }
|
||||||
|
.eyebrow-line { width: 21px; height: 1px; background: #cda763; }
|
||||||
|
.page-heading { display: flex; justify-content: space-between; align-items: center; margin: 16px 0 34px; }
|
||||||
|
h1, h2, h3, p { margin-top: 0; }
|
||||||
|
.page-heading h1 { font: 600 clamp(30px, 3.2vw, 43px)/1.2 'Cinzel', Georgia, serif; letter-spacing: -.035em; color: #f2efe4; margin-bottom: 12px; }
|
||||||
|
.page-heading p { color: #9da9b4; font-size: 21px; line-height: 1.55; max-width: 850px; margin: 0; }
|
||||||
|
.circle-icon { display: grid; place-items: center; overflow: hidden; border: 1px solid #8c744d; border-radius: 50%; background: #e8e5dd; box-shadow: 0 0 0 4px #b9954517; }
|
||||||
|
.circle-icon img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.heading-sigil { width: 74px; height: 74px; flex: 0 0 74px; }
|
||||||
|
.main-nav { display: flex; gap: 5px; border-bottom: 1px solid #33404b; margin-bottom: 24px; }
|
||||||
|
.nav-item { border: 0; border-bottom: 2px solid transparent; background: none; padding: 17px 22px 18px; margin-bottom: -1px; color: #a4afb9; font-weight: 600; font-size: 19px; }
|
||||||
|
.nav-item.active { color: #f4e3bd; border-color: #d6aa59; }
|
||||||
|
.nav-item:hover { color: #fff; }
|
||||||
|
.nav-icon { margin-right: 7px; font-size: 18px; vertical-align: -1px; }
|
||||||
|
.nav-count { color: #d9bd85; margin-left: 8px; border: 1px solid #4d4b40; padding: 2px 7px; border-radius: 10px; font-size: 15px; }
|
||||||
|
.notice { border: 1px solid #856c3a; background: #2a2a25; color: #f2d598; padding: 12px 16px; border-radius: 5px; font-size: 19px; margin-bottom: 16px; }
|
||||||
|
.content-card { background: #142231; border: 1px solid #31404d; border-radius: 7px; box-shadow: 0 20px 75px rgba(0,0,0,.16); }
|
||||||
|
.card-topline { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #2c3b48; min-height: 53px; padding: 0 31px; }
|
||||||
|
.section-kicker { color: #c9a866; font-size: 15px; letter-spacing: .2em; font-weight: 700; }
|
||||||
|
.status, .group-total { color: #9eabb5; font-size: 16px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; }
|
||||||
|
.status { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #77818a; }
|
||||||
|
.status.saved .status-dot { background: #72b58d; }
|
||||||
|
.status.changed .status-dot { background: #e6bd69; }
|
||||||
|
.section-heading { padding: 30px 31px 20px; display: flex; justify-content: space-between; align-items: center; gap: 15px; }
|
||||||
|
.section-heading h2 { font: 600 25px/1.3 'Cinzel', Georgia, serif; color: #f1ebdd; letter-spacing: -.015em; margin-bottom: 7px; }
|
||||||
|
.section-heading p { color: #9facb7; font-size: 19px; line-height: 1.55; margin: 0; }
|
||||||
|
.count-badge { white-space: nowrap; border: 1px solid #46505a; color: #bfaa7d; padding: 8px 11px; font-size: 15px; font-weight: 700; letter-spacing: .12em; }
|
||||||
|
.property-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #35434e; padding: 0 31px; gap: 25px; }
|
||||||
|
.property-tab { padding: 13px 8px 14px; border: 0; border-bottom: 2px solid transparent; background: none; color: #9eacb6; font-size: 19px; font-weight: 700; margin-bottom: -1px; white-space: nowrap; }
|
||||||
|
.property-tab.selected { color: #f0d6a0; border-color: #d8ac61; }
|
||||||
|
.property-tab:hover { color: #fff; }
|
||||||
|
.ranking-layout { padding: 28px 31px 30px; }
|
||||||
|
.ranking-labels { display: flex; justify-content: space-between; padding: 0 1px 9px; color: #a9b6c0; font-size: 14px; letter-spacing: .19em; font-weight: 700; }
|
||||||
|
.rank-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||||||
|
.rank-card { min-height: 68px; background: #1c2d3d; border: 1px solid #3a4b58; border-radius: 4px; display: flex; align-items: center; padding: 0 13px 0 18px; position: relative; transition: border-color .15s, background .15s; }
|
||||||
|
.rank-card:first-child { border-color: #a9834c; background: #29323a; }
|
||||||
|
.rank-card:hover { border-color: #a88d5d; }
|
||||||
|
.rank-card.dragging { z-index: 3; background: #2a3d4c; border-color: #e3bb70; box-shadow: 0 12px 28px #060b12a6; transition: none; }
|
||||||
|
.rank-number { font: 600 19px Georgia, serif; color: #9da6ae; width: 44px; }
|
||||||
|
.rank-card:first-child .rank-number { color: #e5bd72; }
|
||||||
|
.category-name { font-size: 21px; font-weight: 600; color: #e8e9e7; flex: 1; }
|
||||||
|
.rank-actions { display: flex; gap: 1px; align-items: center; }
|
||||||
|
.move-button, .drag-handle { display: grid; place-items: center; border: 0; background: transparent; color: #a5b3bc; height: 40px; width: 38px; padding: 0; font-size: 24px; }
|
||||||
|
.move-button:hover, .drag-handle:hover { color: #f5d28e; }
|
||||||
|
.drag-handle { font-size: 30px; cursor: grab; touch-action: none; }
|
||||||
|
.drag-handle:active { cursor: grabbing; }
|
||||||
|
.exclude-button { display: grid; place-items: center; flex: 0 0 40px; height: 40px; margin-left: 5px; border: 1px solid #805c5b; border-radius: 3px; background: #342c33; color: #f0b5a9; font-size: 27px; line-height: 1; }
|
||||||
|
.exclude-button:hover { background: #58383b; color: #ffe1d8; }
|
||||||
|
.least-label { color: #8896a1; font-size: 14px; letter-spacing: .18em; font-weight: 700; padding-top: 13px; }
|
||||||
|
.rank-empty { color: #a5b2bc; font-size: 18px; margin: 8px 0 0; }
|
||||||
|
.excluded-section { margin-top: 27px; border-top: 1px solid #3b4852; padding-top: 22px; }
|
||||||
|
.excluded-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.excluded-heading strong { color: #e7d1c9; font: 600 22px Georgia, serif; }
|
||||||
|
.excluded-heading span { color: #c3948a; font-size: 14px; font-weight: 700; letter-spacing: .13em; }
|
||||||
|
.excluded-section > p { margin: 8px 0 17px; color: #aab6be; font-size: 17px; line-height: 1.5; }
|
||||||
|
.excluded-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||||||
|
.excluded-card { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-height: 61px; padding: 9px 13px 9px 18px; border: 1px dashed #675452; border-radius: 4px; background: #252b34; }
|
||||||
|
.excluded-card .category-name { color: #b7afb0; }
|
||||||
|
.excluded-tag { color: #c59b90; font-size: 13px; font-weight: 700; letter-spacing: .1em; }
|
||||||
|
.restore-button { border: 1px solid #75654f; border-radius: 3px; background: #29343d; color: #ead0a0; padding: 7px 10px; font-size: 16px; font-weight: 700; }
|
||||||
|
.restore-button:hover { background: #3a4650; }
|
||||||
|
.excluded-section .excluded-empty { margin: 0; color: #8797a2; font-size: 16px; }
|
||||||
|
.content-list { display: grid; gap: 9px; padding: 27px 31px 30px; }
|
||||||
|
.content-row { min-height: 64px; background: #1c2d3d; border: 1px solid #3a4b58; border-radius: 4px; padding: 10px 15px 10px 19px; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||||
|
.content-name { color: #e8e9e7; font-size: 21px; font-weight: 600; }
|
||||||
|
.content-options { display: flex; flex: 0 0 auto; gap: 5px; }
|
||||||
|
.content-option { min-width: 80px; min-height: 44px; border: 1px solid #53616d; border-radius: 3px; background: #172634; color: #aebbc5; font-size: 18px; font-weight: 700; }
|
||||||
|
.content-option:hover { border-color: #b99b68; color: #fff; }
|
||||||
|
.content-option.selected.yes { border-color: #82b99b; background: #28493e; color: #cdf2d8; }
|
||||||
|
.content-option.selected.maybe { border-color: #c5a86b; background: #4a4030; color: #f1deb2; }
|
||||||
|
.content-option.selected.no { border-color: #a9807c; background: #493736; color: #f2cfca; }
|
||||||
|
.card-footer { min-height: 84px; border-top: 1px solid #34424d; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 31px; color: #9cabb6; font-size: 16px; }
|
||||||
|
.primary-button { display: inline-flex; align-items: center; justify-content: center; gap: 30px; background: linear-gradient(#eac57e, #c69951); border: 1px solid #f2d695; border-radius: 3px; color: #1a2228; font-size: 18px; font-weight: 700; padding: 12px 17px; min-height: 48px; box-shadow: inset 0 1px #fff5bc55; }
|
||||||
|
.primary-button:hover:not(:disabled) { filter: brightness(1.09); }
|
||||||
|
.primary-button span { font-size: 17px; line-height: 1; }
|
||||||
|
.footer { color: #627480; text-align: center; font-size: 15px; letter-spacing: .08em; padding: 23px; }
|
||||||
|
.footer span { color: #b58d4e; margin: 0 5px; }
|
||||||
|
|
||||||
|
.group-card .section-heading { padding-bottom: 27px; }
|
||||||
|
.table-tools { display: flex; justify-content: space-between; align-items: center; gap: 15px; padding: 22px 31px 19px; }
|
||||||
|
.table-description { display: flex; gap: 13px; align-items: center; }
|
||||||
|
.table-description strong { color: #eadbbd; font: 600 24px Georgia, serif; }
|
||||||
|
.table-description span { color: #95a5b0; font-size: 16px; }
|
||||||
|
.table-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.view-toggle { display: flex; border: 1px solid #52606a; background: #1b2a37; height: 44px; padding: 2px; border-radius: 3px; white-space: nowrap; }
|
||||||
|
.view-toggle button { border: 0; background: transparent; color: #a8b6bf; font-size: 16px; font-weight: 700; padding: 0 10px; border-radius: 2px; }
|
||||||
|
.view-toggle button.selected { background: #43515a; color: #f1d393; }
|
||||||
|
.view-toggle button:hover:not(.selected) { color: #fff; }
|
||||||
|
.search-box { display: flex; align-items: center; height: 44px; border: 1px solid #465460; background: #1a2a39; padding: 0 10px; color: #a7b5bf; }
|
||||||
|
.search-box span { font-size: 23px; line-height: 1; }
|
||||||
|
.search-box input { border: none; outline: none; background: transparent; color: #f0f1ef; width: 170px; font-size: 18px; }
|
||||||
|
.search-box input::placeholder { color: #8797a4; }
|
||||||
|
.secondary-button { color: #e4c790; border: 1px solid #796342; background: #26313a; padding: 8px 12px; height: 44px; font-size: 16px; font-weight: 700; border-radius: 2px; }
|
||||||
|
.secondary-button:hover { background: #34424b; }
|
||||||
|
.table-scroll { overflow-x: auto; border-top: 1px solid #34434f; }
|
||||||
|
table { width: 100%; border-collapse: collapse; white-space: nowrap; text-align: left; }
|
||||||
|
thead { background: #1a2b3a; }
|
||||||
|
th, td { border-bottom: 1px solid #2e3e4c; padding: 14px 13px; font-size: 18px; }
|
||||||
|
thead th { padding: 0; }
|
||||||
|
thead th:first-child, tbody th { position: sticky; left: 0; z-index: 1; min-width: 170px; }
|
||||||
|
thead th:first-child { background: #1a2b3a; z-index: 2; }
|
||||||
|
tbody th { background: #142231; font-weight: 600; }
|
||||||
|
tbody tr:hover td, tbody tr:hover th { background: #203242; }
|
||||||
|
.sort-button { width: 100%; min-height: 52px; padding: 8px 13px; display: flex; align-items: center; justify-content: space-between; gap: 14px; text-align: left; border: 0; background: none; color: #9eacb7; font-size: 15px; font-weight: 700; letter-spacing: .09em; }
|
||||||
|
.sort-button:hover, .sort-button.active { color: #e8c781; }
|
||||||
|
.sort-button span { font-size: 14px; color: #bc9d65; }
|
||||||
|
.person-cell { display: grid; gap: 3px; }
|
||||||
|
.person-name { color: #e8e9e7; }
|
||||||
|
.person-login { color: #91a2af; font-size: 15px; font-weight: 400; }
|
||||||
|
.person-cell small { display: inline-block; font-size: 12px; letter-spacing: .08em; color: #d8b879; margin-left: 7px; }
|
||||||
|
.table-choice { color: #cbd3d7; }
|
||||||
|
.table-choice.first { color: #f0cf8d; font-weight: 700; }
|
||||||
|
.content-table-choice { display: inline-block; min-width: 68px; text-align: center; border-radius: 3px; padding: 5px 8px; font-size: 16px; font-weight: 700; }
|
||||||
|
.content-table-choice.yes { color: #cdf2d8; background: #28493e; }
|
||||||
|
.content-table-choice.maybe { color: #f1deb2; background: #4a4030; }
|
||||||
|
.content-table-choice.no { color: #f2cfca; background: #493736; }
|
||||||
|
.table-dash { color: #647482; }
|
||||||
|
.date-cell { color: #9aaab5; }
|
||||||
|
.pending { color: #83929d; font-style: italic; }
|
||||||
|
.empty-table { text-align: center; padding: 35px; color: #a2b0b9; }
|
||||||
|
.table-footer { padding: 17px 31px; color: #8d9da9; font-size: 16px; }
|
||||||
|
|
||||||
|
.account-details { margin: 0 31px 28px; padding: 22px 20px; background: #1b2c3a; border: 1px solid #394957; display: grid; gap: 20px; }
|
||||||
|
.account-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||||
|
.account-details label, .danger-kicker { display: block; font-size: 15px; letter-spacing: .16em; font-weight: 700; color: #c9a866; margin-bottom: 8px; }
|
||||||
|
.account-details input { width: 100%; height: 50px; border: 1px solid #52606a; background: #122230; color: #f2f0e8; padding: 0 13px; outline: none; border-radius: 2px; font-size: 19px; }
|
||||||
|
.account-details input:focus { border-color: #d9b674; }
|
||||||
|
.account-details p { margin: 8px 0 0; color: #9facb7; font-size: 17px; line-height: 1.45; }
|
||||||
|
.account-save { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
||||||
|
.account-save .form-error { flex: 1; min-height: 0; padding: 0; }
|
||||||
|
.danger-zone { border-top: 1px solid #34424d; padding: 30px 31px 35px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(230px, 320px); gap: 34px; }
|
||||||
|
.danger-kicker { color: #e49e8b; }
|
||||||
|
.danger-zone h3 { font: 600 19px/1.35 'Cinzel', Georgia, serif; color: #f1e5dc; margin: 10px 0; }
|
||||||
|
.danger-zone p { color: #adb8bf; font-size: 18px; line-height: 1.55; margin: 0; max-width: 490px; }
|
||||||
|
.danger-zone p strong { color: #f1e5dc; }
|
||||||
|
.danger-zone label { display: block; color: #d5b6ab; font-size: 15px; font-weight: 700; letter-spacing: .1em; margin-bottom: 8px; }
|
||||||
|
.danger-zone input { width: 100%; height: 48px; border: 1px solid #725b59; background: #192834; color: #f2eee9; padding: 0 12px; border-radius: 2px; outline: none; font-size: 19px; }
|
||||||
|
.danger-zone input:focus { border-color: #e6a693; }
|
||||||
|
.delete-button { background: #673e3b; color: #ffe2da; border: 1px solid #b46e65; border-radius: 3px; padding: 10px 15px; font-size: 18px; font-weight: 700; }
|
||||||
|
.delete-button:hover:not(:disabled) { background: #854c46; }
|
||||||
|
|
||||||
|
.login-page { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; position: relative; isolation: isolate; overflow-x: hidden; background: radial-gradient(ellipse at 50% 39%, #234359 0, #102638 38%, #0b1726 72%); padding: 35px 16px; }
|
||||||
|
.login-page::before { content: ''; position: absolute; inset: 0; z-index: -2; opacity: .28; background: repeating-linear-gradient(125deg, transparent 0 95px, #7c9bae10 96px 97px); }
|
||||||
|
.login-glow { position: absolute; z-index: -1; width: 640px; height: 640px; border: 1px solid #b9954533; border-radius: 50%; box-shadow: 0 0 0 85px #ad8b3410, 0 0 0 170px #ad8b3408; top: 50%; left: 50%; transform: translate(-50%, -50%); }
|
||||||
|
.login-card { width: min(460px, 100%); border: 1px solid #6d624f; background: linear-gradient(145deg, #1b3040ee, #111e2bee); box-shadow: 0 30px 100px #0009, inset 0 1px #ccad6a33; padding: 34px 48px 38px; text-align: center; }
|
||||||
|
.login-emblem { width: 80px; height: 80px; margin: 0 auto 13px; }
|
||||||
|
.eyebrow.center { display: block; color: #d1b173; }
|
||||||
|
.login-card h1 { color: #f4eddb; font: 600 34px/1.3 'Cinzel', Georgia, serif; margin: 18px 0; }
|
||||||
|
.login-card h1 em { color: #eac686; font-style: normal; }
|
||||||
|
.login-intro { color: #afbdc6; font-size: 18px; line-height: 1.55; max-width: 360px; margin: 0 auto 29px; }
|
||||||
|
.login-card form { text-align: left; }
|
||||||
|
.login-card label { display: block; color: #d3bd90; font-size: 15px; font-weight: 700; letter-spacing: .13em; margin: 17px 0 8px; }
|
||||||
|
.login-card input { width: 100%; border: 1px solid #52606a; background: #122230; color: #f2f0e8; height: 50px; padding: 0 13px; outline: none; border-radius: 2px; font-size: 19px; }
|
||||||
|
.login-card input:focus { border-color: #d9b674; }
|
||||||
|
.login-card input::placeholder { color: #748a99; }
|
||||||
|
.username-suggestions { text-align: left; }
|
||||||
|
.username-suggestions:empty { display: none; }
|
||||||
|
.username-suggestions p { color: #d9c595; font-size: 16px; line-height: 1.4; margin: 13px 0 7px; }
|
||||||
|
.suggestion-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.suggestion-item { border: 1px solid #776b55; color: #e8d9ae; background: #283744; border-radius: 3px; padding: 6px 9px; font-size: 16px; }
|
||||||
|
.suggestion-item:hover { background: #384958; }
|
||||||
|
.create-anyway { display: block; border: 0; background: transparent; color: #dda78b; font-size: 16px; text-decoration: underline; padding: 9px 0 0; }
|
||||||
|
.form-success { color: #a3dfba; font-size: 16px; padding-bottom: 10px; }
|
||||||
|
.form-error { min-height: 30px; padding-top: 8px; color: #ffa795; font-size: 16px; }
|
||||||
|
.primary-button.full { width: 100%; justify-content: space-between; min-height: 52px; }
|
||||||
|
.login-footnote { border-top: 1px solid #3d4b53; margin-top: 26px; padding-top: 20px; color: #8fa0ac; font-size: 16px; }
|
||||||
|
.login-bottom { color: #738693; font-size: 14px; letter-spacing: .17em; margin-top: 25px; }
|
||||||
|
.login-bottom span { color: #d4ad64; margin: 0 6px; }
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.heading-sigil { display: none; }
|
||||||
|
.main { width: min(100% - 32px, 700px); padding-top: 40px; }
|
||||||
|
.danger-zone { grid-template-columns: 1fr; gap: 22px; }
|
||||||
|
.table-tools { align-items: stretch; flex-direction: column; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.topbar { padding: 0 16px; min-height: 68px; }
|
||||||
|
.brand-text strong { font-size: 12px; }
|
||||||
|
.brand-text small { font-size: 6px; }
|
||||||
|
.topbar-right { gap: 9px; }
|
||||||
|
.account { display: none; }
|
||||||
|
.page-heading h1 { font-size: 27px; }
|
||||||
|
.page-heading p { font-size: 18px; }
|
||||||
|
.nav-item { padding: 13px 10px; font-size: 16px; }
|
||||||
|
.main-nav { overflow-x: auto; }
|
||||||
|
.nav-item { white-space: nowrap; }
|
||||||
|
.nav-icon { display: none; }
|
||||||
|
.nav-count { margin-left: 2px; }
|
||||||
|
.card-topline, .section-heading, .ranking-layout, .content-list, .card-footer, .table-tools, .table-footer { padding-left: 17px; padding-right: 17px; }
|
||||||
|
.property-tabs { padding: 0 17px; }
|
||||||
|
.section-heading h2 { font-size: 19px; }
|
||||||
|
.section-heading p { font-size: 16px; }
|
||||||
|
.count-badge { display: none; }
|
||||||
|
.card-footer { flex-direction: column; align-items: stretch; }
|
||||||
|
.card-footer .primary-button { align-self: stretch; }
|
||||||
|
.table-tools { align-items: stretch; flex-direction: column; }
|
||||||
|
.table-actions { width: 100%; }
|
||||||
|
.table-actions { flex-wrap: wrap; }
|
||||||
|
.content-row { align-items: stretch; flex-direction: column; gap: 10px; }
|
||||||
|
.content-options { width: 100%; }
|
||||||
|
.content-option { flex: 1; min-width: 0; }
|
||||||
|
.rank-card { padding-left: 10px; padding-right: 8px; }
|
||||||
|
.rank-number { width: 34px; }
|
||||||
|
.rank-actions { display: grid; grid-template-columns: repeat(2, 34px); gap: 2px; }
|
||||||
|
.move-button, .drag-handle, .exclude-button { width: 34px; height: 34px; }
|
||||||
|
.exclude-button { margin-left: 0; }
|
||||||
|
.view-toggle { width: 100%; }
|
||||||
|
.view-toggle button { flex: 1; }
|
||||||
|
.search-box { flex: 1; }
|
||||||
|
.search-box input { width: 100%; }
|
||||||
|
.account-details { margin-left: 17px; margin-right: 17px; }
|
||||||
|
.account-fields { grid-template-columns: 1fr; }
|
||||||
|
.account-save { flex-direction: column; align-items: stretch; }
|
||||||
|
.danger-zone { padding-left: 17px; padding-right: 17px; }
|
||||||
|
.login-card { padding: 29px 25px; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
import server
|
||||||
|
|
||||||
|
|
||||||
|
class ServerTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
cls.original_db_path = server.DB_PATH
|
||||||
|
server.DB_PATH = Path(cls.tempdir.name) / "preferences.sqlite3"
|
||||||
|
server.init_db()
|
||||||
|
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
|
||||||
|
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||||
|
cls.thread.start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.httpd.shutdown()
|
||||||
|
cls.httpd.server_close()
|
||||||
|
cls.thread.join()
|
||||||
|
server.DB_PATH = cls.original_db_path
|
||||||
|
cls.tempdir.cleanup()
|
||||||
|
|
||||||
|
def request(self, method, path, body=None, cookie=None):
|
||||||
|
connection = http.client.HTTPConnection("127.0.0.1", self.httpd.server_port)
|
||||||
|
headers = {}
|
||||||
|
if body is not None:
|
||||||
|
headers.update({"Content-Type": "application/json", "X-Requested-With": "PreferencesSPA"})
|
||||||
|
if cookie:
|
||||||
|
headers["Cookie"] = cookie
|
||||||
|
connection.request(method, path, body=json.dumps(body) if body is not None else None, headers=headers)
|
||||||
|
response = connection.getresponse()
|
||||||
|
payload = response.read()
|
||||||
|
result = response.status, json.loads(payload) if payload else None, response.getheader("Set-Cookie")
|
||||||
|
connection.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test_schema_uses_stable_user_ids(self):
|
||||||
|
with server.db() as connection:
|
||||||
|
columns = {
|
||||||
|
table: [row["name"] for row in connection.execute(f"PRAGMA table_info({table})")]
|
||||||
|
for table in ("users", "sessions", "preferences")
|
||||||
|
}
|
||||||
|
foreign_keys = {
|
||||||
|
table: [(row["from"], row["to"]) for row in connection.execute(f"PRAGMA foreign_key_list({table})")]
|
||||||
|
for table in ("sessions", "preferences")
|
||||||
|
}
|
||||||
|
self.assertEqual(columns["users"], ["user_id", "login_username", "login_key", "display_name", "created_at"])
|
||||||
|
self.assertEqual(columns["sessions"], ["token_hash", "user_id", "expires_at"])
|
||||||
|
self.assertEqual(columns["preferences"], ["user_id", "property_id", "preference_json", "updated_at"])
|
||||||
|
self.assertEqual(foreign_keys["sessions"], [("user_id", "user_id")])
|
||||||
|
self.assertEqual(foreign_keys["preferences"], [("user_id", "user_id")])
|
||||||
|
|
||||||
|
def test_old_schema_requires_a_fresh_database(self):
|
||||||
|
old_path = Path(self.tempdir.name) / "old.sqlite3"
|
||||||
|
current_db_path = server.DB_PATH
|
||||||
|
connection = sqlite3.connect(old_path)
|
||||||
|
try:
|
||||||
|
connection.execute("CREATE TABLE users (username_key TEXT PRIMARY KEY, username TEXT, created_at TEXT)")
|
||||||
|
connection.execute("INSERT INTO users VALUES ('aly', 'Aly', 'today')")
|
||||||
|
connection.commit()
|
||||||
|
server.DB_PATH = old_path
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Wipe the preproduction database"):
|
||||||
|
server.init_db()
|
||||||
|
self.assertEqual(connection.execute("SELECT username FROM users").fetchone()[0], "Aly")
|
||||||
|
finally:
|
||||||
|
server.DB_PATH = current_db_path
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_login_save_and_group_view(self):
|
||||||
|
status, data, _ = self.request("GET", "/api/bootstrap")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertIsNone(data["user"])
|
||||||
|
self.assertEqual(len(data["properties"][0]["categories"]), 9)
|
||||||
|
|
||||||
|
status, _, _ = self.request("POST", "/api/login", {"loginUsername": "Aly", "password": "wrong"})
|
||||||
|
self.assertEqual(status, 401)
|
||||||
|
|
||||||
|
status, data, cookie_header = self.request("POST", "/api/login", {"loginUsername": "Aly", "password": "galnik"})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["user"]["loginUsername"], "Aly")
|
||||||
|
self.assertEqual(data["user"]["displayName"], "Aly")
|
||||||
|
aly_id = data["user"]["userId"]
|
||||||
|
cookie = cookie_header.split(";", 1)[0]
|
||||||
|
|
||||||
|
bad_ranking = ["Tank", "DPS", "DPS"]
|
||||||
|
status, _, _ = self.request("PUT", "/api/preferences/role", {"ranking": bad_ranking}, cookie)
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
|
||||||
|
ranking = ["Healer", "DPS", "Tank"]
|
||||||
|
status, _, _ = self.request("PUT", "/api/preferences/role", {"ranking": ranking}, cookie)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
|
||||||
|
class_ranking = list(reversed(server.PROPERTIES[0]["categories"]))
|
||||||
|
status, _, _ = self.request("PUT", "/api/preferences/class", {"ranking": class_ranking}, cookie)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
|
||||||
|
content_categories = server.PROPERTY_BY_ID["content"]["categories"]
|
||||||
|
content_choices = {category: "Yes" for category in content_categories}
|
||||||
|
content_choices["Open World PvP"] = "Maybe"
|
||||||
|
content_choices["40-Man Raids"] = "No"
|
||||||
|
status, _, _ = self.request("PUT", "/api/preferences/content", {"choices": {"Group Questing": "Yes"}}, cookie)
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
status, _, _ = self.request("PUT", "/api/preferences/content", {"choices": {**content_choices, "40-Man Raids": "Later"}}, cookie)
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
status, _, _ = self.request("PUT", "/api/preferences/content", {"choices": content_choices}, cookie)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
|
||||||
|
status, _, _ = self.request("POST", "/api/login", {"loginUsername": "Bex", "password": "galnik"})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
|
||||||
|
status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["preferences"]["role"]["ranking"], ranking)
|
||||||
|
self.assertEqual(data["preferences"]["class"]["ranking"], class_ranking)
|
||||||
|
self.assertEqual(data["preferences"]["content"]["choices"], content_choices)
|
||||||
|
self.assertEqual(len(data["people"]), 2)
|
||||||
|
self.assertEqual(data["people"][0]["preferences"]["role"]["ranking"], ranking)
|
||||||
|
self.assertEqual(data["people"][0]["preferences"]["content"]["choices"], content_choices)
|
||||||
|
self.assertEqual(data["people"][1]["preferences"], {})
|
||||||
|
|
||||||
|
status, _, second_cookie_header = self.request("POST", "/api/login", {"loginUsername": "aly", "password": "galnik"})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
second_cookie = second_cookie_header.split(";", 1)[0]
|
||||||
|
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "Aly", "displayName": "Bex"}, cookie)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["user"]["userId"], aly_id)
|
||||||
|
status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie)
|
||||||
|
self.assertEqual(len(data["people"]), 2)
|
||||||
|
self.assertEqual([person["displayName"] for person in data["people"]], ["Bex", "Bex"])
|
||||||
|
self.assertEqual(len({person["userId"] for person in data["people"]}), 2)
|
||||||
|
|
||||||
|
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "BEX", "displayName": "Bex"}, cookie)
|
||||||
|
self.assertEqual(status, 409)
|
||||||
|
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "AlyNew", "displayName": "Bex"}, cookie)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["user"]["userId"], aly_id)
|
||||||
|
status, data, _ = self.request("GET", "/api/bootstrap", cookie=second_cookie)
|
||||||
|
self.assertEqual(data["user"]["loginUsername"], "AlyNew")
|
||||||
|
self.assertEqual(data["user"]["displayName"], "Bex")
|
||||||
|
self.assertEqual(data["preferences"]["role"]["ranking"], ranking)
|
||||||
|
self.assertEqual(data["preferences"]["content"]["choices"], content_choices)
|
||||||
|
status, data, _ = self.request("POST", "/api/login", {"loginUsername": "alynew", "password": "galnik"})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["user"]["userId"], aly_id)
|
||||||
|
status, data, _ = self.request("POST", "/api/username-suggestions", {"loginUsername": "AlyNe"})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertIn("AlyNew", data["suggestions"])
|
||||||
|
|
||||||
|
status, _, _ = self.request("POST", "/api/logout", {}, cookie)
|
||||||
|
self.assertEqual(status, 204)
|
||||||
|
status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie)
|
||||||
|
self.assertIsNone(data["user"])
|
||||||
|
|
||||||
|
def test_partial_and_empty_rankings(self):
|
||||||
|
status, _, cookie_header = self.request(
|
||||||
|
"POST", "/api/login", {"loginUsername": "Sparse", "password": "galnik"}
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
cookie = cookie_header.split(";", 1)[0]
|
||||||
|
for ranking in (["Paladin", "Paladin"], ["Paladin", "Unknown"], [7], {"Paladin": 1}):
|
||||||
|
status, _, _ = self.request("PUT", "/api/preferences/class", {"ranking": ranking}, cookie)
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
|
||||||
|
status, data, _ = self.request("PUT", "/api/preferences/class", {"ranking": ["Paladin", "Mage"]}, cookie)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["ranking"], ["Paladin", "Mage"])
|
||||||
|
status, data, _ = self.request("PUT", "/api/preferences/role", {"ranking": []}, cookie)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["ranking"], [])
|
||||||
|
status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie)
|
||||||
|
self.assertEqual(data["preferences"]["class"]["ranking"], ["Paladin", "Mage"])
|
||||||
|
self.assertEqual(data["preferences"]["role"]["ranking"], [])
|
||||||
|
|
||||||
|
def test_username_suggestions_and_account_deletion(self):
|
||||||
|
status, data, _ = self.request(
|
||||||
|
"POST", "/api/username-suggestions", {"loginUsername": "Merid"}
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["suggestions"], [])
|
||||||
|
status, _, _ = self.request(
|
||||||
|
"POST", "/api/login", {"loginUsername": "Meridian", "password": "gálnik"}
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 401)
|
||||||
|
|
||||||
|
status, data, cookie_header = self.request(
|
||||||
|
"POST", "/api/login", {"loginUsername": "Meridian", "password": "galnik"}
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
meridian_id = data["user"]["userId"]
|
||||||
|
cookie = cookie_header.split(";", 1)[0]
|
||||||
|
status, data, _ = self.request(
|
||||||
|
"POST", "/api/username-suggestions", {"loginUsername": "Meridain"}
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertIn("Meridian", data["suggestions"])
|
||||||
|
|
||||||
|
status, data, _ = self.request(
|
||||||
|
"POST", "/api/login", {"loginUsername": "Meridain", "password": "galnik"}
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 409)
|
||||||
|
self.assertIn("Meridian", data["suggestions"])
|
||||||
|
|
||||||
|
status, data, _ = self.request(
|
||||||
|
"POST", "/api/login", {"loginUsername": "mERIDIAN", "password": "galnik"}
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data["user"]["loginUsername"], "Meridian")
|
||||||
|
|
||||||
|
status, _, _ = self.request(
|
||||||
|
"POST", "/api/login", {"loginUsername": "Meridain", "password": "galnik", "createNew": True}
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
|
||||||
|
status, _, _ = self.request(
|
||||||
|
"PUT", "/api/preferences/role", {"ranking": ["DPS", "Tank", "Healer"]}, cookie
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
status, _, _ = self.request("DELETE", "/api/me", {"confirmDisplayName": "meridian"}, cookie)
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
status, _, _ = self.request("DELETE", "/api/me", {"confirmDisplayName": "Meridian"}, cookie)
|
||||||
|
self.assertEqual(status, 204)
|
||||||
|
|
||||||
|
status, _, _ = self.request("PUT", "/api/preferences/role", {"ranking": ["Tank", "Healer", "DPS"]}, cookie)
|
||||||
|
self.assertEqual(status, 401)
|
||||||
|
with server.db() as connection:
|
||||||
|
self.assertIsNone(connection.execute("SELECT 1 FROM users WHERE login_key = ?", ("meridian",)).fetchone())
|
||||||
|
self.assertIsNone(connection.execute("SELECT 1 FROM preferences WHERE user_id = ?", (meridian_id,)).fetchone())
|
||||||
|
self.assertIsNone(connection.execute("SELECT 1 FROM sessions WHERE user_id = ?", (meridian_id,)).fetchone())
|
||||||
|
self.assertIsNotNone(connection.execute("SELECT 1 FROM users WHERE login_key = ?", ("meridain",)).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const test = require("node:test");
|
||||||
|
const vm = require("node:vm");
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, "../static/app.js"), "utf8");
|
||||||
|
|
||||||
|
test("category columns sort excluded choices as lowest and pending submissions last", () => {
|
||||||
|
const context = vm.createContext({
|
||||||
|
document: { querySelector: () => ({}) },
|
||||||
|
fetch: () => new Promise(() => {}),
|
||||||
|
});
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
context.people = [
|
||||||
|
{ userId: 1, loginUsername: "Third", displayName: "Third", preferences: { class: { ranking: ["Mage", "Warrior", "Paladin"] } } },
|
||||||
|
{ userId: 2, loginUsername: "First", displayName: "First", preferences: { class: { ranking: ["Paladin", "Mage", "Warrior"] } } },
|
||||||
|
{ userId: 3, loginUsername: "Pending", displayName: "Pending", preferences: {} },
|
||||||
|
{ userId: 4, loginUsername: "Second", displayName: "Second", preferences: { class: { ranking: ["Mage", "Paladin", "Warrior"] } } },
|
||||||
|
{ userId: 5, loginUsername: "Excluded", displayName: "Excluded", preferences: { class: { ranking: ["Mage", "Warrior"] } } },
|
||||||
|
];
|
||||||
|
const names = vm.runInContext(
|
||||||
|
"state.sort = { key: 'category:Paladin', direction: 1 }; sortPeople(people, 'class').map(person => person.displayName)",
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
assert.deepEqual(Array.from(names), ["First", "Second", "Third", "Excluded", "Pending"]);
|
||||||
|
const reversed = vm.runInContext(
|
||||||
|
"state.sort = { key: 'category:Paladin', direction: -1 }; sortPeople(people, 'class').map(person => person.displayName)",
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
assert.deepEqual(Array.from(reversed), ["Excluded", "Third", "Second", "First", "Pending"]);
|
||||||
|
const rankColumn = vm.runInContext(
|
||||||
|
"state.sort = { key: 'rank2', direction: 1 }; sortPeople(people, 'class').map(person => person.displayName)",
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
assert.deepEqual(Array.from(rankColumn), ["Third", "First", "Second", "Excluded", "Pending"]);
|
||||||
|
assert.equal(vm.runInContext("ordinal(1)", context), "1st");
|
||||||
|
assert.equal(vm.runInContext("ordinal(12)", context), "12th");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("group tables leave excluded categories and empty rank positions blank", () => {
|
||||||
|
const app = {
|
||||||
|
innerHTML: "",
|
||||||
|
querySelector: () => ({ addEventListener() {} }),
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
document: { querySelector: () => app },
|
||||||
|
fetch: () => new Promise(() => {}),
|
||||||
|
});
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
vm.runInContext(`
|
||||||
|
state.data = {
|
||||||
|
user: { userId: 1, loginUsername: "Aly", displayName: "Aly" },
|
||||||
|
properties: [{ id: "class", name: "Class", categories: ["Paladin", "Mage", "Warrior"] }],
|
||||||
|
preferences: { class: { ranking: ["Mage"] } },
|
||||||
|
people: [{ userId: 1, loginUsername: "Aly", displayName: "Aly", preferences: { class: { ranking: ["Mage"], updatedAt: "2026-10-01T00:00:00Z" } } }],
|
||||||
|
};
|
||||||
|
state.groupMode = "category";
|
||||||
|
renderGroup();
|
||||||
|
`, context);
|
||||||
|
assert.equal((app.innerHTML.match(/aria-label="No preference"><\/td>/g) || []).length, 2);
|
||||||
|
assert.doesNotMatch(app.innerHTML, />0th</);
|
||||||
|
vm.runInContext('state.groupMode = "rank"; renderGroup();', context);
|
||||||
|
assert.equal((app.innerHTML.match(/aria-label="No ranked choice"><\/td>/g) || []).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("content columns sort Yes, Maybe, No and leave unanswered people last", () => {
|
||||||
|
const context = vm.createContext({
|
||||||
|
document: { querySelector: () => ({}) },
|
||||||
|
fetch: () => new Promise(() => {}),
|
||||||
|
});
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
context.people = [
|
||||||
|
{ userId: 1, loginUsername: "Noah", displayName: "Noah", preferences: { content: { choices: { "Group Questing": "No" } } } },
|
||||||
|
{ userId: 2, loginUsername: "Yara", displayName: "Yara", preferences: { content: { choices: { "Group Questing": "Yes" } } } },
|
||||||
|
{ userId: 3, loginUsername: "Missing", displayName: "Missing", preferences: {} },
|
||||||
|
{ userId: 4, loginUsername: "Mira", displayName: "Mira", preferences: { content: { choices: { "Group Questing": "Maybe" } } } },
|
||||||
|
];
|
||||||
|
const names = vm.runInContext(
|
||||||
|
"state.sort = { key: 'category:Group Questing', direction: 1 }; sortPeople(people, 'content').map(person => person.displayName)",
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
assert.deepEqual(Array.from(names), ["Yara", "Mira", "Noah", "Missing"]);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue