design is now terminal alike

This commit is contained in:
Priec
2026-05-19 14:48:47 +02:00
parent 67b7c8e5ae
commit cbd642c62c
13 changed files with 801 additions and 755 deletions

View File

@@ -1,399 +0,0 @@
# Universal Web — Loco.rs Rewrite Specification
This document is the implementation spec for re-building the existing **Axum** app
(parent directory `../`) on top of **Loco.rs**. It captures every feature so the
rewrite reaches parity. The GUI is being rewritten as part of this effort, so
templates/markup are *not* meant to be ported verbatim — only behaviour and routes.
- **Source app:** `../` — Axum 0.8, sqlx, Askama, axum-login, OpenDAL.
- **Target app:** this directory — Loco 0.16 (SaaS, server-side rendered), SeaORM 1.1, Tera.
- **Scaffold already generated by `loco new`:** auth controller, `users` model + migration,
auth mailer, background worker example, Tera view engine, i18n.
---
## 1. Stack mapping (old → Loco)
| Concern | Old (Axum) | Loco target |
|--------------------|---------------------------------------------|----------------------------------------------------------|
| HTTP framework | Axum 0.8 + tower | Loco (Axum under the hood) — `controllers/` |
| DB access | sqlx 0.8 (raw SQL, `query_as`) | SeaORM 1.1 — entities in `models/_entities/` |
| Migrations | `migrations/*.sql` (sqlx-cli) | `migration/` crate (SeaORM, Rust DSL) |
| Templating | Askama (compile-time) | Tera (`assets/views/`) via `view_engine` initializer |
| Auth/session | axum-login + tower-sessions (PG store) | **Decision needed — see §3.1.** Loco ships JWT auth. |
| Password hashing | argon2 0.5 | Loco built-in (`AuthnUser`/`hash_password`) — argon2 |
| File storage | OpenDAL 0.50 (fs/S3/Azure/GCS) | Loco `storage` module (local/S3/multi-mirror) |
| Background jobs | none (manual) | Loco workers (`workers/`) — bg mode `async` selected |
| Email | none (verification stored, not sent) | Loco mailers (`mailers/`) — scaffold already present |
| API docs | utoipa + Swagger UI + Scalar | Manual — see §13 |
| Validation | garde | `validator` crate (already a dep) |
| Config | `.env` + `config.rs` singleton | `config/{development,production,test}.yaml` |
| Logging | tracing-subscriber | Loco logger (configured in yaml) |
---
## 2. Feature inventory (parity checklist)
The old app ships these feature modules. Each must exist in the rewrite:
- [ ] **Auth** — register, login, logout, current-user, email verification
- [ ] **RBAC** — 4 roles, ~14 permissions, per-request permission loading
- [ ] **Admin** — dashboard, user management, role assignment, audit log
- [ ] **Blog** — articles CRUD, publish workflow, public listing, view counts
- [ ] **Audio dashboard** — albums + tracks + tags CRUD, publish workflow
- [x] **Audio streaming** — range-aware track/file streaming, raw upload
- [ ] **Audio player** — persistent bottom-bar player (frontend)
- [x] **Images** — upload + serve, used as cover/featured images
- [ ] **Theme** — per-user light/dark preference
- [x] **Storage** — pluggable backend (fs default, S3/Azure/GCS capable)
- [ ] **Home + layout** — landing page, dynamic navbar, footer
- [ ] **Swagger/OpenAPI** — API docs (optional, lower priority)
---
## 3. Key migration decisions / gotchas
### 3.1 Sessions vs JWT
The old app uses **cookie sessions** (axum-login + `tower-sessions-sqlx-store`,
1-day inactivity timeout, PG-backed `session` table). Loco's SaaS starter ships
**JWT bearer auth** by default.
Because this is a **server-side rendered** app (forms post HTML, navbar reflects
auth state), bearer tokens are awkward. Recommended path:
- Keep Loco's `users` model + password hashing + the `auth` controller for the
*API-style* endpoints.
- Add **cookie-session middleware** for the HTML pages. Loco exposes the Axum
router, so `tower-sessions` can be layered in an initializer
(`src/initializers/`). Store the user id in the session.
- Alternatively store the JWT in an `HttpOnly` cookie and add an extractor that
reads it from the cookie instead of the `Authorization` header.
Pick one and apply it consistently. The old behaviour to match: login sets a
cookie, navbar/`/auth/me` reflect it, logout clears it, ~1-day expiry.
### 3.2 sqlx → SeaORM
Every raw query becomes a SeaORM entity + `ActiveModel`. Generate entities from
migrations with `cargo loco db entities`. Slug-uniqueness and `ON CONFLICT DO
NOTHING` semantics must be re-expressed (unique index + handle the error).
### 3.3 Askama → Tera
Templates are NOT ported. Re-author markup as Tera under `assets/views/`. The new
GUI may keep HTMX + Alpine + DaisyUI (still works with server-rendered Tera) or go
a different direction — that is a GUI decision, not a backend one.
### 3.4 OpenDAL → Loco storage
Loco has its own `storage` abstraction (local, S3, multi-backend mirror/replica).
Map the old `Storage`/`ImageStorage`/`AudioStorage` wrappers onto it. Default to
local (`uploads/`); keep S3/Azure/GCS reachable via config only.
### 3.5 Range requests for audio
The old `stream_track` forwards the request so `ServeFile` honours `Range`
headers (seeking/progressive playback). The Loco equivalent must also emit
`Accept-Ranges`/`Content-Range` — use `tower-http` `ServeFile`/`ServeDir` or a
range-aware handler. Do not regress this; it is required for the player's seek bar.
---
## 4. Database schema
Old app: 8 migrations under `../migrations/`. Re-create as SeaORM migrations in
`migration/src/` (the `loco new` scaffold already created `m20220101_000001_users`).
Prefer Loco generators: `cargo loco generate model <name> ...`.
### 4.1 `users`
| column | type | notes |
|-------------------|--------------|----------------------------------------|
| id | UUID PK | `gen_random_uuid()` |
| email | VARCHAR(255) | UNIQUE NOT NULL |
| password_hash | TEXT | NOT NULL (argon2) |
| email_verified | BOOLEAN | default FALSE |
| email_verified_at | TIMESTAMPTZ | nullable |
| theme | VARCHAR(10) | default `'light'` |
| created_at | TIMESTAMPTZ | default NOW |
| updated_at | TIMESTAMPTZ | default NOW |
> Loco's scaffold `users` table differs (has `pid`, `api_key`, reset/verification
> token columns). Reconcile: either adopt Loco's columns and add `theme`, or align
> Loco's model to this schema. Adopting Loco's is less work — it already wires
> tokens for verification/reset.
### 4.2 `email_verifications`
`id UUID PK`, `user_id UUID FK→users ON DELETE CASCADE`, `token VARCHAR(255) UNIQUE`,
`expires_at TIMESTAMPTZ`, `created_at TIMESTAMPTZ`. Indexes on `token`, `user_id`.
> Loco's `users` table already carries verification tokens — this table may become
> redundant if you adopt Loco's auth columns.
### 4.3 `user_roles`
`user_id UUID FK→users CASCADE`, `role VARCHAR(50)` (`user`|`moderator`|`admin`|
`super_admin`), `assigned_by UUID FK→users ON DELETE SET NULL`, `assigned_at
TIMESTAMPTZ`. **PK = (user_id, role).** Index on `user_id`. Migration back-fills
`user` role for every existing user.
### 4.4 `blog_articles`
`id UUID PK`, `title VARCHAR(500)`, `slug VARCHAR(500) UNIQUE`, `content TEXT`,
`excerpt VARCHAR(1000) NULL`, `published BOOL default false`, `author_id UUID
FK→users CASCADE`, `featured_image_id VARCHAR(500) NULL`, `view_count INT default 0`,
`created_at`, `updated_at` (trigger-updated), `published_at TIMESTAMPTZ NULL`.
Indexes: `slug`, `(published, published_at DESC)`, `author_id`.
### 4.5 `audit_logs`
`id UUID PK`, `admin_user_id UUID FK→users CASCADE`, `action VARCHAR(100)`,
`target_type VARCHAR(50) NULL`, `target_id UUID NULL`, `details JSONB NULL`,
`ip_address INET NULL`, `user_agent TEXT NULL`, `created_at TIMESTAMPTZ`.
Indexes: `admin_user_id`, `action`, `(target_type,target_id)`, `created_at DESC`.
### 4.6 `audio_albums`
`id UUID PK`, `title VARCHAR(500)`, `slug VARCHAR(500) UNIQUE`, `description TEXT
NULL`, `cover_image_id VARCHAR(500) NULL`, `artist VARCHAR(500) NULL`, `release_date
DATE NULL`, `published BOOL default false`, `uploader_id UUID FK→users CASCADE`,
`view_count INT default 0`, `created_at`, `updated_at` (trigger), `published_at NULL`.
Indexes: `slug`, `published`, `uploader_id`.
### 4.7 `audio_tracks`
`id UUID PK`, `album_id UUID FK→audio_albums CASCADE`, `title VARCHAR(500)`, `slug
VARCHAR(500)`, `audio_file_id VARCHAR(500) NOT NULL`, `track_number INT NULL`,
`duration INT NULL` (seconds), `featured BOOL default false`, `play_count INT
default 0`, `created_at`, `updated_at` (trigger). **UNIQUE(album_id, slug).**
### 4.8 `audio_tags` + `audio_track_tags`
`audio_tags`: `id UUID PK`, `name VARCHAR(100) UNIQUE`, `slug VARCHAR(100) UNIQUE`,
`created_at`. `audio_track_tags` (M:N junction): `track_id UUID FK→audio_tracks
CASCADE`, `tag_id UUID FK→audio_tags CASCADE`, `created_at`, **PK=(track_id,tag_id)**.
> The old schema uses `updated_at` triggers. SeaORM convention is to set
> `updated_at` in the `ActiveModel` (`before_save` hook) instead — either works.
---
## 5. Routes (parity table)
`method path → behaviour (auth requirement)`. Loco controllers live in
`src/controllers/`; register each with `.add_route(...)` in `app.rs`.
### Auth (`/auth`)
- `GET /auth/login` → login page (public)
- `POST /auth/login` → login, HTML/HTMX response; sets session cookie
- `POST /auth/login/json` → login, JSON response
- `GET /auth/register` → register page (public)
- `POST /auth/register` → register, HTML/HTMX
- `POST /auth/register/json` → register, JSON
- `POST /auth/logout` → clear session
- `GET /auth/me` → current user (auth required)
- `GET /auth/verify?token=` → verify email
- `POST /auth/verify/resend` → resend verification (auth required)
### RBAC (`/rbac`)
- `GET /rbac/permissions` → list all permissions (public)
- `GET /rbac/roles` → list all roles (public)
- `GET /rbac/me` → current user roles+permissions (auth)
- `GET /rbac/users/{id}/roles` → (auth)
- `GET /rbac/users/{id}/permissions` → (auth)
- `POST /rbac/users/{id}/roles` → assign role (SuperAdmin)
- `DELETE /rbac/users/{id}/roles/{role}` → remove role (SuperAdmin)
### Blog
- `GET /blog` → published articles (public)
- `GET /blog/{slug}` → article, increments `view_count` (public)
- `GET /admin/blog` & `/admin/blog/articles` → admin list (Moderator+)
- `GET|POST /admin/blog/articles/create` → create form / submit (Moderator+)
- `GET|POST /admin/blog/articles/{id}/edit` → edit form / submit (Moderator+)
- `GET /admin/blog/articles/{id}/delete` → delete (Moderator+)
### Admin (`/admin`)
- `GET /admin` → redirect to `/admin/dashboard`
- `GET /admin/dashboard` → dashboard + system stats (Moderator+)
- `GET /admin/users` & `/admin/users/list` → user list, filter+paginate (Admin+)
- `GET /admin/users/{id}` → user detail (Admin+)
- `GET /admin/users/{id}/roles` → roles form (Admin+)
- `POST /admin/users/{id}/roles/assign` → assign (Admin+)
- `POST /admin/users/{id}/roles/{role}/remove` → remove (Admin+)
- `GET /admin/audit-logs` → audit log list (Moderator+)
### Audio (public + admin)
- `POST /audio/upload` → raw audio upload (auth)
- `GET /audio/stream/{filename}` → stream raw file, **range-aware** (public)
- `GET /audio/albums` → published albums (public)
- `GET /audio/albums/{slug}` → album + tracks (public)
- `GET /audio/tracks/{id}/stream` → stream track, **range-aware** (public)
- `GET /admin/audio/albums` → admin album list (Moderator+)
- `GET|POST /admin/audio/albums/create` → (Moderator+)
- `GET|POST /admin/audio/albums/{id}/edit` → (Moderator+)
- `GET /admin/audio/albums/{id}/delete` → (Moderator+)
- `GET /admin/audio/albums/{id}/tracks` → track list (Moderator+)
- `GET /admin/audio/albums/{id}/tracks/upload` → upload form (Moderator+)
- `POST /admin/audio/albums/{id}/tracks/upload-file` → multipart upload, 50 MB (Moderator+)
- `GET|POST /admin/audio/tracks/{id}/edit` → (Moderator+)
- `GET /admin/audio/tracks/{id}/delete` → (Moderator+)
- `GET /admin/audio/tags` → tag list (Moderator+)
- `POST /admin/audio/tags` → create tag (Moderator+)
### Images
- `POST /images/upload` → upload, validate ext+MIME, max 10 MB (auth)
- `GET /images/serve/{filename}` → serve with MIME + cache headers (public)
### Misc
- `GET /` → home (public)
- `GET /layout/navbar` → navbar fragment reflecting auth state (public)
- `GET /theme` → current theme; `GET /theme/set?...` → set theme (public)
- `GET /static/*` → static files
- `GET /swagger-ui/`, `GET /api-docs/openapi.json` → API docs
---
## 6. Feature details
### 6.1 Auth
- Register: validate email + password (864 chars), reject duplicate email,
argon2-hash password, insert user, assign default `user` role.
- Login: look up by email, verify password (constant-time), create session.
- **Admin bootstrap:** on successful login, if `user.email` (case-insensitive)
equals config `admin_email`, assign `SuperAdmin` role (idempotent) and write an
`audit_logs` row with `action="admin_bootstrap"`. This is how the first admin is
created — there is no seed user. Keep this behaviour.
- Email verification: token table + `email_verified`/`email_verified_at`. The old
app stored tokens but never emailed them — the Loco rewrite **should actually
send** the email via the scaffolded `mailers/auth`.
- Every response that changes auth state should let the navbar refresh
(`GET /layout/navbar`).
### 6.2 RBAC
- **Roles:** `SuperAdmin`, `Admin` (same perms as SuperAdmin), `Moderator`, `User`.
- **Permissions (~14):** `users.{create,read,update,delete,manage_roles}`,
`roles.assign`, `content.{create,read,update,delete,moderate}`,
`images.{create,read,update,delete}`.
- Role→permission mapping:
- SuperAdmin / Admin → all permissions.
- Moderator → `content.read/update/moderate`, `users.read`, `images.read/delete`.
- User (default) → `content.read`, `images.create/read`.
- A **middleware** loads the current user's `UserPermissions` into request
extensions on every request so handlers/views can check access. In Loco, do this
with a middleware layer or a custom extractor. Provide guard helpers equivalent
to "Moderator+", "Admin+", "SuperAdmin".
### 6.3 Admin
- Dashboard with system stats (user counts, content counts) — Moderator+.
- User management: list with search filter + pagination, detail view — Admin+.
- Role assign/remove per user, each writing an `audit_logs` entry — Admin+.
- Audit log viewer — Moderator+.
- `AuditLog` fields: action, target_type, target_id, JSONB details, ip_address,
user_agent. Populate IP + user-agent from request headers.
### 6.4 Blog
- Public: list published articles (paginate), view by slug (increments `view_count`).
- Admin (Moderator+): create / edit / delete, draft↔publish (`published_at` set on
publish), drafts visible only in admin.
- Slug auto-generated from title: lowercase, alphanumeric + hyphens, collapse
repeats; uniqueness enforced by DB — surface a clear "already exists" error.
- `author_id` = current user. `featured_image_id` references an uploaded image id.
### 6.5 Audio dashboard
- **Albums:** CRUD, publish workflow, public list shows published only, slug URLs,
`cover_image_id` references an image, `view_count`.
- **Tracks:** belong to an album, multipart upload (50 MB cap, extensions
`mp3 wav ogg flac aac m4a webm`), `track_number`, `duration` (seconds, nullable —
upload does not extract it; the player reads it from the audio element instead),
`featured`, `play_count`, slug unique per album.
- **Tags:** create + assign to tracks (M:N). Tag has unique name + slug.
### 6.6 Audio streaming & player
- `stream_track` / `stream_handler` must honour `Range` requests (see §3.5).
- The old app has a **persistent bottom-bar player** (`templates/player/player.html`):
hidden `<audio>` + Alpine.js, play/pause, prev/next, seek bar, volume persisted to
`localStorage`, auto-advance on track end. It is a sibling of the main content so
HTMX navigation never tears it down — music keeps playing across pages.
- Pages drive it via one global JS API: `MusicPlayer.playQueue(containerSelector,
startIndex)`, reading `data-*` attributes off track rows (no JSON in markup).
- This is a **frontend concern** — reproduce the behaviour in the new GUI; the
backend only needs the range-aware stream endpoints + track metadata.
### 6.7 Images
- Upload: multipart, validate extension (`jpg jpeg png webp gif`) and MIME, max
10 MB, store via storage backend, return a file id.
- Serve: public, correct MIME (guessed from extension), long-cache headers.
- Image ids are referenced by `blog_articles.featured_image_id` and
`audio_albums.cover_image_id` (stored as strings, not FKs).
### 6.8 Theme
- Per-user `theme` column (`light`/`dark`); also a cookie for anonymous users.
- `GET /theme` returns current, `GET /theme/set` toggles/sets it.
- Maps to DaisyUI themes in the old GUI (`winter`/`dracula`). New GUI decides its
own theming, but keep the per-user persisted preference.
### 6.9 Storage
- Default backend: local filesystem under `uploads/` (`uploads/images`,
`uploads/audio`). Keep S3/Azure/GCS reachable via config only.
- Path-traversal protection on every user-supplied filename/path.
- Use Loco's `storage` module; configure backends in `config/*.yaml`.
---
## 7. Configuration / env
Old `.env`:
```
DATABASE_URL=postgres://uni_web_user:1@localhost/universal_web
ADMIN_EMAIL=filippriec@gmail.com
```
In Loco, move these into `config/development.yaml` (DB url under `database:`) and
add a custom `settings:` entry for `admin_email` (read via `ctx.config.settings`).
The old default bind address is `0.0.0.0:3000` — set under `server:` in the yaml.
---
## 8. What the Loco scaffold already gives you
Already generated in this directory — reuse, don't rebuild:
- `src/controllers/auth.rs` — register / login / verify / forgot / reset endpoints.
- `src/models/users.rs` + `_entities/users.rs` — user model with password hashing.
- `src/mailers/auth/` — welcome / forgot-password / magic-link email templates.
- `src/workers/downloader.rs` — example background worker (bg mode = `async`).
- `src/initializers/view_engine.rs` — Tera view engine + i18n wiring.
- `migration/` — SeaORM migration crate.
- `config/{development,production,test}.yaml` — config files.
---
## 9. Suggested implementation order
1. **DB & models** — port all 8 tables as SeaORM migrations; `cargo loco db
entities`; reconcile `users` with the scaffold (§4.1).
2. **Auth + sessions** — settle §3.1, get register/login/logout/me working,
including admin-bootstrap.
3. **RBAC** — roles/permissions, the permission-loading middleware, guard helpers.
4. **Storage + images** — DONE: storage backend, image upload/serve (unblocks blog/audio
cover images).
5. **Blog** — CRUD + publish + public pages.
6. **Audio dashboard** — albums, tracks (multipart upload), tags.
7. **Audio streaming + player** — DONE for range-aware endpoints; player remains GUI work.
8. **Admin** — dashboard, user management, role UI, audit log.
9. **Theme**, **home/layout/navbar**.
10. **Swagger/OpenAPI** (optional), tests, polish.
---
## 10. Testing
The old app has 19 tests (17 integration hitting a live server + 2 multipart
upload) plus unit tests; see `../TESTING.md`. Loco has a first-class testing story
(`#[tokio::test]` + `loco-rs` testing feature, `serial_test`, `insta` snapshots —
all already dev-deps). Re-create integration coverage for every route in §5,
especially: public pages return 200, protected routes return 401/403 without auth,
multipart uploads, and range requests on the streaming endpoints.
---
## 11. Notes / known quirks carried over
- Track `duration` is frequently `NULL` (upload never extracts it). The player
reads duration from the `<audio>` element's metadata, so the seek bar works
anyway — don't block on server-side duration extraction.
- `Admin` and `SuperAdmin` have identical permission sets; only role-assignment
endpoints are SuperAdmin-gated.
- Image references (`featured_image_id`, `cover_image_id`) are plain strings, not
foreign keys — the rewrite may upgrade these to real FKs if desired.

