docs: add block/mute user relations, emoji-sync CLI, and uid indexes

- Add `/block`, `/mute` endpoints with block/unblock and mute/unmute functionality in `routers/relations.py`, hiding blocked users' content everywhere except their own profile while muting only suppresses notifications
- Introduce `devplace emoji-sync` CLI command to regenerate `static/js/emoji-shortcodes.js` from the emoji library, documented in `CLAUDE.md` and wired in `cli.py`
- Create `get_blocked_uids()` database helper and apply it in `content.py` `load_detail()` to filter blocked users' posts from detail views
- Implement `_uid_index()` and `_drop_index()` helpers in `database.py` for unique uid indexes across tables, with `user_relations` added to `SOFT_DELETE_TABLES`
- Document new routes in `AGENTS.md` and `README.md`, including emoji shortcodes rendering behavior distinct from the emoji picker
This commit is contained in:
2026-06-19 08:06:09 +00:00
parent f3a4667fce
commit 741d7aade6
136 changed files with 3025 additions and 564 deletions
+15 -1
View File
@@ -2,7 +2,7 @@
import argparse
import sys
from devplacepy.database import get_table
from devplacepy.database import get_table, invalidate_admins_cache
from devplacepy.utils import strip_html
@@ -46,6 +46,7 @@ def cmd_role_set(args):
old_role = user.get("role")
users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"])
invalidate_admins_cache()
from devplacepy.services.audit import record as audit
_audit_cli(
@@ -738,6 +739,14 @@ def _migrate_db(source, dest, dry_run, report):
)
def cmd_emoji_sync(args):
from devplacepy.rendering import EMOJI_JS_PATH, write_emoji_module
count = write_emoji_module()
_audit_cli("cli.emoji.sync", f"CLI regenerated {count} emoji shortcodes", metadata={"count": count})
print(f"Wrote {count} emoji shortcodes to {EMOJI_JS_PATH}")
def cmd_migrate_data(args):
import os
from pathlib import Path
@@ -993,6 +1002,11 @@ def main():
"gc-workspaces", help="Remove workspace dirs with no instances"
).set_defaults(func=cmd_containers_gc_workspaces)
sub.add_parser(
"emoji-sync",
help="Regenerate static/js/emoji-shortcodes.js from the emoji library",
).set_defaults(func=cmd_emoji_sync)
migrate = sub.add_parser(
"migrate-data",
help="Relocate legacy runtime files into the consolidated data/ directory",
+3
View File
@@ -16,6 +16,7 @@ from devplacepy.database import (
get_user_votes,
get_reactions_by_targets,
get_user_bookmarks,
get_blocked_uids,
get_poll_for_post,
update_target_stars,
get_target_owner_uid,
@@ -591,6 +592,8 @@ def load_detail(
item = resolve_by_slug(get_table(table_name), slug)
if not item:
return None
if user and item["user_uid"] in get_blocked_uids(user["uid"]):
return None
author = get_users_by_uids([item["user_uid"]]).get(item["user_uid"])
ups, downs = get_vote_counts([item["uid"]])
reactions = (
+239 -7
View File
@@ -114,15 +114,40 @@ def sync_local_cache(name: str, cache) -> None:
_local_cache_versions[name] = current
def _index(db, table, name, columns):
def _index(db, table, name, columns, *, where=None, unique=False):
try:
if table in db.tables:
cols = ", ".join(columns)
db.query(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({cols})")
kind = "UNIQUE INDEX" if unique else "INDEX"
clause = f" WHERE {where}" if where else ""
with db:
db.query(
f"CREATE {kind} IF NOT EXISTS {name} ON {table} ({cols}){clause}"
)
except Exception as e:
logger.warning(f"Could not create index {name} on {table}: {e}")
def _drop_index(db, name):
try:
with db:
db.query(f"DROP INDEX IF EXISTS {name}")
except Exception as e:
logger.warning(f"Could not drop index {name}: {e}")
def _uid_index(db, table):
if table not in db.tables or "uid" not in get_table(table).columns:
return
name = f"idx_{table}_uid"
try:
with db:
db.query(f"CREATE UNIQUE INDEX IF NOT EXISTS {name} ON {table} (uid)")
except Exception as e:
logger.warning(f"Unique uid index on {table} failed ({e}); using non-unique")
_index(db, table, name, ["uid"])
def _add_usage(usage_table: str, user_uid: str, totals: dict) -> None:
if not user_uid or int(totals.get("calls") or 0) <= 0 or usage_table not in db.tables:
return
@@ -321,6 +346,7 @@ SOFT_DELETE_TABLES = [
"devrant_tokens",
"access_tokens",
"email_accounts",
"user_relations",
]
@@ -333,7 +359,14 @@ def ensure_soft_delete_columns(table, *, db_handle=None):
target.create_column_by_example("deleted_at", "")
if not target.has_column("deleted_by"):
target.create_column_by_example("deleted_by", "")
_index(handle, table, f"idx_{table}_deleted", ["deleted_at"])
_drop_index(handle, f"idx_{table}_deleted")
_index(
handle,
table,
f"idx_{table}_trash",
["deleted_at"],
where="deleted_at IS NOT NULL",
)
BUG_TABLE_RENAMES = (
@@ -440,7 +473,17 @@ def init_db():
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
_index(db, "sessions", "idx_sessions_token", ["session_token"])
projects = get_table("projects")
for column, example in (("stars", 0), ("project_type", "")):
for column, example in (
("uid", ""),
("user_uid", ""),
("slug", ""),
("created_at", ""),
("stars", 0),
("project_type", ""),
("is_private", 0),
("read_only", 0),
("updated_at", ""),
):
if not projects.has_column(column):
projects.create_column_by_example(column, example)
@@ -448,6 +491,28 @@ def init_db():
_index(db, "projects", "idx_projects_private", ["is_private"])
_index(db, "projects", "idx_projects_stars", ["stars"])
_index(db, "projects", "idx_projects_type", ["project_type"])
project_files = get_table("project_files")
for column, example in (
("uid", ""),
("project_uid", ""),
("user_uid", ""),
("path", ""),
("name", ""),
("parent_path", ""),
("type", ""),
("content", ""),
("is_binary", 0),
("stored_name", ""),
("directory", ""),
("mime_type", ""),
("size", 0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not project_files.has_column(column):
project_files.create_column_by_example(column, example)
_index(db, "project_files", "idx_project_files_path", ["project_uid", "path"])
_index(
db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"]
@@ -455,6 +520,20 @@ def init_db():
_index(db, "badges", "idx_badges_user", ["user_uid"])
_index(db, "follows", "idx_follows_follower", ["follower_uid"])
_index(db, "follows", "idx_follows_following", ["following_uid"])
user_relations = get_table("user_relations")
for column, example in (
("uid", ""),
("user_uid", ""),
("target_uid", ""),
("kind", ""),
("created_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not user_relations.has_column(column):
user_relations.create_column_by_example(column, example)
_index(db, "user_relations", "idx_user_relations_owner_kind", ["user_uid", "kind"])
_index(db, "user_relations", "idx_user_relations_target_kind", ["target_uid", "kind"])
_index(db, "password_resets", "idx_password_resets_token", ["token"])
_index(db, "gists", "idx_gists_user_uid", ["user_uid"])
_index(db, "gists", "idx_gists_language", ["language"])
@@ -683,6 +762,31 @@ def init_db():
["endpoint", "created_at"],
)
_index(db, "gateway_concurrency_samples", "idx_gw_conc_time", ["created_at"])
jobs_table = get_table("jobs")
for column, example in (
("uid", ""),
("kind", ""),
("status", ""),
("owner_kind", ""),
("owner_id", ""),
("preferred_name", ""),
("payload", ""),
("result", ""),
("error", ""),
("retry_count", 0),
("created_at", ""),
("started_at", ""),
("completed_at", ""),
("updated_at", ""),
("duration_ms", 0),
("last_accessed_at", ""),
("expires_at", ""),
("bytes_in", 0),
("bytes_out", 0),
("item_count", 0),
):
if not jobs_table.has_column(column):
jobs_table.create_column_by_example(column, example)
_index(db, "jobs", "idx_jobs_kind_status", ["kind", "status"])
_index(db, "jobs", "idx_jobs_owner", ["owner_kind", "owner_id"])
_index(db, "jobs", "idx_jobs_expires", ["expires_at"])
@@ -963,6 +1067,34 @@ def init_db():
deepsearch_url_cache.create_column_by_example(column, example)
_index(db, "deepsearch_url_cache", "idx_deepsearch_url_cache_hash", ["url_hash"])
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
_index(
db,
"posts",
"idx_posts_live_created",
["created_at"],
where="deleted_at IS NULL",
)
_index(
db,
"comments",
"idx_comments_target_created",
["target_type", "target_uid", "created_at"],
)
_index(db, "comments", "idx_comments_user_created", ["user_uid", "created_at"])
_index(db, "votes", "idx_votes_user_target", ["user_uid", "target_uid"])
_index(db, "gists", "idx_gists_user_created", ["user_uid", "created_at"])
_index(db, "projects", "idx_projects_user_created", ["user_uid", "created_at"])
_index(
db,
"notifications",
"idx_notifications_user_created",
["user_uid", "created_at"],
)
for table in db.tables:
_uid_index(db, table)
for table in SOFT_DELETE_TABLES:
ensure_soft_delete_columns(table)
@@ -1060,6 +1192,20 @@ def init_db():
_backfill_gamification()
backfill_api_keys()
migrate_ai_gateway_settings()
_refresh_query_planner_stats()
def _refresh_query_planner_stats() -> None:
try:
has_stats = bool(
list(db.query("SELECT name FROM sqlite_master WHERE name='sqlite_stat1'"))
)
with db:
if not has_stats:
db.query("ANALYZE")
db.query("PRAGMA optimize")
except Exception as e:
logger.warning(f"Could not refresh query planner stats: {e}")
logger.info("Database initialized")
@@ -1358,11 +1504,25 @@ def get_users_by_uids(uids):
return {u["uid"]: u for u in users.find(users.table.columns.uid.in_(unique))}
_admins_cache = TTLCache(ttl=300, max_size=4)
def invalidate_admins_cache() -> None:
_admins_cache.clear()
bump_cache_version("admins")
def get_admin_uids():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("uids")
if cached is not None:
return list(cached)
if "users" not in db.tables:
return []
rows = db.query("SELECT uid FROM users WHERE role = 'Admin'")
return [row["uid"] for row in rows]
uids = [row["uid"] for row in rows]
_admins_cache.set("uids", uids)
return list(uids)
def set_user_timezone(user_uid: str, tz_name: str) -> None:
@@ -1378,6 +1538,10 @@ def set_user_timezone(user_uid: str, tz_name: str) -> None:
def get_primary_admin_uid():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("primary")
if cached is not None:
return cached or None
if "users" not in db.tables:
return None
rows = list(
@@ -1386,7 +1550,9 @@ def get_primary_admin_uid():
"ORDER BY created_at ASC, id ASC LIMIT 1"
)
)
return rows[0]["uid"] if rows else None
primary = rows[0]["uid"] if rows else None
_admins_cache.set("primary", primary or "")
return primary
def search_users_by_username(q, *, exclude_uid=None, limit=10):
@@ -1575,6 +1741,15 @@ def get_poll_for_post(post_uid, user=None):
return get_polls_by_post_uids([post_uid], user).get(post_uid)
def _drop_blocked(raw, user):
if not user:
return raw
blocked = get_blocked_uids(user["uid"])
if not blocked:
return raw
return [c for c in raw if c["user_uid"] not in blocked]
def _build_comment_items(raw, user=None):
uids = [c["user_uid"] for c in raw]
cids = [c["uid"] for c in raw]
@@ -1619,6 +1794,7 @@ def load_comments(target_type, target_uid, user=None):
post_uid=target_uid, deleted_at=None, order_by=["created_at"]
)
)
raw = _drop_blocked(raw, user)
if not raw:
return []
cmap = _build_comment_items(raw, user)
@@ -1648,6 +1824,7 @@ def get_recent_comments_by_target_uids(target_type, target_uids, limit=3, user=N
**params,
)
)
raw = _drop_blocked(raw, user)
if not raw:
return {}
items = _build_comment_items(raw, user)
@@ -1687,6 +1864,7 @@ def load_comments_by_target_uids(target_type, target_uids, user=None):
**params,
)
)
raw = _drop_blocked(raw, user)
if not raw:
return {}
from collections import defaultdict
@@ -1838,6 +2016,48 @@ def clear_settings_cache() -> None:
bump_cache_version("settings")
_relations_cache = TTLCache(ttl=300, max_size=2000)
def get_user_relations(viewer_uid: str | None) -> dict:
if not viewer_uid:
return {"block": frozenset(), "mute": frozenset()}
sync_local_cache("relations", _relations_cache)
cached = _relations_cache.get(viewer_uid)
if cached is not None:
return cached
block: set = set()
mute: set = set()
if "user_relations" in db.tables:
for row in db["user_relations"].find(user_uid=viewer_uid, deleted_at=None):
target = row["target_uid"]
if row["kind"] == "block":
block.add(target)
elif row["kind"] == "mute":
mute.add(target)
result = {"block": frozenset(block), "mute": frozenset(mute)}
_relations_cache.set(viewer_uid, result)
return result
def get_blocked_uids(viewer_uid: str | None) -> frozenset:
return get_user_relations(viewer_uid)["block"]
def get_muted_uids(viewer_uid: str | None) -> frozenset:
return get_user_relations(viewer_uid)["mute"]
def get_silenced_uids(viewer_uid: str | None) -> frozenset:
relations = get_user_relations(viewer_uid)
return relations["block"] | relations["mute"]
def invalidate_user_relations(viewer_uid: str) -> None:
_relations_cache.pop(viewer_uid)
bump_cache_version("relations")
CUSTOMIZATION_GLOBAL_SCOPE = "global"
CUSTOMIZATION_LANGS = ("css", "js")
@@ -2760,12 +2980,22 @@ def text_search_clause(table, search, fields=("title", "description")):
def paginate(
table, *clauses, before=None, order=None, cursor_field="created_at", **filters
table,
*clauses,
before=None,
order=None,
cursor_field="created_at",
viewer_uid=None,
**filters,
):
order = order or ["-" + cursor_field]
clauses = list(clauses)
if table.has_column("deleted_at") and "deleted_at" not in filters:
clauses.append(table.table.columns.deleted_at.is_(None))
if viewer_uid and table.has_column("user_uid"):
blocked = get_blocked_uids(viewer_uid)
if blocked:
clauses.append(table.table.columns.user_uid.notin_(blocked))
if before:
clauses.append(table.table.columns[cursor_field] < before)
rows = list(table.find(*clauses, **filters, order_by=order, _limit=PAGE_SIZE + 1))
@@ -2801,6 +3031,7 @@ def paginate_diverse(
order=None,
cursor_field="created_at",
uid_key="user_uid",
viewer_uid=None,
**filters,
):
rows, next_cursor = paginate(
@@ -2809,6 +3040,7 @@ def paginate_diverse(
before=before,
order=order,
cursor_field=cursor_field,
viewer_uid=viewer_uid,
**filters,
)
return interleave_by_author(rows, uid_key=uid_key), next_cursor
+80
View File
@@ -2038,6 +2038,86 @@ four ways to sign requests.
)
],
),
endpoint(
id="block-user",
method="POST",
path="/block/{username}",
title="Block a user",
summary="Block a user. Their posts, comments and messages are hidden from you everywhere except their own profile, and they can no longer create notifications for you. Idempotent.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to block.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="unblock-user",
method="POST",
path="/block/unblock/{username}",
title="Unblock a user",
summary="Reverse a block. Their content becomes visible again.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to unblock.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="mute-user",
method="POST",
path="/mute/{username}",
title="Mute a user",
summary="Mute a user so they can no longer create notifications for you. Their content stays visible. Idempotent.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to mute.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="unmute-user",
method="POST",
path="/mute/unmute/{username}",
title="Unmute a user",
summary="Reverse a mute. They can create notifications for you again.",
auth="user",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Username to unmute.",
)
],
sample_response={"ok": True, "redirect": "/profile/bob_test"},
),
endpoint(
id="list-followers",
method="GET",
+7 -1
View File
@@ -2,6 +2,7 @@
import html
import re
from functools import lru_cache
import mistune
@@ -16,9 +17,14 @@ _RENDER_BLOCK = re.compile(
)
@lru_cache(maxsize=512)
def _render_markdown(source: str) -> str:
return _markdown(source)
def _convert(match: re.Match) -> str:
source = html.unescape(match.group(1)).strip()
return f'<div class="docs-content">{_markdown(source)}</div>'
return f'<div class="docs-content">{_render_markdown(source)}</div>'
def render_prose(slug: str, context: dict) -> str:
+44 -8
View File
@@ -35,8 +35,10 @@ from devplacepy.database import (
interleave_by_author,
get_user_post_count,
get_user_stars,
get_blocked_uids,
)
from devplacepy.templating import templates
from devplacepy.cache import TTLCache
from devplacepy.responses import respond, wants_json, json_error
from devplacepy.schemas import LandingOut, ValidationErrorOut
from fastapi.responses import JSONResponse
@@ -54,6 +56,7 @@ from devplacepy.routers import (
votes,
avatar,
follow,
relations,
admin,
seo,
issues,
@@ -371,6 +374,7 @@ app.include_router(bookmarks.router, prefix="/bookmarks")
app.include_router(polls.router, prefix="/polls")
app.include_router(avatar.router, prefix="/avatar")
app.include_router(follow.router, prefix="/follow")
app.include_router(relations.router)
app.include_router(leaderboard.router, prefix="/leaderboard")
app.include_router(admin.router, prefix="/admin")
app.include_router(seo.router)
@@ -418,6 +422,10 @@ async def add_security_headers(request: Request, call_next):
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
if not request.url.path.startswith("/p/"):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = (
"object-src 'none'; base-uri 'self'; "
"frame-ancestors 'none'; form-action 'self'"
)
if request.url.path.startswith("/admin"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
@@ -512,11 +520,14 @@ async def response_timing(request: Request, call_next):
return response
@app.get("/")
async def landing(request: Request):
user = get_current_user(request)
_home_cache = TTLCache(ttl=60, max_size=4)
landing_articles = []
def _landing_news():
cached = _home_cache.get("news")
if cached is not None:
return cached
articles = []
if "news" in db.tables:
news_table = get_table("news")
raw = list(
@@ -524,7 +535,7 @@ async def landing(request: Request):
)
images_by_news = get_news_images_by_uids([a["uid"] for a in raw])
for a in raw:
landing_articles.append(
articles.append(
{
"uid": a["uid"],
"slug": a.get("slug", ""),
@@ -539,12 +550,25 @@ async def landing(request: Request):
"image_url": a.get("image_url", "") or images_by_news.get(a["uid"], ""),
}
)
_home_cache.set("news", articles)
return articles
landing_posts = []
def _landing_recent_posts(blocked):
if not blocked:
cached = _home_cache.get("posts")
if cached is not None:
return cached
posts = []
if "posts" in db.tables:
posts_table = get_table("posts")
clauses = []
if blocked:
clauses.append(posts_table.table.columns.user_uid.notin_(blocked))
raw_posts = list(
posts_table.find(deleted_at=None, order_by=["-created_at"], _limit=6)
posts_table.find(
*clauses, deleted_at=None, order_by=["-created_at"], _limit=6
)
)
raw_posts = interleave_by_author(raw_posts)
if raw_posts:
@@ -554,7 +578,7 @@ async def landing(request: Request):
comment_counts = get_comment_counts_by_post_uids(post_uids)
upvotes, downvotes = get_vote_counts(post_uids)
for p in raw_posts:
landing_posts.append(
posts.append(
{
"post": p,
"author": authors.get(p["user_uid"]),
@@ -564,6 +588,18 @@ async def landing(request: Request):
"slug": p.get("slug", "") or p["uid"],
}
)
if not blocked:
_home_cache.set("posts", posts)
return posts
@app.get("/")
async def landing(request: Request):
user = get_current_user(request)
landing_articles = _landing_news()
blocked = get_blocked_uids(user["uid"]) if user else frozenset()
landing_posts = _landing_recent_posts(blocked)
base = site_url(request)
seo_ctx = base_seo_context(
+34 -24
View File
@@ -1,39 +1,49 @@
# retoor <retoor@molodetz.nl>
import html
import json
import re
from functools import lru_cache
from html.parser import HTMLParser
from pathlib import Path
import emoji
import mistune
from markupsafe import Markup
EMOJI_MAP = {
"grinning": "\U0001F600", "smiley": "\U0001F603", "smile": "\U0001F604",
"grin": "\U0001F601", "laughing": "\U0001F606", "sweat_smile": "\U0001F605",
"joy": "\U0001F602", "blush": "\U0001F60A", "innocent": "\U0001F607",
"wink": "\U0001F609", "heart_eyes": "\U0001F60D", "kissing_heart": "\U0001F618",
"stuck_out_tongue": "\U0001F61B", "stuck_out_tongue_winking_eye": "\U0001F61C",
"sunglasses": "\U0001F60E", "unamused": "\U0001F612", "sweat": "\U0001F613",
"sob": "\U0001F62D", "scream": "\U0001F631", "sleeping": "\U0001F634",
"relieved": "\U0001F60C", "heart": "❤️", "broken_heart": "\U0001F494",
"two_hearts": "\U0001F495", "sparkling_heart": "\U0001F496",
"star": "", "sparkles": "", "zap": "",
"fire": "\U0001F525", "rocket": "\U0001F680", "100": "\U0001F4AF",
"clap": "\U0001F44F", "ok_hand": "\U0001F44C", "wave": "\U0001F44B",
"thumbsup": "\U0001F44D", "+1": "\U0001F44D", "thumbsdown": "\U0001F44E",
"-1": "\U0001F44E", "pray": "\U0001F64F", "muscle": "\U0001F4AA",
"tada": "\U0001F389", "confetti_ball": "\U0001F38A", "party": "\U0001F973",
"beers": "\U0001F37B", "coffee": "", "pizza": "\U0001F355",
"computer": "\U0001F4BB", "bug": "\U0001F41B", "gear": "⚙️",
"warning": "⚠️", "question": "", "exclamation": "",
"checkered_flag": "\U0001F3C1", "eyes": "\U0001F440", "brain": "\U0001F9E0",
"robot": "\U0001F916", "skull": "\U0001F480", "point_up": "☝️",
"point_down": "\U0001F447", "point_left": "\U0001F448", "point_right": "\U0001F449",
"pencil2": "✏️", "memo": "\U0001F4DD", "book": "\U0001F4D6",
"tools": "\U0001F6E0", "hammer_and_wrench": "\U0001F6E0",
EMOJI_JS_PATH = Path(__file__).parent / "static" / "js" / "emoji-shortcodes.js"
CUSTOM_SHORTCODES = {
"party": "\U0001F973",
"tools": "\U0001F6E0",
}
def build_emoji_shortcodes() -> dict[str, str]:
shortcodes: dict[str, str] = {}
for char, data in emoji.EMOJI_DATA.items():
if data.get("status", 0) > emoji.STATUS["fully_qualified"]:
continue
names = [data["en"]] if data.get("en") else []
names += data.get("alias", [])
for name in names:
shortcodes.setdefault(name.strip(":").lower(), char)
shortcodes.update(CUSTOM_SHORTCODES)
return shortcodes
def write_emoji_module(path: Path = EMOJI_JS_PATH) -> int:
shortcodes = build_emoji_shortcodes()
body = json.dumps(shortcodes, ensure_ascii=False, sort_keys=True)
path.write_text(
f"// retoor <retoor@molodetz.nl>\n\nexport const EMOJI_SHORTCODES = {body};\n",
encoding="utf-8",
)
return len(shortcodes)
EMOJI_MAP = build_emoji_shortcodes()
_SHORTCODE_RE = re.compile(r":([A-Za-z0-9_+\-]+):")
_YOUTUBE_RE = re.compile(
r"(?:https?://)?(?:www\.)?"
+2
View File
@@ -9,6 +9,7 @@ from devplacepy.database import (
get_table,
build_pagination,
get_post_counts_by_user_uids,
invalidate_admins_cache,
)
from devplacepy.utils import (
require_admin,
@@ -133,6 +134,7 @@ async def admin_user_role(
old_role = target_user.get("role") if target_user else None
users.update({"uid": uid, "role": role}, ["uid"])
clear_user_cache(uid)
invalidate_admins_cache()
logger.info(f"Admin {admin['username']} set user {uid} role to {role}")
audit.record(
request,
+12
View File
@@ -63,6 +63,18 @@ DOCS_PAGES = [
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "block-and-mute",
"title": "Block and mute",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "emoji-shortcodes",
"title": "Emoji shortcodes",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "devii",
"title": "Devii Assistant",
+2
View File
@@ -52,6 +52,7 @@ def get_feed_posts(
*search_clauses,
before=before,
order=order,
viewer_uid=user["uid"] if user else None,
)
else:
filters = {"topic": topic} if topic else {}
@@ -60,6 +61,7 @@ def get_feed_posts(
*search_clauses,
before=before,
order=order,
viewer_uid=user["uid"] if user else None,
**filters,
)
+7 -1
View File
@@ -81,7 +81,13 @@ def get_gists_list(user_uid=None, language=None, search="", before=None, viewer=
clauses = [search_match] if search_match is not None else []
total = gists_table.count(*clauses, deleted_at=None, **filters)
gists, next_cursor = paginate(gists_table, *clauses, before=before, **filters)
gists, next_cursor = paginate(
gists_table,
*clauses,
before=before,
viewer_uid=viewer["uid"] if viewer else None,
**filters,
)
if not gists:
return [], next_cursor, total
+8 -1
View File
@@ -5,7 +5,7 @@ import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import build_pagination, get_users_by_uids
from devplacepy.database import build_pagination, get_users_by_uids, get_blocked_uids
from devplacepy.responses import respond
from devplacepy.schemas import IssueDetailOut, IssuesOut
from devplacepy.services.gitea import runtime, store
@@ -65,6 +65,13 @@ async def issues_page(request: Request, state: str = STATE_OPEN, page: int = 1):
numbers = [int(issue.get("number", 0)) for issue in issues]
author_by_number = store.author_map(numbers)
blocked = get_blocked_uids(user["uid"]) if user else frozenset()
if blocked:
issues = [
issue
for issue in issues
if author_by_number.get(int(issue.get("number", 0))) not in blocked
]
users_map = get_users_by_uids([uid for uid in author_by_number.values() if uid])
base_ctx["issues"] = [
issue_item(issue, author_by_number.get(int(issue.get("number", 0))), users_map)
+13 -1
View File
@@ -6,7 +6,13 @@ from typing import Annotated, Optional
from fastapi import Depends, APIRouter, Request, WebSocket, WebSocketDisconnect
from devplacepy.models import MessageForm
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import get_table, db, get_users_by_uids, search_users_by_username
from devplacepy.database import (
get_table,
db,
get_users_by_uids,
search_users_by_username,
get_blocked_uids,
)
from devplacepy.attachments import get_attachments_batch
from devplacepy.templating import clear_messages_cache
from devplacepy.utils import (
@@ -57,12 +63,15 @@ def get_conversations(user_uid: str):
seen.add(m["uid"])
all_messages.append(m)
blocked = get_blocked_uids(user_uid)
conversation_map = {}
other_uids = set()
for msg in all_messages:
other_uid = (
msg["receiver_uid"] if msg["sender_uid"] == user_uid else msg["sender_uid"]
)
if other_uid in blocked:
continue
other_uids.add(other_uid)
if (
other_uid not in conversation_map
@@ -88,6 +97,8 @@ def get_conversations(user_uid: str):
return conversations
def get_conversation_messages(user_uid: str, other_uid: str):
if other_uid in get_blocked_uids(user_uid):
return [], None
messages_table = get_table("messages")
raw = list(messages_table.find(sender_uid=user_uid, receiver_uid=other_uid)) + list(
messages_table.find(sender_uid=other_uid, receiver_uid=user_uid)
@@ -221,6 +232,7 @@ async def broadcast_message(
async def _finalize_and_broadcast(
sender: dict, message: dict, request: object, client_id: Optional[str] = None
) -> None:
message_hub.mark_delivered(message["uid"])
scope = getattr(request, "scope", None)
pending = scope.get(PENDING_SCOPE_KEY) if scope is not None else None
if pending:
+8
View File
@@ -22,6 +22,7 @@ from devplacepy.database import (
get_follow_counts,
get_follow_list,
get_following_among,
get_user_relations,
get_user_media,
search_users_by_username,
)
@@ -226,6 +227,8 @@ async def profile_page(
streak = get_streaks(profile_user["uid"])
is_following = False
is_blocked = False
is_muted = False
if current_user:
follows = get_table("follows")
is_following = bool(
@@ -235,6 +238,9 @@ async def profile_page(
deleted_at=None,
)
)
relations = get_user_relations(current_user["uid"])
is_blocked = profile_user["uid"] in relations["block"]
is_muted = profile_user["uid"] in relations["mute"]
is_owner = bool(current_user and current_user["uid"] == profile_user["uid"])
viewer_is_admin = bool(current_user and current_user.get("role") == "Admin")
@@ -342,6 +348,8 @@ async def profile_page(
"current_tab": tab,
"posts_count": posts_count,
"is_following": is_following,
"is_blocked": is_blocked,
"is_muted": is_muted,
"is_owner": is_owner,
"viewer_is_admin": viewer_is_admin,
"media": media,
+6 -1
View File
@@ -95,7 +95,12 @@ def get_projects_list(
order = ["-stars", "-created_at"] if tab == "popular" else ["-created_at"]
total = projects.count(*clauses, **filters)
page, next_cursor = paginate(
projects, *clauses, before=before, order=order, **filters
projects,
*clauses,
before=before,
order=order,
viewer_uid=viewer["uid"] if viewer else None,
**filters,
)
if page:
+13 -3
View File
@@ -11,12 +11,19 @@ from devplacepy.services.manager import service_manager
from devplacepy.services.pubsub import policy
from devplacepy.services.pubsub.hub import pubsub
from devplacepy.routers.dbapi._shared import error, read_body, require_dbapi_caller
from devplacepy.routers.dbapi._shared import error, read_body
logger = logging.getLogger(__name__)
router = APIRouter()
def _require_privileged(request):
actor = policy.resolve_actor(request)
if not actor.privileged:
return None
return actor
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
@@ -87,7 +94,9 @@ async def pubsub_ws(websocket: WebSocket):
@router.post("/publish")
async def pubsub_http_publish(request: Request):
caller = require_dbapi_caller(request)
caller = _require_privileged(request)
if caller is None:
return error(403, "Admin or internal access required.")
if not service_manager.owns_lock():
return error(409, "Pub/sub is served by the lock-owner worker. Retry.")
body = await read_body(request)
@@ -111,5 +120,6 @@ async def pubsub_http_publish(request: Request):
@router.get("/topics")
async def pubsub_list_topics(request: Request):
require_dbapi_caller(request)
if _require_privileged(request) is None:
return error(403, "Admin or internal access required.")
return JSONResponse({"topics": pubsub.topics()})
+104
View File
@@ -0,0 +1,104 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from devplacepy.database import get_table, _now_iso, invalidate_user_relations
from devplacepy.utils import generate_uid, require_user
from devplacepy.responses import action_result
from devplacepy.services.audit import record as audit
logger = logging.getLogger(__name__)
router = APIRouter()
def _toggle_on(request, username, kind, audit_key, verb):
user = require_user(request)
target = get_table("users").find_one(username=username)
if not target or target["uid"] == user["uid"]:
return action_result(request, f"/profile/{username}")
relations = get_table("user_relations")
existing = relations.find_one(
user_uid=user["uid"], target_uid=target["uid"], kind=kind
)
if existing and not existing.get("deleted_at"):
return action_result(request, f"/profile/{username}")
if existing:
relations.update(
{"id": existing["id"], "deleted_at": None, "deleted_by": None}, ["id"]
)
else:
relations.insert(
{
"uid": generate_uid(),
"user_uid": user["uid"],
"target_uid": target["uid"],
"kind": kind,
"created_at": _now_iso(),
"deleted_at": None,
"deleted_by": None,
}
)
invalidate_user_relations(user["uid"])
logger.info(f"{user['username']} {verb} {username}")
audit.record(
request,
audit_key,
user=user,
target_type="user",
target_uid=target["uid"],
target_label=username,
summary=f"{user['username']} {verb} {username}",
links=[audit.target("user", target["uid"], username)],
)
return action_result(request, f"/profile/{username}")
def _toggle_off(request, username, kind, audit_key, verb):
user = require_user(request)
target = get_table("users").find_one(username=username)
if not target:
return action_result(request, f"/profile/{username}")
relations = get_table("user_relations")
existing = relations.find_one(
user_uid=user["uid"], target_uid=target["uid"], kind=kind, deleted_at=None
)
if existing:
relations.update(
{"id": existing["id"], "deleted_at": _now_iso(), "deleted_by": user["uid"]},
["id"],
)
invalidate_user_relations(user["uid"])
logger.info(f"{user['username']} {verb} {username}")
audit.record(
request,
audit_key,
user=user,
target_type="user",
target_uid=target["uid"],
target_label=username,
summary=f"{user['username']} {verb} {username}",
links=[audit.target("user", target["uid"], username)],
)
return action_result(request, f"/profile/{username}")
@router.post("/block/unblock/{username}")
async def unblock_user(request: Request, username: str):
return _toggle_off(request, username, "block", "relation.unblock", "unblocked")
@router.post("/block/{username}")
async def block_user(request: Request, username: str):
return _toggle_on(request, username, "block", "relation.block", "blocked")
@router.post("/mute/unmute/{username}")
async def unmute_user(request: Request, username: str):
return _toggle_off(request, username, "mute", "relation.unmute", "unmuted")
@router.post("/mute/{username}")
async def mute_user(request: Request, username: str):
return _toggle_on(request, username, "mute", "relation.mute", "muted")
+2
View File
@@ -731,6 +731,8 @@ class ProfileOut(_Out):
current_tab: Optional[str] = None
posts_count: Optional[int] = None
is_following: bool = False
is_blocked: bool = False
is_muted: bool = False
is_owner: bool = False
can_view_api_key: bool = False
api_key: Optional[str] = None
+1
View File
@@ -4,6 +4,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
"auth": "auth",
"profile": "account",
"follow": "social",
"relation": "social",
"push": "push",
"notification": "notification",
"message": "message",
+6 -6
View File
@@ -264,6 +264,7 @@ class DevPlaceBot:
or []
)
self.state.username = self._next_handle()
self.state.account_api_key = ""
self.state.email = self.faker.email()
self.state.password = self.faker.password(length=random.randint(12, 18))
self.state.started_at = datetime.now().isoformat()
@@ -312,6 +313,7 @@ class DevPlaceBot:
self.state.username = f"{base}{random.randint(10, 9999)}"[:20]
else:
self.state.username = self._next_handle()
self.state.account_api_key = ""
self.state.email = self.faker.email()
self._log(f"Signup failed, retrying as {self.state.username}")
return False
@@ -337,16 +339,14 @@ class DevPlaceBot:
return (key or "").strip()
async def _adopt_account_api_key(self) -> bool:
if self.state.account_api_key:
self.llm.api_key = self.state.account_api_key
return True
for attempt in range(5):
key = await self._fetch_account_api_key()
if key:
self.state.account_api_key = key
if key != self.state.account_api_key:
self.state.account_api_key = key
self._save()
self._log("Adopted account API key for gateway calls")
self.llm.api_key = key
self._save()
self._log("Adopted account API key for gateway calls")
return True
if attempt < 4:
await asyncio.sleep(2)
@@ -853,6 +853,34 @@ ACTIONS: tuple[Action, ...] = (
summary="Unfollow a user",
params=(path("username", "Username to unfollow."),),
),
Action(
name="block_user",
method="POST",
path="/block/{username}",
summary="Block a user so their posts, comments and messages are hidden and they cannot notify you",
params=(path("username", "Username to block."),),
),
Action(
name="unblock_user",
method="POST",
path="/block/unblock/{username}",
summary="Unblock a user",
params=(path("username", "Username to unblock."),),
),
Action(
name="mute_user",
method="POST",
path="/mute/{username}",
summary="Mute a user so they can no longer create notifications for you",
params=(path("username", "Username to mute."),),
),
Action(
name="unmute_user",
method="POST",
path="/mute/unmute/{username}",
summary="Unmute a user",
params=(path("username", "Username to unmute."),),
),
Action(
name="list_followers",
method="GET",
+4 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from typing import Any, Optional
from devplacepy.attachments import get_attachments, link_attachments
from devplacepy.database import get_table
from devplacepy.database import get_table, get_blocked_uids
from devplacepy.templating import clear_messages_cache
from devplacepy.utils import (
create_mention_notifications,
@@ -72,6 +72,9 @@ def persist_message(
if not receiver:
return None
if sender["uid"] in get_blocked_uids(receiver_uid):
return None
sender_uid = sender["uid"]
sender_username = sender.get("username", "")
messages_table = get_table("messages")
+30 -9
View File
@@ -562,9 +562,6 @@ img {
}
.topnav-link:hover { color: var(--text-primary); background: var(--bg-card); }
.topnav-link.active { color: var(--accent); background: var(--accent-light); }
.topnav-link.topnav-link-feature { color: var(--accent); font-weight: 600; background: var(--accent-light); }
.topnav-link.topnav-link-feature:hover { color: var(--accent); background: var(--accent-light); filter: brightness(1.2); }
.topnav-link.topnav-link-feature.active { color: var(--white); background: var(--accent-gradient); box-shadow: var(--glow-accent); }
.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; }
.topnav-icon:hover { color: var(--text-primary); }
@@ -1064,6 +1061,36 @@ body:has(.page-messages) {
border: 0;
}
.skip-link {
position: absolute;
top: 0;
left: 0;
z-index: 2000;
transform: translateY(-150%);
padding: 0.75rem 1.25rem;
background: var(--accent);
color: #fff;
border-radius: 0 0 var(--radius-input) 0;
font-weight: 600;
text-decoration: none;
transition: transform 0.15s ease;
}
.skip-link:focus {
transform: translateY(0);
outline: 2px solid #fff;
outline-offset: 2px;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
[inert] {
pointer-events: none;
}
@media (max-width: 1024px) {
.topnav-links {
@@ -1220,12 +1247,6 @@ body:has(.page-messages) {
opacity: 0.55;
}
.login-hint {
font-size: 0.8125rem;
color: var(--text-muted);
text-decoration: underline;
}
.response-time-indicator {
position: fixed;
left: 0.5rem;
+2 -1
View File
@@ -63,7 +63,8 @@
text-decoration: none;
}
.search-dropdown-item:hover {
.search-dropdown-item:hover,
.search-dropdown-item.active {
background: var(--bg-card-hover);
}
+38
View File
@@ -0,0 +1,38 @@
// retoor <retoor@molodetz.nl>
export class Accessibility {
constructor() {
this.hideDecorativeIcons();
this.markCurrentLinks();
this.labelIconControls();
}
hideDecorativeIcons() {
document.querySelectorAll(".icon, .topnav-caret").forEach((icon) => {
if (icon.hasAttribute("aria-hidden")) return;
const host = icon.closest("a, button, label, summary, li, h1, h2, h3, h4, span, div");
if (!host) return;
const named =
host.getAttribute("aria-label") ||
host.getAttribute("title") ||
(host.textContent || "").replace(icon.textContent || "", "").trim();
if (named) icon.setAttribute("aria-hidden", "true");
});
}
markCurrentLinks() {
document.querySelectorAll("nav a.active").forEach((link) => {
if (!link.hasAttribute("aria-current")) {
link.setAttribute("aria-current", "page");
}
});
}
labelIconControls() {
document.querySelectorAll("a[title], button[title]").forEach((el) => {
if (el.getAttribute("aria-label")) return;
if ((el.textContent || "").trim()) return;
el.setAttribute("aria-label", el.getAttribute("title"));
});
}
}
+2
View File
@@ -37,9 +37,11 @@ import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
import { PubSubClient } from "./PubSubClient.js";
import { LiveNotifications } from "./LiveNotifications.js";
import { LocalTime } from "./LocalTime.js";
import { Accessibility } from "./Accessibility.js";
class Application {
constructor() {
this.accessibility = new Accessibility();
this.dialog = document.createElement("dp-dialog");
this.contextMenu = document.createElement("dp-context-menu");
this.toast = document.createElement("dp-toast");
+3 -30
View File
@@ -1,43 +1,16 @@
// retoor <retoor@molodetz.nl>
import { EMOJI_SHORTCODES } from "./emoji-shortcodes.js";
export class ContentRenderer {
constructor() {
this.emojiMap = this.buildEmojiMap();
this.emojiMap = EMOJI_SHORTCODES;
this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i;
this.videoExtRe = /\.(mp4|webm|ogg|ogv|mov|m4v)(\?.*)?$/i;
this.audioExtRe = /\.(mp3|wav|flac|ogg|aac|m4a|wma|opus)(\?.*)?$/i;
this.youtubeRe = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
}
buildEmojiMap() {
return {
"grinning": "\u{1F600}", "smiley": "\u{1F603}", "smile": "\u{1F604}",
"grin": "\u{1F601}", "laughing": "\u{1F606}", "sweat_smile": "\u{1F605}",
"joy": "\u{1F602}", "blush": "\u{1F60A}", "innocent": "\u{1F607}",
"wink": "\u{1F609}", "heart_eyes": "\u{1F60D}", "kissing_heart": "\u{1F618}",
"stuck_out_tongue": "\u{1F61B}", "stuck_out_tongue_winking_eye": "\u{1F61C}",
"sunglasses": "\u{1F60E}", "unamused": "\u{1F612}", "sweat": "\u{1F613}",
"sob": "\u{1F62D}", "scream": "\u{1F631}", "sleeping": "\u{1F634}",
"relieved": "\u{1F60C}", "heart": "\u{2764}\u{FE0F}", "broken_heart": "\u{1F494}",
"two_hearts": "\u{1F495}", "sparkling_heart": "\u{1F496}",
"star": "\u{2B50}", "sparkles": "\u{2728}", "zap": "\u{26A1}",
"fire": "\u{1F525}", "rocket": "\u{1F680}", "100": "\u{1F4AF}",
"clap": "\u{1F44F}", "ok_hand": "\u{1F44C}", "wave": "\u{1F44B}",
"thumbsup": "\u{1F44D}", "+1": "\u{1F44D}", "thumbsdown": "\u{1F44E}",
"-1": "\u{1F44E}", "pray": "\u{1F64F}", "muscle": "\u{1F4AA}",
"tada": "\u{1F389}", "confetti_ball": "\u{1F38A}", "party": "\u{1F973}",
"beers": "\u{1F37B}", "coffee": "\u{2615}", "pizza": "\u{1F355}",
"computer": "\u{1F4BB}", "bug": "\u{1F41B}", "gear": "\u{2699}\u{FE0F}",
"warning": "\u{26A0}\u{FE0F}", "question": "\u{2753}", "exclamation": "\u{2757}",
"checkered_flag": "\u{1F3C1}", "rocket": "\u{1F680}",
"eyes": "\u{1F440}", "brain": "\u{1F9E0}", "robot": "\u{1F916}",
"skull": "\u{1F480}", "point_up": "\u{261D}\u{FE0F}", "point_down": "\u{1F447}",
"point_left": "\u{1F448}", "point_right": "\u{1F449}",
"pencil2": "\u{270F}\u{FE0F}", "memo": "\u{1F4DD}", "book": "\u{1F4D6}",
"tools": "\u{1F6E0}\u{FE0F}", "hammer_and_wrench": "\u{1F6E0}\u{FE0F}",
};
}
replaceShortcodes(text) {
return text.replace(/:([a-zA-Z0-9_+\-]+):/g, (match, name) => {
return this.emojiMap[name.toLowerCase()] || match;
+8
View File
@@ -45,6 +45,14 @@ export class CounterManager {
}
badge.textContent = count;
badge.hidden = count === 0;
const noun = target.dataset.counterNoun;
if (noun) {
const label =
count > 0
? `${count} unread ${noun}`
: `${noun.charAt(0).toUpperCase()}${noun.slice(1)}, no unread`;
target.setAttribute("aria-label", label);
}
});
}
}
+107
View File
@@ -0,0 +1,107 @@
// retoor <retoor@molodetz.nl>
export class ListNav {
constructor(input, container, options) {
this.input = input;
this.container = container;
this.itemSelector = options.itemSelector;
this.activeClass = options.activeClass || "active";
this.firstDefault = options.firstDefault !== false;
this.chooseOnTab = options.chooseOnTab !== false;
this.onChoose = options.onChoose;
this.onEscape = options.onEscape || null;
this.isOpen = options.isOpen || (() => this.items().length > 0);
this.index = -1;
if (!this.container.id) {
this.container.id = `listnav-${Math.random().toString(36).slice(2)}`;
}
this.input.setAttribute("role", "combobox");
this.input.setAttribute("aria-autocomplete", "list");
this.input.setAttribute("aria-controls", this.container.id);
this.input.setAttribute("aria-expanded", "false");
this.input.addEventListener("keydown", (e) => this.onKeydown(e));
}
items() {
return Array.from(this.container.querySelectorAll(this.itemSelector));
}
opened() {
const items = this.items();
items.forEach((el, i) => {
if (!el.id) el.id = `${this.container.id}-opt-${i}`;
el.setAttribute("role", "option");
el.setAttribute("aria-selected", "false");
});
this.input.setAttribute("aria-expanded", items.length ? "true" : "false");
this.setActive(this.firstDefault && items.length ? 0 : -1);
}
closed() {
this.index = -1;
this.input.setAttribute("aria-expanded", "false");
this.input.removeAttribute("aria-activedescendant");
}
setActive(index) {
const items = this.items();
this.index = index;
items.forEach((el, i) => {
const on = i === index;
el.classList.toggle(this.activeClass, on);
el.setAttribute("aria-selected", on ? "true" : "false");
if (on) {
this.input.setAttribute("aria-activedescendant", el.id);
el.scrollIntoView({ block: "nearest" });
}
});
if (index < 0) this.input.removeAttribute("aria-activedescendant");
}
target() {
const items = this.items();
if (this.index >= 0 && items[this.index]) return items[this.index];
return this.firstDefault ? items[0] || null : null;
}
choose(item) {
if (item) this.onChoose(item);
}
onKeydown(e) {
const items = this.items();
if (!items.length || !this.isOpen()) return;
const last = items.length - 1;
if (e.key === "ArrowDown") {
e.preventDefault();
this.setActive(this.index < last ? this.index + 1 : 0);
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.setActive(this.index > 0 ? this.index - 1 : last);
} else if (e.key === "Home") {
e.preventDefault();
this.setActive(0);
} else if (e.key === "End") {
e.preventDefault();
this.setActive(last);
} else if (e.key === "Enter") {
const item = this.target();
if (item) {
e.preventDefault();
this.choose(item);
}
} else if (e.key === "Tab" && this.chooseOnTab) {
const item = this.target();
if (item && !e.shiftKey) {
e.preventDefault();
this.choose(item);
}
} else if (e.key === "Escape") {
e.preventDefault();
if (this.onEscape) this.onEscape();
}
}
}
+25 -44
View File
@@ -4,14 +4,15 @@ import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { TextInput } from "./TextInput.js";
import { DomUtils } from "./DomUtils.js";
import { ListNav } from "./ListNav.js";
export class MentionInput {
constructor(element) {
this.input = element;
this.dropdown = null;
this.debounceTimer = null;
this.selectedIndex = -1;
this.lastMatch = null;
this.nav = null;
this.build();
}
@@ -23,13 +24,26 @@ export class MentionInput {
this.dropdown = document.createElement("div");
this.dropdown.className = "mention-dropdown";
this.dropdown.setAttribute("role", "listbox");
wrap.appendChild(this.dropdown);
this.input.addEventListener("input", () => this.onInput());
this.input.addEventListener("keydown", (e) => this.onKeydown(e));
this.input.addEventListener("blur", () => {
setTimeout(() => DomUtils.hide(this.dropdown), 200);
this.nav = new ListNav(this.input, this.dropdown, {
itemSelector: ".mention-dropdown-item",
activeClass: "active",
isOpen: () => DomUtils.isShown(this.dropdown),
onChoose: (item) => this.insert(item.dataset.username),
onEscape: () => this.hide(),
});
this.input.addEventListener("input", () => this.onInput());
this.input.addEventListener("blur", () => {
setTimeout(() => this.hide(), 200);
});
}
hide() {
DomUtils.hide(this.dropdown);
this.nav.closed();
}
onInput() {
@@ -39,7 +53,7 @@ export class MentionInput {
let match = text.match(/(?:^|\s|\x28)@([a-zA-Z0-9_-]*)$/);
if (!match) {
DomUtils.hide(this.dropdown);
this.hide();
this.lastMatch = null;
return;
}
@@ -48,7 +62,7 @@ export class MentionInput {
this.lastMatch = { query, index: match.index + (text[match.index] === "@" ? 0 : 1) };
if (query.length < 1) {
DomUtils.hide(this.dropdown);
this.hide();
return;
}
@@ -60,18 +74,17 @@ export class MentionInput {
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
const results = data.results || [];
if (results.length === 0) {
DomUtils.hide(this.dropdown);
this.hide();
return;
}
this.render(results);
} catch (e) {
DomUtils.hide(this.dropdown);
this.hide();
}
}
render(results) {
this.dropdown.innerHTML = "";
this.selectedIndex = -1;
for (const r of results) {
const item = document.createElement("button");
item.type = "button";
@@ -87,39 +100,7 @@ export class MentionInput {
this.dropdown.appendChild(item);
}
DomUtils.show(this.dropdown);
}
onKeydown(e) {
const items = this.dropdown.querySelectorAll(".mention-dropdown-item");
if (!DomUtils.isShown(this.dropdown) || items.length === 0) {
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
this.highlight(items);
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.highlight(items);
} else if (e.key === "Enter" || e.key === "Tab") {
if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
e.preventDefault();
this.insert(items[this.selectedIndex].dataset.username);
}
} else if (e.key === "Escape") {
DomUtils.hide(this.dropdown);
}
}
highlight(items) {
items.forEach((item, i) => {
item.classList.toggle("active", i === this.selectedIndex);
});
if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
items[this.selectedIndex].scrollIntoView({ block: "nearest" });
}
this.nav.opened();
}
insert(username) {
@@ -130,7 +111,7 @@ export class MentionInput {
before = before.replace(/@+$/, "");
after = after.replace(/^@+/, "");
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
DomUtils.hide(this.dropdown);
this.hide();
this.lastMatch = null;
}
}
+21 -13
View File
@@ -3,6 +3,7 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { DomUtils } from "./DomUtils.js";
import { ListNav } from "./ListNav.js";
export class MessageSearch {
constructor() {
@@ -18,8 +19,23 @@ export class MessageSearch {
const wrap = searchInput.parentElement;
const dropdown = document.createElement("div");
dropdown.className = "search-dropdown";
dropdown.setAttribute("role", "listbox");
wrap.appendChild(dropdown);
const hide = () => {
DomUtils.hide(dropdown);
nav.closed();
};
const nav = new ListNav(searchInput, dropdown, {
itemSelector: ".search-dropdown-item",
activeClass: "active",
chooseOnTab: false,
isOpen: () => DomUtils.isShown(dropdown),
onChoose: (item) => { window.location.href = item.href; },
onEscape: hide,
});
let debounceTimer = null;
searchInput.addEventListener("input", () => {
@@ -27,7 +43,7 @@ export class MessageSearch {
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
DomUtils.hide(dropdown);
hide();
return;
}
debounceTimer = setTimeout(async () => {
@@ -35,7 +51,7 @@ export class MessageSearch {
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
const results = data.results || [];
if (results.length === 0) {
DomUtils.hide(dropdown);
hide();
return;
}
dropdown.innerHTML = "";
@@ -49,24 +65,16 @@ export class MessageSearch {
dropdown.appendChild(item);
}
DomUtils.show(dropdown);
nav.opened();
} catch (e) {
// silently fail - no suggestions
hide();
}
}, 200);
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const first = dropdown.querySelector(".search-dropdown-item");
if (first) {
window.location.href = first.href;
}
}
});
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
DomUtils.hide(dropdown);
hide();
}
});
}
+5
View File
@@ -235,6 +235,11 @@ export class MessagesLayout {
}
handleIncoming(frame) {
if (frame.uid && this.thread &&
this.thread.querySelector(`.message-bubble[data-msg-uid="${frame.uid}"]`)) {
if (frame.client_id) this.clearPendingSend(frame.client_id);
return;
}
if (frame.sender_uid === this.selfUid && frame.client_id) {
this.clearPendingSend(frame.client_id);
const pending = this.thread.querySelector(`.message-bubble[data-client-id="${frame.client_id}"]`);
+13 -2
View File
@@ -19,12 +19,18 @@ export class MobileNav {
panel.classList.remove("open");
overlay.style.display = "none";
btn.innerHTML = "☰";
btn.setAttribute("aria-expanded", "false");
panel.setAttribute("inert", "");
};
const open = () => {
panel.classList.add("open");
overlay.style.display = "block";
btn.innerHTML = "✕";
btn.setAttribute("aria-expanded", "true");
panel.removeAttribute("inert");
const first = panel.querySelector("a, button");
if (first) first.focus();
};
btn.addEventListener("click", () => {
@@ -44,6 +50,7 @@ export class MobileNav {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && panel.classList.contains("open")) {
close();
btn.focus();
}
});
}
@@ -105,18 +112,22 @@ export class MobileNav {
btn.addEventListener("click", (e) => {
e.stopPropagation();
dropdown.classList.toggle("open");
const open = dropdown.classList.toggle("open");
btn.setAttribute("aria-expanded", open ? "true" : "false");
});
document.addEventListener("click", (e) => {
if (!dropdown.contains(e.target)) {
dropdown.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (e.key === "Escape" && dropdown.classList.contains("open")) {
dropdown.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
btn.focus();
}
});
}
+60
View File
@@ -44,7 +44,67 @@ export class ModalManager {
modal.classList.remove("visible");
});
});
this.enhanceModal(modal);
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
const open = [...document.querySelectorAll(".modal-overlay.visible")].pop();
if (open) open.classList.remove("visible");
});
}
enhanceModal(modal) {
if (!modal.getAttribute("role")) modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
const heading = modal.querySelector(
".modal-title, .modal-header h1, .modal-header h2, .modal-header h3, h1, h2, h3"
);
if (heading) {
if (!heading.id) {
heading.id = `modal-title-${Math.random().toString(36).slice(2, 9)}`;
}
modal.setAttribute("aria-labelledby", heading.id);
}
const trap = (e) => {
if (e.key !== "Tab") return;
const items = this.focusable(modal);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
const observer = new MutationObserver(() => {
const visible = modal.classList.contains("visible");
if (visible && !modal._a11yOpen) {
modal._a11yOpen = true;
modal._lastFocus = document.activeElement;
modal.addEventListener("keydown", trap);
const items = this.focusable(modal);
if (items.length) setTimeout(() => items[0].focus(), 20);
} else if (!visible && modal._a11yOpen) {
modal._a11yOpen = false;
modal.removeEventListener("keydown", trap);
if (modal._lastFocus && modal._lastFocus.focus) modal._lastFocus.focus();
}
});
observer.observe(modal, { attributes: true, attributeFilter: ["class"] });
}
focusable(root) {
return [
...root.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
),
].filter((el) => el.offsetParent !== null || el === document.activeElement);
}
initConfirmations() {
+1
View File
@@ -40,6 +40,7 @@ export class ProfileEditor {
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "x";
remove.setAttribute("aria-label", `Remove ${val}`);
remove.style.marginLeft = "4px";
remove.style.fontSize = "0.75rem";
remove.style.padding = "0";
+54 -4
View File
@@ -1,5 +1,7 @@
// retoor <retoor@molodetz.nl>
let tabsSeq = 0;
class Tabs {
constructor() {
document.querySelectorAll("[data-tabs]").forEach((root) => this.init(root));
@@ -10,16 +12,64 @@ class Tabs {
const panes = [...root.querySelectorAll("[data-tab-pane]")];
if (!tabs.length) return;
const show = (name) => {
tabs.forEach((t) => t.classList.toggle("active", t.dataset.tab === name));
panes.forEach((p) => p.classList.toggle("active", p.dataset.tabPane === name));
const list = tabs[0].parentElement;
if (list && !list.getAttribute("role")) {
list.setAttribute("role", "tablist");
}
const base = `tabs-${tabsSeq++}`;
tabs.forEach((tab, index) => {
const name = tab.dataset.tab;
const pane = panes.find((p) => p.dataset.tabPane === name);
const tabId = tab.id || `${base}-tab-${name}`;
tab.id = tabId;
tab.setAttribute("role", "tab");
if (pane) {
const paneId = pane.id || `${base}-pane-${name}`;
pane.id = paneId;
pane.setAttribute("role", "tabpanel");
pane.setAttribute("aria-labelledby", tabId);
const focusable = pane.querySelector(
"a[href], button, input, select, textarea, [tabindex]"
);
if (!focusable && !pane.hasAttribute("tabindex")) {
pane.setAttribute("tabindex", "0");
}
tab.setAttribute("aria-controls", paneId);
}
});
const show = (name, focusTab) => {
tabs.forEach((tab) => {
const selected = tab.dataset.tab === name;
tab.classList.toggle("active", selected);
tab.setAttribute("aria-selected", selected ? "true" : "false");
tab.setAttribute("tabindex", selected ? "0" : "-1");
if (selected && focusTab) tab.focus();
});
panes.forEach((p) => {
const selected = p.dataset.tabPane === name;
p.classList.toggle("active", selected);
});
};
tabs.forEach((tab) => {
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => {
show(tab.dataset.tab);
history.replaceState(null, "", "#" + tab.dataset.tab);
});
tab.addEventListener("keydown", (e) => {
let next = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = index + 1;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = index - 1;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = tabs.length - 1;
if (next === null) return;
e.preventDefault();
const target = tabs[(next + tabs.length) % tabs.length];
show(target.dataset.tab, true);
history.replaceState(null, "", "#" + target.dataset.tab);
});
});
const hash = (location.hash || "").slice(1);
@@ -19,6 +19,21 @@ export class AppContextMenu extends Component {
this.appendChild(menu);
this.menu = menu;
menu.addEventListener("click", (e) => e.stopPropagation());
menu.addEventListener("keydown", (e) => this.onKeydown(e));
}
onKeydown(e) {
const items = [...this.menu.querySelectorAll(".context-menu-item:not([disabled])")];
if (!items.length) return;
const index = items.indexOf(document.activeElement);
let next = null;
if (e.key === "ArrowDown") next = index + 1;
else if (e.key === "ArrowUp") next = index - 1;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
if (next === null) return;
e.preventDefault();
items[(next + items.length) % items.length].focus();
}
bindGlobal() {
@@ -42,18 +57,24 @@ export class AppContextMenu extends Component {
this.menu.textContent = "";
const list = document.createElement("ul");
list.className = "context-menu-list";
list.setAttribute("role", "none");
items.forEach((item) => {
const li = document.createElement("li");
if (item.separator) {
li.className = "context-menu-sep";
li.setAttribute("role", "separator");
list.appendChild(li);
return;
}
li.setAttribute("role", "none");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "context-menu-item" + (item.danger ? " danger" : "");
btn.setAttribute("role", "menuitem");
btn.tabIndex = -1;
btn.disabled = !!item.disabled;
const icon = item.icon ? `<span class="context-menu-icon">${item.icon}</span>` : "";
if (item.disabled) btn.setAttribute("aria-disabled", "true");
const icon = item.icon ? `<span class="context-menu-icon" aria-hidden="true">${item.icon}</span>` : "";
btn.innerHTML = icon + '<span class="context-menu-label"></span>';
btn.querySelector(".context-menu-label").textContent = item.label;
if (!item.disabled && item.onSelect) {
@@ -84,6 +105,8 @@ export class AppContextMenu extends Component {
if (top + rect.height > offY + safeH) top = offY + safeH - rect.height - 8;
this.menu.style.left = Math.max(offX + 8, left) + "px";
this.menu.style.top = Math.max(offY + 8, top) + "px";
const first = this.menu.querySelector(".context-menu-item:not([disabled])");
if (first) setTimeout(() => first.focus(), 0);
}
close() {
@@ -35,6 +35,11 @@ export class AppDialog extends Component {
this.overlay = overlay;
this.titleEl = overlay.querySelector(".dialog-title");
this.messageEl = overlay.querySelector(".dialog-message");
const uid = Math.random().toString(36).slice(2, 9);
this.titleEl.id = `dialog-title-${uid}`;
this.messageEl.id = `dialog-message-${uid}`;
overlay.setAttribute("aria-labelledby", this.titleEl.id);
overlay.setAttribute("aria-describedby", this.messageEl.id);
this.field = overlay.querySelector(".dialog-field");
this.fieldLabel = overlay.querySelector(".dialog-field-label");
this.input = overlay.querySelector(".dialog-input");
@@ -59,6 +64,24 @@ export class AppDialog extends Component {
this.dismiss();
}
});
overlay.addEventListener("keydown", (e) => {
if (e.key !== "Tab") return;
const items = [
...overlay.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
),
].filter((el) => el.offsetParent !== null);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
}
open(mode, options) {
@@ -32,6 +32,7 @@ export class AppLightbox extends Component {
overlay.className = "lightbox-overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", "Image viewer");
overlay.innerHTML =
'<button type="button" class="lightbox-close" aria-label="Close">&times;</button>' +
'<img class="lightbox-image" src="" alt="">';
@@ -53,6 +54,12 @@ export class AppLightbox extends Component {
this.close();
}
});
this.overlay.addEventListener("keydown", (e) => {
if (e.key === "Tab" && this.overlay.classList.contains("visible")) {
e.preventDefault();
this.closeBtn.focus();
}
});
document.addEventListener("click", (e) => {
const thumb = e.target.closest("img[data-lightbox]");
if (!thumb || thumb.closest("a")) return;
@@ -12,6 +12,10 @@ export class AppToast extends Component {
const ms = options.ms || 3000;
const toast = document.createElement("div");
toast.className = `dp-toast dp-toast-${type}`;
const assertive = type === "error" || type === "warning";
toast.setAttribute("role", assertive ? "alert" : "status");
toast.setAttribute("aria-live", assertive ? "assertive" : "polite");
toast.setAttribute("aria-atomic", "true");
toast.textContent = message;
const action = this._action(options);
if (action) {
+2 -1
View File
@@ -30,13 +30,14 @@ export class AppUpload extends Component {
this.button.type = "button";
this.button.className = "dp-upload-btn";
this.button.innerHTML =
'<span class="dp-upload-icon">&#x1F4CE;</span>' +
'<span class="dp-upload-icon" aria-hidden="true">&#x1F4CE;</span>' +
'<span class="dp-upload-label"></span>' +
'<span class="dp-upload-count" hidden></span>';
const label = this.attr("label", "");
const labelEl = this.button.querySelector(".dp-upload-label");
labelEl.textContent = label;
labelEl.hidden = !label;
if (!label) this.button.setAttribute("aria-label", "Upload file");
this.countEl = this.button.querySelector(".dp-upload-count");
this.input = document.createElement("input");
@@ -118,13 +118,15 @@ export default class FloatingWindow extends Component {
this.innerHTML = "";
this.win = document.createElement("div");
this.win.className = "fw-window";
this.win.setAttribute("role", "dialog");
const titleId = `fw-title-${Math.random().toString(36).slice(2, 9)}`;
const fontButtons = this.fontControls ? [
' <button type="button" data-win="font-dec" title="Smaller font">&#8722;</button>',
' <button type="button" data-win="font-inc" title="Larger font">&#43;</button>',
] : [];
this.win.innerHTML = [
'<div class="fw-titlebar">',
' <span class="fw-title"><span class="fw-dot"></span><span class="fw-title-text"></span></span>',
` <span class="fw-title"><span class="fw-dot" aria-hidden="true"></span><span class="fw-title-text" id="${titleId}"></span></span>`,
' <span class="fw-winctl">',
...fontButtons,
' <button type="button" data-win="minimize" title="Minimize (smallest usable size)">&#9643;</button>',
@@ -138,10 +140,12 @@ export default class FloatingWindow extends Component {
'<div class="fw-resize"></div>',
].join("");
this.appendChild(this.win);
this.win.setAttribute("aria-labelledby", titleId);
this.titlebar = this.win.querySelector(".fw-titlebar");
this.body = this.win.querySelector(".fw-body");
this.win.querySelector(".fw-title-text").textContent = this.titleText;
this.win.querySelectorAll(".fw-winctl button").forEach((button) => {
if (button.title) button.setAttribute("aria-label", button.title);
button.addEventListener("click", () => this._window(button.dataset.win));
});
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
+7 -4
View File
@@ -256,9 +256,11 @@ export default class DeviiTerminalElement extends FloatingWindow {
this.win = document.createElement("div");
this.win.className = "devii-window";
this.win.setAttribute("role", "dialog");
this.win.setAttribute("aria-label", "Devii assistant");
this.win.innerHTML = [
'<div class="devii-titlebar">',
' <span class="devii-title"><span class="devii-dot"></span>Devii</span>',
' <span class="devii-title"><span class="devii-dot" aria-hidden="true"></span>Devii</span>',
' <span class="devii-winctl">',
' <button type="button" data-win="font-dec" title="Smaller font">&#8722;</button>',
' <button type="button" data-win="font-inc" title="Larger font">&#43;</button>',
@@ -274,10 +276,10 @@ export default class DeviiTerminalElement extends FloatingWindow {
' <button type="button" data-cmd="/hide">hide devii</button>',
' <button type="button" data-cmd="/clear">clear</button>',
"</div>",
'<div class="devii-output"></div>',
'<div class="devii-output" role="log" aria-live="polite" aria-label="Conversation"></div>',
'<div class="devii-inputrow">',
' <span class="devii-prompt">you&gt;</span>',
' <input class="devii-input" type="text" autocomplete="off" spellcheck="false" />',
' <span class="devii-prompt" aria-hidden="true">you&gt;</span>',
' <input class="devii-input" type="text" autocomplete="off" spellcheck="false" aria-label="Message Devii" />',
"</div>",
].join("");
this.appendChild(this.win);
@@ -290,6 +292,7 @@ export default class DeviiTerminalElement extends FloatingWindow {
button.addEventListener("click", () => this._submit(button.dataset.cmd));
});
this.win.querySelectorAll(".devii-winctl button").forEach((button) => {
if (button.title) button.setAttribute("aria-label", button.title);
button.addEventListener("click", () => this._window(button.dataset.win));
});
this.input.addEventListener("keydown", (event) => this._onKey(event));
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
<button type="button" class="post-action-btn bookmark-btn{% if _bookmarked %} bookmarked{% endif %}" data-bookmark-type="{{ _type }}" data-bookmark-uid="{{ _uid }}" title="{% if user %}Save{% else %}Log in to participate{% endif %}" aria-label="Save"{{ guest_disabled(user) }}>
<button type="button" class="post-action-btn bookmark-btn{% if _bookmarked %} bookmarked{% endif %}" data-bookmark-type="{{ _type }}" data-bookmark-uid="{{ _uid }}" title="Save" aria-label="Save" aria-pressed="{% if _bookmarked %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>
<span class="bookmark-icon">&#x1F516;</span> <span class="bookmark-label">{% if _bookmarked %}Saved{% else %}Save{% endif %}</span>
</button>
+3 -3
View File
@@ -1,14 +1,14 @@
{% macro render_comment(item, depth=0) %}
<div class="comment" style="--comment-depth: {{ depth }};" data-depth="{{ depth }}">
<div class="comment-votes">
<div class="comment-votes" role="group" aria-label="Comment votes">
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote"{{ guest_disabled(user) }}>+</button>
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if item.my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button>
</form>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote"{{ guest_disabled(user) }}>-</button>
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if item.my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>-</button>
</form>
</div>
+3 -4
View File
@@ -3,7 +3,7 @@
<input type="hidden" name="target_uid" value="{{ _comment_target_uid }}">
<input type="hidden" name="target_type" value="{{ _comment_target_type }}">
{% set _user = user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<textarea name="content" placeholder="Your opinion goes here..." required maxlength="1000" class="emoji-picker-target" data-mention></textarea>
<textarea name="content" placeholder="Your opinion goes here..." required aria-required="true" aria-label="Comment" maxlength="1000" class="emoji-picker-target" data-mention></textarea>
<div class="comment-form-actions">
<dp-upload multiple
max-size="{{ max_upload_size_mb() }}"
@@ -14,10 +14,9 @@
</form>
{% else %}
<form class="comment-form comment-form-guest" onsubmit="return false">
<textarea name="content" placeholder="Your opinion goes here..." disabled aria-disabled="true"></textarea>
<textarea name="content" placeholder="Your opinion goes here..." disabled aria-disabled="true" aria-label="Comment"></textarea>
<div class="comment-form-actions">
<button type="button" class="comment-form-submit" disabled aria-disabled="true" title="Log in to participate"><span class="icon">&#x1F4E4;</span><span class="label"> Post</span></button>
{{ login_hint(user) }}
<button type="button" class="comment-form-submit" disabled aria-disabled="true"><span class="icon">&#x1F4E4;</span><span class="label"> Post</span></button>
</div>
</form>
{% endif %}
+1 -1
View File
@@ -1,4 +1,4 @@
<section class="comments-section">
<section class="comments-section" aria-label="Comments">
<h3>Comments</h3>
{% from "_comment.html" import render_comment with context %}
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="card modal-card{% if wide %} modal-card-wide{% endif %}">
<div class="modal-header">
<h3>{{ title }}</h3>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">&times;</button>
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn" aria-label="Close">&times;</button>
</div>
{{ caller() }}
</div>
+5 -5
View File
@@ -1,21 +1,21 @@
{% set pagination_prefix = pagination_query | default("") %}
{% if pagination and pagination.total > 0 and pagination.total_pages > 1 %}
{% set _base = '?' ~ pagination_prefix ~ 'page=' %}
<div class="pagination">
<div class="pagination" role="navigation" aria-label="Pagination">
<span class="pagination-info">{{ pagination.total }} items &middot; Page {{ pagination.page }} of {{ pagination.total_pages }}</span>
<div class="pagination-controls">
{% if pagination.has_prev %}
<a href="{{ _base }}{{ pagination.prev_page }}" class="pagination-btn"><span class="icon">&#x2190;</span>Previous</a>
{% else %}
<span class="pagination-btn pagination-btn-disabled"><span class="icon">&#x2190;</span>Previous</span>
<span class="pagination-btn pagination-btn-disabled" aria-disabled="true"><span class="icon">&#x2190;</span>Previous</span>
{% endif %}
<div class="pagination-pages">
{% for p in range(1, pagination.total_pages + 1) %}
{% if p == 1 or p == pagination.total_pages or (p >= pagination.page - 2 and p <= pagination.page + 2) %}
<a href="{{ _base }}{{ p }}" class="pagination-page {% if p == pagination.page %}pagination-page-active{% endif %}">{{ p }}</a>
<a href="{{ _base }}{{ p }}" class="pagination-page {% if p == pagination.page %}pagination-page-active{% endif %}"{% if p == pagination.page %} aria-current="page"{% endif %} aria-label="Page {{ p }}">{{ p }}</a>
{% elif p == pagination.page - 3 or p == pagination.page + 3 %}
<span class="pagination-ellipsis">&hellip;</span>
<span class="pagination-ellipsis" aria-hidden="true">&hellip;</span>
{% endif %}
{% endfor %}
</div>
@@ -23,7 +23,7 @@
{% if pagination.has_next %}
<a href="{{ _base }}{{ pagination.next_page }}" class="pagination-btn">Next <span class="icon">&#x2192;</span></a>
{% else %}
<span class="pagination-btn pagination-btn-disabled">Next <span class="icon">&#x2192;</span></span>
<span class="pagination-btn pagination-btn-disabled" aria-disabled="true">Next <span class="icon">&#x2192;</span></span>
{% endif %}
</div>
</div>
+1 -1
View File
@@ -7,7 +7,7 @@
<span class="poll-option-bar" style="width: {{ opt.pct }}%;"></span>
<span class="poll-option-label">{{ render_title(opt.label) }}</span>
<span class="poll-option-meta">
<span class="poll-option-check"{% if _poll.my_choice != opt.uid %} hidden{% endif %}>&#x2713;</span>
<span class="poll-option-check" aria-hidden="true"{% if _poll.my_choice != opt.uid %} hidden{% endif %}>&#x2713;</span>
<span class="poll-option-pct">{{ opt.pct }}%</span>
</span>
</button>
+4 -4
View File
@@ -24,15 +24,15 @@
<div class="post-actions">
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn" aria-label="{{ item.comment_count }} comments">
<span aria-hidden="true">&#x1F4AC;</span> {{ item.comment_count }}
</a>
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share">
&#x2197;&#xFE0E; Open
<span aria-hidden="true">&#x2197;&#xFE0E;</span> Open
</a>
{% if _show_share %}
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">&#x1F517; Share</button>
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}"><span aria-hidden="true">&#x1F517;</span> Share</button>
{% endif %}
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _bookmarked = item.bookmarked %}{% include "_bookmark_button.html" %}
</div>
+3 -3
View File
@@ -1,11 +1,11 @@
<div class="post-votes">
<div class="post-votes" role="group" aria-label="Post votes">
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote"{{ guest_disabled(user) }}>+</button>
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button>
</form>
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span>
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote"{{ guest_disabled(user) }}></button>
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if _my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button>
</form>
</div>
+4 -4
View File
@@ -1,21 +1,21 @@
{% set _counts = (_reactions or {}).get('counts', {}) %}
{% set _mine = (_reactions or {}).get('mine', []) %}
{% if user or _counts %}
<div class="reaction-bar" data-reaction-type="{{ _type }}" data-reaction-uid="{{ _uid }}">
<div class="reaction-bar" data-reaction-type="{{ _type }}" data-reaction-uid="{{ _uid }}" role="group" aria-label="Reactions">
<div class="reaction-list">
{% for emoji in REACTION_EMOJI %}
<button type="button" class="reaction-chip{% if emoji in _mine %} reacted{% endif %}" data-reaction-emoji="{{ emoji }}"{% if not _counts.get(emoji) and emoji not in _mine %} hidden{% endif %}{{ guest_disabled(user) }}>
<button type="button" class="reaction-chip{% if emoji in _mine %} reacted{% endif %}" data-reaction-emoji="{{ emoji }}" aria-label="React with {{ emoji }}" aria-pressed="{% if emoji in _mine %}true{% else %}false{% endif %}"{% if not _counts.get(emoji) and emoji not in _mine %} hidden{% endif %}{{ guest_disabled(user) }}>
<span class="reaction-emoji">{{ emoji }}</span>
<span class="reaction-count">{{ _counts.get(emoji, 0) }}</span>
</button>
{% endfor %}
</div>
<div class="reaction-add">
<button type="button" class="reaction-add-btn" aria-label="Add reaction" title="Add reaction"{{ guest_disabled(user) }}><span class="reaction-add-icon">&#x1F642;</span><span class="reaction-add-label">React</span></button>
<button type="button" class="reaction-add-btn" aria-label="Add reaction" title="Add reaction" aria-expanded="false"{{ guest_disabled(user) }}><span class="reaction-add-icon">&#x1F642;</span><span class="reaction-add-label">React</span></button>
{% if user %}
<div class="reaction-palette" hidden>
{% for emoji in REACTION_EMOJI %}
<button type="button" class="reaction-palette-btn" data-reaction-emoji="{{ emoji }}">{{ emoji }}</button>
<button type="button" class="reaction-palette-btn" data-reaction-emoji="{{ emoji }}" aria-label="React with {{ emoji }}">{{ emoji }}</button>
{% endfor %}
</div>
{% endif %}
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="sidebar-heading">Filter</div>
<div class="sidebar-section">
<form method="GET" action="{{ _action }}">
<form method="GET" action="{{ _action }}" role="search">
{% for _key, _value in (_hidden or {}).items() %}{% if _value %}<input type="hidden" name="{{ _key }}" value="{{ _value }}">{% endif %}{% endfor %}
<input type="text" name="search" placeholder="{{ _placeholder }}" value="{{ search or '' }}" aria-label="{{ _placeholder }}" maxlength="100">
</form>
+1 -1
View File
@@ -1,4 +1,4 @@
<form method="POST" action="/votes/{{ _type }}/{{ _uid }}" class="inline-form"{% if _stop %} data-stop-propagation{% endif %}>
<input type="hidden" name="value" value="1">
<button type="submit" class="{{ _btn_class }} vote-star{% if _my_vote == 1 %} voted{% endif %}"{{ guest_disabled(user) }}><span class="vote-count-value" data-vote-count="{{ _uid }}">{{ _count }}</span></button>
<button type="submit" class="{{ _btn_class }} vote-star{% if _my_vote == 1 %} voted{% endif %}" aria-label="Star" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}><span class="vote-count-value" data-vote-count="{{ _uid }}">{{ _count }}</span></button>
</form>
+1 -1
View File
@@ -1,4 +1,4 @@
<div class="form-row">
<div class="form-row" role="radiogroup" aria-label="Topic">
{% for t in _topics %}
<label class="topic-label">
<input type="radio" name="topic" value="{{ t }}" {% if t == _selected %}checked{% endif %} class="topic-radio">
+5 -4
View File
@@ -79,12 +79,13 @@
{% if links %}
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Objects related to this audit event</caption>
<thead>
<tr>
<th>Relation</th>
<th>Type</th>
<th>Label</th>
<th>UID</th>
<th scope="col">Relation</th>
<th scope="col">Type</th>
<th scope="col">Label</th>
<th scope="col">UID</th>
</tr>
</thead>
<tbody>
+11 -10
View File
@@ -9,7 +9,7 @@
<span class="admin-count">{{ pagination.total }} events</span>
</div>
<form method="get" action="/admin/audit-log" class="audit-filter-bar">
<form method="get" action="/admin/audit-log" class="audit-filter-bar" role="search" aria-label="Filter audit log">
<div class="audit-filter-field">
<label for="audit-q">Search</label>
<input type="text" id="audit-q" name="q" value="{{ filters.get('q', '') }}" placeholder="summary, key, target">
@@ -75,16 +75,17 @@
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Audit log events matching the current filters</caption>
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Actor</th>
<th>Role</th>
<th>Origin</th>
<th>Target</th>
<th>Result</th>
<th></th>
<th scope="col">Time</th>
<th scope="col">Event</th>
<th scope="col">Actor</th>
<th scope="col">Role</th>
<th scope="col">Origin</th>
<th scope="col">Target</th>
<th scope="col">Result</th>
<th scope="col"><span class="sr-only">Details</span></th>
</tr>
</thead>
<tbody>
@@ -108,7 +109,7 @@
</td>
<td>{{ e.get('target_label') or e.get('target_uid') or '' }}</td>
<td><span class="audit-result audit-result-{{ e.get('result', 'success') }}">{{ e.get('result', 'success') }}</span></td>
<td><a href="/admin/audit-log/{{ e['uid'] }}" class="admin-btn admin-btn-sm">View</a></td>
<td><a href="/admin/audit-log/{{ e['uid'] }}" class="admin-btn admin-btn-sm" aria-label="View details for {{ e['event_key'] }}">View</a></td>
</tr>
{% else %}
<tr><td colspan="8" class="admin-text-muted">No audit events match the current filters.</td></tr>
+17 -17
View File
@@ -8,8 +8,8 @@
<div class="admin-toolbar">
<h2>Backups</h2>
<div class="backup-controls">
<form id="backup-run-form" method="POST" action="/admin/backups/run" class="admin-inline-form">
<label>Target
<form id="backup-run-form" method="POST" action="/admin/backups/run" class="admin-inline-form" aria-label="Create backup">
<label for="backup-run-target">Target
<select name="target" id="backup-run-target">
{% for target in targets %}
<option value="{{ target.key }}">{{ target.label }}</option>
@@ -19,11 +19,11 @@
<button type="submit" class="admin-btn admin-btn-primary"><span class="icon">&#x1F4BE;</span> Create backup</button>
</form>
<a href="#" class="admin-btn admin-btn-sm" data-modal="backup-schedule-modal" id="backup-schedule-new"><span class="icon">&#x1F551;</span> New schedule</a>
<span class="backup-generated" data-meta="generated">-</span>
<span class="backup-generated" data-meta="generated" role="status" aria-live="polite" aria-label="Last generated">-</span>
</div>
</div>
<div id="backups-root" class="backups">
<div id="backups-root" class="backups" role="region" aria-label="Backup status" aria-live="polite">
<p class="admin-empty" data-empty>Loading backup status...</p>
</div>
@@ -31,33 +31,33 @@
<div class="modal">
<div class="modal-header">
<h3 id="backup-schedule-title">New backup schedule</h3>
<a href="#" class="modal-close">&times;</a>
<a href="#" class="modal-close" aria-label="Close dialog">&times;</a>
</div>
<form id="backup-schedule-form" method="POST" action="/admin/backups/schedules/create" class="modal-body backup-schedule-form">
<label>Name
<input type="text" name="name" maxlength="120" required placeholder="Nightly full backup">
<form id="backup-schedule-form" method="POST" action="/admin/backups/schedules/create" class="modal-body backup-schedule-form" aria-labelledby="backup-schedule-title">
<label for="backup-schedule-name">Name
<input type="text" id="backup-schedule-name" name="name" maxlength="120" required aria-required="true" placeholder="Nightly full backup">
</label>
<label>Target
<select name="target">
<label for="backup-schedule-target">Target
<select name="target" id="backup-schedule-target">
{% for target in targets %}
<option value="{{ target.key }}">{{ target.label }}</option>
{% endfor %}
</select>
</label>
<label>Schedule type
<label for="backup-schedule-kind">Schedule type
<select name="kind" id="backup-schedule-kind">
<option value="interval">Every N seconds</option>
<option value="cron">Cron expression</option>
</select>
</label>
<label data-kind="interval">Interval (seconds)
<input type="number" name="every_seconds" min="60" value="86400">
<label data-kind="interval" for="backup-schedule-every">Interval (seconds)
<input type="number" id="backup-schedule-every" name="every_seconds" min="60" value="86400">
</label>
<label data-kind="cron" hidden>Cron (minute hour dom month dow)
<input type="text" name="cron" maxlength="120" placeholder="0 3 * * *">
<label data-kind="cron" hidden for="backup-schedule-cron">Cron (minute hour dom month dow)
<input type="text" id="backup-schedule-cron" name="cron" maxlength="120" placeholder="0 3 * * *">
</label>
<label>Keep last N backups (0 = keep all)
<input type="number" name="keep_last" min="0" max="1000" value="7">
<label for="backup-schedule-keep">Keep last N backups (0 = keep all)
<input type="number" id="backup-schedule-keep" name="keep_last" min="0" max="1000" value="7">
</label>
<div class="modal-actions">
<button type="submit" class="admin-btn admin-btn-primary">Save schedule</button>
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="admin-layout">
<aside class="sidebar-card">
<div class="sidebar-heading">Admin Panel</div>
<nav class="sidebar-nav">
<nav class="sidebar-nav" aria-label="Admin">
<a href="/admin/users" class="sidebar-link {% if admin_section == 'users' %}active{% endif %}">
<span class="sidebar-icon">&#x1F465;</span> Users
</a>
+3 -2
View File
@@ -13,12 +13,13 @@
<p class="admin-empty">The Bots service is not enabled. Enable it on <a href="/admin/services">Services</a> to see live screenshots.</p>
{% endif %}
<div class="bots-grid" id="bots-grid" data-endpoint="/admin/bots/data">
<div class="bots-grid" id="bots-grid" data-endpoint="/admin/bots/data" role="region" aria-label="Live bot screenshots" aria-live="polite">
{% for frame in frames %}
<figure class="bot-card {% if frame['active'] %}bot-active{% else %}bot-idle{% endif %}" data-slot="{{ frame['slot'] }}">
<span class="sr-only">{% if frame['active'] %}active{% else %}idle{% endif %}</span>
<div class="bot-shot">
{% if frame['has_image'] %}
<img src="{{ frame['frame_url'] }}" alt="bot{{ frame['slot'] }} screenshot" loading="lazy" data-lightbox>
<img src="{{ frame['frame_url'] }}" alt="Screenshot of bot {{ frame['label'] }}" loading="lazy" data-lightbox>
<span class="bot-shot-age" data-bot-age data-age-seconds="{{ frame['age_seconds'] }}"></span>
{% else %}
<div class="bot-shot-empty">no frame yet</div>
+24 -22
View File
@@ -7,7 +7,7 @@
<div id="gateway-admin">
<div class="admin-toolbar">
<h2>Gateway routing</h2>
<span class="admin-count" id="gw-count"></span>
<span class="admin-count" id="gw-count" role="status" aria-live="polite"></span>
</div>
<p class="gw-intro">Map any requested model name onto a provider and target model, each with its own pricing economy and an optional vision model for image to text merging. Unmapped requests fall through to the default upstream unchanged.</p>
@@ -16,21 +16,22 @@
<h3>Providers</h3>
</div>
<p class="gw-section-hint">Named upstreams reused across model routes. A model route with a blank provider uses the default below.</p>
<div class="gw-default" id="gw-default"></div>
<div class="gw-default" id="gw-default" role="status" aria-live="polite"></div>
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Providers</caption>
<thead>
<tr><th>Name</th><th>Base URL</th><th>Active</th><th class="gw-actions">Actions</th></tr>
<tr><th scope="col">Name</th><th scope="col">Base URL</th><th scope="col">Active</th><th scope="col" class="gw-actions">Actions</th></tr>
</thead>
<tbody id="gw-providers"></tbody>
</table>
</div>
<form class="gw-form" id="gw-provider-form" autocomplete="off">
<form class="gw-form" id="gw-provider-form" autocomplete="off" role="group" aria-label="Add or update provider">
<p class="gw-form-title">Add or update provider</p>
<div class="gw-field"><label>Name</label><input type="text" name="name" placeholder="openrouter" required></div>
<div class="gw-field"><label>Base URL (chat completions)</label><input type="url" name="base_url" placeholder="https://openrouter.ai/api/v1/chat/completions"></div>
<div class="gw-field"><label>API key</label><input type="password" name="api_key" placeholder="sk-..." autocomplete="new-password"></div>
<div class="gw-field"><label>Active</label><select name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
<div class="gw-field"><label for="gw-provider-name">Name</label><input type="text" id="gw-provider-name" name="name" placeholder="openrouter" required aria-required="true"></div>
<div class="gw-field"><label for="gw-provider-base-url">Base URL (chat completions)</label><input type="url" id="gw-provider-base-url" name="base_url" placeholder="https://openrouter.ai/api/v1/chat/completions"></div>
<div class="gw-field"><label for="gw-provider-api-key">API key</label><input type="password" id="gw-provider-api-key" name="api_key" placeholder="sk-..." autocomplete="new-password"></div>
<div class="gw-field"><label for="gw-provider-active">Active</label><select id="gw-provider-active" name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
<div class="gw-form-actions"><button type="submit" class="admin-btn admin-btn-primary">Save provider</button></div>
</form>
</section>
@@ -42,26 +43,27 @@
<p class="gw-section-hint">Each source model maps to a provider and target model with its own economy (USD per 1M tokens). Chat uses cache-hit, cache-miss and output prices; embeddings use the input price; a vision model adds input and output pricing for the image description merge.</p>
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Model routes</caption>
<thead>
<tr><th>Source</th><th>Provider</th><th>Target</th><th>Kind</th><th>Vision</th><th class="gw-actions">Actions</th></tr>
<tr><th scope="col">Source</th><th scope="col">Provider</th><th scope="col">Target</th><th scope="col">Kind</th><th scope="col">Vision</th><th scope="col" class="gw-actions">Actions</th></tr>
</thead>
<tbody id="gw-models"></tbody>
</table>
</div>
<form class="gw-form" id="gw-model-form" autocomplete="off">
<form class="gw-form" id="gw-model-form" autocomplete="off" role="group" aria-label="Add or update model route">
<p class="gw-form-title">Add or update model route</p>
<div class="gw-field"><label>Source model (requested)</label><input type="text" name="source_model" placeholder="gpt-4o" required></div>
<div class="gw-field"><label>Provider</label><select name="provider" data-provider-select></select></div>
<div class="gw-field"><label>Target model (upstream)</label><input type="text" name="target_model" placeholder="openai/gpt-4o" required></div>
<div class="gw-field"><label>Kind</label><select name="kind"><option value="chat">chat</option><option value="embed">embed</option></select></div>
<div class="gw-field"><label>Vision provider</label><select name="vision_provider" data-provider-select></select></div>
<div class="gw-field"><label>Vision model</label><input type="text" name="vision_model" placeholder="(optional) google/gemma-3-12b-it"></div>
<div class="gw-field"><label>Context window</label><input type="number" name="context_window" min="0" value="0"></div>
<div class="gw-field"><label>Active</label><select name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
<div class="gw-field"><label>Price cache-hit / 1M ($)</label><input type="number" name="price_cache_hit_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field"><label>Price cache-miss / 1M ($)</label><input type="number" name="price_cache_miss_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field"><label>Price output / 1M ($)</label><input type="number" name="price_output_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field"><label>Price input / 1M ($) (embed/vision)</label><input type="number" name="price_input_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field"><label for="gw-model-source">Source model (requested)</label><input type="text" id="gw-model-source" name="source_model" placeholder="gpt-4o" required aria-required="true"></div>
<div class="gw-field"><label for="gw-model-provider">Provider</label><select id="gw-model-provider" name="provider" data-provider-select></select></div>
<div class="gw-field"><label for="gw-model-target">Target model (upstream)</label><input type="text" id="gw-model-target" name="target_model" placeholder="openai/gpt-4o" required aria-required="true"></div>
<div class="gw-field"><label for="gw-model-kind">Kind</label><select id="gw-model-kind" name="kind"><option value="chat">chat</option><option value="embed">embed</option></select></div>
<div class="gw-field"><label for="gw-model-vision-provider">Vision provider</label><select id="gw-model-vision-provider" name="vision_provider" data-provider-select></select></div>
<div class="gw-field"><label for="gw-model-vision-model">Vision model</label><input type="text" id="gw-model-vision-model" name="vision_model" placeholder="(optional) google/gemma-3-12b-it"></div>
<div class="gw-field"><label for="gw-model-context">Context window</label><input type="number" id="gw-model-context" name="context_window" min="0" value="0"></div>
<div class="gw-field"><label for="gw-model-active">Active</label><select id="gw-model-active" name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
<div class="gw-field"><label for="gw-model-price-cache-hit">Price cache-hit / 1M ($)</label><input type="number" id="gw-model-price-cache-hit" name="price_cache_hit_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field"><label for="gw-model-price-cache-miss">Price cache-miss / 1M ($)</label><input type="number" id="gw-model-price-cache-miss" name="price_cache_miss_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field"><label for="gw-model-price-output">Price output / 1M ($)</label><input type="number" id="gw-model-price-output" name="price_output_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field"><label for="gw-model-price-input">Price input / 1M ($) (embed/vision)</label><input type="number" id="gw-model-price-input" name="price_input_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-form-actions"><button type="submit" class="admin-btn admin-btn-primary">Save model route</button></div>
</form>
</section>
@@ -20,12 +20,12 @@
{% if not configured %}
<div class="empty-state">The issue tracker is not configured yet. Configure Gitea in Services first.</div>
{% else %}
<div class="planning-status" data-planning-status hidden>
<div class="planning-status" data-planning-status hidden role="status" aria-live="polite">
<span class="planning-spinner" aria-hidden="true"></span>
<span class="planning-status-text">Generating the planning report...</span>
</div>
<div class="planning-result" data-planning-result hidden>
<div class="planning-result" data-planning-result hidden role="region" aria-label="Planning report">
<div class="planning-actions">
<a class="btn btn-secondary" data-planning-download href="#" download>Download markdown</a>
<span class="planning-hint">Hover the report and use the Copy button to copy the markdown source.</span>
+2 -2
View File
@@ -27,10 +27,10 @@
</div>
<div class="media-tile-actions">
<form method="POST" action="/media/{{ item['uid'] }}/restore" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">&#x267B;&#xFE0F;</span> Restore</button>
<button type="submit" class="admin-btn admin-btn-sm" aria-label="Restore {{ item.get('original_filename', 'media') }}"><span class="icon">&#x267B;&#xFE0F;</span> Restore</button>
</form>
<form method="POST" action="/admin/media/{{ item['uid'] }}/purge" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Permanently delete this media? This cannot be undone."><span class="icon">&#x1F5D1;&#xFE0F;</span> Purge</button>
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Permanently delete this media? This cannot be undone." aria-label="Permanently delete {{ item.get('original_filename', 'media') }}"><span class="icon">&#x1F5D1;&#xFE0F;</span> Purge</button>
</form>
</div>
</div>
+14 -13
View File
@@ -7,17 +7,18 @@
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Imported news articles with grade, status, and curation controls</caption>
<thead>
<tr>
<th style="width: 30%;">Title</th>
<th>Source</th>
<th>Grade</th>
<th>Status</th>
<th>Img</th>
<th>Imported</th>
<th>Landing</th>
<th>Featured</th>
<th></th>
<th scope="col" style="width: 30%;">Title</th>
<th scope="col">Source</th>
<th scope="col">Grade</th>
<th scope="col">Status</th>
<th scope="col">Img</th>
<th scope="col">Imported</th>
<th scope="col">Landing</th>
<th scope="col">Featured</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@@ -41,7 +42,7 @@
<td>
<div style="display: flex; align-items: center; gap: 0.375rem;">
<form method="POST" action="/admin/news/{{ item.article['uid'] }}/publish" class="admin-inline-form">
<button type="submit" class="admin-toggle-switch {% if item.article.get('status') == 'published' %}active{% endif %}">
<button type="submit" class="admin-toggle-switch {% if item.article.get('status') == 'published' %}active{% endif %}" aria-pressed="{% if item.article.get('status') == 'published' %}true{% else %}false{% endif %}" aria-label="Toggle published status">
<span class="admin-toggle-knob"></span>
</button>
</form>
@@ -66,21 +67,21 @@
</td>
<td>
<form method="POST" action="/admin/news/{{ item.article['uid'] }}/landing" class="admin-inline-form">
<button type="submit" class="admin-toggle-switch {% if item.article.get('show_on_landing') %}active{% endif %}">
<button type="submit" class="admin-toggle-switch {% if item.article.get('show_on_landing') %}active{% endif %}" aria-pressed="{% if item.article.get('show_on_landing') %}true{% else %}false{% endif %}" aria-label="Toggle landing page placement">
<span class="admin-toggle-knob"></span>
</button>
</form>
</td>
<td>
<form method="POST" action="/admin/news/{{ item.article['uid'] }}/toggle" class="admin-inline-form">
<button type="submit" class="admin-toggle-switch {% if item.article.get('featured') %}active{% endif %}">
<button type="submit" class="admin-toggle-switch {% if item.article.get('featured') %}active{% endif %}" aria-pressed="{% if item.article.get('featured') %}true{% else %}false{% endif %}" aria-label="Toggle featured status">
<span class="admin-toggle-knob"></span>
</button>
</form>
</td>
<td>
<form method="POST" action="/admin/news/{{ item.article['uid'] }}/delete" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm" style="color: var(--danger);" data-confirm="Delete this article?">&times;</button>
<button type="submit" class="admin-btn admin-btn-sm" style="color: var(--danger);" data-confirm="Delete this article?" aria-label="Delete article">&times;</button>
</form>
</td>
</tr>
@@ -11,11 +11,12 @@
<p>These are the platform-wide defaults applied to every user who has not customized a notification on their own profile. Changing a default does not override a user's explicit choice.</p>
</div>
<table class="notif-prefs-table">
<caption class="sr-only">Platform-wide default notification channels per notification type</caption>
<thead>
<tr>
<th>Notification</th>
<th class="notif-prefs-col">In-app</th>
<th class="notif-prefs-col">Push</th>
<th scope="col">Notification</th>
<th scope="col" class="notif-prefs-col">In-app</th>
<th scope="col" class="notif-prefs-col">Push</th>
</tr>
</thead>
<tbody>
@@ -27,13 +28,13 @@
</td>
<td class="notif-prefs-col">
<label class="notif-switch">
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="in_app" {% if pref.in_app %}checked{% endif %}>
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="in_app" {% if pref.in_app %}checked{% endif %} aria-label="In-app default for {{ pref.label }}">
<span class="notif-switch-track"></span>
</label>
</td>
<td class="notif-prefs-col">
<label class="notif-switch">
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="push" {% if pref.push %}checked{% endif %}>
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="push" {% if pref.push %}checked{% endif %} aria-label="Push default for {{ pref.label }}">
<span class="notif-switch-track"></span>
</label>
</td>
+8 -7
View File
@@ -5,7 +5,7 @@
<span class="admin-count">{{ pagination.total }} soft-deleted</span>
</div>
<nav class="admin-tabs">
<nav class="admin-tabs" aria-label="Trash categories">
{% for tab in tables %}
<a href="/admin/trash?table={{ tab['key'] }}" class="admin-tab {% if tab['active'] %}active{% endif %}">
{{ tab['label'] }} <span class="admin-tab-count">{{ tab['count'] }}</span>
@@ -14,12 +14,13 @@
</nav>
<table class="admin-table">
<caption class="sr-only">Soft-deleted items with restore and purge actions</caption>
<thead>
<tr>
<th>Item</th>
<th>Removed</th>
<th>Removed by</th>
<th class="admin-table-actions">Actions</th>
<th scope="col">Item</th>
<th scope="col">Removed</th>
<th scope="col">Removed by</th>
<th scope="col" class="admin-table-actions">Actions</th>
</tr>
</thead>
<tbody>
@@ -36,10 +37,10 @@
<td>{{ item['deleted_by'] or 'system' }}</td>
<td class="admin-table-actions">
<form method="POST" action="/admin/trash/{{ item['table'] }}/{{ item['uid'] }}/restore" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">&#x267B;&#xFE0F;</span> Restore</button>
<button type="submit" class="admin-btn admin-btn-sm" aria-label="Restore {{ item['label'] }}"><span class="icon">&#x267B;&#xFE0F;</span> Restore</button>
</form>
<form method="POST" action="/admin/trash/{{ item['table'] }}/{{ item['uid'] }}/purge" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Permanently delete this item and everything removed with it? This cannot be undone."><span class="icon">&#x1F5D1;&#xFE0F;</span> Purge</button>
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Permanently delete this item and everything removed with it? This cannot be undone." aria-label="Permanently delete {{ item['label'] }}"><span class="icon">&#x1F5D1;&#xFE0F;</span> Purge</button>
</form>
</td>
</tr>
+13 -12
View File
@@ -7,14 +7,15 @@
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Registered users with role, status, and management actions</caption>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Posts</th>
<th>Status</th>
<th>Actions</th>
<th scope="col">Username</th>
<th scope="col">Email</th>
<th scope="col">Role</th>
<th scope="col">Posts</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
@@ -30,7 +31,7 @@
<td>
{% if u['uid'] != user['uid'] %}
<form method="POST" action="/admin/users/{{ u['uid'] }}/role" class="admin-inline-form">
<select name="role" data-auto-submit data-confirm-danger data-confirm="Change this user's role?" class="admin-select">
<select name="role" data-auto-submit data-confirm-danger data-confirm="Change this user's role?" class="admin-select" aria-label="Role for {{ u['username'] }}">
<option value="member" {% if u.get('role') == 'Member' %}selected{% endif %}>Member</option>
<option value="admin" {% if u.get('role') == 'Admin' %}selected{% endif %}>Admin</option>
</select>
@@ -51,17 +52,17 @@
<div class="admin-actions">
{% if u['uid'] != user['uid'] %}
<form method="POST" action="/admin/users/{{ u['uid'] }}/toggle" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="{% if u.get('is_active', True) %}Disable this user's account?{% else %}Enable this user's account?{% endif %}">
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="{% if u.get('is_active', True) %}Disable this user's account?{% else %}Enable this user's account?{% endif %}" aria-label="{% if u.get('is_active', True) %}Disable account for {{ u['username'] }}{% else %}Enable account for {{ u['username'] }}{% endif %}">
<span class="icon">{% if u.get('is_active', True) %}&#x26A1;{% else %}&#x1F512;{% endif %}</span> {% if u.get('is_active', True) %}Disable{% else %}Enable{% endif %}
</button>
</form>
<button type="button" class="admin-btn admin-btn-sm" data-toggle="pw-{{ u['uid'] }}"><span class="icon">&#x1F511;</span> Password</button>
<button type="button" class="admin-btn admin-btn-sm" data-toggle="pw-{{ u['uid'] }}" aria-expanded="false" aria-controls="pw-{{ u['uid'] }}" aria-label="Set password for {{ u['username'] }}"><span class="icon">&#x1F511;</span> Password</button>
<form id="pw-{{ u['uid'] }}" method="POST" action="/admin/users/{{ u['uid'] }}/password" class="admin-pw-form hidden">
<input type="password" name="password" placeholder="New password" minlength="6" class="admin-input-sm">
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">&#x1F4BE;</span> Set</button>
<input type="password" name="password" placeholder="New password" minlength="6" class="admin-input-sm" aria-label="New password for {{ u['username'] }}">
<button type="submit" class="admin-btn admin-btn-sm" aria-label="Save new password for {{ u['username'] }}"><span class="icon">&#x1F4BE;</span> Set</button>
</form>
<form method="POST" action="/admin/users/{{ u['uid'] }}/reset-ai-quota" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Reset this user's AI quota?"><span class="icon">&#x1F504;</span> Reset quota</button>
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Reset this user's AI quota?" aria-label="Reset AI quota for {{ u['username'] }}"><span class="icon">&#x1F504;</span> Reset quota</button>
</form>
{% else %}
<span class="admin-text-muted">You</span>
+5 -5
View File
@@ -8,7 +8,7 @@
<div class="admin-toolbar">
<h2>AI usage</h2>
<div class="ai-usage-controls">
<label>Window
<label for="ai-usage-window">Window
<select id="ai-usage-window">
<option value="1">1 hour</option>
<option value="24">24 hours</option>
@@ -18,16 +18,16 @@
</select>
</label>
<label><input type="checkbox" id="ai-usage-divided"> Show breakdowns</label>
<span class="ai-usage-generated" data-meta="generated">-</span>
<form method="POST" action="/admin/ai-quota/reset-guests" class="admin-inline-form">
<span class="ai-usage-generated" data-meta="generated" role="status" aria-live="polite" aria-label="Last generated">-</span>
<form method="POST" action="/admin/ai-quota/reset-guests" class="admin-inline-form" aria-label="Reset guest quotas">
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Reset guest AI quotas for all guests?"><span class="icon">&#x1F504;</span> Reset guest quotas</button>
</form>
<form method="POST" action="/admin/ai-quota/reset-all" class="admin-inline-form">
<form method="POST" action="/admin/ai-quota/reset-all" class="admin-inline-form" aria-label="Reset all quotas">
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Reset AI quotas for every user and guest?"><span class="icon">&#x1F504;</span> Reset all quotas</button>
</form>
</div>
</div>
<div id="ai-usage-root" class="ai-usage">
<div id="ai-usage-root" class="ai-usage" role="region" aria-label="AI usage metrics" aria-live="polite">
<p class="admin-empty" data-empty>Loading metrics...</p>
</div>
{% endblock %}
+47 -46
View File
@@ -53,19 +53,20 @@
{% endif %}
</head>
<body data-user-uid="{{ user['uid'] if user else '' }}">
<nav class="topnav">
<a class="skip-link" href="#main-content">Skip to main content</a>
<nav class="topnav" aria-label="Primary">
<div class="topnav-inner">
<a href="/feed" class="topnav-logo">Dev<span>Place</span></a>
<div class="topnav-links">
<a href="/" class="topnav-link {% if request.url.path == '/' %}active{% endif %}"><span class="icon">🏠</span> Home</a>
<a href="/feed" class="topnav-link {% if request.url.path == '/feed' %}active{% endif %}"><span class="icon">📝</span> Posts</a>
<a href="/news" class="topnav-link {% if request.url.path == '/news' or request.url.path.startswith('/news/') %}active{% endif %}"><span class="icon">📰</span> News</a>
<a href="/gists" class="topnav-link {% if request.url.path == '/gists' or request.url.path.startswith('/gists/') %}active{% endif %}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-link topnav-link-feature {% if request.url.path == '/projects' or request.url.path.startswith('/projects/') %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-link {% if request.url.path == '/leaderboard' or request.url.path.startswith('/leaderboard/') %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
<div class="topnav-tools-dropdown {% if request.url.path.startswith('/tools') %}active{% endif %}">
<button type="button" class="topnav-link topnav-tools-toggle" aria-haspopup="true" aria-expanded="false"><span class="icon">🧰</span> Tools <span class="topnav-caret"></span></button>
<div class="dropdown-menu">
<a href="/" class="topnav-link {{ nav_active(request, '/') }}"><span class="icon">🏠</span> Home</a>
<a href="/feed" class="topnav-link {{ nav_active(request, '/feed') }}"><span class="icon">📝</span> Posts</a>
<a href="/news" class="topnav-link {{ nav_active(request, '/news') }}"><span class="icon">📰</span> News</a>
<a href="/gists" class="topnav-link {{ nav_active(request, '/gists') }}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-link {{ nav_active(request, '/projects') }}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-link {{ nav_active(request, '/leaderboard') }}"><span class="icon">🏆</span> Leaderboard</a>
<div class="topnav-tools-dropdown {{ nav_active(request, '/tools') }}">
<button type="button" class="topnav-link topnav-tools-toggle" aria-expanded="false" aria-controls="tools-menu"><span class="icon">🧰</span> Tools <span class="topnav-caret"></span></button>
<div class="dropdown-menu" id="tools-menu">
<a href="/tools/seo" class="dropdown-item"><span class="icon">🔍</span> SEO Diagnostics</a>
<a href="/tools/deepsearch" class="dropdown-item"><span class="icon">🧠</span> DeepSearch</a>
</div>
@@ -74,30 +75,30 @@
<div class="topnav-right">
{% if user %}
{% set msg_unread = get_unread_messages(user["uid"]) %}
<a href="/messages" class="topnav-icon {% if request.url.path == '/messages' or request.url.path.startswith('/messages/') %}active{% endif %}" data-counter="messages" title="Messages" aria-label="Messages">
<span class="nav-bell">✉️<span class="nav-badge" data-counter-badge {% if msg_unread == 0 %}hidden{% endif %}>{{ msg_unread }}</span></span>
<a href="/messages" class="topnav-icon {{ nav_active(request, '/messages') }}" data-counter="messages" data-counter-noun="messages" title="Messages" aria-label="{% if msg_unread %}{{ msg_unread }} unread messages{% else %}Messages, no unread{% endif %}">
<span class="nav-bell" aria-hidden="true">✉️<span class="nav-badge" data-counter-badge {% if msg_unread == 0 %}hidden{% endif %}>{{ msg_unread }}</span></span>
</a>
<button type="button" class="topnav-icon" data-push-enable hidden title="Enable push notifications" aria-label="Enable push notifications">
<span class="nav-bell">🔕</span>
</button>
{% set unread_count = get_unread_count(user["uid"]) %}
<a href="/notifications" class="topnav-icon" data-counter="notifications" title="Notifications" aria-label="Notifications">
<span class="nav-bell">🔔<span class="nav-badge" data-counter-badge {% if unread_count == 0 %}hidden{% endif %}>{{ unread_count }}</span></span>
<a href="/notifications" class="topnav-icon" data-counter="notifications" data-counter-noun="notifications" title="Notifications" aria-label="{% if unread_count %}{{ unread_count }} unread notifications{% else %}Notifications, no unread{% endif %}">
<span class="nav-bell" aria-hidden="true">🔔<span class="nav-badge" data-counter-badge {% if unread_count == 0 %}hidden{% endif %}>{{ unread_count }}</span></span>
</a>
{% if is_admin(user) %}
<a href="/admin" class="topnav-icon {% if request.url.path == '/admin' or request.url.path.startswith('/admin/') %}active{% endif %}" title="Admin" aria-label="Admin">
<a href="/admin" class="topnav-icon {{ nav_active(request, '/admin') }}" title="Admin" aria-label="Admin">
<span class="nav-bell">⚙️</span>
</a>
{% endif %}
<div class="topnav-user-dropdown">
<button type="button" class="topnav-user" aria-label="User menu">
<button type="button" class="topnav-user" aria-label="User menu" aria-expanded="false" aria-controls="user-menu">
<img src="{{ avatar_url('multiavatar', user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
<div class="topnav-user-info">
<span class="topnav-user-name">{{ user['username'] }}</span>
<span class="topnav-user-level">Level {{ user.get('level', 1) }}</span>
</div>
</button>
<div class="dropdown-menu">
<div class="dropdown-menu" id="user-menu">
<a href="/profile/{{ user['username'] }}" class="dropdown-item"><span class="icon">👤</span> Profile</a>
<a href="/devii/" class="dropdown-item" data-devii-open><span class="icon">🤖</span> Devii</a>
<a href="/bookmarks/saved" class="dropdown-item"><span class="icon">🔖</span> Saved</a>
@@ -108,42 +109,42 @@
<a href="/auth/login" class="topnav-link"><span class="icon">🔑</span> Login</a>
<a href="/auth/signup" class="btn btn-primary btn-sm"><span class="icon"></span>Sign Up</a>
{% endif %}
<button type="button" class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu"></button>
<button type="button" class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu" aria-controls="mobile-panel" aria-expanded="false"></button>
</div>
</div>
</nav>
<div class="topnav-mobile-overlay" id="mobile-overlay"></div>
<div class="topnav-mobile-panel" id="mobile-panel">
<nav class="topnav-mobile-links">
<a href="/" class="topnav-mobile-link {% if request.url.path == '/' %}active{% endif %}"><span class="icon">🏠</span> Home</a>
<a href="/feed" class="topnav-mobile-link {% if request.url.path == '/feed' %}active{% endif %}"><span class="icon">📝</span> Posts</a>
<a href="/news" class="topnav-mobile-link {% if request.url.path == '/news' or request.url.path.startswith('/news/') %}active{% endif %}"><span class="icon">📰</span> News</a>
<a href="/gists" class="topnav-mobile-link {% if request.url.path == '/gists' or request.url.path.startswith('/gists/') %}active{% endif %}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-mobile-link {% if request.url.path == '/projects' or request.url.path.startswith('/projects/') %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-mobile-link {% if request.url.path == '/leaderboard' or request.url.path.startswith('/leaderboard/') %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
<div class="topnav-mobile-overlay" id="mobile-overlay" aria-hidden="true"></div>
<div class="topnav-mobile-panel" id="mobile-panel" inert>
<nav class="topnav-mobile-links" aria-label="Mobile">
<a href="/" class="topnav-mobile-link {{ nav_active(request, '/') }}"><span class="icon">🏠</span> Home</a>
<a href="/feed" class="topnav-mobile-link {{ nav_active(request, '/feed') }}"><span class="icon">📝</span> Posts</a>
<a href="/news" class="topnav-mobile-link {{ nav_active(request, '/news') }}"><span class="icon">📰</span> News</a>
<a href="/gists" class="topnav-mobile-link {{ nav_active(request, '/gists') }}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-mobile-link {{ nav_active(request, '/projects') }}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-mobile-link {{ nav_active(request, '/leaderboard') }}"><span class="icon">🏆</span> Leaderboard</a>
<div class="topnav-mobile-divider"></div>
<div class="topnav-mobile-section-label">Tools</div>
<a href="/tools/seo" class="topnav-mobile-link {% if request.url.path == '/tools/seo' or request.url.path.startswith('/tools/seo/') %}active{% endif %}"><span class="icon">🔍</span> SEO Diagnostics</a>
<a href="/tools/deepsearch" class="topnav-mobile-link {% if request.url.path == '/tools/deepsearch' or request.url.path.startswith('/tools/deepsearch/') %}active{% endif %}"><span class="icon">🧠</span> DeepSearch</a>
<a href="/tools/seo" class="topnav-mobile-link {{ nav_active(request, '/tools/seo') }}"><span class="icon">🔍</span> SEO Diagnostics</a>
<a href="/tools/deepsearch" class="topnav-mobile-link {{ nav_active(request, '/tools/deepsearch') }}"><span class="icon">🧠</span> DeepSearch</a>
{% if user %}
<a href="/messages" class="topnav-mobile-link {% if request.url.path == '/messages' or request.url.path.startswith('/messages/') %}active{% endif %}" data-counter="messages"><span class="icon">✉️</span> Messages<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_messages(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_messages(user["uid"]) }}</span></a>
<a href="/notifications" class="topnav-mobile-link {% if request.url.path == '/notifications' or request.url.path.startswith('/notifications/') %}active{% endif %}" data-counter="notifications"><span class="icon">🔔</span> Notifications<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_count(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_count(user["uid"]) }}</span></a>
<a href="/messages" class="topnav-mobile-link {{ nav_active(request, '/messages') }}" data-counter="messages" data-counter-noun="messages" aria-label="{% if get_unread_messages(user['uid']) %}Messages, {{ get_unread_messages(user['uid']) }} unread{% else %}Messages, no unread{% endif %}"><span class="icon">✉️</span> Messages<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_messages(user["uid"]) == 0 %}hidden{% endif %} aria-hidden="true">{{ get_unread_messages(user["uid"]) }}</span></a>
<a href="/notifications" class="topnav-mobile-link {{ nav_active(request, '/notifications') }}" data-counter="notifications" data-counter-noun="notifications" aria-label="{% if get_unread_count(user['uid']) %}Notifications, {{ get_unread_count(user['uid']) }} unread{% else %}Notifications, no unread{% endif %}"><span class="icon">🔔</span> Notifications<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_count(user["uid"]) == 0 %}hidden{% endif %} aria-hidden="true">{{ get_unread_count(user["uid"]) }}</span></a>
{% if is_admin(user) %}
<div class="topnav-mobile-divider"></div>
<div class="topnav-mobile-section-label">Admin</div>
<a href="/admin" class="topnav-mobile-link {% if request.url.path == '/admin' %}active{% endif %}"><span class="icon">🛡️</span> Dashboard</a>
<a href="/admin/users" class="topnav-mobile-link {% if request.url.path == '/admin/users' or request.url.path.startswith('/admin/users/') %}active{% endif %}"><span class="icon">👥</span> Users</a>
<a href="/admin/news" class="topnav-mobile-link {% if request.url.path == '/admin/news' or request.url.path.startswith('/admin/news/') %}active{% endif %}"><span class="icon">📰</span> News</a>
<a href="/admin/media" class="topnav-mobile-link {% if request.url.path == '/admin/media' or request.url.path.startswith('/admin/media/') %}active{% endif %}"><span class="icon">🖼️</span> Media</a>
<a href="/admin/trash" class="topnav-mobile-link {% if request.url.path == '/admin/trash' or request.url.path.startswith('/admin/trash/') %}active{% endif %}"><span class="icon">🗑️</span> Trash</a>
<a href="/admin/services" class="topnav-mobile-link {% if request.url.path == '/admin/services' or request.url.path.startswith('/admin/services/') %}active{% endif %}"><span class="icon">⚙️</span> Services</a>
<a href="/admin/containers" class="topnav-mobile-link {% if request.url.path == '/admin/containers' or request.url.path.startswith('/admin/containers/') %}active{% endif %}"><span class="icon">📦</span> Containers</a>
<a href="/admin/bots" class="topnav-mobile-link {% if request.url.path == '/admin/bots' or request.url.path.startswith('/admin/bots/') %}active{% endif %}"><span class="icon">🤖</span> Bot Monitor</a>
<a href="/admin/ai-usage" class="topnav-mobile-link {% if request.url.path == '/admin/ai-usage' or request.url.path.startswith('/admin/ai-usage/') %}active{% endif %}"><span class="icon">📊</span> AI usage</a>
<a href="/admin/audit-log" class="topnav-mobile-link {% if request.url.path == '/admin/audit-log' or request.url.path.startswith('/admin/audit-log/') %}active{% endif %}"><span class="icon">📋</span> Audit Log</a>
<a href="/admin/notifications" class="topnav-mobile-link {% if request.url.path == '/admin/notifications' or request.url.path.startswith('/admin/notifications/') %}active{% endif %}"><span class="icon">🔔</span> Notifications</a>
<a href="/admin/settings" class="topnav-mobile-link {% if request.url.path == '/admin/settings' or request.url.path.startswith('/admin/settings/') %}active{% endif %}"><span class="icon">🔧</span> Settings</a>
<a href="/admin" class="topnav-mobile-link {{ nav_active(request, '/admin', exact=True) }}"><span class="icon">🛡️</span> Dashboard</a>
<a href="/admin/users" class="topnav-mobile-link {{ nav_active(request, '/admin/users') }}"><span class="icon">👥</span> Users</a>
<a href="/admin/news" class="topnav-mobile-link {{ nav_active(request, '/admin/news') }}"><span class="icon">📰</span> News</a>
<a href="/admin/media" class="topnav-mobile-link {{ nav_active(request, '/admin/media') }}"><span class="icon">🖼️</span> Media</a>
<a href="/admin/trash" class="topnav-mobile-link {{ nav_active(request, '/admin/trash') }}"><span class="icon">🗑️</span> Trash</a>
<a href="/admin/services" class="topnav-mobile-link {{ nav_active(request, '/admin/services') }}"><span class="icon">⚙️</span> Services</a>
<a href="/admin/containers" class="topnav-mobile-link {{ nav_active(request, '/admin/containers') }}"><span class="icon">📦</span> Containers</a>
<a href="/admin/bots" class="topnav-mobile-link {{ nav_active(request, '/admin/bots') }}"><span class="icon">🤖</span> Bot Monitor</a>
<a href="/admin/ai-usage" class="topnav-mobile-link {{ nav_active(request, '/admin/ai-usage') }}"><span class="icon">📊</span> AI usage</a>
<a href="/admin/audit-log" class="topnav-mobile-link {{ nav_active(request, '/admin/audit-log') }}"><span class="icon">📋</span> Audit Log</a>
<a href="/admin/notifications" class="topnav-mobile-link {{ nav_active(request, '/admin/notifications') }}"><span class="icon">🔔</span> Notifications</a>
<a href="/admin/settings" class="topnav-mobile-link {{ nav_active(request, '/admin/settings') }}"><span class="icon">🔧</span> Settings</a>
{% endif %}
<div class="topnav-mobile-divider"></div>
<div class="topnav-mobile-user">
@@ -155,7 +156,7 @@
</div>
<a href="/profile/{{ user['username'] }}" class="topnav-mobile-link"><span class="icon">👤</span> Profile</a>
<a href="/devii/" class="topnav-mobile-link" data-devii-open><span class="icon">🤖</span> Devii</a>
<a href="/bookmarks/saved" class="topnav-mobile-link {% if request.url.path == '/bookmarks/saved' or request.url.path.startswith('/bookmarks/') %}active{% endif %}"><span class="icon">🔖</span> Saved</a>
<a href="/bookmarks/saved" class="topnav-mobile-link {{ nav_active(request, '/bookmarks') }}"><span class="icon">🔖</span> Saved</a>
<a href="/auth/logout" class="topnav-mobile-link"><span class="icon">🚪</span> Logout</a>
{% else %}
<div class="topnav-mobile-divider"></div>
@@ -181,7 +182,7 @@
</nav>
{% endif %}
<main class="page" data-page-type="{{ page_type_for(request) }}">
<main id="main-content" class="page" tabindex="-1" data-page-type="{{ page_type_for(request) }}">
{% block content %}{% endblock %}
</main>
@@ -194,7 +195,7 @@
{% endblock %}
{%- set _response_time = response_time_ms(request) -%}
{%- if _response_time %}<div class="response-time-indicator" title="Server response time">{{ _response_time }} ms</div>{% endif -%}
{%- if _response_time %}<div class="response-time-indicator" title="Server response time" aria-hidden="true">{{ _response_time }} ms</div>{% endif -%}
{% if user %}
<template id="comment-reply-template">
+2 -2
View File
@@ -20,7 +20,7 @@
<button type="button" class="btn btn-primary" data-modal="cm-new-instance-modal">New</button>
</div>
<p class="cm-hint">Every instance runs the shared <code>ppy</code> image with this project's files mounted at <code>/app</code>.</p>
<ul class="cm-list" id="cm-instance-list"></ul>
<ul class="cm-list" id="cm-instance-list" aria-live="polite" aria-label="Container instances"></ul>
</section>
</div>
@@ -31,7 +31,7 @@
<form id="cm-new-instance-form" class="cm-modal-form">
<div class="auth-field auth-field-gap">
<label for="cm-inst-new-name">Name</label>
<input type="text" id="cm-inst-new-name" name="name" required maxlength="64" placeholder="my-service">
<input type="text" id="cm-inst-new-name" name="name" required aria-required="true" maxlength="64" placeholder="my-service">
</div>
<div class="auth-field auth-field-gap">
<label for="cm-inst-new-boot">Boot command</label>
+19 -18
View File
@@ -13,19 +13,20 @@
<div class="admin-table-wrap">
<table class="admin-table" id="cm-admin-list" data-endpoint="/admin/containers/data">
<caption class="sr-only">Container instances</caption>
<thead>
<tr>
<th>Instance</th>
<th>Project</th>
<th>Status</th>
<th>Boot</th>
<th>Ingress</th>
<th>Restart</th>
<th>Boot on start</th>
<th>Actions</th>
<th scope="col">Instance</th>
<th scope="col">Project</th>
<th scope="col">Status</th>
<th scope="col">Boot</th>
<th scope="col">Ingress</th>
<th scope="col">Restart</th>
<th scope="col">Boot on start</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="cm-admin-rows">
<tbody id="cm-admin-rows" aria-live="polite">
{% for inst in instances %}
<tr class="cm-row" data-uid="{{ inst['uid'] }}" data-slug="{{ inst['project_slug'] }}" data-name="{{ inst['name'] }}">
<td><a class="cm-row-name" href="/admin/containers/{{ inst['uid'] }}">{{ inst['name'] }}</a></td>
@@ -44,12 +45,12 @@
<td>{{ inst['restart_policy'] or 'never' }}</td>
<td>{% if inst['start_on_boot'] %}<span class="cm-badge cm-running">on</span>{% else %}<span class="cm-muted">off</span>{% endif %}</td>
<td class="cm-actions">
<button class="admin-btn admin-btn-sm" data-cm-action="start">Start</button>
<button class="admin-btn admin-btn-sm" data-cm-action="stop">Stop</button>
<button class="admin-btn admin-btn-sm" data-cm-action="restart">Restart</button>
<button class="admin-btn admin-btn-sm" data-cm-action="terminal">Terminal</button>
<a class="admin-btn admin-btn-sm" href="/admin/containers/{{ inst['uid'] }}/edit">Edit</a>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-cm-action="delete">Delete</button>
<button class="admin-btn admin-btn-sm" data-cm-action="start" aria-label="Start {{ inst['name'] }}">Start</button>
<button class="admin-btn admin-btn-sm" data-cm-action="stop" aria-label="Stop {{ inst['name'] }}">Stop</button>
<button class="admin-btn admin-btn-sm" data-cm-action="restart" aria-label="Restart {{ inst['name'] }}">Restart</button>
<button class="admin-btn admin-btn-sm" data-cm-action="terminal" aria-label="Open terminal for {{ inst['name'] }}">Terminal</button>
<a class="admin-btn admin-btn-sm" href="/admin/containers/{{ inst['uid'] }}/edit" aria-label="Edit {{ inst['name'] }}">Edit</a>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-cm-action="delete" aria-label="Delete {{ inst['name'] }}">Delete</button>
</td>
</tr>
{% else %}
@@ -64,15 +65,15 @@
<label>Project
<input type="text" name="project_query" autocomplete="off" placeholder="Search projects by title" data-search="project">
<input type="hidden" name="project_slug">
<ul class="cm-suggest" data-suggest="project"></ul>
<ul class="cm-suggest" data-suggest="project" aria-live="polite" aria-label="Project suggestions"></ul>
</label>
<label>Run as user (identity + API key only)
<input type="text" name="run_as_query" autocomplete="off" placeholder="Search a user (optional)" data-search="user">
<input type="hidden" name="run_as_uid">
<ul class="cm-suggest" data-suggest="user"></ul>
<ul class="cm-suggest" data-suggest="user" aria-live="polite" aria-label="User suggestions"></ul>
</label>
<label>Name
<input type="text" name="name" maxlength="64" required>
<input type="text" name="name" maxlength="64" required aria-required="true">
</label>
<label>Boot language
<select name="boot_language">
+2 -2
View File
@@ -9,12 +9,12 @@
<a class="admin-btn admin-btn-sm" href="/admin/containers/{{ instance['uid'] }}">&larr; Back to instance</a>
</div>
<form id="cm-edit-form" class="cm-form cm-card" data-uid="{{ instance['uid'] }}">
<form id="cm-edit-form" class="cm-form cm-card" data-uid="{{ instance['uid'] }}" role="group" aria-label="Edit {{ instance['name'] }}">
<label>Run as user (identity + API key only; container OS user is always pravda)
<input type="text" name="run_as_query" autocomplete="off" placeholder="Search a user (optional)" data-search="user"
value="{{ run_as_user['username'] if run_as_user else '' }}">
<input type="hidden" name="run_as_uid" value="{{ instance['run_as_uid'] or '' }}">
<ul class="cm-suggest" data-suggest="user"></ul>
<ul class="cm-suggest" data-suggest="user" aria-live="polite" aria-label="User suggestions"></ul>
</label>
<label>Boot language
<select name="boot_language">
+10 -9
View File
@@ -13,7 +13,7 @@
<a href="/admin/containers" class="back-link">&larr; Containers</a>
<div class="ci-titlebar">
<h1 class="ci-title">{{ instance['name'] }}</h1>
<span class="cm-badge cm-{{ instance['status'] }}" id="ci-status">{{ instance['status'] }}</span>
<span class="cm-badge cm-{{ instance['status'] }}" id="ci-status" role="status" aria-live="polite" aria-label="Status">{{ instance['status'] }}</span>
</div>
{% if project %}
<a class="ci-project" href="/projects/{{ project_slug }}/containers">{{ project['title'] or project_slug }}</a>
@@ -23,7 +23,7 @@
<div class="ci-grid">
<section class="card ci-card ci-actions-card">
<h2>Controls</h2>
<div class="ci-actions" id="ci-actions"></div>
<div class="ci-actions" id="ci-actions" role="group" aria-label="Instance controls"></div>
<div class="ci-runtime">
<div class="ci-kv"><span>Command</span><code>{{ runtime['command'] }}</code></div>
<div class="ci-kv"><span>Restart policy</span><code>{{ instance['restart_policy'] or 'never' }}</code></div>
@@ -46,7 +46,7 @@
<section class="card ci-card ci-metrics-card">
<h2>Metrics</h2>
<div class="ci-metrics" id="ci-metrics">
<div class="ci-metrics" id="ci-metrics" role="status" aria-live="polite" aria-label="Container metrics">
<div class="ci-metric"><span class="ci-metric-label">Samples</span><span class="ci-metric-value" data-metric="samples">{{ stats['samples'] }}</span></div>
<div class="ci-metric"><span class="ci-metric-label">CPU avg</span><span class="ci-metric-value" data-metric="cpu_avg">{{ stats['cpu_avg'] }}%</span></div>
<div class="ci-metric"><span class="ci-metric-label">CPU p95</span><span class="ci-metric-value" data-metric="cpu_p95">{{ stats['cpu_p95'] }}%</span></div>
@@ -60,13 +60,13 @@
<h2>Schedules</h2>
<button type="button" class="btn btn-secondary btn-sm" data-modal="ci-schedule-modal">Add schedule</button>
</div>
<ul class="ci-schedules" id="ci-schedules">
<ul class="ci-schedules" id="ci-schedules" aria-live="polite" aria-label="Schedules">
{% for s in schedules %}
<li class="ci-schedule" data-sid="{{ s['uid'] }}">
<span class="ci-schedule-action">{{ s['action'] }}</span>
<span class="ci-schedule-next">next {{ s['next_run_at'] or '-' }}</span>
<span class="cm-muted">runs {{ s['run_count'] or 0 }}</span>
<button type="button" class="btn btn-secondary btn-sm" data-schedule-delete="{{ s['uid'] }}">Delete</button>
<button type="button" class="btn btn-secondary btn-sm" data-schedule-delete="{{ s['uid'] }}" aria-label="Delete {{ s['action'] }} schedule">Delete</button>
</li>
{% else %}
<li class="cm-muted ci-schedule-empty">No schedules.</li>
@@ -77,18 +77,19 @@
<section class="card ci-card">
<h2>Exec</h2>
<div class="ci-exec">
<label for="ci-exec-input" class="sr-only">One-shot command</label>
<input type="text" id="ci-exec-input" placeholder="one-shot command, e.g. ls -la /app">
<button type="button" class="btn btn-secondary" id="ci-exec-run">Run</button>
<button type="button" class="btn btn-secondary" id="ci-term-toggle">Open terminal</button>
<button type="button" class="btn btn-secondary" id="ci-term-toggle" aria-label="Open interactive terminal for {{ instance['name'] }}">Open terminal</button>
</div>
<div class="ci-terminal" id="ci-terminal" hidden>
<pre class="ci-log ci-term-output" id="ci-term"></pre>
<pre class="ci-log ci-term-output" id="ci-term" role="log" aria-live="polite" aria-label="Terminal output"></pre>
</div>
</section>
<section class="card ci-card">
<h2>Status history</h2>
<ul class="ci-events" id="ci-events">
<ul class="ci-events" id="ci-events" aria-live="polite" aria-label="Status history">
{% for ev in events %}
<li class="ci-event">
<span class="ci-event-name">{{ ev['event'] }}</span>
@@ -103,7 +104,7 @@
<section class="card ci-card">
<h2>Logs</h2>
<pre class="ci-log" id="ci-log"></pre>
<pre class="ci-log" id="ci-log" role="log" aria-live="polite" aria-label="Container logs"></pre>
</section>
</div>
@@ -0,0 +1,50 @@
<div class="docs-content" data-render>
# Block and mute
Every profile that is not your own carries a **Block** and a **Mute** button next to Follow. Both act
only on your own account, are reversible, and are never disclosed to the other person. Each click asks
for confirmation before it takes effect.
## Block
Blocking someone removes them from your experience entirely. Once you block a user:
- Their posts disappear from your feed, the landing page, and the projects and gists listings.
- Their comments are hidden from you on every post, project, gist and news item.
- Opening a direct link to one of their posts, projects or gists returns "not found".
- Their conversations vanish from your messages, and they can no longer send you a direct message.
- The issue list hides issues they reported.
- They can no longer create notifications for you.
Blocking is **one-directional**: it changes only what you see. The other person is not told and their
own view of the site is unchanged.
### The one exception: their profile
The blocked user's **own profile page stays fully visible to you**, including all of their content.
This is deliberate, so you can always review who you blocked and lift the block. Use the **Unblock**
button there to restore them everywhere at once.
## Mute
Muting is the lighter option. A muted user can no longer create notifications for you - no follow,
mention, reaction, comment-reply or direct-message alert - but **all of their content stays visible**
to you across the site. Use it when you still want to read someone but no longer want to be pinged by
them. The **Unmute** button reverses it.
## Block versus mute
| | Hides their content | Stops their notifications |
|---|---|---|
| **Mute** | No | Yes |
| **Block** | Yes | Yes |
Block is the stronger action and already includes everything mute does, so there is no need to mute
someone you have blocked.
## Where to manage them
Both are managed from the other user's profile page. There is nothing to configure in settings: the
buttons toggle between Block/Unblock and Mute/Unmute based on the current state, and the change applies
immediately after you confirm.
</div>
+1 -1
View File
@@ -15,7 +15,7 @@ A DevRant auth token (the `key` field) also works on the **main DevPlace API**:
Bearer token or `X-API-KEY` header on any DevPlace endpoint. See
[Authentication](/docs/authentication.html) for details.
DevPlace also has its own native token endpoint at `POST /auth/token` see the
DevPlace also has its own native token endpoint at `POST /auth/token` - see the
[Authentication](/docs/authentication.html) page.
</div>
{% set devrant_slug = 'devrant-auth' %}
@@ -0,0 +1,48 @@
<div class="docs-content" data-render>
# Emoji shortcodes
Type a colon-wrapped name like `:rocket:` in any text you write and it is replaced with the matching
emoji 🚀 when the content is shown. This is the same `:name:` convention used by GitHub and Discord, so
shortcodes you already know work here too.
## How to use it
Write the shortcode inline with your text:
- `:smile:` becomes 😄
- `:rocket:` becomes 🚀
- `:tada:` becomes 🎉
- `:fire:` becomes 🔥
- `:+1:` becomes 👍 and `:-1:` becomes 👎
- `:smiling_imp:` becomes 😈 and `:imp:` becomes 👿
A shortcode that is not a real name is left exactly as you typed it, so `:not_an_emoji:` stays as plain
text. Names are case-insensitive.
## Where it works
Shortcodes are expanded everywhere your prose is rendered:
- Posts, comments, and post and comment titles
- Project and gist titles and descriptions
- News articles
- Direct messages
- Live content as it appears (new comments, chat replies, message bubbles)
Both the server-rendered pages and the live, in-browser updates use the **same shortcode list**, so what
you see while typing matches what everyone else sees after the page reloads.
## The full set
The list is the complete standard emoji set, the same one GitHub and Discord draw from: several thousand
names covering faces, hands, hearts, objects, food, flags, and symbols. If a name exists in that
standard set, it works here.
If you prefer to pick from a visual grid instead of remembering names, use the **emoji picker** button in
the composer; it inserts the actual emoji directly.
## Code stays untouched
Shortcodes inside code spans and fenced code blocks are never expanded, so technical text such as
`a:rocket:b` written as code keeps its colons. This keeps snippets and configuration examples exact.
</div>
@@ -21,7 +21,7 @@ The in-process caches (`_user_cache` in `utils.py`, `_settings_cache` in `databa
- A mutation that must invalidate a cache calls `bump_cache_version(name)` - an atomic `UPDATE ... version + 1` in the shared table.
- Before serving from a cache, the worker calls `sync_local_cache(name, cache)`, which reads the table's version and compares it to the version its local cache was built against; if they differ, it clears and rebuilds the local cache from the database. The version read is an uncached indexed primary-key lookup on the tiny `cache_state` table, so invalidation is immediate across workers.
A logout, ban, role change, or settings save in one worker is therefore honored by every other worker on its next request, while the common path stays a cheap in-memory hit. `auth` covers `_user_cache` (logout, deactivation, role and password changes); `settings` covers `_settings_cache` (every `set_setting` and the admin settings save).
A logout, ban, role change, or settings save in one worker is therefore honored by every other worker on its next request, while the common path stays a cheap in-memory hit. The named versions in use are `auth` (covers `_user_cache`: logout, deactivation, role and password changes), `settings` (`_settings_cache`: every `set_setting` and the admin settings save), `relations` (block/mute lists), `customizations` (per-user CSS/JS), `notif_prefs` (notification preferences), `gateway_routing` (AI provider/model routes), and `admins` (the cached admin-user set behind `get_admin_uids`/`get_primary_admin_uid`, invalidated by `invalidate_admins_cache()` on every role change). Caches whose value is deterministic or only cosmetically stale do not use a version: the docs prose markdown is memoized on its static source, and the guest home page blocks use a short 60-second time-to-live.
## Rate limiting
+5 -5
View File
@@ -13,8 +13,8 @@
"isAdmin": is_admin(user)
} | tojson | safe }};</script>
<div class="docs-layout">
<aside class="sidebar-card">
<div class="sidebar-heading">Documentation</div>
<aside class="sidebar-card" role="navigation" aria-label="Documentation">
<div class="sidebar-heading" role="heading" aria-level="2">Documentation</div>
<form class="docs-search-form" method="get" action="/docs/search.html" role="search">
<input type="search" name="q" value="{{ search_query|default('', true) }}" placeholder="Search docs…" aria-label="Search documentation" class="docs-search-input">
</form>
@@ -24,9 +24,9 @@
Search
</a>
{% for audience, tiers in nav %}
<div class="sidebar-heading sidebar-tier">{{ audience }}</div>
<div class="sidebar-heading sidebar-tier" role="heading" aria-level="2">{{ audience }}</div>
{% for section, section_pages in tiers %}
<div class="sidebar-heading sidebar-subheading">{{ section }}</div>
<div class="sidebar-heading sidebar-subheading" role="heading" aria-level="3">{{ section }}</div>
{% for p in section_pages %}
<a href="/docs/{{ p.slug }}.html" class="sidebar-link sidebar-link-nested {% if p.slug == current %}active{% endif %}">
<span class="icon">&#x1F4D8;</span>
@@ -36,7 +36,7 @@
{% endfor %}
{% endfor %}
</div>
<div class="sidebar-heading docs-download-heading">Download</div>
<div class="sidebar-heading docs-download-heading" role="heading" aria-level="2">Download</div>
<div class="sidebar-nav">
<a href="/docs/download.html" class="sidebar-link"><span class="icon">&#x1F4E5;</span> Single HTML</a>
<a href="/docs/download.md" class="sidebar-link"><span class="icon">&#x1F4DD;</span> Markdown</a>
+1 -1
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block content %}
<div class="error-page">
<div class="error-page" role="alert">
<h1 class="error-code">{{ error_code }}</h1>
<p class="error-message">{{ error_message }}</p>
<a href="/feed" class="btn btn-primary">Back to Feed</a>
+8 -8
View File
@@ -8,7 +8,7 @@
{% block content %}
<h1 class="sr-only">Feed</h1>
<div class="feed-layout">
<aside class="sidebar-card">
<aside class="sidebar-card" role="complementary" aria-label="Feed filters">
{% set _action = "/feed" %}{% set _placeholder = "Search posts..." %}{% set _hidden = {"tab": current_tab, "topic": current_topic} %}{% include "_sidebar_search.html" %}
<div class="sidebar-heading">Topics</div>
@@ -74,7 +74,7 @@
</div>
</div>
<div class="feed-posts">
<div class="feed-posts" role="feed" aria-label="Posts">
{% for item in posts %}
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = true %}{% set _show_comment_form = true %}{% include "_post_card.html" %}
{% else %}
@@ -85,7 +85,7 @@
{% include "_load_more.html" %}
</div>
<aside class="feed-right">
<aside class="feed-right" role="complementary" aria-label="Community">
<div class="daily-topic-card">
<div class="daily-topic-label">Daily Topic</div>
{% if daily_topic.get('image_url', '') %}
@@ -139,7 +139,7 @@
</div>
{% if user %}
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post">+</a>
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post" aria-label="Create New Post">+</a>
{% call modal('create-post-modal', 'Create New Post') %}
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
@@ -176,10 +176,10 @@
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">&#x1F4CA;</span> <span data-poll-toggle-label>Add poll</span></button>
</div>
<div class="poll-builder" data-poll-builder hidden>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" class="poll-builder-question" disabled>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" aria-label="Poll question" class="poll-builder-question" disabled>
<div class="poll-builder-options" data-poll-options>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" aria-label="Poll option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" aria-label="Poll option 2" disabled></div>
</div>
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
<p class="poll-builder-error" data-poll-error hidden></p>
@@ -192,7 +192,7 @@
</form>
{% endcall %}
{% else %}
<a href="/auth/login" class="feed-fab login-required" title="Log in to participate">+</a>
<a href="/auth/login" class="feed-fab login-required" aria-label="Log in">+</a>
{% endif %}
{% endblock %}
+3 -3
View File
@@ -10,13 +10,13 @@
<p class="subtitle">Enter your email and we will send you a reset link</p>
{% if sent %}
<div class="auth-success">
<div class="auth-success" role="status">
If an account with that email exists, a reset link has been sent. Check your email or server logs.
</div>
{% endif %}
{% if errors %}
<div class="auth-error">
<div class="auth-error" role="alert">
<ul>
{% for error in errors %}
<li>{{ error }}</li>
@@ -28,7 +28,7 @@
<form class="auth-form" method="POST" action="/auth/forgot-password">
<div class="auth-field">
<label for="email">Email address</label>
<input type="email" id="email" name="email" required maxlength="255" placeholder="you@example.com">
<input type="email" id="email" name="email" required aria-required="true" maxlength="255" placeholder="you@example.com">
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x1F4E7;</span> Send Reset Link</button>
</form>
+3 -3
View File
@@ -10,7 +10,7 @@
{% endblock %}
{% block content %}
<div class="gists-layout">
<aside class="sidebar-card">
<aside class="sidebar-card" role="complementary" aria-label="Gist filters">
{% set _action = "/gists" %}{% set _placeholder = "Search gists..." %}{% set _hidden = {"language": current_language, "user_uid": request.query_params.get('user_uid')} %}{% include "_sidebar_search.html" %}
<div class="sidebar-heading">Languages</div>
@@ -99,7 +99,7 @@
</div>
{% if user %}
<button type="button" class="feed-fab" id="create-gist-btn" title="Create Gist" data-modal="create-gist-modal">+</button>
<button type="button" class="feed-fab" id="create-gist-btn" title="Create Gist" data-modal="create-gist-modal" aria-label="Create Gist">+</button>
{% call modal('create-gist-modal', 'Create Gist', wide=true) %}
<form method="POST" action="/gists/create">
@@ -136,7 +136,7 @@
</form>
{% endcall %}
{% else %}
<a href="/auth/login" class="feed-fab login-required" title="Log in to participate">+</a>
<a href="/auth/login" class="feed-fab login-required" aria-label="Log in">+</a>
{% endif %}
{% endblock %}
+3 -3
View File
@@ -13,7 +13,7 @@
<div class="issue-detail-header">
<span class="issue-number">#{{ issue.number }}</span>
<h1 class="issue-detail-title">{{ render_title(issue.title) }}</h1>
<span class="issue-status {{ issue.state }}">{{ issue.state }}</span>
<span class="issue-status {{ issue.state }}"><span class="sr-only">Status: </span>{{ issue.state }}</span>
</div>
<div class="issue-meta">
{% if issue.is_local_author %}
@@ -51,7 +51,7 @@
{% if comment.is_local_author %}
{% set _user = {'username': comment.author_username} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
{% else %}
<span class="issue-comment-badge">dev</span>
<span class="issue-comment-badge"><span class="sr-only">Developer reply from </span>dev</span>
{% endif %}
<span class="issue-author">{{ comment.author_username }}</span>
<span class="issue-dot">&middot;</span>
@@ -65,7 +65,7 @@
{% if can_comment %}
<form method="POST" action="/issues/{{ issue.number }}/comment" class="comment-form issue-comment-form">
<textarea name="body" required maxlength="5000" placeholder="Add a comment (posted to the tracker)..." class="min-h-120" data-mention></textarea>
<textarea name="body" required aria-required="true" maxlength="5000" placeholder="Add a comment (posted to the tracker)..." class="min-h-120" data-mention aria-label="Add a comment"></textarea>
<div class="issue-comment-form-footer">
<button type="submit" class="btn btn-primary btn-sm"><span class="btn-spinner" aria-hidden="true"></span>Comment</button>
</div>
+10 -10
View File
@@ -14,30 +14,30 @@
{% if user %}
<button type="button" class="btn btn-primary btn-sm" data-modal="create-issue-modal"><span class="icon">&#x1F41B;</span> Report Issue</button>
{% else %}
<a href="/auth/login" class="btn btn-primary btn-sm login-required" title="Log in to participate"><span class="icon">&#x1F41B;</span> Report Issue</a>
<a href="/auth/login" class="btn btn-primary btn-sm login-required"><span class="icon">&#x1F41B;</span> Report Issue</a>
{% endif %}
</div>
</div>
<div class="issues-filters">
<a href="?state=open" class="issues-filter {% if state == 'open' %}active{% endif %}">Open</a>
<a href="?state=closed" class="issues-filter {% if state == 'closed' %}active{% endif %}">Closed</a>
<a href="?state=all" class="issues-filter {% if state == 'all' %}active{% endif %}">All</a>
<div class="issues-filters" role="group" aria-label="Filter issues by state">
<a href="?state=open" class="issues-filter {% if state == 'open' %}active{% endif %}"{% if state == 'open' %} aria-current="true"{% endif %}>Open</a>
<a href="?state=closed" class="issues-filter {% if state == 'closed' %}active{% endif %}"{% if state == 'closed' %} aria-current="true"{% endif %}>Closed</a>
<a href="?state=all" class="issues-filter {% if state == 'all' %}active{% endif %}"{% if state == 'all' %} aria-current="true"{% endif %}>All</a>
</div>
{% if not configured or error_message %}
<div class="empty-state">{{ error_message }}</div>
{% else %}
<div class="issues-list">
<div class="issues-list" role="list" aria-label="Issue reports">
{% for issue in issues %}
<div class="issue-card card-link-host">
<div class="issue-card card-link-host" role="listitem">
{% set _href = "/issues/" ~ issue.number %}
{% set _label = issue.title %}
{% include "_card_link.html" %}
<div class="issue-card-header">
<span class="issue-number">#{{ issue.number }}</span>
<span class="issue-title">{{ render_title(issue.title) }}</span>
<span class="issue-status {{ issue.state }}">{{ issue.state }}</span>
<span class="issue-status {{ issue.state }}"><span class="sr-only">Status: </span>{{ issue.state }}</span>
</div>
<div class="issue-meta">
{% if issue.is_local_author %}
@@ -66,11 +66,11 @@
<p class="issues-modal-hint">Your report is improved by AI into a consistent ticket and filed on the tracker.</p>
<div class="auth-field auth-field-gap">
<label for="issue-title">Title</label>
<input type="text" id="issue-title" name="title" required maxlength="200" placeholder="Brief description of the issue">
<input type="text" id="issue-title" name="title" required aria-required="true" maxlength="200" placeholder="Brief description of the issue">
</div>
<div class="auth-field auth-field-gap">
<label for="issue-description">Description</label>
<textarea id="issue-description" name="description" required maxlength="5000" placeholder="Steps to reproduce, expected vs actual, environment..." class="min-h-120" data-mention></textarea>
<textarea id="issue-description" name="description" required aria-required="true" maxlength="5000" placeholder="Steps to reproduce, expected vs actual, environment..." class="min-h-120" data-mention></textarea>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
+6 -6
View File
@@ -71,9 +71,9 @@
{% endif %}
{% if landing_posts %}
<section class="landing-section">
<section class="landing-section" aria-labelledby="landing-posts-heading">
<div class="landing-section-header">
<h2>Latest Posts</h2>
<h2 id="landing-posts-heading">Latest Posts</h2>
<a href="/feed" class="landing-section-link">Browse all posts &rarr;</a>
</div>
<div class="landing-posts-grid">
@@ -108,9 +108,9 @@
{% endif %}
{% if landing_articles %}
<section class="landing-section">
<section class="landing-section" aria-labelledby="landing-news-heading">
<div class="landing-section-header">
<h2>Developer News</h2>
<h2 id="landing-news-heading">Developer News</h2>
<a href="/news" class="landing-section-link">View all news &rarr;</a>
</div>
<div class="news-grid">
@@ -154,9 +154,9 @@
</section>
{% endif %}
<section class="landing-section landing-help">
<section class="landing-section landing-help" aria-labelledby="landing-help-heading">
<div class="landing-section-header">
<h2>Build With Us</h2>
<h2 id="landing-help-heading">Build With Us</h2>
<a href="/docs/index.html" class="landing-section-link">Browse the docs &rarr;</a>
</div>
<p class="landing-help-intro">DevPlace is built in the open and made to be extended. Read the docs, explore the API, file an issue, or let Devii - our in-platform AI developer - do the work with you. Everything you need to start contributing is one click away.</p>
+3 -3
View File
@@ -4,7 +4,7 @@
{% endblock %}
{% block content %}
<div class="leaderboard-page">
<aside class="community-stats">
<aside class="community-stats" aria-label="Community statistics">
<h3>Community</h3>
<div class="stat-row">
<span class="label">Total Members</span>
@@ -54,7 +54,7 @@
{% set _user = entry %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
{% set _user = entry %}{% set _class = "leaderboard-name" %}{% include "_user_link.html" %}
<span class="leaderboard-level">Level {{ entry.get('level', 1) }}</span>
<span class="leaderboard-stars">{{ entry['stars'] }} <span class="leaderboard-star-icon">&#x2605;</span></span>
<span class="leaderboard-stars">{{ entry['stars'] }} <span class="leaderboard-star-icon" aria-hidden="true">&#x2605;</span></span>
</li>
{% else %}
<li class="leaderboard-empty">No ranked contributors yet. Earn stars on your posts, projects, and gists to appear here.</li>
@@ -62,7 +62,7 @@
</ol>
</div>
<aside class="feed-right">
<aside class="feed-right" aria-label="Featured news">
<div class="featured-news">
<h3>Featured</h3>
{% for article in featured_news %}
+4 -4
View File
@@ -10,7 +10,7 @@
<p class="subtitle">Sign in to continue to DevPlace</p>
{% if errors %}
<div class="auth-error">
<div class="auth-error" role="alert">
<ul>
{% for error in errors %}
<li>{{ error }}</li>
@@ -23,14 +23,14 @@
<input type="hidden" name="next" value="{{ next_url or '' }}">
<div class="auth-field">
<label for="email">Email address</label>
<input type="email" id="email" name="email" value="{{ email or '' }}" required maxlength="255" placeholder="you@example.com">
<input type="email" id="email" name="email" value="{{ email or '' }}" required aria-required="true" maxlength="255" placeholder="you@example.com">
</div>
<div class="auth-field">
<label for="password">Password</label>
<div class="input-wrap">
<input type="password" id="password" name="password" required minlength="6" maxlength="128" placeholder="Your password">
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">&#x1F441;</button>
<input type="password" id="password" name="password" required aria-required="true" minlength="6" maxlength="128" placeholder="Your password">
<button type="button" class="auth-toggle-pw" aria-label="Show password" aria-pressed="false">&#x1F441;</button>
</div>
</div>
+10 -10
View File
@@ -7,20 +7,20 @@
<h1 class="sr-only">Messages</h1>
<div class="page-messages">
<div class="messages-layout" data-self-uid="{{ user['uid'] }}"{% if current_conversation %} data-other-uid="{{ current_conversation }}" data-other-online="{{ '1' if other_online else '0' }}"{% if other_last_seen %} data-other-last-seen="{{ other_last_seen }}"{% endif %}{% endif %}>
<div class="messages-list">
<div class="messages-list-header">
<input type="text" id="message-search" placeholder="Search conversations..." value="{{ search }}">
<div class="messages-list" role="navigation" aria-label="Conversations">
<div class="messages-list-header" role="search">
<input type="text" id="message-search" placeholder="Search conversations..." value="{{ search }}" aria-label="Search conversations">
</div>
<div class="messages-conversations">
<div class="messages-conversations" role="list" aria-label="Conversation list">
{% for conv in conversations %}
<a href="/messages?with_uid={{ conv.other_user['uid'] }}" class="conversation-item {% if current_conversation == conv.other_user['uid'] %}active{% endif %}" data-conv-uid="{{ conv.other_user['uid'] }}">
<a href="/messages?with_uid={{ conv.other_user['uid'] }}" class="conversation-item {% if current_conversation == conv.other_user['uid'] %}active{% endif %}" data-conv-uid="{{ conv.other_user['uid'] }}" role="listitem"{% if current_conversation == conv.other_user['uid'] %} aria-current="true"{% endif %}>
<img src="{{ avatar_url('multiavatar', conv.other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ conv.other_user['username'] }}" loading="lazy">
<div class="conversation-info">
<div class="conversation-name">{{ conv.other_user['username'] }}</div>
<div class="conversation-preview">{{ conv.last_message[:60] }}</div>
</div>
<span class="conversation-unread-dot"{% if not conv.unread %} hidden{% endif %}></span>
<span class="conversation-unread-dot"{% if not conv.unread %} hidden{% endif %}><span class="sr-only">Unread messages</span></span>
<span class="conversation-time">{{ dt_ago(conv.last_message_at) if conv.last_message_at else format_date(conv.last_message_at) }}</span>
</a>
{% else %}
@@ -36,11 +36,11 @@
{% set _user = other_user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<div class="messages-header-info">
<h3>{% set _user = other_user %}{% set _class = none %}{% include "_user_link.html" %}</h3>
<span class="messages-presence" id="messages-presence"></span>
<span class="messages-presence" id="messages-presence" role="status" aria-live="polite"></span>
</div>
</div>
<div class="messages-thread">
<div class="messages-thread" role="log" aria-label="Message transcript" aria-live="polite" aria-relevant="additions">
{% for msg in messages %}
<div class="message-bubble {% if msg.is_mine %}mine{% else %}theirs{% endif %}" data-msg-uid="{{ msg.message['uid'] }}">
<div class="rendered-content">{{ render_content(msg.message['content']) }}</div>
@@ -50,7 +50,7 @@
{% endwith %}
{% endif %}
<span class="message-time">{{ dt_ago(msg.created_at) if msg.created_at else msg.time_ago }}</span>
{% if msg.is_mine %}<span class="message-receipt"{% if not msg.message['read'] %} hidden{% endif %}>&#x2713;&#x2713;</span>{% endif %}
{% if msg.is_mine %}<span class="message-receipt"{% if not msg.message['read'] %} hidden{% endif %}>&#x2713;&#x2713;<span class="sr-only">Read</span></span>{% endif %}
</div>
{% endfor %}
<div class="typing-indicator" id="typing-indicator" hidden><span></span><span></span><span></span></div>
@@ -58,7 +58,7 @@
<form class="messages-input-area" method="POST" action="/messages/send" data-live-form>
<input type="hidden" name="receiver_uid" value="{{ other_user['uid'] }}">
<input type="text" name="content" placeholder="Type a message..." maxlength="2000" autocomplete="off" data-mention>
<input type="text" name="content" placeholder="Type a message..." maxlength="2000" autocomplete="off" data-mention aria-label="Type a message">
<dp-upload multiple
max-size="{{ max_upload_size_mb() }}"
max-files="5"
+4 -3
View File
@@ -14,12 +14,13 @@
</div>
</div>
<div class="notifications-list">
<div class="notifications-list" role="list" aria-label="Notifications">
{% for group in notification_groups %}
<div class="notification-group">
<div class="notification-group-label">{{ group.label }}</div>
{% for item in group.entries %}
<div class="notification-card card-link-host {% if not item.notification['read'] %}unread{% endif %}">
<div class="notification-card card-link-host {% if not item.notification['read'] %}unread{% endif %}" role="listitem">
{% if not item.notification['read'] %}<span class="sr-only">Unread notification. </span>{% endif %}
{% set actor_username = item.actor['username'] if item.actor else '#' %}
{% set _href = "/notifications/open/" ~ item.notification['uid'] %}
{% set _label = item.notification['message'] %}
@@ -32,7 +33,7 @@
<div class="notification-time">{{ dt_ago(item.created_at) if item.created_at else item.time_ago }}</div>
</div>
<form method="POST" action="/notifications/mark-read/{{ item.notification['uid'] }}" style="display:inline;">
<button type="submit" class="notification-dismiss">&times;</button>
<button type="submit" class="notification-dismiss" aria-label="Mark as read">&times;</button>
</form>
</div>
{% endfor %}
+3 -3
View File
@@ -70,10 +70,10 @@
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">&#x1F4CA;</span> <span data-poll-toggle-label>Add poll</span></button>
</div>
<div class="poll-builder" data-poll-builder hidden>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" class="poll-builder-question" disabled>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" aria-label="Poll question" class="poll-builder-question" disabled>
<div class="poll-builder-options" data-poll-options>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" aria-label="Poll option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" aria-label="Poll option 2" disabled></div>
</div>
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
<p class="poll-builder-error" data-poll-error hidden></p>
+40 -22
View File
@@ -28,7 +28,7 @@
<span class="profile-stat-label">Stars</span>
</div>
<div class="profile-stat">
<a href="/leaderboard" class="profile-stat-value">{% if rank %}#{{ rank }}{% else %}-{% endif %}</a>
<a href="/leaderboard" class="profile-stat-value" aria-label="Rank{% if rank %} number {{ rank }}{% else %} not ranked{% endif %}, view leaderboard">{% if rank %}#{{ rank }}{% else %}-{% endif %}</a>
<span class="profile-stat-label">Rank</span>
</div>
</div>
@@ -56,7 +56,7 @@
{% if not is_self(user, profile_user['uid']) %}
<div class="profile-actions">
<a href="{% if user %}/messages?with_uid={{ profile_user['uid'] }}{% else %}/auth/login{% endif %}" class="btn btn-secondary btn-sm full-width justify-center{% if not user %} login-required{% endif %}"{% if not user %} title="Log in to participate"{% endif %}><span class="icon">&#x2709;&#xFE0F;</span>Send Message</a>
<a href="{% if user %}/messages?with_uid={{ profile_user['uid'] }}{% else %}/auth/login{% endif %}" class="btn btn-secondary btn-sm full-width justify-center{% if not user %} login-required{% endif %}"><span class="icon">&#x2709;&#xFE0F;</span>Send Message</a>
{% if is_following %}
<form method="POST" action="/follow/unfollow/{{ profile_user['username'] }}">
<button type="submit" class="btn btn-secondary btn-sm full-width"{{ guest_disabled(user) }}><span class="icon">&#x2796;</span>Unfollow</button>
@@ -66,6 +66,24 @@
<button type="submit" class="btn btn-primary btn-sm full-width"{{ guest_disabled(user) }}><span class="icon">&#x2795;</span>Follow</button>
</form>
{% endif %}
{% if is_blocked %}
<form method="POST" action="/block/unblock/{{ profile_user['username'] }}">
<button type="submit" class="btn btn-secondary btn-sm full-width" data-confirm="Unblock {{ profile_user['username'] }}? Their posts, comments and messages will be visible to you again."{{ guest_disabled(user) }}><span class="icon">&#x1F513;</span>Unblock</button>
</form>
{% else %}
<form method="POST" action="/block/{{ profile_user['username'] }}">
<button type="submit" class="btn btn-secondary btn-sm full-width" data-confirm="Block {{ profile_user['username'] }}? You will no longer see their posts, comments or messages anywhere except on this profile." data-confirm-danger{{ guest_disabled(user) }}><span class="icon">&#x1F6AB;</span>Block</button>
</form>
{% endif %}
{% if is_muted %}
<form method="POST" action="/mute/unmute/{{ profile_user['username'] }}">
<button type="submit" class="btn btn-secondary btn-sm full-width" data-confirm="Unmute {{ profile_user['username'] }}? They will be able to create notifications for you again."{{ guest_disabled(user) }}><span class="icon">&#x1F50A;</span>Unmute</button>
</form>
{% else %}
<form method="POST" action="/mute/{{ profile_user['username'] }}">
<button type="submit" class="btn btn-secondary btn-sm full-width" data-confirm="Mute {{ profile_user['username'] }}? They will no longer create notifications for you." data-confirm-danger{{ guest_disabled(user) }}><span class="icon">&#x1F507;</span>Mute</button>
</form>
{% endif %}
</div>
{% endif %}
@@ -76,44 +94,44 @@
<span class="profile-info-label">Bio</span>
<div class="flex-center-gap">
<span class="profile-info-value bio-display" id="display-bio">{{ profile_user.get('bio', '') or 'Not set' }}</span>
<button type="button" class="profile-info-edit" data-edit-field="bio">&#x270E;</button>
<button type="button" class="profile-info-edit" data-edit-field="bio" aria-label="Edit bio">&#x270E;</button>
</div>
</div>
<div class="auth-field hidden input-edit-field" id="input-bio">
<textarea name="bio" maxlength="500" rows="2" class="input-sm">{{ profile_user.get('bio', '') }}</textarea>
<textarea name="bio" maxlength="500" rows="2" class="input-sm" aria-label="Bio">{{ profile_user.get('bio', '') }}</textarea>
</div>
<div class="profile-info-row">
<span class="profile-info-label">Location</span>
<div class="flex-center-gap">
<span class="profile-info-value" id="display-location">{{ profile_user.get('location', '') or 'Not set' }}</span>
<button type="button" class="profile-info-edit" data-edit-field="location">&#x270E;</button>
<button type="button" class="profile-info-edit" data-edit-field="location" aria-label="Edit location">&#x270E;</button>
</div>
</div>
<div class="auth-field hidden input-edit-field" id="input-location">
<input type="text" name="location" maxlength="200" value="{{ profile_user.get('location', '') }}" class="input-sm">
<input type="text" name="location" maxlength="200" value="{{ profile_user.get('location', '') }}" class="input-sm" aria-label="Location">
</div>
<div class="profile-info-row">
<span class="profile-info-label">Git Link</span>
<div class="flex-center-gap">
<span class="profile-info-value" id="display-git_link">{{ profile_user.get('git_link', '') or 'Not set' }}</span>
<button type="button" class="profile-info-edit" data-edit-field="git_link">&#x270E;</button>
<button type="button" class="profile-info-edit" data-edit-field="git_link" aria-label="Edit Git link">&#x270E;</button>
</div>
</div>
<div class="auth-field hidden input-edit-field" id="input-git_link">
<input type="url" name="git_link" maxlength="500" value="{{ profile_user.get('git_link', '') }}" class="input-sm">
<input type="url" name="git_link" maxlength="500" value="{{ profile_user.get('git_link', '') }}" class="input-sm" aria-label="Git link">
</div>
<div class="profile-info-row">
<span class="profile-info-label">Website</span>
<div class="flex-center-gap">
<span class="profile-info-value" id="display-website">{{ profile_user.get('website', '') or 'Not set' }}</span>
<button type="button" class="profile-info-edit" data-edit-field="website">&#x270E;</button>
<button type="button" class="profile-info-edit" data-edit-field="website" aria-label="Edit website">&#x270E;</button>
</div>
</div>
<div class="auth-field hidden input-edit-field" id="input-website">
<input type="url" name="website" maxlength="500" value="{{ profile_user.get('website', '') }}" class="input-sm">
<input type="url" name="website" maxlength="500" value="{{ profile_user.get('website', '') }}" class="input-sm" aria-label="Website">
</div>
<button type="submit" class="btn btn-primary btn-sm full-width save-btn-top">Save Changes</button>
@@ -189,20 +207,20 @@
<div class="customization-row">
<span class="customization-label">Enable correction</span>
<label class="ai-correction-switch">
<input type="checkbox" data-ai-correction-toggle {% if ai_correction_enabled %}checked{% endif %}>
<input type="checkbox" data-ai-correction-toggle aria-label="Enable AI content correction" {% if ai_correction_enabled %}checked{% endif %}>
</label>
</div>
<div class="customization-row">
<span class="customization-label">Apply mode</span>
<select data-ai-correction-sync class="input-sm">
<select data-ai-correction-sync class="input-sm" aria-label="AI content correction apply mode">
<option value="async" {% if not ai_correction_sync %}selected{% endif %}>In background (faster, applies after saving)</option>
<option value="sync" {% if ai_correction_sync %}selected{% endif %}>Synchronously (wait while saving)</option>
</select>
</div>
<textarea data-ai-correction-prompt class="input-sm ai-correction-prompt" maxlength="2000" rows="3">{{ ai_correction_prompt }}</textarea>
<textarea data-ai-correction-prompt class="input-sm ai-correction-prompt" maxlength="2000" rows="3" aria-label="AI content correction prompt">{{ ai_correction_prompt }}</textarea>
<div class="customization-row">
<button type="button" class="btn btn-sm btn-primary" data-ai-correction-save>Save</button>
<span class="ai-correction-status" data-ai-correction-status></span>
<span class="ai-correction-status" data-ai-correction-status role="status" aria-live="polite"></span>
</div>
<p class="customization-hint">Corrections apply to new and edited content using your API key, on posts, comments, projects, gists, direct messages and your bio. Code and source files are never changed. In background mode the correction is applied just after saving; in synchronous mode the save waits for the correction to finish.</p>
</div>
@@ -214,20 +232,20 @@
<div class="customization-row">
<span class="customization-label">Enable modifier</span>
<label class="ai-correction-switch">
<input type="checkbox" data-ai-modifier-toggle {% if ai_modifier_enabled %}checked{% endif %}>
<input type="checkbox" data-ai-modifier-toggle aria-label="Enable AI modifier" {% if ai_modifier_enabled %}checked{% endif %}>
</label>
</div>
<div class="customization-row">
<span class="customization-label">Apply mode</span>
<select data-ai-modifier-sync class="input-sm">
<select data-ai-modifier-sync class="input-sm" aria-label="AI modifier apply mode">
<option value="async" {% if not ai_modifier_sync %}selected{% endif %}>In background (applies after saving)</option>
<option value="sync" {% if ai_modifier_sync %}selected{% endif %}>Synchronously (wait while saving)</option>
</select>
</div>
<textarea data-ai-modifier-prompt class="input-sm ai-correction-prompt" maxlength="2000" rows="3">{{ ai_modifier_prompt }}</textarea>
<textarea data-ai-modifier-prompt class="input-sm ai-correction-prompt" maxlength="2000" rows="3" aria-label="AI modifier prompt">{{ ai_modifier_prompt }}</textarea>
<div class="customization-row">
<button type="button" class="btn btn-sm btn-primary" data-ai-modifier-save>Save</button>
<span class="ai-correction-status" data-ai-modifier-status></span>
<span class="ai-correction-status" data-ai-modifier-status role="status" aria-live="polite"></span>
</div>
<p class="customization-hint">Runs only when your text contains an inline `@ai instruction` directive: it executes that instruction and removes the `@ai` marker, using your API key. Applies to posts, comments, projects, gists, direct messages and your bio. Code and source files are never touched.</p>
</div>
@@ -239,7 +257,7 @@
<p class="customization-hint">Chat with your Devii from Telegram. Open the DevPlace bot in Telegram, then send it the four digit code below to connect this account.</p>
<div class="customization-row">
<span class="customization-label">Status</span>
<span data-telegram-status>{{ 'Connected' if telegram_paired else 'Not connected' }}</span>
<span data-telegram-status role="status" aria-live="polite">{{ 'Connected' if telegram_paired else 'Not connected' }}</span>
</div>
<div class="customization-row">
<button type="button" class="btn btn-sm btn-primary" data-telegram-request>Request pairing code</button>
@@ -249,7 +267,7 @@
<code class="api-key-value" data-telegram-code-value></code>
<p class="customization-hint" data-telegram-code-hint></p>
</div>
<span class="ai-correction-status" data-telegram-status-msg></span>
<span class="ai-correction-status" data-telegram-status-msg role="status" aria-live="polite"></span>
</div>
{% endif %}
</aside>
@@ -577,13 +595,13 @@
</td>
<td class="notif-prefs-col">
<label class="notif-switch">
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="in_app" {% if pref.in_app %}checked{% endif %}>
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="in_app" aria-label="In-app notifications for {{ pref.label }}" {% if pref.in_app %}checked{% endif %}>
<span class="notif-switch-track"></span>
</label>
</td>
<td class="notif-prefs-col">
<label class="notif-switch">
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="push" {% if pref.push %}checked{% endif %}>
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="push" aria-label="Push notifications for {{ pref.label }}" {% if pref.push %}checked{% endif %}>
<span class="notif-switch-track"></span>
</label>
</td>
+4 -4
View File
@@ -72,7 +72,7 @@
{% endif %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
<button type="button" class="project-star-btn project-actions-more" aria-haspopup="menu" aria-label="More actions"><span class="icon">&#x22EF;</span><span class="label"> More</span></button>
<button type="button" class="project-star-btn project-actions-more" aria-haspopup="menu" aria-expanded="false" aria-label="More actions"><span class="icon">&#x22EF;</span><span class="label"> More</span></button>
<div class="project-actions-overflow" hidden>
{% if is_admin(user) %}
@@ -128,7 +128,7 @@
<div class="auth-field auth-field-gap">
<label>Type</label>
<div class="flex-wrap-gap">
<div class="flex-wrap-gap" role="group" aria-label="Type">
{% for t in [('game', 'Game'), ('game_asset', 'Game Asset'), ('software', 'Software'), ('mobile_app', 'Mobile App'), ('website', 'Website')] %}
<label class="topic-label">
<input type="radio" name="project_type" value="{{ t[0] }}" {% if t[0] == project.get('project_type', 'software') %}checked{% endif %} class="topic-radio">
@@ -140,7 +140,7 @@
<div class="auth-field auth-field-gap">
<label>Status</label>
<div class="flex-wrap-gap">
<div class="flex-wrap-gap" role="group" aria-label="Status">
<label class="topic-label">
<input type="radio" name="status" value="In Development" {% if project.get('status', 'In Development') != 'Released' %}checked{% endif %} class="topic-radio"> In Development
</label>
@@ -153,7 +153,7 @@
<div class="auth-field auth-field-gap-last">
<label>Platforms</label>
<div id="platforms-tags" class="flex-wrap-gap-bottom"></div>
<input type="text" id="platforms-input" class="input-sm" placeholder="Add platform (press Enter)">
<input type="text" id="platforms-input" class="input-sm" placeholder="Add platform (press Enter)" aria-label="Add platform">
<input type="hidden" id="platforms" name="platforms" value="{{ project.get('platforms', '') }}">
<div class="platform-presets-wrap">
{% for p in ['PC', 'Windows', 'Mac', 'Linux', 'iOS', 'Android', 'Web', 'Console'] %}
+2 -2
View File
@@ -18,7 +18,7 @@
<button type="button" class="btn btn-primary" id="pf-save">Save</button>
{% endif %}
<button type="button" class="btn btn-secondary" data-zip-download="/projects/{{ project['slug'] or project['uid'] }}/files/zip">Download as zip</button>
<span class="pf-status" id="pf-status"></span>
<span class="pf-status" id="pf-status" role="status" aria-live="polite"></span>
</div>
</div>
@@ -27,7 +27,7 @@
{% endif %}
<div class="pf-body">
<aside class="pf-tree" id="pf-tree"></aside>
<aside class="pf-tree" id="pf-tree" role="complementary" aria-label="File tree"></aside>
<section class="pf-main">
<div class="pf-current" id="pf-current"></div>
<div class="pf-editor-wrap" id="pf-editor-wrap" hidden>
+6 -6
View File
@@ -8,7 +8,7 @@
{% endblock %}
{% block content %}
<div class="projects-layout">
<aside class="sidebar-card">
<aside class="sidebar-card" role="complementary" aria-label="Project filters">
{% set _action = "/projects" %}{% set _placeholder = "Search projects..." %}{% set _hidden = {"tab": current_tab, "project_type": project_type} %}{% include "_sidebar_search.html" %}
<div class="sidebar-section">
@@ -101,7 +101,7 @@
</div>
{% if user %}
<button type="button" class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal">+</button>
<button type="button" class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal" aria-label="Add Project">+</button>
{% call modal('create-project-modal', 'Create Project') %}
<form method="POST" action="/projects/create">
@@ -128,7 +128,7 @@
<div class="auth-field auth-field-gap">
<label>Type</label>
<div class="flex-wrap-gap">
<div class="flex-wrap-gap" role="group" aria-label="Type">
{% for t in [('game', 'Game'), ('game_asset', 'Game Asset'), ('software', 'Software'), ('mobile_app', 'Mobile App'), ('website', 'Website')] %}
<label class="topic-label">
<input type="radio" name="project_type" value="{{ t[0] }}" {% if t[0] == 'software' %}checked{% endif %} class="topic-radio">
@@ -140,7 +140,7 @@
<div class="auth-field auth-field-gap">
<label>Status</label>
<div class="flex-wrap-gap">
<div class="flex-wrap-gap" role="group" aria-label="Status">
<label class="topic-label">
<input type="radio" name="status" value="In Development" checked class="topic-radio"> In Development
</label>
@@ -153,7 +153,7 @@
<div class="auth-field auth-field-gap-last">
<label>Platforms</label>
<div id="platforms-tags" class="flex-wrap-gap-bottom"></div>
<input type="text" id="platforms-input" class="input-sm" placeholder="Add platform (press Enter)">
<input type="text" id="platforms-input" class="input-sm" placeholder="Add platform (press Enter)" aria-label="Add platform">
<input type="hidden" id="platforms" name="platforms" value="">
<div class="platform-presets-wrap">
{% for p in ['PC', 'Windows', 'Mac', 'Linux', 'iOS', 'Android', 'Web', 'Console'] %}
@@ -177,6 +177,6 @@
</form>
{% endcall %}
{% else %}
<a href="/auth/login" class="feed-fab login-required" title="Log in to participate">+</a>
<a href="/auth/login" class="feed-fab login-required" aria-label="Log in">+</a>
{% endif %}
{% endblock %}
+5 -5
View File
@@ -10,7 +10,7 @@
<p class="subtitle">Choose a new password for your account</p>
{% if errors %}
<div class="auth-error">
<div class="auth-error" role="alert">
<ul>
{% for error in errors %}
<li>{{ error }}</li>
@@ -23,15 +23,15 @@
<div class="auth-field">
<label for="password">New Password</label>
<div class="input-wrap">
<input type="password" id="password" name="password" required maxlength="128" placeholder="At least 6 characters">
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">&#x1F441;</button>
<input type="password" id="password" name="password" required aria-required="true" maxlength="128" placeholder="At least 6 characters">
<button type="button" class="auth-toggle-pw" aria-label="Show password" aria-pressed="false">&#x1F441;</button>
</div>
</div>
<div class="auth-field">
<label for="confirm_password">Confirm Password</label>
<div class="input-wrap">
<input type="password" id="confirm_password" name="confirm_password" required maxlength="128" placeholder="Repeat your password">
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">&#x1F441;</button>
<input type="password" id="confirm_password" name="confirm_password" required aria-required="true" maxlength="128" placeholder="Repeat your password">
<button type="button" class="auth-toggle-pw" aria-label="Show password" aria-pressed="false">&#x1F441;</button>
</div>
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x1F512;</span> Reset Password</button>
+11 -11
View File
@@ -21,10 +21,10 @@
</details>
{% endif %}
<div class="service-controls">
<button type="button" class="service-btn" data-action="start" data-service="{{ svc.name }}" {% if svc.enabled %}disabled{% endif %}>Start</button>
<button type="button" class="service-btn" data-action="stop" data-service="{{ svc.name }}" data-confirm="Stop the {{ svc.title }} service?" {% if not svc.enabled %}disabled{% endif %}>Stop</button>
<button type="button" class="service-btn" data-action="run" data-service="{{ svc.name }}">Run now</button>
<button type="button" class="service-btn" data-action="clear-logs" data-service="{{ svc.name }}" data-confirm="Clear the log buffer for {{ svc.title }}?">Clear logs</button>
<button type="button" class="service-btn" data-action="start" data-service="{{ svc.name }}" aria-label="Start {{ svc.title }}" {% if svc.enabled %}disabled{% endif %}>Start</button>
<button type="button" class="service-btn" data-action="stop" data-service="{{ svc.name }}" aria-label="Stop {{ svc.title }}" data-confirm="Stop the {{ svc.title }} service?" {% if not svc.enabled %}disabled{% endif %}>Stop</button>
<button type="button" class="service-btn" data-action="run" data-service="{{ svc.name }}" aria-label="Run {{ svc.title }} now">Run now</button>
<button type="button" class="service-btn" data-action="clear-logs" data-service="{{ svc.name }}" aria-label="Clear logs for {{ svc.title }}" data-confirm="Clear the log buffer for {{ svc.title }}?">Clear logs</button>
</div>
</div>
@@ -43,7 +43,7 @@
<div class="svc-meta-item"><span class="svc-meta-label">Heartbeat</span><span class="svc-meta-value" data-meta="heartbeat">{% if svc.heartbeat %}{{ format_date(svc.heartbeat, include_time=True) }}{% else %}-{% endif %}</span></div>
<div class="svc-meta-item"><span class="svc-meta-label">Interval</span><span class="svc-meta-value" data-meta="interval">every {{ svc.interval_seconds }}s</span></div>
</div>
<div class="service-metrics" data-metrics></div>
<div class="service-metrics" data-metrics role="status" aria-live="polite" aria-label="Service metrics"></div>
</section>
<section class="svc-tabpane" data-tab-pane="config">
@@ -79,28 +79,28 @@
<input type="text" id="{{ svc.name }}_{{ field.key }}" name="{{ field.key }}" value="{{ field.value }}">
{% endif %}
{% if field.help %}<small class="hint-text">{{ field.help }}</small>{% endif %}
<small class="field-error" data-error="{{ field.key }}"></small>
<small class="field-error" data-error="{{ field.key }}" role="alert" aria-live="assertive"></small>
</div>
{% endfor %}
</fieldset>
{% endfor %}
<div class="config-actions">
<button type="submit" class="btn btn-primary">Save configuration</button>
<span class="config-status" data-config-status></span>
<span class="config-status" data-config-status role="status" aria-live="polite"></span>
</div>
</form>
</section>
<section class="svc-tabpane" data-tab-pane="logs">
<div class="service-log">
<div class="log-header">Log</div>
<pre class="log-output">{% for line in svc.log_buffer %}{{ line }}
<div class="log-header" id="service-log-header">Log</div>
<pre class="log-output" role="region" aria-labelledby="service-log-header">{% for line in svc.log_buffer %}{{ line }}
{% else %}No log entries yet.
{% endfor %}</pre>
</div>
<div class="service-log">
<div class="log-header">Live output</div>
<pre class="log-output" data-live-log>No live output yet.</pre>
<div class="log-header" id="service-live-log-header">Live output</div>
<pre class="log-output" data-live-log role="log" aria-live="polite" aria-labelledby="service-live-log-header">No live output yet.</pre>
</div>
</section>
</div>
+8 -7
View File
@@ -11,15 +11,16 @@
<div class="admin-table-wrap">
<table class="admin-table services-index" id="services-list">
<caption class="sr-only">Background services</caption>
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Uptime</th>
<th>Last run</th>
<th>Next run</th>
<th>Interval</th>
<th></th>
<th scope="col">Service</th>
<th scope="col">Status</th>
<th scope="col">Uptime</th>
<th scope="col">Last run</th>
<th scope="col">Next run</th>
<th scope="col">Interval</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
+9 -9
View File
@@ -10,7 +10,7 @@
<p class="subtitle">Create your DevPlace account and start connecting with developers</p>
{% if errors %}
<div class="auth-error">
<div class="auth-error" role="alert">
<ul>
{% for error in errors %}
<li>{{ error }}</li>
@@ -20,7 +20,7 @@
{% endif %}
{% if registration_closed %}
<div class="auth-error">
<div class="auth-error" role="alert">
<ul>
<li>Registration is currently closed.</li>
</ul>
@@ -35,28 +35,28 @@
<form class="auth-form" method="POST" action="/auth/signup">
<div class="auth-field">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ username or '' }}" required maxlength="32" placeholder="cooldev42">
<small class="hint-text">Letters, numbers, hyphens, and underscores only</small>
<input type="text" id="username" name="username" value="{{ username or '' }}" required aria-required="true" maxlength="32" placeholder="cooldev42" aria-describedby="username-hint">
<small class="hint-text" id="username-hint">Letters, numbers, hyphens, and underscores only</small>
</div>
<div class="auth-field">
<label for="email">Email address</label>
<input type="email" id="email" name="email" value="{{ email or '' }}" required maxlength="255" placeholder="you@example.com">
<input type="email" id="email" name="email" value="{{ email or '' }}" required aria-required="true" maxlength="255" placeholder="you@example.com">
</div>
<div class="auth-field">
<label for="password">Password</label>
<div class="input-wrap">
<input type="password" id="password" name="password" required maxlength="128" placeholder="At least 6 characters">
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">&#x1F441;</button>
<input type="password" id="password" name="password" required aria-required="true" maxlength="128" placeholder="At least 6 characters">
<button type="button" class="auth-toggle-pw" aria-label="Show password" aria-pressed="false">&#x1F441;</button>
</div>
</div>
<div class="auth-field">
<label for="confirm_password">Confirm Password</label>
<div class="input-wrap">
<input type="password" id="confirm_password" name="confirm_password" required maxlength="128" placeholder="Repeat your password">
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">&#x1F441;</button>
<input type="password" id="confirm_password" name="confirm_password" required aria-required="true" maxlength="128" placeholder="Repeat your password">
<button type="button" class="auth-toggle-pw" aria-label="Show password" aria-pressed="false">&#x1F441;</button>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More