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:
1 parent
68b9f851ef
commit
3aad88415d
10 files changed
+521
-12
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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")
|
||||
),
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 = '<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"));
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user