View File

@@ -1,31 +1,39 @@
/* ============================================================
* Gruvbox dark theme
* Gruvbox terminal theme
* ------------------------------------------------------------
* Project-owned theme overrides. The vendored `app.css` is a
* pre-compiled Tailwind + DaisyUI bundle and is NOT edited.
* This file is loaded *after* it (see base.html / admin/base.html)
* and only redefines DaisyUI's built-in `dark` theme.
* Project-owned styling. The vendored `app.css` (a pre-compiled
* Tailwind + DaisyUI bundle) is NOT edited. This file loads
* after it (see base.html / admin/base.html) and provides:
*
* DaisyUI consumes each variable as `oklch(var(--x))`, so values
* must be OKLch "L% C H" triplets (no function wrapper). The hex
* next to each line is the source color it was converted from.
* 1. the Gruvbox palette for DaisyUI's `dark` theme
* 2. square corners (terminals have none)
* 3. a terminal look & feel: monospace, window chrome,
* vim-style statusline, CRT scanlines
* 4. `.term-*` building blocks used by the templates
*
* Why CSS classes and not utility classes: `app.css` is frozen
* and only contains the utilities the original project used, so
* new Tailwind classes would not exist. The DaisyUI *components*
* (card/btn/badge/menu/...) do exist and are reused; everything
* else is defined here as real, themeable CSS.
*
* Palette: https://github.com/morhetz/gruvbox (dark, bright)
* To retune, change the hex, reconvert to OKLch, update the line.
* DaisyUI color vars are OKLch "L% C H" triplets; this file can
* therefore tint anything with `oklch(var(--x) / <alpha>)`.
* ============================================================ */
/* === 1. Gruvbox dark palette ================================ */
/* Source hex noted per line. To retune: change hex, reconvert
* to OKLch, update the value. */
[data-theme="dark"] {
/* --- surfaces ------------------------------------------------ */
--b1: 27.69% 0 0; /* #282828 bg0 page background */
--b2: 31.10% 0.003 49.7; /* #32302f bg0_s panels / elevated */
--b3: 34.40% 0.0066 48.7; /* #3c3836 bg1 borders / dividers */
--b1: 27.69% 0 0; /* #282828 bg0 screen background */
--b2: 31.10% 0.003 49.7; /* #32302f bg0_s panels / chrome */
--b3: 34.40% 0.0066 48.7; /* #3c3836 bg1 borders */
--bc: 89.42% 0.0566 89.5; /* #ebdbb2 fg body text */
/* --- neutral ------------------------------------------------- */
--n: 34.40% 0.0066 48.7; /* #3c3836 bg1 */
--nc: 89.42% 0.0566 89.5; /* #ebdbb2 fg */
/* --- brand accents ------------------------------------------ */
--p: 73.10% 0.182 51.7; /* #fe8019 bright orange primary */
--pc: 27.69% 0 0; /* #282828 text on primary */
--s: 70.54% 0.097 2.3; /* #d3869b bright purple secondary */
@@ -33,13 +41,278 @@
--a: 75.57% 0.108 137.6; /* #8ec07c bright aqua accent */
--ac: 27.69% 0 0; /* #282828 text on accent */
/* --- status colors ------------------------------------------ */
--in: 69.26% 0.042 169.8; /* #83a598 bright blue info */
--su: 76.52% 0.158 110.8; /* #b8bb26 bright green success */
--wa: 83.49% 0.160 83.6; /* #fabd2f bright yellow warning */
--er: 65.97% 0.217 30.4; /* #fb4934 bright red error */
--inc: 24.07% 0.005 220.9; /* #1d2021 bg0_h text on status */
--suc: 24.07% 0.005 220.9; /* #1d2021 */
--wac: 24.07% 0.005 220.9; /* #1d2021 */
--erc: 24.07% 0.005 220.9; /* #1d2021 */
--suc: 24.07% 0.005 220.9;
--wac: 24.07% 0.005 220.9;
--erc: 24.07% 0.005 220.9;
}
/* === 2. Square corners ====================================== */
/* `[data-theme]` matches the same <html> element as the vendored
* `[data-theme=dark|light]` rules with equal specificity, and
* wins by load order. Applies to both themes. */
[data-theme] {
--rounded-box: 0;
--rounded-btn: 0;
--rounded-badge: 0;
--tab-radius: 0;
--animation-btn: 0;
--animation-input: 0;
}
/* === 3. Terminal look & feel ================================ */
body {
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code",
ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 15px;
line-height: 1.6;
}
/* Gruvbox text selection + scrollbars (dark only) */
[data-theme="dark"] ::selection { background: #fe8019; color: #282828; }
[data-theme="dark"] { scrollbar-color: #504945 #282828; }
[data-theme="dark"] ::-webkit-scrollbar { width: 12px; height: 12px; }
[data-theme="dark"] ::-webkit-scrollbar-track { background: #282828; }
[data-theme="dark"] ::-webkit-scrollbar-thumb {
background: #504945; border: 3px solid #282828;
}
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #665c54; }
/* Faint CRT scanlines — dark only. Remove this block to drop it. */
[data-theme="dark"] body::before {
content: "";
position: fixed;
inset: 0;
z-index: 90;
pointer-events: none;
background: repeating-linear-gradient(
0deg, rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15) 1px,
transparent 1px, transparent 3px);
opacity: 0.45;
}
/* --- color helpers (theme-adaptive: gruvbox in dark) -------- */
.t-orange { color: oklch(var(--p)); }
.t-purple { color: oklch(var(--s)); }
.t-aqua { color: oklch(var(--a)); }
.t-blue { color: oklch(var(--in)); }
.t-green { color: oklch(var(--su)); }
.t-yellow { color: oklch(var(--wa)); }
.t-red { color: oklch(var(--er)); }
.t-dim { color: oklch(var(--bc) / 0.5); }
/* --- window titlebar (the header) -------------------------- */
.term-titlebar {
position: sticky;
top: 0;
z-index: 50;
background: oklch(var(--b2));
border-bottom: 1px solid oklch(var(--b3));
}
.term-nav {
display: flex;
align-items: center;
gap: 0.85rem;
width: 100%;
max-width: 72rem;
margin: 0 auto;
padding: 0.5rem 1rem;
}
.term-dots { display: inline-flex; gap: 0.45rem; flex: none; }
.term-dot {
width: 0.72rem;
height: 0.72rem;
border-radius: 9999px;
display: block;
}
.term-dot.r { background: oklch(var(--er)); }
.term-dot.y { background: oklch(var(--wa)); }
.term-dot.g { background: oklch(var(--su)); }
.term-brand {
font-size: 0.85rem;
white-space: nowrap;
text-decoration: none;
}
.term-brand:hover { text-decoration: none; }
.term-nav-right { margin-left: auto; display: flex; align-items: center; gap: 0.25rem; }
/* horizontal nav links */
.term-navlinks { padding: 0; gap: 0; }
.term-navlinks li > a,
.term-navlinks li > form > button {
padding: 0.2rem 0.55rem;
font-size: 0.85rem;
border-radius: 0;
}
.term-navlinks li > a::before { content: ""; }
.term-navlinks li > a:hover,
.term-navlinks li > form > button:hover {
background: transparent;
color: oklch(var(--p));
}
.term-navlinks a.is-active {
color: oklch(var(--p));
background: oklch(var(--p) / 0.12);
}
.term-navlinks a.is-active::before {
content: "▸ ";
color: oklch(var(--p));
}
/* --- page body layout -------------------------------------- */
.term-main {
flex: 1 1 auto;
width: 100%;
max-width: 72rem;
margin: 0 auto;
padding: 2.25rem 1rem 3rem;
}
/* --- command-prompt page heading --------------------------- */
.term-cmd {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.75rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid oklch(var(--b3));
}
.term-cmd-line { font-size: 0.8rem; color: oklch(var(--bc) / 0.85); }
.term-title {
margin-top: 0.4rem;
font-size: 1.7rem;
font-weight: 700;
line-height: 1.15;
color: oklch(var(--p));
}
.term-sub { margin-top: 0.2rem; font-size: 0.85rem; color: oklch(var(--bc) / 0.55); }
.term-cmd-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
/* --- responsive card grid ---------------------------------- */
.term-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
}
.term-stack > * + * { margin-top: 1rem; }
/* --- cards as terminal windows ----------------------------- */
.card {
background: oklch(var(--b2));
border: 1px solid oklch(var(--b3));
box-shadow: none;
}
.card:hover { border-color: oklch(var(--p) / 0.5); }
/* filename strip at the top of a card */
.term-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.85rem;
font-size: 0.74rem;
color: oklch(var(--bc) / 0.6);
background: oklch(var(--b1));
border-bottom: 1px solid oklch(var(--b3));
}
.term-head .term-dots .term-dot { width: 0.55rem; height: 0.55rem; }
.term-head-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.term-head-meta { margin-left: auto; }
.card-title a { color: oklch(var(--p)); text-decoration: none; }
.card-title a:hover { text-decoration: underline; }
/* --- inline tags ------------------------------------------- */
.term-tag {
display: inline-block;
padding: 0.02rem 0.45rem;
font-size: 0.7rem;
letter-spacing: 0.03em;
border: 1px solid oklch(var(--p) / 0.55);
color: oklch(var(--p));
}
.term-tag.is-aqua { border-color: oklch(var(--a) / 0.55); color: oklch(var(--a)); }
.term-tag.is-purple { border-color: oklch(var(--s) / 0.55); color: oklch(var(--s)); }
.term-tag.is-blue { border-color: oklch(var(--in) / 0.55); color: oklch(var(--in)); }
.term-tag.is-green { border-color: oklch(var(--su) / 0.55); color: oklch(var(--su)); }
/* --- empty / "no results" state ---------------------------- */
.term-empty {
padding: 2.25rem 1rem;
text-align: center;
color: oklch(var(--bc) / 0.6);
border: 1px dashed oklch(var(--b3));
}
.term-empty-cmd { font-size: 0.8rem; color: oklch(var(--bc) / 0.45); }
/* --- terminal session block (mockup-code substitute) ------- */
.term-screen {
background: oklch(var(--b1));
border: 1px solid oklch(var(--b3));
padding: 0.85rem 1rem;
font-size: 0.85rem;
overflow-x: auto;
}
.term-screen .line { white-space: pre-wrap; }
.term-screen .line::before {
content: attr(data-p) " ";
color: oklch(var(--su));
}
.term-screen .line.out::before { content: ""; }
.term-screen .line.out { color: oklch(var(--bc) / 0.8); }
/* --- prose (article / about bodies) ------------------------ */
.term-prose { line-height: 1.7; }
.term-prose a { color: oklch(var(--in)); }
/* --- audio rows -------------------------------------------- */
.term-track { padding: 0.6rem 0; border-top: 1px solid oklch(var(--b3)); }
.term-track:first-child { border-top: 0; }
.term-track-name { font-size: 0.9rem; }
audio { width: 100%; margin-top: 0.45rem; }
/* --- vim-style statusline (the footer) --------------------- */
.term-statusline {
display: flex;
flex-wrap: wrap;
align-items: stretch;
font-size: 0.72rem;
border-top: 1px solid oklch(var(--b3));
}
.term-seg {
display: flex;
align-items: center;
padding: 0.25rem 0.8rem;
background: oklch(var(--b3));
color: oklch(var(--bc));
white-space: nowrap;
}
.term-seg.is-mode {
background: oklch(var(--p));
color: oklch(var(--pc));
font-weight: 700;
letter-spacing: 0.06em;
}
.term-seg.is-alt {
background: oklch(var(--s));
color: oklch(var(--sc));
font-weight: 700;
}
.term-seg.is-fill {
flex: 1 1 8rem;
background: oklch(var(--b2));
color: oklch(var(--bc) / 0.55);
}
/* --- square the icon buttons ------------------------------- */
.btn-circle { border-radius: 0; }
/* --- small screens ----------------------------------------- */
@media (max-width: 767px) {
.term-nav { gap: 0.5rem; }
.term-title { font-size: 1.4rem; }
}

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" data-theme="light">
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -23,19 +23,23 @@
applyTheme(t);
highlightTheme(t);
}
applyTheme(localStorage.getItem('theme') || 'system');
applyTheme(localStorage.getItem('theme') || 'dark');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if ((localStorage.getItem('theme') || 'system') === 'system') applyTheme('system');
if ((localStorage.getItem('theme') || 'dark') === 'system') applyTheme('system');
});
document.addEventListener('DOMContentLoaded', function () {
highlightTheme(localStorage.getItem('theme') || 'system');
highlightTheme(localStorage.getItem('theme') || 'dark');
var path = location.pathname;
document.querySelectorAll('.term-navlinks a[data-nav]').forEach(function (a) {
var h = a.getAttribute('data-nav');
if (h === path || (h !== '/' && path.indexOf(h) === 0)) a.classList.add('is-active');
});
});
</script>
<link href="/static/css/app.css" rel="stylesheet" type="text/css">
<link href="/static/css/theme.css" rel="stylesheet" type="text/css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
.btn { --animation-btn: 0; --btn-focus-scale: 1; }
@media (min-width: 768px) {
.nav-menu { flex-direction: row; }
}
@@ -46,12 +50,12 @@
position: fixed;
inset: 0;
z-index: 40;
background-color: rgba(0, 0, 0, 0.25);
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.2s;
}
.navbar:has(.dropdown:focus-within) ~ #nav-backdrop {
.term-titlebar:has(.dropdown:focus-within) ~ #nav-backdrop {
opacity: 1;
visibility: visible;
transition: opacity 0.15s ease, visibility 0s;
@@ -59,24 +63,29 @@
}
</style>
</head>
<body class="min-h-screen bg-base-200 font-sans text-base-content antialiased">
<header class="navbar bg-base-100 shadow-sm">
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4">
<a href="/admin/dashboard" class="min-w-0 truncate text-lg font-bold">Admin</a>
<ul class="nav-menu menu menu-sm hidden items-center gap-1 md:flex">
<li><a href="/admin/dashboard">Dashboard</a></li>
<li><a href="/admin/blog/articles">Blog</a></li>
<li><a href="/admin/audio/albums">Audio</a></li>
<li><a href="/admin/images">Images</a></li>
<li><a href="/admin/about">About</a></li>
<li><a href="/">View site</a></li>
<body class="flex min-h-screen flex-col bg-base-100 text-base-content antialiased">
<header class="term-titlebar">
<nav class="term-nav">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<a href="/admin/dashboard" class="term-brand">
<span class="t-red">root</span><span class="t-dim">@universal-web</span><span class="t-dim">:</span><span class="t-yellow">/admin</span><span class="t-dim">#</span>
</a>
<ul class="nav-menu term-navlinks menu menu-sm hidden items-center md:flex">
<li><a href="/admin/dashboard" data-nav="/admin/dashboard">dashboard</a></li>
<li><a href="/admin/blog/articles" data-nav="/admin/blog">blog</a></li>
<li><a href="/admin/audio/albums" data-nav="/admin/audio">audio</a></li>
<li><a href="/admin/images" data-nav="/admin/images">images</a></li>
<li><a href="/admin/about" data-nav="/admin/about">about</a></li>
<li><a href="/" class="t-blue">exit</a></li>
<li>
<form method="post" action="/admin/logout">
<button type="submit" class="w-full">Logout</button>
<button type="submit" class="t-red w-full">logout</button>
</form>
</li>
</ul>
<div class="flex items-center gap-1">
<div class="term-nav-right">
<div class="dropdown dropdown-end md:hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Menu">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
@@ -85,16 +94,16 @@
</svg>
</div>
<ul tabindex="0"
class="menu dropdown-content z-50 mt-3 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
<li><a href="/admin/dashboard">Dashboard</a></li>
<li><a href="/admin/blog/articles">Blog</a></li>
<li><a href="/admin/audio/albums">Audio</a></li>
<li><a href="/admin/images">Images</a></li>
<li><a href="/admin/about">About</a></li>
<li><a href="/">View site</a></li>
class="menu dropdown-content z-50 mt-3 w-52 border border-base-300 bg-base-200 p-2 shadow-lg">
<li><a href="/admin/dashboard">dashboard</a></li>
<li><a href="/admin/blog/articles">blog</a></li>
<li><a href="/admin/audio/albums">audio</a></li>
<li><a href="/admin/images">images</a></li>
<li><a href="/admin/about">about</a></li>
<li><a href="/" class="t-blue">exit</a></li>
<li>
<form method="post" action="/admin/logout">
<button type="submit" class="w-full">Logout</button>
<button type="submit" class="t-red w-full">logout</button>
</form>
</li>
</ul>
@@ -108,19 +117,28 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
<li class="menu-title">Theme</li>
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">System <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">Light <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">Dark <span class="opt-check ml-auto hidden"></span></button></li>
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 border border-base-300 bg-base-200 p-2 shadow-lg">
<li class="menu-title">:set theme</li>
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">system <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">light <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">dark <span class="opt-check ml-auto hidden"></span></button></li>
</ul>
</div>
</div>
</nav>
</header>
<div id="nav-backdrop" aria-hidden="true"></div>
<main class="mx-auto max-w-6xl px-4 py-6">
<main class="term-main">
{% block content %}{% endblock content %}
</main>
<footer class="term-statusline">
<span class="term-seg is-mode"> ADMIN </span>
<span class="term-seg">universal-web</span>
<span class="term-seg is-fill">/admin/{% block crumb %}{% endblock crumb %}</span>
<span class="term-seg">utf-8</span>
<span class="term-seg">root</span>
<span class="term-seg is-alt">gruvbox-dark</span>
<span class="term-seg is-mode">100%</span>
</footer>
</body>
</html>

