Add notice pages with a pinned Home board as the default view

Pages are a second item type beside services: title + icon + plain-text body,
stored in data/pages.json (PageStore mirrors ServiceStore: lock, atomic temp
+ os.replace, slugify-collision ids) and served by the /api/pages family with
the same admin-write / any-session-read gate.

The viewer area opens on the Home notice board instead of "Nothing selected":
state.selected starts at the HOME sentinel, the startup chain ends in openHome(),
and deleting whatever was being viewed falls back to the board via showHome(),
which repaints without touching which section is active so a delete from the
Settings table leaves Home ready for settingsClose.

PUT is omitted on purpose: the UI only posts/patches and pages have no
non-patchable fields.
This commit is contained in:
Archibold committed 2026-11-07 16:09:55 +11:00
1 parent 68b9f851ef
commit 3aad88415d
10 files changed
+521 -12

No files matched your search

+45 -3
View File
@@ -47,8 +47,8 @@ clean stage installs it into `/app/.venv`, and the entrypoint is the
(`UWUDASH_HOST` / `UWUDASH_PORT` are baked in as `ENV` defaults - override both
the `--env` and the `-p` mapping if you change the port).
`/app/data` is a `VOLUME` holding `services.json` (and `users.json` once auth is
on): a named volume keeps your service list across restarts, a bind mount
`/app/data` is a `VOLUME` holding `services.json`, `pages.json` (and `users.json`
once auth is on): a named volume keeps your service list across restarts, a bind mount
(`-v ./data:/app/data`) seeds the container from the host file instead. `data/`
is in `.dockerignore`, so internal URLs and per-service `headers` tokens never
land in an image layer. With a bind mount the directory must be writable by uid
@@ -78,6 +78,7 @@ container stays `healthy` with auth on.
| `--port` | `8000` | bind port |
| `--data-dir` | `data` | directory holding `services.json` |
| `--services-file` | `<data-dir>/services.json` | explicit store path |
| `--pages-file` | `<data-dir>/pages.json` | explicit notice store path |
| `--oidc-issuer` | unset | OIDC issuer; enables sign-in together with the client env vars |
| `--public-url` | request origin | external base URL used for the OIDC `redirect_uri` |
| `--reload` | off | uvicorn reload on source changes |
@@ -90,6 +91,7 @@ container stays `healthy` with auth on.
| `UWUDASH_PORT` | `8000` | bind port |
| `UWUDASH_DATA_DIR` | `data` | data directory |
| `UWUDASH_SERVICES_FILE` | `data/services.json` | services store path |
| `UWUDASH_PAGES_FILE` | `data/pages.json` | notice store path |
| `UWUDASH_PROXY_TIMEOUT` | `30.0` | upstream request timeout (seconds) |
| `UWUDASH_STRIP_FRAME_OPTIONS` | `true` | strip `X-Frame-Options` and CSP `frame-ancestors` from proxied responses |
| `UWUDASH_OIDC_ISSUER` | unset | OIDC issuer, e.g. `https://id.example.com` (auth stays off unless issuer, client id and secret are all set) |
@@ -203,6 +205,40 @@ Services live in `data/services.json`:
Edit them from the **Settings** page in the UI, or via the API.
## Notice pages
Notices are native dashboard content - no URL, no proxy. They live in
`data/pages.json` (same atomic-write format as `services.json`):
```json
{
"version": 1,
"pages": [
{
"id": "maintenance",
"name": "Maintenance",
"icon": "🔧",
"body": "Server down tonight\nBring snacks",
"created_at": "2026-10-28T09:29:31+00:00",
"updated_at": "2026-10-28T09:29:31+00:00"
}
]
}
```
- `id` is slugified from the name and stays stable across edits; `body` is capped
at 5000 characters and keeps its line breaks (`white-space: pre-wrap`, escaped).
- Each notice gets a sidebar button; clicking it shows a single card in the viewer
area instead of the iframe.
- The notice board is the **default view**: landing on `/` already shows it in the
viewer area (banner plus every notice, newest first), no click needed. The pinned
**Home** button (`🏠`, always present) re-opens it, and deleting whatever you were
viewing falls back to it. `/` itself stays the dashboard.
- Writes are admin-only when OIDC is on, reads any session - same gate as services.
Edit them from the **Settings** page (the "Add notice" form and the notice table),
or via the API.
## How the proxy works
Only the upstream **origin** (`scheme://host:port`) is mapped onto the dashboard:
@@ -249,6 +285,11 @@ Details:
| `PATCH` / `PUT` | `/api/services/{id}` | update |
| `DELETE` | `/api/services/{id}` | delete |
| `GET` | `/api/services/{id}/health` | upstream reachability check |
| `GET` | `/api/pages` | list notices |
| `POST` | `/api/pages` | create - admin only |
| `GET` | `/api/pages/{id}` | read |
| `PATCH` | `/api/pages/{id}` | update - admin only |
| `DELETE` | `/api/pages/{id}` | delete - admin only |
| `GET` | `/api/config` | effective config (never includes the OIDC secret) |
| `GET` | `/api/me` | current user (only when auth is enabled) |
| `GET` | `/api/users` | list users - admin only |
@@ -265,8 +306,9 @@ Details:
```
src/uwudash_mk3/
config.py env-driven Config dataclass
models.py Service / ServiceInput / ServicePatch / User / UserPatch + URL helpers
models.py Service / ServiceInput / ServicePatch / Page / PageInput / PagePatch / User / UserPatch + URL helpers
store.py JSON file store (atomic writes, lock)
pages.py JSON notice store (same pattern as store.py)
users.py JSON user store (same pattern as store.py)
auth.py OIDC client, login/callback/logout, role dependencies
proxy.py reverse proxy: header filtering, HTML/redirect rewriting
+44 -2
View File
@@ -1,4 +1,4 @@
"""JSON API used by the dashboard UI to manage services and users."""
"""JSON API used by the dashboard UI to manage services, notices, and users."""
from __future__ import annotations
@@ -7,7 +7,9 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from .config import Config
from .models import Service, ServiceInput, ServicePatch, User, UserPatch
from .models import Page, PageInput, PagePatch, Service, ServiceInput, ServicePatch
from .models import User, UserPatch
from .pages import PageStore
from .proxy import Proxy
from .store import ServiceStore
from .users import UserStore
@@ -15,6 +17,7 @@ from .users import UserStore
def create_api_router(
store: ServiceStore,
pages: PageStore,
proxy: Proxy,
config: Config,
users: Optional[UserStore] = None,
@@ -37,6 +40,7 @@ def create_api_router(
"host": config.host,
"port": config.port,
"services_file": str(config.services_file),
"pages_file": str(config.pages_file),
"strip_frame_options": config.strip_frame_options,
"proxy_timeout": config.proxy_timeout,
"auth_enabled": config.auth_enabled,
@@ -98,6 +102,44 @@ def create_api_router(
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
return await proxy.check(service)
@router.get("/pages")
def list_pages() -> List[Page]:
return pages.list()
@router.get("/pages/{page_id}")
def get_page(page_id: str) -> Page:
page = pages.get(page_id)
if page is None:
raise HTTPException(status_code=404, detail=f"unknown page: {page_id}")
return page
@router.post(
"/pages",
status_code=201,
dependencies=[Depends(require_admin)] if require_admin else [],
)
def create_page(payload: PageInput) -> Page:
return pages.create(payload)
@router.patch(
"/pages/{page_id}",
dependencies=[Depends(require_admin)] if require_admin else [],
)
def update_page(page_id: str, payload: PagePatch) -> Page:
page = pages.update(page_id, payload)
if page is None:
raise HTTPException(status_code=404, detail=f"unknown page: {page_id}")
return page
@router.delete(
"/pages/{page_id}",
status_code=204,
dependencies=[Depends(require_admin)] if require_admin else [],
)
def delete_page(page_id: str) -> None:
if not pages.delete(page_id):
raise HTTPException(status_code=404, detail=f"unknown page: {page_id}")
if users is not None:
@router.get("/me")
def me(user: User = Depends(require_login)) -> User:
+4 -1
View File
@@ -25,6 +25,7 @@ from starlette.middleware.sessions import SessionMiddleware
from .api import create_api_router
from .auth import auth_dependencies, create_auth_router, create_oauth, get_current_user
from .config import Config
from .pages import PageStore
from .proxy import Proxy
from .store import ServiceStore
from .users import UserStore
@@ -37,6 +38,7 @@ def create_app(config: Config | None = None) -> FastAPI:
config = config or Config.from_env()
store = ServiceStore(config.services_file)
pages = PageStore(config.pages_file)
proxy = Proxy(store, config)
users = UserStore(config.users_file) if config.auth_enabled else None
oauth = create_oauth(config) if config.auth_enabled else None
@@ -71,6 +73,7 @@ def create_app(config: Config | None = None) -> FastAPI:
app.state.config = config
app.state.store = store
app.state.pages = pages
app.state.proxy = proxy
app.state.users = users
app.state.oauth = oauth
@@ -85,7 +88,7 @@ def create_app(config: Config | None = None) -> FastAPI:
require_login = require_admin = None
app.include_router(
create_api_router(store, proxy, config, users, require_login, require_admin)
create_api_router(store, pages, proxy, config, users, require_login, require_admin)
)
@app.get("/")
+7 -1
View File
@@ -4,7 +4,7 @@ Everything is driven by environment variables (with sane defaults) so the
dashboard can be deployed without editing code:
UWUDASH_HOST, UWUDASH_PORT, UWUDASH_DATA_DIR, UWUDASH_SERVICES_FILE,
UWUDASH_PROXY_TIMEOUT, UWUDASH_STRIP_FRAME_OPTIONS
UWUDASH_PAGES_FILE, UWUDASH_PROXY_TIMEOUT, UWUDASH_STRIP_FRAME_OPTIONS
Authentication is opt-in: the OIDC routes and the session middleware only take
effect when an issuer, client id and client secret are all present.
@@ -87,10 +87,13 @@ class Config:
session_ttl_seconds: int = DEFAULT_SESSION_TTL
users_file: Optional[Path] = None
public_url: Optional[str] = None
pages_file: Optional[Path] = None
def __post_init__(self) -> None:
if self.users_file is None:
object.__setattr__(self, "users_file", self.data_dir / "users.json")
if self.pages_file is None:
object.__setattr__(self, "pages_file", self.data_dir / "pages.json")
if self.oidc_issuer:
object.__setattr__(self, "oidc_issuer", self.oidc_issuer.rstrip("/"))
@@ -123,4 +126,7 @@ class Config:
os.environ.get("UWUDASH_USERS_FILE", data_dir / "users.json")
),
public_url=os.environ.get("UWUDASH_PUBLIC_URL") or None,
pages_file=Path(
os.environ.get("UWUDASH_PAGES_FILE", data_dir / "pages.json")
),
)
+5
View File
@@ -18,6 +18,7 @@ def parse_args(argv=None) -> argparse.Namespace:
parser.add_argument("--port", type=int, help="bind port (default 8000)")
parser.add_argument("--data-dir", help="directory holding services.json")
parser.add_argument("--services-file", help="path to the services JSON file")
parser.add_argument("--pages-file", help="path to the pages JSON file")
parser.add_argument("--reload", action="store_true", help="restart on source changes")
parser.add_argument(
"--oidc-issuer",
@@ -42,6 +43,10 @@ def build_config(args: argparse.Namespace) -> Config:
config = replace(config, services_file=Path(args.services_file))
elif args.data_dir:
config = replace(config, services_file=Path(args.data_dir) / "services.json")
if args.pages_file:
config = replace(config, pages_file=Path(args.pages_file))
elif args.data_dir:
config = replace(config, pages_file=Path(args.data_dir) / "pages.json")
if args.oidc_issuer:
config = replace(config, oidc_issuer=args.oidc_issuer)
if args.public_url:
+24
View File
@@ -119,6 +119,30 @@ class ServicePatch(BaseModel):
return value
class PageInput(BaseModel):
"""Payload accepted by the page create/update endpoints."""
name: str = Field(min_length=1, max_length=120)
icon: str = Field(default="", max_length=8)
body: str = Field(min_length=1, max_length=5000)
class Page(PageInput):
"""A stored notice page."""
id: str
created_at: str
updated_at: str
class PagePatch(BaseModel):
"""Partial page update; unset fields are left alone."""
name: Optional[str] = Field(default=None, min_length=1, max_length=120)
icon: Optional[str] = Field(default=None, max_length=8)
body: Optional[str] = Field(default=None, min_length=1, max_length=5000)
ROLES = ("admin", "viewer")
+105
View File
@@ -0,0 +1,105 @@
"""Persistence for notice pages.
The store is a plain JSON file so it is easy to inspect, back up, or seed from
a container. Writes are atomic (temp file + ``os.replace``) and serialised with
a lock so concurrent API requests cannot interleave.
"""
from __future__ import annotations
import json
import os
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from .models import Page, PageInput, PagePatch, slugify
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class PageStore:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = threading.Lock()
self.path.parent.mkdir(parents=True, exist_ok=True)
if not self.path.exists():
self._write([])
# ------------------------------------------------------------------ read
def _read(self) -> List[Page]:
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except FileNotFoundError:
return []
except json.JSONDecodeError as exc:
raise ValueError(f"{self.path} is not valid JSON: {exc}") from exc
items = raw.get("pages", []) if isinstance(raw, dict) else raw
return [Page.model_validate(item) for item in items]
def _write(self, pages: List[Page]) -> None:
payload = {
"version": 1,
"pages": [p.model_dump() for p in pages],
}
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, self.path)
def list(self) -> List[Page]:
with self._lock:
return self._read()
def get(self, page_id: str) -> Optional[Page]:
with self._lock:
for page in self._read():
if page.id == page_id:
return page
return None
# ----------------------------------------------------------------- write
def create(self, data: PageInput) -> Page:
with self._lock:
pages = self._read()
base = slugify(data.name)
candidate = base
suffix = 1
existing = {p.id for p in pages}
while candidate in existing:
suffix += 1
candidate = f"{base}-{suffix}"
now = _now()
page = Page(
**data.model_dump(), id=candidate, created_at=now, updated_at=now
)
pages.append(page)
self._write(pages)
return page
def update(self, page_id: str, patch: PagePatch) -> Optional[Page]:
with self._lock:
pages = self._read()
for index, page in enumerate(pages):
if page.id != page_id:
continue
updates = {k: v for k, v in patch.model_dump().items() if v is not None}
if not updates:
return page
updated = page.model_copy(update=updates)
updated.updated_at = _now()
pages[index] = updated
self._write(pages)
return updated
return None
def delete(self, page_id: str) -> bool:
with self._lock:
pages = self._read()
remaining = [p for p in pages if p.id != page_id]
if len(remaining) == len(pages):
return False
self._write(remaining)
return True
+187 -3
View File
@@ -3,17 +3,20 @@
const el = {
serviceList: document.getElementById("service-list"),
homeBtn: document.getElementById("home-btn"),
viewer: document.getElementById("viewer"),
viewerTitle: document.getElementById("viewer-title"),
viewerUrl: document.getElementById("viewer-url"),
viewerFrame: document.getElementById("viewer-frame"),
emptyState: document.getElementById("empty-state"),
emptyText: document.getElementById("empty-text"),
pagePanel: document.getElementById("page-panel"),
reloadBtn: document.getElementById("reload-btn"),
settings: document.getElementById("settings"),
settingsToggle: document.getElementById("settings-toggle"),
settingsClose: document.getElementById("settings-close"),
serviceTable: document.getElementById("service-table"),
pageTable: document.getElementById("page-table"),
form: document.getElementById("service-form"),
formTitle: document.getElementById("form-title"),
name: document.getElementById("f-name"),
@@ -24,6 +27,12 @@ const el = {
headers: document.getElementById("f-headers"),
enabled: document.getElementById("f-enabled"),
formCancel: document.getElementById("form-cancel"),
noticeForm: document.getElementById("notice-form"),
noticeFormTitle: document.getElementById("notice-form-title"),
noticeName: document.getElementById("n-name"),
noticeIcon: document.getElementById("n-icon"),
noticeBody: document.getElementById("n-body"),
noticeCancel: document.getElementById("notice-cancel"),
toast: document.getElementById("toast"),
userChip: document.getElementById("user-chip"),
userName: document.getElementById("user-name"),
@@ -31,7 +40,18 @@ const el = {
userTable: document.getElementById("user-table"),
};
const state = { services: [], selected: null, editing: null, user: null };
// Sentinel for state.selected while the Home notice board is open; slugify()
// only ever emits [a-z0-9-], so no page id can collide with it.
const HOME = "@home";
const state = {
services: [],
pages: [],
selected: HOME,
editing: null,
editingPage: null,
user: null,
};
/* ---------------------------------------------------------------- helpers */
const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
@@ -83,7 +103,7 @@ function textToHeaders(text) {
/* ---------------------------------------------------------------- rendering */
function renderSidebar() {
el.serviceList.innerHTML = "";
if (!state.services.length) {
if (!state.services.length && !state.pages.length) {
el.serviceList.innerHTML = '<p class="status">No services configured yet.</p>';
return;
}
@@ -101,6 +121,17 @@ function renderSidebar() {
button.addEventListener("click", () => openService(service));
el.serviceList.appendChild(button);
}
for (const page of state.pages) {
const button = document.createElement("button");
button.className = "page-btn" + (state.selected === page.id ? " active" : "");
button.title = page.name;
button.innerHTML =
`<span class="icon">${esc(page.icon || "📌")}</span>` +
`<span><span class="label">${esc(page.name)}</span>` +
`<span class="sub">${esc(page.body.slice(0, 60))}</span></span>`;
button.addEventListener("click", () => openPage(page));
el.serviceList.appendChild(button);
}
}
function renderTable() {
@@ -124,10 +155,53 @@ function renderTable() {
}
}
function renderPageTable() {
el.pageTable.innerHTML = "";
if (!state.pages.length) {
el.pageTable.innerHTML = '<p class="status">No notices yet - pin one above.</p>';
return;
}
for (const page of state.pages) {
const row = document.createElement("div");
row.className = "page-row";
row.innerHTML =
`<div class="meta"><strong>${esc(page.name)}</strong>` +
`<span>${esc(page.body.slice(0, 80))}</span></div>` +
`<button class="btn ghost" data-action="edit" data-id="${esc(page.id)}">Edit</button>` +
`<button class="btn ghost" data-action="delete" data-id="${esc(page.id)}">Delete</button>`;
el.pageTable.appendChild(row);
}
}
function noticeCard(page) {
return (
`<div class="notice-card">` +
`<span class="icon">${esc(page.icon || "📌")}</span>` +
`<div class="notice-title">${esc(page.name)}</div>` +
`<div class="notice-body">${esc(page.body)}</div>` +
`</div>`
);
}
function renderHome() {
const banner =
`<div class="home-banner">` +
`<div class="home-title">Home (uwu)</div>` +
`<div class="home-sub">the notice board — pin anything the crew should know</div>` +
`</div>`;
const cards = state.pages.slice().reverse().map(noticeCard).join("");
el.pagePanel.innerHTML =
banner + (cards || '<p class="home-empty">no notices yet ( 🥯🥯 ) — pin one from Settings!</p>');
}
function findService(id) {
return state.services.find((s) => s.id === id);
}
function findPage(id) {
return state.pages.find((p) => p.id === id);
}
/* ---------------------------------------------------------------- auth */
async function loadUser() {
const response = await fetch("/api/me");
@@ -193,6 +267,7 @@ function openService(service) {
if (service.open_in === "newtab") {
window.open(service.proxy_path, "_blank");
el.settings.hidden = true;
el.pagePanel.hidden = true;
el.viewerFrame.hidden = true;
el.viewerFrame.src = "";
el.reloadBtn.hidden = true;
@@ -204,6 +279,7 @@ function openService(service) {
el.settings.hidden = true;
el.viewer.hidden = false;
el.emptyState.hidden = true;
el.pagePanel.hidden = true;
el.viewerFrame.hidden = false;
el.reloadBtn.hidden = false;
el.viewerTitle.textContent = service.name;
@@ -211,11 +287,49 @@ function openService(service) {
el.viewerFrame.src = service.proxy_path;
}
function openPage(page) {
state.selected = page.id;
renderSidebar();
el.settings.hidden = true;
el.viewer.hidden = false;
el.emptyState.hidden = true;
el.pagePanel.hidden = false;
el.viewerFrame.hidden = true;
el.viewerFrame.src = "";
el.reloadBtn.hidden = true;
el.viewerTitle.textContent = page.name;
el.viewerUrl.textContent = "";
el.pagePanel.innerHTML = noticeCard(page);
}
// Paint the Home board into the viewer area without touching which section is
// active, so a delete performed from Settings leaves Home ready for settingsClose.
function showHome() {
el.emptyState.hidden = true;
el.pagePanel.hidden = false;
el.viewerFrame.hidden = true;
el.viewerFrame.src = "";
el.reloadBtn.hidden = true;
el.viewerTitle.textContent = "Home";
el.viewerUrl.textContent = "";
renderHome();
}
function openHome() {
state.selected = HOME;
renderSidebar();
el.settings.hidden = true;
el.viewer.hidden = false;
showHome();
}
function showSettings() {
el.settings.hidden = false;
el.viewer.hidden = true;
el.viewerFrame.src = "";
renderTable();
renderPageTable();
renderUsers();
}
@@ -243,10 +357,28 @@ function fillForm(service) {
el.enabled.checked = service.enabled;
}
function resetNoticeForm() {
state.editingPage = null;
el.noticeFormTitle.textContent = "Add notice";
el.noticeName.value = "";
el.noticeIcon.value = "";
el.noticeBody.value = "";
}
function fillNoticeForm(page) {
state.editingPage = page.id;
el.noticeFormTitle.textContent = `Edit ${page.name}`;
el.noticeName.value = page.name;
el.noticeIcon.value = page.icon || "";
el.noticeBody.value = page.body;
}
async function refresh() {
state.services = await api("/api/services");
state.pages = await api("/api/pages");
renderSidebar();
renderTable();
renderPageTable();
}
async function saveService(event) {
@@ -272,14 +404,50 @@ async function saveService(event) {
}
}
async function saveNotice(event) {
event.preventDefault();
const payload = {
name: el.noticeName.value.trim(),
icon: el.noticeIcon.value.trim(),
body: el.noticeBody.value.trim(),
};
try {
const saved = state.editingPage
? await api(`/api/pages/${state.editingPage}`, { method: "PATCH", body: JSON.stringify(payload) })
: await api("/api/pages", { method: "POST", body: JSON.stringify(payload) });
toast(state.editingPage ? `Updated ${saved.name}` : `Added ${saved.name}`);
resetNoticeForm();
await refresh();
} catch (error) {
toast(error.message, "error");
}
}
async function deleteService(id) {
const service = findService(id);
if (!service || !confirm(`Delete ${service.name}?`)) return;
try {
await api(`/api/services/${id}`, { method: "DELETE" });
if (state.selected === id) state.selected = null;
const wasViewing = state.selected === id;
if (wasViewing) state.selected = HOME;
toast(`Deleted ${service.name}`);
await refresh();
if (wasViewing) showHome();
} catch (error) {
toast(error.message, "error");
}
}
async function deletePage(id) {
const page = findPage(id);
if (!page || !confirm(`Delete ${page.name}?`)) return;
try {
await api(`/api/pages/${id}`, { method: "DELETE" });
const wasViewing = state.selected === id;
if (wasViewing) state.selected = HOME;
toast(`Deleted ${page.name}`);
await refresh();
if (wasViewing) showHome();
} catch (error) {
toast(error.message, "error");
}
@@ -315,8 +483,23 @@ el.serviceTable.addEventListener("click", (event) => {
}
});
el.pageTable.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const id = button.dataset.id;
if (button.dataset.action === "edit") {
const page = findPage(id);
if (page) fillNoticeForm(page);
} else if (button.dataset.action === "delete") {
deletePage(id);
}
});
el.form.addEventListener("submit", saveService);
el.formCancel.addEventListener("click", resetForm);
el.noticeForm.addEventListener("submit", saveNotice);
el.noticeCancel.addEventListener("click", resetNoticeForm);
el.homeBtn.addEventListener("click", openHome);
el.settingsToggle.addEventListener("click", showSettings);
el.settingsClose.addEventListener("click", () => {
el.settings.hidden = true;
@@ -350,4 +533,5 @@ loadUser()
renderUser();
return refresh();
})
.then(openHome)
.catch((error) => toast(error.message, "error"));
+71
View File
@@ -232,6 +232,77 @@ body {
.btn.ghost { background: transparent; color: var(--fg); border-color: var(--border); }
.btn:hover { filter: brightness(1.1); }
/* ------------------------------------------------------------------ pages */
.home-btn,
.page-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-2);
color: var(--fg);
cursor: pointer;
text-align: left;
font: inherit;
}
.home-btn {
border-color: var(--accent);
border-bottom: 3px solid var(--accent);
}
.home-btn:hover { filter: brightness(1.1); }
.page-btn { border-left: 3px solid var(--accent); }
.page-btn:hover { border-color: var(--accent); }
.page-btn.active { border-color: var(--accent); background: var(--accent); color: #fff; }
.home-btn .icon,
.page-btn .icon { font-size: 16px; }
.home-btn .label,
.page-btn .label { display: block; font-weight: 600; }
.page-btn .sub { display: block; font-size: 11px; color: var(--muted); }
.page-panel { flex: 1; overflow: auto; padding: 24px; }
.home-banner {
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px 22px;
border-radius: 12px;
border: 1px solid var(--accent);
background: var(--panel-2);
}
.home-title { font-size: 22px; font-weight: 800; }
.home-sub { color: var(--muted); font-size: 13px; }
.notice-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 12px;
}
.notice-card .icon { font-size: 16px; }
.notice-card .notice-title { font-weight: 700; }
.notice-body { white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
.home-empty { color: var(--muted); font-size: 14px; }
.page-table { display: flex; flex-direction: column; gap: 10px; }
.page-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
align-items: center;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.page-row .meta { min-width: 0; }
.page-row .meta strong { display: block; }
.page-row .meta span { color: var(--muted); font-size: 12px; display: block; }
/* ----------------------------------------------------------------- toast */
.toast {
position: fixed;
+29 -2
View File
@@ -13,6 +13,11 @@
<span class="brand-sub">mk3</span>
</div>
<button class="home-btn" id="home-btn" title="Home">
<span class="icon">🏠</span>
<span class="label">Home</span>
</button>
<div class="service-list" id="service-list"></div>
<div class="sidebar-foot">
@@ -28,12 +33,13 @@
<main class="main">
<section class="viewer" id="viewer">
<header class="viewer-bar">
<span class="viewer-title" id="viewer-title">Nothing selected</span>
<span class="viewer-title" id="viewer-title">Home</span>
<span class="viewer-url" id="viewer-url"></span>
<button class="btn ghost" id="reload-btn" hidden>Reload</button>
</header>
<iframe id="viewer-frame" title="Service view" hidden></iframe>
<div class="empty" id="empty-state">
<div class="page-panel" id="page-panel" hidden></div>
<div class="empty" id="empty-state" hidden>
<p id="empty-text">Pick a service from the sidebar, or add one in Settings.</p>
</div>
</section>
@@ -83,7 +89,28 @@
</div>
</form>
<form id="notice-form" class="card form" autocomplete="off">
<h2 id="notice-form-title">Add notice</h2>
<label>
Name
<input id="n-name" required maxlength="120" placeholder="Server status" />
</label>
<label>
Icon
<input id="n-icon" maxlength="8" placeholder="emoji or initials" />
</label>
<label>
Body
<textarea id="n-body" required rows="6" placeholder="Anything the crew should know"></textarea>
</label>
<div class="form-actions">
<button type="submit" class="btn">Save</button>
<button type="button" class="btn ghost" id="notice-cancel">Cancel</button>
</div>
</form>
<div class="service-table" id="service-table"></div>
<div class="page-table" id="page-table"></div>
<div class="user-table" id="user-table" hidden></div>
</div>
</section>