diff --git a/README.md b/README.md index 5ad0945..19facb5 100644 --- a/README.md +++ b/README.md @@ -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` | `/services.json` | explicit store path | +| `--pages-file` | `/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 diff --git a/src/uwudash_mk3/api.py b/src/uwudash_mk3/api.py index 0b84a45..36b8a90 100644 --- a/src/uwudash_mk3/api.py +++ b/src/uwudash_mk3/api.py @@ -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: diff --git a/src/uwudash_mk3/app.py b/src/uwudash_mk3/app.py index 973ca5a..22907c1 100644 --- a/src/uwudash_mk3/app.py +++ b/src/uwudash_mk3/app.py @@ -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("/") diff --git a/src/uwudash_mk3/config.py b/src/uwudash_mk3/config.py index 4975c63..53d4a2b 100644 --- a/src/uwudash_mk3/config.py +++ b/src/uwudash_mk3/config.py @@ -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") + ), ) diff --git a/src/uwudash_mk3/main.py b/src/uwudash_mk3/main.py index 59472fb..a2365f6 100644 --- a/src/uwudash_mk3/main.py +++ b/src/uwudash_mk3/main.py @@ -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: diff --git a/src/uwudash_mk3/models.py b/src/uwudash_mk3/models.py index 587c629..1631ae2 100644 --- a/src/uwudash_mk3/models.py +++ b/src/uwudash_mk3/models.py @@ -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") diff --git a/src/uwudash_mk3/pages.py b/src/uwudash_mk3/pages.py new file mode 100644 index 0000000..7c8b520 --- /dev/null +++ b/src/uwudash_mk3/pages.py @@ -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 diff --git a/src/uwudash_mk3/static/app.js b/src/uwudash_mk3/static/app.js index ab6971d..cee8134 100644 --- a/src/uwudash_mk3/static/app.js +++ b/src/uwudash_mk3/static/app.js @@ -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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; @@ -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 = '

No services configured yet.

'; 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 = + `${esc(page.icon || "📌")}` + + `${esc(page.name)}` + + `${esc(page.body.slice(0, 60))}`; + 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 = '

No notices yet - pin one above.

'; + return; + } + for (const page of state.pages) { + const row = document.createElement("div"); + row.className = "page-row"; + row.innerHTML = + `
${esc(page.name)}` + + `${esc(page.body.slice(0, 80))}
` + + `` + + ``; + el.pageTable.appendChild(row); + } +} + +function noticeCard(page) { + return ( + `
` + + `${esc(page.icon || "📌")}` + + `
${esc(page.name)}
` + + `
${esc(page.body)}
` + + `
` + ); +} + +function renderHome() { + const banner = + `
` + + `
Home (uwu)
` + + `
the notice board — pin anything the crew should know
` + + `
`; + const cards = state.pages.slice().reverse().map(noticeCard).join(""); + el.pagePanel.innerHTML = + banner + (cards || '

no notices yet ( 🥯🥯 ) — pin one from Settings!

'); +} + 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")); diff --git a/src/uwudash_mk3/static/styles.css b/src/uwudash_mk3/static/styles.css index 61d2b79..bca7945 100644 --- a/src/uwudash_mk3/static/styles.css +++ b/src/uwudash_mk3/static/styles.css @@ -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; diff --git a/src/uwudash_mk3/templates/index.html b/src/uwudash_mk3/templates/index.html index 1fd3777..05e17a0 100644 --- a/src/uwudash_mk3/templates/index.html +++ b/src/uwudash_mk3/templates/index.html @@ -13,6 +13,11 @@ mk3 + +
+
+

Add notice

+ + + +
+ + +
+
+
+