View File

@@ -1,69 +1,95 @@
{% extends "admin/base.html" %}
{% block title %}Admin{% endblock title %}
{% block crumb %}dashboard{% endblock crumb %}
{% block content %}
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<header class="term-cmd">
<div>
<h1 class="text-2xl font-bold">Dashboard</h1>
<p class="text-sm opacity-70">Logged in as {{ admin.email }}</p>
<p class="term-cmd-line">
<span class="t-red">root@universal-web</span><span class="t-dim">:</span><span class="t-yellow">/admin</span><span class="t-dim">#</span>
ls -la
</p>
<h1 class="term-title">dashboard</h1>
<p class="term-sub">// session: {{ admin.email }}</p>
</div>
<a href="/" class="btn btn-ghost btn-sm">View site</a>
<div class="term-cmd-actions">
<a href="/" class="btn btn-outline btn-sm">[ view site ]</a>
</div>
</header>
<div class="grid grid-cols-2 gap-4 pt-4">
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between gap-2">
<h2 class="card-title text-base">Blog</h2>
<span class="badge">Content</span>
</div>
<p class="text-sm opacity-70">Create and update blog articles.</p>
<div class="pt-2">
<a href="/admin/blog/articles" class="btn btn-neutral btn-sm">Manage blog</a>
</div>
</div>
</div>
<div class="term-screen mb-6">
<p class="line" data-p="root@universal-web:/admin#">ls</p>
<p class="line out">about/ audio/ blog/ images/</p>
</div>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="term-grid">
<article class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">/admin/blog</span>
<span class="term-head-meta term-tag">content</span>
</div>
<div class="card-body">
<div class="flex items-center justify-between gap-2">
<h2 class="card-title text-base">About page</h2>
<span class="badge">Page</span>
</div>
<p class="text-sm opacity-70">Edit the public about page content.</p>
<h2 class="card-title text-base">blog</h2>
<p class="text-sm opacity-70">create and update blog articles.</p>
<div class="pt-2">
<a href="/admin/about" class="btn btn-neutral btn-sm">Edit about</a>
</div>
<a href="/admin/blog/articles" class="btn btn-primary btn-sm">[ manage → ]</a>
</div>
</div>
</article>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<article class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">/admin/about</span>
<span class="term-head-meta term-tag is-blue">page</span>
</div>
<div class="card-body">
<div class="flex items-center justify-between gap-2">
<h2 class="card-title text-base">Audio</h2>
<span class="badge">Media</span>
</div>
<p class="text-sm opacity-70">Create albums and upload tracks.</p>
<h2 class="card-title text-base">about page</h2>
<p class="text-sm opacity-70">edit the public about page content.</p>
<div class="pt-2">
<a href="/admin/audio/albums" class="btn btn-neutral btn-sm">Manage audio</a>
</div>
<a href="/admin/about" class="btn btn-primary btn-sm">[ edit → ]</a>
</div>
</div>
</article>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<article class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">/admin/audio</span>
<span class="term-head-meta term-tag is-purple">media</span>
</div>
<div class="card-body">
<div class="flex items-center justify-between gap-2">
<h2 class="card-title text-base">Images</h2>
<span class="badge">Uploads</span>
</div>
<p class="text-sm opacity-70">Upload images for covers and articles.</p>
<h2 class="card-title text-base">audio</h2>
<p class="text-sm opacity-70">create albums and upload tracks.</p>
<div class="pt-2">
<a href="/admin/images" class="btn btn-neutral btn-sm">Upload image</a>
<a href="/admin/audio/albums" class="btn btn-primary btn-sm">[ manage → ]</a>
</div>
</div>
</article>
<article class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">/admin/images</span>
<span class="term-head-meta term-tag is-green">uploads</span>
</div>
<div class="card-body">
<h2 class="card-title text-base">images</h2>
<p class="text-sm opacity-70">upload images for covers and articles.</p>
<div class="pt-2">
<a href="/admin/images" class="btn btn-primary btn-sm">[ upload → ]</a>
</div>
</div>
</article>
</div>
{% endblock content %}

View File

@@ -1,27 +1,38 @@
{% extends "base.html" %}
{% block title %}Admin login{% endblock title %}
{% block crumb %}admin/login{% endblock crumb %}
{% block content %}
<div class="mx-auto mt-8 max-w-sm">
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">/bin/login — tty1</span>
<span class="term-head-meta term-tag is-red">auth</span>
</div>
<div class="card-body">
<h1 class="card-title">Admin login</h1>
<p class="term-cmd-line">
<span class="t-dim">universal-web login:</span> <span class="t-red">root</span>
</p>
<h1 class="term-title">authenticate</h1>
{% if error %}
<div class="alert alert-error">
<span>Invalid email or password.</span>
<div class="alert alert-error mt-2">
<span>✗ access denied — invalid email or password.</span>
</div>
{% endif %}
<form method="post" action="/admin/login" class="space-y-2">
<div class="form-control">
<label class="label"><span class="label-text">Email</span></label>
<input type="email" name="email" required class="input input-bordered w-full">
<label class="label"><span class="label-text t-green">email:</span></label>
<input type="email" name="email" required autofocus class="input input-bordered w-full">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Password</span></label>
<label class="label"><span class="label-text t-green">password:</span></label>
<input type="password" name="password" required class="input input-bordered w-full">
</div>
<button class="btn btn-neutral mt-2 w-full">Login</button>
<button class="btn btn-primary mt-2 w-full">[ authenticate ]</button>
</form>
</div>
</div>

View File

@@ -1,48 +1,77 @@
{% extends "base.html" %}
{% block title %}{{ album.title }}{% endblock title %}
{% block crumb %}audio/{{ album.slug }}{% endblock crumb %}
{% block content %}
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<header class="term-cmd">
<div>
<h1 class="text-2xl font-bold">{{ album.title }}</h1>
<p class="term-cmd-line">
<span class="t-green">visitor@universal-web</span><span class="t-dim">:</span><span class="t-blue">~/audio</span><span class="t-dim">$</span>
cd {{ album.slug }}/ && ls
</p>
<h1 class="term-title">{{ album.title }}</h1>
{% if album.artist %}
<p class="text-sm opacity-70">{{ album.artist }}</p>
<p class="term-sub">// by {{ album.artist }}</p>
{% endif %}
</div>
<a href="/audio/albums" class="btn btn-ghost btn-sm">Back to albums</a>
<div class="term-cmd-actions">
<a href="/audio/albums" class="btn btn-outline btn-sm">[ cd .. ]</a>
</div>
</header>
{% if album.cover_image_id %}
<img src="/images/{{ album.cover_image_id }}" alt="" class="mb-4 rounded">
{% endif %}
{% if album.description %}
<div class="card border border-base-300 bg-base-100 shadow-sm">
{% if album.cover_image_id %}
<div class="card mb-6">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">~/audio/{{ album.slug }}/cover.png</span>
</div>
<div class="card-body">
<p class="whitespace-pre-line">{{ album.description }}</p>
<img src="/images/{{ album.cover_image_id }}" alt="">
</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="card border border-base-300 bg-base-100 shadow-sm">
{% if album.description %}
<div class="card mb-6">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">~/audio/{{ album.slug }}/notes.txt</span>
</div>
<div class="card-body">
<p class="term-prose whitespace-pre-line">{{ album.description }}</p>
</div>
</div>
{% endif %}
<div class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">~/audio/{{ album.slug }}/tracklist</span>
<span class="term-head-meta term-tag is-green">{{ tracks | length }} tracks</span>
</div>
<div class="card-body">
{% if tracks | length > 0 %}
<div class="space-y-2">
{% for track in tracks %}
<div class="border-t border-base-300 pt-2">
<p class="font-medium">{% if track.track_number %}{{ track.track_number }}. {% endif %}{{ track.title }}</p>
<audio controls preload="metadata" class="mt-2 w-full">
<div class="term-track">
<p class="term-track-name">
<span class="t-dim">{% if track.track_number %}{{ track.track_number }}{% else %}-{% endif %}</span>
<span class="t-green"></span> {{ track.title }}
</p>
<audio controls preload="metadata">
<source src="/audio/tracks/{{ track.id }}/stream">
</audio>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-center font-medium">No tracks yet.</p>
<p class="term-empty-cmd">$ ls → no tracks yet</p>
{% endif %}
</div>
</div>
</div>
{% endblock content %}

View File

@@ -1,42 +1,56 @@
{% extends "base.html" %}
{% block title %}Audio{% endblock title %}
{% block crumb %}audio{% endblock crumb %}
{% block content %}
<div class="space-y-2">
<header class="term-cmd">
<div>
<h1 class="text-2xl font-bold">Audio</h1>
<p class="text-sm opacity-70">Published albums.</p>
<p class="term-cmd-line">
<span class="t-green">visitor@universal-web</span><span class="t-dim">:</span><span class="t-blue">~/audio</span><span class="t-dim">$</span>
ls -d */
</p>
<h1 class="term-title">audio</h1>
<p class="term-sub">// {{ albums | length }} published album(s).</p>
</div>
<div class="term-cmd-actions">
<a href="/audio/tracks" class="btn btn-outline btn-sm">[ all songs ]</a>
</div>
</header>
{% if albums | length > 0 %}
<div class="grid grid-cols-2 gap-4 pt-4">
{% if albums | length > 0 %}
<div class="term-grid">
{% for album in albums %}
<article class="card border border-base-300 bg-base-100 shadow-sm">
<article class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">~/audio/{{ album.slug }}/</span>
<span class="term-head-meta term-tag is-purple">album</span>
</div>
<div class="card-body">
{% if album.cover_image_id %}
<img src="/images/{{ album.cover_image_id }}" alt="" class="mb-3 rounded">
<img src="/images/{{ album.cover_image_id }}" alt="" class="mb-3">
{% endif %}
<h2 class="card-title text-base">{{ album.title }}</h2>
{% if album.artist %}
<p class="text-sm opacity-70">{{ album.artist }}</p>
<p class="text-sm t-aqua">{{ album.artist }}</p>
{% endif %}
{% if album.description %}
<p class="text-sm opacity-80">{{ album.description }}</p>
<p class="term-prose text-sm opacity-80">{{ album.description }}</p>
{% endif %}
<div class="pt-2">
<a href="/audio/albums/{{ album.slug }}" class="btn btn-neutral btn-sm">Open album</a>
<a href="/audio/albums/{{ album.slug }}" class="btn btn-primary btn-sm">[ open → ]</a>
</div>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body text-center">
<p class="font-medium">No published albums yet.</p>
{% else %}
<div class="term-empty">
<p class="font-medium">no published albums yet</p>
<p class="term-empty-cmd">$ ls ~/audio → 0 results</p>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endblock content %}

View File

@@ -1,34 +1,44 @@
{% extends "base.html" %}
{% block title %}Songs{% endblock title %}
{% block crumb %}audio/tracks{% endblock crumb %}
{% block content %}
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<header class="term-cmd">
<div>
<h1 class="text-2xl font-bold">Songs</h1>
<p class="text-sm opacity-70">Published songs from every album and ungrouped uploads.</p>
<p class="term-cmd-line">
<span class="t-green">visitor@universal-web</span><span class="t-dim">:</span><span class="t-blue">~/audio</span><span class="t-dim">$</span>
find . -name '*.mp3'
</p>
<h1 class="term-title">songs</h1>
<p class="term-sub">// {{ tracks | length }} track(s) across every album.</p>
</div>
<a href="/audio/albums" class="btn btn-ghost btn-sm">Albums</a>
<div class="term-cmd-actions">
<a href="/audio/albums" class="btn btn-outline btn-sm">[ albums ]</a>
</div>
</header>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">~/audio/playlist.m3u</span>
<span class="term-head-meta term-tag is-green">{{ tracks | length }} tracks</span>
</div>
<div class="card-body">
{% if tracks | length > 0 %}
<div class="space-y-2">
{% for track in tracks %}
<div class="border-t border-base-300 pt-2">
<p class="font-medium">{{ track.title }}</p>
<audio controls preload="metadata" class="mt-2 w-full">
<div class="term-track">
<p class="term-track-name"><span class="t-green"></span> {{ track.title }}</p>
<audio controls preload="metadata">
<source src="/audio/tracks/{{ track.id }}/stream">
</audio>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-center font-medium">No published songs yet.</p>
<p class="term-empty-cmd">$ find ~/audio -name '*.mp3' → 0 results</p>
{% endif %}
</div>
</div>
</div>
{% endblock content %}

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" data-theme="light">
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -23,19 +23,23 @@
applyTheme(t);
highlightTheme(t);
}
applyTheme(localStorage.getItem('theme') || 'system');
applyTheme(localStorage.getItem('theme') || 'dark');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if ((localStorage.getItem('theme') || 'system') === 'system') applyTheme('system');
if ((localStorage.getItem('theme') || 'dark') === 'system') applyTheme('system');
});
document.addEventListener('DOMContentLoaded', function () {
highlightTheme(localStorage.getItem('theme') || 'system');
highlightTheme(localStorage.getItem('theme') || 'dark');
var path = location.pathname;
document.querySelectorAll('.term-navlinks a[data-nav]').forEach(function (a) {
var h = a.getAttribute('data-nav');
if (h === path || (h !== '/' && path.indexOf(h) === 0)) a.classList.add('is-active');
});
});
</script>
<link href="/static/css/app.css" rel="stylesheet" type="text/css">
<link href="/static/css/theme.css" rel="stylesheet" type="text/css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
.btn { --animation-btn: 0; --btn-focus-scale: 1; }
@media (min-width: 768px) {
.nav-menu { flex-direction: row; }
}
@@ -46,12 +50,12 @@
position: fixed;
inset: 0;
z-index: 40;
background-color: rgba(0, 0, 0, 0.25);
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.2s;
}
.navbar:has(.dropdown:focus-within) ~ #nav-backdrop {
.term-titlebar:has(.dropdown:focus-within) ~ #nav-backdrop {
opacity: 1;
visibility: visible;
transition: opacity 0.15s ease, visibility 0s;
@@ -59,28 +63,33 @@
}
</style>
</head>
<body class="min-h-screen bg-base-200 font-sans text-base-content antialiased">
<header class="navbar bg-base-100 shadow-sm">
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4">
<a href="/" class="min-w-0 truncate text-lg font-bold">Universal Web</a>
<ul class="nav-menu menu menu-sm hidden items-center gap-1 md:flex">
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/audio/albums">Audio</a></li>
<li><a href="/audio/tracks">Songs</a></li>
<body class="flex min-h-screen flex-col bg-base-100 text-base-content antialiased">
<header class="term-titlebar">
<nav class="term-nav">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<a href="/" class="term-brand">
<span class="t-green">{% if logged_in_admin %}root{% else %}visitor{% endif %}</span><span class="t-dim">@universal-web</span><span class="t-dim">:~$</span>
</a>
<ul class="nav-menu term-navlinks menu menu-sm hidden items-center md:flex">
<li><a href="/" data-nav="/">home</a></li>
<li><a href="/about" data-nav="/about">about</a></li>
<li><a href="/blog" data-nav="/blog">blog</a></li>
<li><a href="/audio/albums" data-nav="/audio/albums">audio</a></li>
<li><a href="/audio/tracks" data-nav="/audio/tracks">songs</a></li>
{% if logged_in_admin %}
<li><a href="/admin/dashboard">Dashboard</a></li>
<li><a href="/admin/dashboard" class="t-yellow" data-nav="/admin">admin</a></li>
<li>
<form method="post" action="/admin/logout">
<button type="submit" class="w-full">Logout</button>
<button type="submit" class="t-red w-full">logout</button>
</form>
</li>
{% else %}
<li><a href="/admin/login">Admin</a></li>
<li><a href="/admin/login" data-nav="/admin/login">login</a></li>
{% endif %}
</ul>
<div class="flex items-center gap-1">
<div class="term-nav-right">
<div class="dropdown dropdown-end md:hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Menu">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
@@ -89,21 +98,21 @@
</svg>
</div>
<ul tabindex="0"
class="menu dropdown-content z-50 mt-3 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/audio/albums">Audio</a></li>
<li><a href="/audio/tracks">Songs</a></li>
class="menu dropdown-content z-50 mt-3 w-52 border border-base-300 bg-base-200 p-2 shadow-lg">
<li><a href="/">home</a></li>
<li><a href="/about">about</a></li>
<li><a href="/blog">blog</a></li>
<li><a href="/audio/albums">audio</a></li>
<li><a href="/audio/tracks">songs</a></li>
{% if logged_in_admin %}
<li><a href="/admin/dashboard">Dashboard</a></li>
<li><a href="/admin/dashboard" class="t-yellow">admin</a></li>
<li>
<form method="post" action="/admin/logout">
<button type="submit" class="w-full">Logout</button>
<button type="submit" class="t-red w-full">logout</button>
</form>
</li>
{% else %}
<li><a href="/admin/login">Admin</a></li>
<li><a href="/admin/login">login</a></li>
{% endif %}
</ul>
</div>
@@ -116,19 +125,28 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
<li class="menu-title">Theme</li>
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">System <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">Light <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">Dark <span class="opt-check ml-auto hidden"></span></button></li>
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 border border-base-300 bg-base-200 p-2 shadow-lg">
<li class="menu-title">:set theme</li>
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">system <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">light <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">dark <span class="opt-check ml-auto hidden"></span></button></li>
</ul>
</div>
</div>
</nav>
</header>
<div id="nav-backdrop" aria-hidden="true"></div>
<main class="mx-auto max-w-6xl px-4 py-6">
<main class="term-main">
{% block content %}{% endblock content %}
</main>
<footer class="term-statusline">
<span class="term-seg is-mode">{% if logged_in_admin %} ADMIN {% else %} NORMAL {% endif %}</span>
<span class="term-seg">universal-web</span>
<span class="term-seg is-fill">~/{% block crumb %}{% endblock crumb %}</span>
<span class="term-seg">utf-8</span>
<span class="term-seg">EADGBE</span>
<span class="term-seg is-alt">gruvbox-dark</span>
<span class="term-seg is-mode">100%</span>
</footer>
</body>
</html>

View File

@@ -1,47 +1,54 @@
{% extends "base.html" %}
{% block title %}Blog{% endblock title %}
{% block crumb %}blog{% endblock crumb %}
{% block content %}
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<header class="term-cmd">
<div>
<h1 class="text-2xl font-bold">Blog</h1>
<p class="text-sm opacity-70">Published articles.</p>
<p class="term-cmd-line">
<span class="t-green">visitor@universal-web</span><span class="t-dim">:</span><span class="t-blue">~/blog</span><span class="t-dim">$</span>
ls -la
</p>
<h1 class="term-title">blog</h1>
<p class="term-sub">// {{ articles | length }} published article(s).</p>
</div>
{% if logged_in_admin %}
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">Manage blog</a>
{% endif %}
<div class="term-cmd-actions">
<a href="/admin/blog/articles" class="btn btn-outline btn-sm">[ manage ]</a>
</div>
{% endif %}
</header>
{% if articles | length > 0 %}
<div class="grid gap-4 pt-4">
{% if articles | length > 0 %}
<div class="term-stack">
{% for article in articles %}
<article class="card border border-base-300 bg-base-100 shadow-sm">
<article class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">~/blog/{{ article.slug }}.txt</span>
<span class="term-head-meta term-tag">post</span>
</div>
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
</h2>
<span class="badge">Post</span>
</div>
{% if article.excerpt %}
<p class="text-sm leading-relaxed opacity-80">{{ article.excerpt }}</p>
<p class="term-prose text-sm opacity-80">{{ article.excerpt }}</p>
{% endif %}
<div class="pt-2">
<a href="/blog/{{ article.slug }}" class="btn btn-neutral btn-sm">Read</a>
<a href="/blog/{{ article.slug }}" class="btn btn-primary btn-sm">[ cat → ]</a>
</div>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body text-center">
<p class="font-medium">No published posts yet.</p>
<p class="text-sm opacity-70">Published articles will appear here.</p>
{% else %}
<div class="term-empty">
<p class="font-medium">no published posts yet</p>
<p class="term-empty-cmd">$ ls ~/blog → 0 results</p>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endblock content %}

View File

@@ -1,25 +1,37 @@
{% extends "base.html" %}
{% block title %}{{ article.title }}{% endblock title %}
{% block crumb %}blog/{{ article.slug }}{% endblock crumb %}
{% block content %}
<article class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<header class="term-cmd">
<div>
<h1 class="text-2xl font-bold">{{ article.title }}</h1>
<p class="text-sm opacity-70">Views: {{ article.view_count }}</p>
<p class="term-cmd-line">
<span class="t-green">visitor@universal-web</span><span class="t-dim">:</span><span class="t-blue">~/blog</span><span class="t-dim">$</span>
cat {{ article.slug }}.txt
</p>
<h1 class="term-title">{{ article.title }}</h1>
<p class="term-sub">// {{ article.view_count }} view(s) logged.</p>
</div>
<a href="/blog" class="btn btn-ghost btn-sm">Back to blog</a>
<div class="term-cmd-actions">
<a href="/blog" class="btn btn-outline btn-sm">[ cd .. ]</a>
</div>
</header>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<article class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">~/blog/{{ article.slug }}.txt</span>
<span class="term-head-meta term-tag is-blue">readonly</span>
</div>
<div class="card-body">
{% if article.excerpt %}
<p class="text-base leading-relaxed opacity-80">{{ article.excerpt }}</p>
<p class="term-prose t-yellow"># {{ article.excerpt }}</p>
<div class="border-t border-base-300 pt-4"></div>
{% endif %}
<div class="leading-relaxed whitespace-pre-line">{{ article.content }}</div>
</div>
<div class="term-prose whitespace-pre-line">{{ article.content }}</div>
</div>
</article>
{% endblock content %}

View File

@@ -1,47 +1,62 @@
{% extends "base.html" %}
{% block title %}Home{% endblock title %}
{% block crumb %}{% endblock crumb %}
{% block content %}
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<header class="term-cmd">
<div>
<h1 class="text-2xl font-bold">Universal Web</h1>
<p class="text-sm opacity-70">Latest updates from the site.</p>
<p class="term-cmd-line">
<span class="t-green">visitor@universal-web</span><span class="t-dim">:</span><span class="t-blue">~</span><span class="t-dim">$</span>
ls -la
</p>
<h1 class="term-title">home</h1>
<p class="term-sub">// latest news and updates.</p>
</div>
<a href="/blog" class="btn btn-ghost btn-sm">All posts</a>
<div class="term-cmd-actions">
<a href="/blog" class="btn btn-outline btn-sm">[ all posts ]</a>
</div>
</header>
<section class="pt-4">
<div class="term-screen mb-6">
<p class="line" data-p="visitor@universal-web:~$">whoami</p>
<p class="line out">→ guitar player - original songs, albums and notes</p>
<p class="line" data-p="visitor@universal-web:~$">ls ~/sections</p>
<p class="line out">about/ blog/ audio/ songs/</p>
</div>
<section>
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>recent posts <span class="t-dim">({{ articles | length }})</span></p>
{% if articles | length > 0 %}
<div class="grid gap-4">
<div class="term-stack">
{% for article in articles %}
<article class="card border border-base-300 bg-base-100 shadow-sm">
<article class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">~/blog/{{ article.slug }}.txt</span>
<span class="term-head-meta term-tag">post</span>
</div>
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
</h2>
<span class="badge">Post</span>
</div>
{% if article.excerpt %}
<p class="text-sm leading-relaxed opacity-80">{{ article.excerpt }}</p>
<p class="term-prose text-sm opacity-80">{{ article.excerpt }}</p>
{% endif %}
<div class="pt-2">
<a href="/blog/{{ article.slug }}" class="btn btn-neutral btn-sm">Read</a>
<a href="/blog/{{ article.slug }}" class="btn btn-primary btn-sm">[ cat → ]</a>
</div>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body text-center">
<p class="font-medium">No published posts yet.</p>
<p class="text-sm opacity-70">Check back later.</p>
</div>
<div class="term-empty">
<p class="font-medium">no published posts yet</p>
<p class="term-empty-cmd">$ ls ~/blog → 0 results</p>
</div>
{% endif %}
</section>
</div>
</section>
{% endblock content %}

View File

@@ -1,23 +1,35 @@
{% extends "base.html" %}
{% block title %}{{ page.title }}{% endblock title %}
{% block crumb %}about{% endblock crumb %}
{% block content %}
<article class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<header class="term-cmd">
<div>
<h1 class="text-2xl font-bold">{{ page.title }}</h1>
<p class="text-sm opacity-70">About this site.</p>
<p class="term-cmd-line">
<span class="t-green">visitor@universal-web</span><span class="t-dim">:</span><span class="t-blue">~</span><span class="t-dim">$</span>
cat about.txt
</p>
<h1 class="term-title">{{ page.title }}</h1>
<p class="term-sub">// about this site.</p>
</div>
{% if logged_in_admin %}
<a href="/admin/about" class="btn btn-ghost btn-sm">Edit page</a>
<div class="term-cmd-actions">
<a href="/admin/about" class="btn btn-outline btn-sm">[ edit ]</a>
</div>
{% endif %}
</div>
</header>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body">
<div class="leading-relaxed whitespace-pre-line">{{ page.content }}</div>
<article class="card">
<div class="term-head">
<span class="term-dots" aria-hidden="true">
<span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span>
</span>
<span class="term-head-name">~/about.txt</span>
<span class="term-head-meta term-tag is-blue">readonly</span>
</div>
<div class="card-body">
<div class="term-prose whitespace-pre-line">{{ page.content }}</div>
</div>
</article>
{% endblock content %}