23 Commits

Author SHA1 Message Date
Priec
8236fb83bc home page removed slogan
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-05-19 23:47:52 +02:00
Priec
78cef07ed9 home page improved 2026-05-19 23:41:34 +02:00
Priec
f467f2b417 fixing blog:
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-05-19 22:56:55 +02:00
Priec
57798b5ea0 quill for a blog page 2026-05-19 22:16:37 +02:00
Priec
6feb6f210d pictures in the blog
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-05-19 21:53:55 +02:00
Priec
66e6e1bf9a admin logged in working perfectly well in the slovak language now 2026-05-19 20:55:01 +02:00
Priec
6dd947028c temrinal stuff removed from the admin 2026-05-19 20:43:21 +02:00
Priec
d95559fc94 its now perfect 2026-05-19 20:13:44 +02:00
Priec
b86aa60dfe catppucin latte 2026-05-19 20:09:03 +02:00
Priec
c1db8358c4 simplified and removed terminal stuff 2026-05-19 20:03:20 +02:00
Priec
98a1c69582 second play album button added 2026-05-19 19:32:35 +02:00
Priec
aa6aea613d removed terminal stuff 2026-05-19 19:30:54 +02:00
Priec
f405ddab65 player for the songs works now 2026-05-19 19:11:01 +02:00
Priec
4597b120f4 audio player at the bottom 2026-05-19 18:03:32 +02:00
Priec
e9439382cc terminal based website playing music now 2026-05-19 17:13:23 +02:00
Priec
cbd642c62c design is now terminal alike 2026-05-19 14:48:47 +02:00
Priec
67b7c8e5ae gruvbox dark mode 2026-05-19 13:20:14 +02:00
Priec
ec5a3a3d73 add song and add album working now
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-05-17 19:01:01 +02:00
Priec
c1ecfa459d song upload 2026-05-17 18:25:23 +02:00
Priec
d164edf87c audio 2026-05-17 18:15:22 +02:00
Priec
1d51a23bfb some simple UI to make it work 2026-05-17 17:46:04 +02:00
Priec
0a36e8839c proper layout 2026-05-17 16:30:34 +02:00
Priec
046f7c04c8 .env login is now working well 2026-05-17 15:31:37 +02:00
65 changed files with 4886 additions and 684 deletions

2
.gitignore vendored
View File

@@ -20,3 +20,5 @@ target/
*.sqlite-* *.sqlite-*
.env .env
.env.production .env.production
uploads/
*.report.html

2
Cargo.lock generated
View File

@@ -5059,7 +5059,9 @@ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"axum-extra", "axum-extra",
"bytes",
"chrono", "chrono",
"dotenvy",
"fluent-templates", "fluent-templates",
"include_dir", "include_dir",
"insta", "insta",

View File

@@ -20,7 +20,7 @@ tokio = { version = "1.45", default-features = false, features = [
"rt-multi-thread", "rt-multi-thread",
] } ] }
async-trait = { version = "0.1" } async-trait = { version = "0.1" }
axum = { version = "0.8" } axum = { version = "0.8", features = ["multipart"] }
tracing = { version = "0.1" } tracing = { version = "0.1" }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
regex = { version = "1.11" } regex = { version = "1.11" }
@@ -33,6 +33,7 @@ sea-orm = { version = "1.1", features = [
] } ] }
chrono = { version = "0.4" } chrono = { version = "0.4" }
time = { version = "0.3" } time = { version = "0.3" }
dotenvy = { version = "0.15" }
validator = { version = "0.20" } validator = { version = "0.20" }
uuid = { version = "1.6", features = ["v4"] } uuid = { version = "1.6", features = ["v4"] }
include_dir = { version = "0.7" } include_dir = { version = "0.7" }
@@ -41,6 +42,7 @@ fluent-templates = { version = "0.13", features = ["tera"] }
unic-langid = { version = "0.9" } unic-langid = { version = "0.9" }
# /view engine # /view engine
axum-extra = { version = "0.10", features = ["form"] } axum-extra = { version = "0.10", features = ["form"] }
bytes = { version = "1" }
[[bin]] [[bin]]
name = "universal_web-cli" name = "universal_web-cli"

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
- [ ] **Audio streaming** — range-aware track/file streaming, raw upload
- [ ] **Audio player** — persistent bottom-bar player (frontend)
- [ ] **Images** — upload + serve, used as cover/featured images
- [ ] **Theme** — per-user light/dark preference
- [ ] **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** — 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** — range-aware endpoints, then the player in the GUI.
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

@@ -2,3 +2,8 @@ hello-world = Hallo Welt!
greeting = Hallochen { $name }! greeting = Hallochen { $name }!
.placeholder = Hallo Freund! .placeholder = Hallo Freund!
about = Uber about = Uber
image-size = Image size
image-size-small = Small
image-size-medium = Medium
image-size-full = Full width
image-width-px = Width px

View File

@@ -1,10 +1,174 @@
hello-world = Hello World! brand = Universal Web
greeting = Hello { $name }! hello-world = Hello world!
.placeholder = Hello Friend! meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place.
about = About nav-home = Home
simple = simple text nav-about = About
reference = simple text with a reference: { -something } nav-blog = Blog
parameter = text with a { $param } nav-audio = Albums
parameter2 = text one { $param } second { $multi-word-param } nav-songs = Songs
email = text with an EMAIL("example@example.org") nav-admin = Admin
fallback = this should fall back admin-title = Admin
admin-dashboard = Dashboard
admin-blog = Blog
admin-audio = Audio
admin-about = About
admin-exit = Exit
view-site = View site
admin-blog-desc = create and update blog articles.
admin-about-desc = edit the public about page content.
admin-audio-desc = upload songs, then group them into albums.
logout = Log out
settings = Settings
settings-language = Language
settings-theme = Theme
language-en = English
language-sk = Slovak
menu = Menu
theme-system = System
theme-light = Light
theme-dark = Dark
home-title = Home
home-sub = news and updates.
home-all-posts = All posts
home-recent = Recent posts
home-picks = have a listen
home-no-posts = no published posts yet
blog-title = Blog
blog-sub = published article(s)
blog-manage = Manage
blog-read = Read
blog-no-posts = no published posts yet
blog-views = views logged
cd-up = cd ..
about-sub = about this site.
about-readonly = readonly
audio-title = Audio
audio-sub = published album(s)
audio-all-songs = All songs
audio-open = Open
audio-play = Play
audio-no-albums = no published albums yet
songs-title = Songs
songs-sub = track(s) across every album.
songs-play-all = Play all
songs-albums = Albums
songs-no-tracks = no tracks yet
album-by = by
album-play-full = Play full album
album-queue-all = queue all tracks in order
album-no-tracks = no tracks yet
login-title = Admin login
login-error = Access denied - invalid email or password.
login-root = root
login-auth = Authenticate
login-email = Email
login-password = Password
auth = Auth
admin-session = Session
readonly = readonly
post = post
album = album
published = published
draft = draft
single = single
manage = Manage
open = Open
play = Play
new-article = New article
edit = Edit
delete = Delete
save = Save
cancel = Cancel
create = Create
upload = Upload
view = View
back-to-dashboard = Back to dashboard
back-to-articles = Back to articles
title = Title
status = Status
actions = Actions
content = Content
excerpt = Excerpt
featured-image-id = Featured image id
image-file = Image file
uploaded-image-id = Uploaded image id
url = URL
upload-featured-image = Upload image
image-upload-help = Upload an image here to use it as the article image.
image-uploading = Uploading...
image-uploaded = Image uploaded and selected.
image-upload-error = Image upload failed.
image-size = Image size
image-size-small = Small
image-size-medium = Medium
image-size-full = Full width
image-width-px = Width px
admin-blog-articles = Blog articles
admin-blog-index-desc = Create, edit, and remove blog posts.
admin-blog-create-desc = Create a blog post for the public site.
admin-no-articles = No articles yet.
admin-create-first-post = Create the first blog post.
edit-article = Edit article
create-article = Create article
edit-about = Edit About
update-about-page = Update the public about page.
view-page = View page
albums-title = Albums
new-album = New album
admin-albums-desc = Step 2 - group songs into a release with a cover.
admin-albums-before = Before you make an album
admin-albums-step-upload = Upload your songs first - an album is built from songs that already exist.
admin-albums-step-create = Create the album here, then tick the songs that belong to it.
admin-no-albums = No albums yet
admin-create-album-empty = Create an album to group your songs into a release.
open-edit = Open and edit
songs-title-admin = Songs
admin-songs-desc = Step 1 - every audio file you upload becomes a song.
upload-song = Upload song
admin-audio-how = How audio works
admin-audio-step-upload = Upload a song - pick an audio file here; it becomes a song you can publish.
admin-audio-step-album = Make an album (optional) - group songs together with a cover and track order.
admin-audio-note = A song can be published on its own or as part of an album.
song = Song
where = Where
in-album = In an album
publish = Publish
unpublish = Unpublish
featured = Featured
remove-from-album = Remove from album
admin-no-songs = No songs yet
admin-upload-first-song = Upload your first audio file.
admin-tracklist = Tracklist
admin-add-existing-song = Add an existing song
admin-existing-song-help = These are songs you have uploaded that are not in an album yet.
admin-add-to-album = Add to album
admin-album-empty = This album has no songs yet
admin-album-empty-help = Upload a file into the album, or add an existing song above.
admin-two-ways-title = Two ways to add a song to this album
admin-two-ways-upload = Upload a new file straight into the album using the button above.
admin-two-ways-pick = Pick an existing song that is not in any album yet.
album-title-label = Album title *
artist = Artist
release-date = Release date
cover-image = Cover image
description = Description
songs-in-album = Songs in this album
admin-new-album-desc = Fill in the details, then tick the songs to include.
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
free-songs-help = Only songs that are not in an album yet are shown.
no-free-songs = No free songs to add.
upload-song-first = Upload a song first
create-empty-add-later = or create the album empty and add songs later.
publish-album-now = Publish now - visitors can see this album.
create-album = Create album
upload-song-into-album = Upload song into album
upload-song-title = Upload song
upload-into-album-help = Goes straight into the album
upload-single-help = Uploads as a standalone song. You can add it to an album later.
audio-file = Audio file *
audio-file-help = Required - mp3, wav, ogg, flac, aac, m4a or webm.
title-help = Optional - leave blank to use the audio file's name.
track-number = Track number
track-number-help = Optional - this song's position in the album track list.
featured-help = Highlight this song on the site
publish-song-now = Publish now - visitors can see it.

174
assets/i18n/en/main.ftl Normal file
View File

@@ -0,0 +1,174 @@
brand = Universal Web
hello-world = Hello world!
meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place.
nav-home = Home
nav-about = About
nav-blog = Blog
nav-audio = Albums
nav-songs = Songs
nav-admin = Admin
admin-title = Admin
admin-dashboard = Dashboard
admin-blog = Blog
admin-audio = Audio
admin-about = About
admin-exit = Exit
view-site = View site
admin-blog-desc = create and update blog articles.
admin-about-desc = edit the public about page content.
admin-audio-desc = upload songs, then group them into albums.
logout = Log out
settings = Settings
settings-language = Language
settings-theme = Theme
language-en = English
language-sk = Slovak
menu = Menu
theme-system = System
theme-light = Light
theme-dark = Dark
home-title = Home
home-sub = news and updates.
home-all-posts = All posts
home-recent = Recent posts
home-picks = have a listen
home-no-posts = no published posts yet
blog-title = Blog
blog-sub = published article(s)
blog-manage = Manage
blog-read = Read
blog-no-posts = no published posts yet
blog-views = views logged
cd-up = cd ..
about-sub = about this site.
about-readonly = readonly
audio-title = Audio
audio-sub = published album(s)
audio-all-songs = All songs
audio-open = Open
audio-play = Play
audio-no-albums = no published albums yet
songs-title = Songs
songs-sub = track(s) across every album.
songs-play-all = Play all
songs-albums = Albums
songs-no-tracks = no tracks yet
album-by = by
album-play-full = Play full album
album-queue-all = queue all tracks in order
album-no-tracks = no tracks yet
login-title = Admin login
login-error = Access denied - invalid email or password.
login-root = root
login-auth = Authenticate
login-email = Email
login-password = Password
auth = Auth
admin-session = Session
readonly = readonly
post = post
album = album
published = published
draft = draft
single = single
manage = Manage
open = Open
play = Play
new-article = New article
edit = Edit
delete = Delete
save = Save
cancel = Cancel
create = Create
upload = Upload
view = View
back-to-dashboard = Back to dashboard
back-to-articles = Back to articles
title = Title
status = Status
actions = Actions
content = Content
excerpt = Excerpt
featured-image-id = Featured image id
image-file = Image file
uploaded-image-id = Uploaded image id
url = URL
upload-featured-image = Upload image
image-upload-help = Upload an image here to use it as the article image.
image-uploading = Uploading...
image-uploaded = Image uploaded and selected.
image-upload-error = Image upload failed.
image-size = Image size
image-size-small = Small
image-size-medium = Medium
image-size-full = Full width
image-width-px = Width px
admin-blog-articles = Blog articles
admin-blog-index-desc = Create, edit, and remove blog posts.
admin-blog-create-desc = Create a blog post for the public site.
admin-no-articles = No articles yet.
admin-create-first-post = Create the first blog post.
edit-article = Edit article
create-article = Create article
edit-about = Edit About
update-about-page = Update the public about page.
view-page = View page
albums-title = Albums
new-album = New album
admin-albums-desc = Step 2 - group songs into a release with a cover.
admin-albums-before = Before you make an album
admin-albums-step-upload = Upload your songs first - an album is built from songs that already exist.
admin-albums-step-create = Create the album here, then tick the songs that belong to it.
admin-no-albums = No albums yet
admin-create-album-empty = Create an album to group your songs into a release.
open-edit = Open and edit
songs-title-admin = Songs
admin-songs-desc = Step 1 - every audio file you upload becomes a song.
upload-song = Upload song
admin-audio-how = How audio works
admin-audio-step-upload = Upload a song - pick an audio file here; it becomes a song you can publish.
admin-audio-step-album = Make an album (optional) - group songs together with a cover and track order.
admin-audio-note = A song can be published on its own or as part of an album.
song = Song
where = Where
in-album = In an album
publish = Publish
unpublish = Unpublish
featured = Featured
remove-from-album = Remove from album
admin-no-songs = No songs yet
admin-upload-first-song = Upload your first audio file.
admin-tracklist = Tracklist
admin-add-existing-song = Add an existing song
admin-existing-song-help = These are songs you have uploaded that are not in an album yet.
admin-add-to-album = Add to album
admin-album-empty = This album has no songs yet
admin-album-empty-help = Upload a file into the album, or add an existing song above.
admin-two-ways-title = Two ways to add a song to this album
admin-two-ways-upload = Upload a new file straight into the album using the button above.
admin-two-ways-pick = Pick an existing song that is not in any album yet.
album-title-label = Album title *
artist = Artist
release-date = Release date
cover-image = Cover image
description = Description
songs-in-album = Songs in this album
admin-new-album-desc = Fill in the details, then tick the songs to include.
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
free-songs-help = Only songs that are not in an album yet are shown.
no-free-songs = No free songs to add.
upload-song-first = Upload a song first
create-empty-add-later = or create the album empty and add songs later.
publish-album-now = Publish now - visitors can see this album.
create-album = Create album
upload-song-into-album = Upload song into album
upload-song-title = Upload song
upload-into-album-help = Goes straight into the album
upload-single-help = Uploads as a standalone song. You can add it to an album later.
audio-file = Audio file *
audio-file-help = Required - mp3, wav, ogg, flac, aac, m4a or webm.
title-help = Optional - leave blank to use the audio file's name.
track-number = Track number
track-number-help = Optional - this song's position in the album track list.
featured-help = Highlight this song on the site
publish-song-now = Publish now - visitors can see it.

174
assets/i18n/sk/main.ftl Normal file
View File

@@ -0,0 +1,174 @@
brand = Universal Web
hello-world = Ahoj svet!
meta-description = Osobná stránka gitaristu. Novinky, blog, albumy a skladby na jednom mieste.
nav-home = Domov
nav-about = O mne
nav-blog = Blog
nav-audio = Albumy
nav-songs = Skladby
nav-admin = Admin
admin-title = Administrácia
admin-dashboard = Prehľad
admin-blog = Blog
admin-audio = Hudba
admin-about = O mne
admin-exit = Späť na web
view-site = Zobraziť web
admin-blog-desc = vytvoriť a upravovať blogové články.
admin-about-desc = upraviť obsah verejnej stránky o mne.
admin-audio-desc = nahrať skladby a potom ich zoskupiť do albumov.
logout = Odhlásiť sa
settings = Nastavenia
settings-language = Jazyk
settings-theme = Téma
language-en = Angličtina
language-sk = Slovenčina
menu = Menu
theme-system = Systém
theme-light = Svetlý
theme-dark = Tmavý
home-title = Domov
home-sub = novinky a aktuality.
home-all-posts = Všetky príspevky
home-recent = Posledné príspevky
home-picks = vypočuj si
home-no-posts = zatiaľ žiadne zverejnené príspevky
blog-title = Blog
blog-sub = zverejnené články
blog-manage = Spravovať
blog-read = Čítať
blog-no-posts = zatiaľ žiadne zverejnené príspevky
blog-views = zobrazení
cd-up = cd ..
about-sub = o tejto stránke.
about-readonly = iba na čítanie
audio-title = Hudba
audio-sub = zverejnené albumy
audio-all-songs = Všetky skladby
audio-open = Otvoriť
audio-play = Prehrať
audio-no-albums = zatiaľ žiadne zverejnené albumy
songs-title = Skladby
songs-sub = skladieb naprieč všetkými albumami.
songs-play-all = Prehrať všetko
songs-albums = Albumy
songs-no-tracks = zatiaľ žiadne skladby
album-by = od
album-play-full = Prehrať celý album
album-queue-all = zoradiť všetky skladby v poradí
album-no-tracks = zatiaľ žiadne skladby
login-title = Prihlásenie admina
login-error = Prístup odmietnutý - nesprávny e-mail alebo heslo.
login-root = root
login-auth = Prihlásiť sa
login-email = E-mail
login-password = Heslo
auth = Overenie
admin-session = Relácia
readonly = iba na čítanie
post = príspevok
album = album
published = zverejnené
draft = koncept
single = samostatne
manage = Spravovať
open = Otvoriť
play = Prehrať
new-article = Nový článok
edit = Upraviť
delete = Zmazať
save = Uložiť
cancel = Zrušiť
create = Vytvoriť
upload = Nahrať
view = Zobraziť
back-to-dashboard = Späť na prehľad
back-to-articles = Späť na články
title = Názov
status = Stav
actions = Akcie
content = Obsah
excerpt = Úryvok
featured-image-id = ID hlavného obrázka
image-file = Súbor obrázka
uploaded-image-id = ID nahratého obrázka
url = URL
upload-featured-image = Nahrať obrázok
image-upload-help = Tu nahraj obrázok, ktorý sa použije ako obrázok článku.
image-uploading = Nahrávam...
image-uploaded = Obrázok je nahratý a vybraný.
image-upload-error = Nahratie obrázka zlyhalo.
image-size = Veľkosť obrázka
image-size-small = Malý
image-size-medium = Stredný
image-size-full = Na celú šírku
image-width-px = Šírka px
admin-blog-articles = Blogové články
admin-blog-index-desc = Vytvárať, upravovať a odstraňovať blogové články.
admin-blog-create-desc = Vytvoriť blogový článok pre verejný web.
admin-no-articles = Zatiaľ žiadne články.
admin-create-first-post = Vytvor prvý blogový článok.
edit-article = Upraviť článok
create-article = Vytvoriť článok
edit-about = Upraviť O mne
update-about-page = Upraviť verejnú stránku O mne.
view-page = Zobraziť stránku
albums-title = Albumy
new-album = Nový album
admin-albums-desc = Krok 2 - zoskupiť skladby do vydania s obalom.
admin-albums-before = Pred vytvorením albumu
admin-albums-step-upload = Najprv nahraj skladby - album sa skladá zo skladieb, ktoré už existujú.
admin-albums-step-create = Tu vytvor album a potom označ skladby, ktoré doň patria.
admin-no-albums = Zatiaľ žiadne albumy
admin-create-album-empty = Vytvor album, do ktorého zoskupíš skladby.
open-edit = Otvoriť a upraviť
songs-title-admin = Skladby
admin-songs-desc = Krok 1 - každý nahratý zvukový súbor sa stane skladbou.
upload-song = Nahrať skladbu
admin-audio-how = Ako funguje hudba
admin-audio-step-upload = Nahraj skladbu - vyber zvukový súbor, ktorý potom môžeš zverejniť.
admin-audio-step-album = Vytvor album (voliteľné) - zoskup skladby s obalom a poradím.
admin-audio-note = Skladba môže byť zverejnená samostatne alebo ako súčasť albumu.
song = Skladba
where = Kde
in-album = V albume
publish = Zverejniť
unpublish = Stiahnuť
featured = Zvýraznené
remove-from-album = Odstrániť z albumu
admin-no-songs = Zatiaľ žiadne skladby
admin-upload-first-song = Nahraj prvý zvukový súbor.
admin-tracklist = Zoznam skladieb
admin-add-existing-song = Pridať existujúcu skladbu
admin-existing-song-help = Toto sú skladby, ktoré ešte nie sú v albume.
admin-add-to-album = Pridať do albumu
admin-album-empty = Tento album zatiaľ nemá skladby
admin-album-empty-help = Nahraj súbor do albumu alebo pridaj existujúcu skladbu vyššie.
admin-two-ways-title = Dva spôsoby, ako pridať skladbu do albumu
admin-two-ways-upload = Nahraj nový súbor priamo do albumu pomocou tlačidla vyššie.
admin-two-ways-pick = Vyber existujúcu skladbu, ktorá ešte nie je v albume.
album-title-label = Názov albumu *
artist = Interpret
release-date = Dátum vydania
cover-image = Obrázok obalu
description = Popis
songs-in-album = Skladby v albume
admin-new-album-desc = Vyplň údaje a potom označ skladby, ktoré chceš zahrnúť.
cover-help = Voliteľné - png, jpg, webp alebo gif; zobrazí sa na stránke albumu.
free-songs-help = Zobrazujú sa iba skladby, ktoré ešte nie sú v albume.
no-free-songs = Žiadne voľné skladby na pridanie.
upload-song-first = Najprv nahraj skladbu
create-empty-add-later = alebo vytvor prázdny album a skladby pridaj neskôr.
publish-album-now = Zverejniť teraz - návštevníci uvidia tento album.
create-album = Vytvoriť album
upload-song-into-album = Nahrať skladbu do albumu
upload-song-title = Nahrať skladbu
upload-into-album-help = Skladba pôjde priamo do albumu
upload-single-help = Nahrá sa ako samostatná skladba. Do albumu ju môžeš pridať neskôr.
audio-file = Zvukový súbor *
audio-file-help = Povinné - mp3, wav, ogg, flac, aac, m4a alebo webm.
title-help = Voliteľné - nechaj prázdne, ak chceš použiť názov zvukového súboru.
track-number = Číslo skladby
track-number-help = Voliteľné - pozícia skladby v zozname albumu.
featured-help = Zvýrazniť túto skladbu na webe
publish-song-now = Zverejniť teraz - návštevníci ju uvidia.

755
assets/static/css/theme.css Normal file
View File

@@ -0,0 +1,755 @@
/* ============================================================
* Terminal 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:
*
* 1. Catppuccin Latte for DaisyUI's `light` theme
* and Gruvbox 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.
*
* Palettes:
* - https://github.com/catppuccin/catppuccin (Latte)
* - https://github.com/morhetz/gruvbox (dark, bright)
* DaisyUI color vars are OKLch "L% C H" triplets; this file can
* therefore tint anything with `oklch(var(--x) / <alpha>)`.
* ============================================================ */
/* === 1. Theme palettes ====================================== */
/* Catppuccin Latte. */
[data-theme="light"] {
--b1: 95.78% 0.006 264.5; /* #eff1f5 base */
--b2: 93.35% 0.009 264.5; /* #e6e9ef mantle */
--b3: 90.60% 0.012 264.5; /* #dce0e8 crust */
--bc: 43.55% 0.043 279.3; /* #4c4f69 text */
--n: 80.83% 0.017 271.2; /* #bcc0cc surface1 */
--nc: 43.55% 0.043 279.3; /* #4c4f69 text */
--p: 55.86% 0.226 262.1; /* #1e66f5 blue primary */
--pc: 95.78% 0.006 264.5; /* #eff1f5 text on primary */
--s: 55.47% 0.250 297.0; /* #8839ef mauve secondary */
--sc: 95.78% 0.006 264.5; /* #eff1f5 text on secondary */
--a: 60.23% 0.098 201.1; /* #179299 teal accent */
--ac: 95.78% 0.006 264.5; /* #eff1f5 text on accent */
--in: 68.20% 0.145 235.4; /* #04a5e5 sky info */
--su: 62.50% 0.177 140.4; /* #40a02b green success */
--wa: 71.40% 0.149 67.8; /* #df8e1d yellow warning */
--er: 55.05% 0.216 19.8; /* #d20f39 red error */
--inc: 43.55% 0.043 279.3; /* #4c4f69 text on status */
--suc: 95.78% 0.006 264.5;
--wac: 95.78% 0.006 264.5;
--erc: 95.78% 0.006 264.5;
}
/* Source hex noted per line. To retune: change hex, reconvert
* to OKLch, update the value. */
[data-theme="dark"] {
--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 */
--n: 34.40% 0.0066 48.7; /* #3c3836 bg1 */
--nc: 89.42% 0.0566 89.5; /* #ebdbb2 fg */
--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 */
--sc: 27.69% 0 0; /* #282828 text on secondary */
--a: 75.57% 0.108 137.6; /* #8ec07c bright aqua accent */
--ac: 27.69% 0 0; /* #282828 text on accent */
--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;
--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;
}
/* Text selection + scrollbars */
[data-theme="light"] ::selection { background: #acb0be; color: #4c4f69; }
[data-theme="light"] { scrollbar-color: #bcc0cc #eff1f5; }
[data-theme="light"] ::-webkit-scrollbar { width: 12px; height: 12px; }
[data-theme="light"] ::-webkit-scrollbar-track { background: #eff1f5; }
[data-theme="light"] ::-webkit-scrollbar-thumb {
background: #bcc0cc; border: 3px solid #eff1f5;
}
[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: #acb0be; }
[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); }
/* --- how-it-works note + form helpers (admin) -------------- */
.term-note {
margin-bottom: 1.5rem;
padding: 0.9rem 1.1rem;
background: oklch(var(--b2));
border: 1px solid oklch(var(--b3));
border-left: 3px solid oklch(var(--a));
}
.term-note-title { margin-bottom: 0.55rem; font-size: 0.8rem; color: oklch(var(--a)); }
.term-step { display: flex; gap: 0.55rem; font-size: 0.88rem; }
.term-step + .term-step { margin-top: 0.3rem; }
.term-step-n { flex: none; color: oklch(var(--p)); }
.term-note-foot { margin-top: 0.6rem; font-size: 0.8rem; color: oklch(var(--bc) / 0.6); }
.term-help { margin-top: 0.2rem; font-size: 0.76rem; color: oklch(var(--bc) / 0.55); }
.term-picklist {
border: 1px solid oklch(var(--b3));
background: oklch(var(--b1));
max-height: 18rem;
overflow-y: auto;
}
.term-pick {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.5rem 0.7rem;
border-top: 1px solid oklch(var(--b3));
cursor: pointer;
}
.term-pick:first-child { border-top: 0; }
.term-pick:hover { background: oklch(var(--b2)); }
.term-formdiv { margin: 1.25rem 0; border-top: 1px dashed oklch(var(--b3)); }
/* --- 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 {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.5rem 0;
border-top: 1px solid oklch(var(--b3));
}
.term-track:first-child { border-top: 0; }
.term-track .btn { flex: none; }
.term-track-name { font-size: 0.9rem; }
/* "play album" row sitting above the per-track list */
.term-track-bar {
display: flex;
align-items: center;
gap: 0.7rem;
padding-bottom: 0.65rem;
margin-bottom: 0.15rem;
border-bottom: 1px solid oklch(var(--b3));
}
.term-track-bar .btn { flex: none; }
/* --- persistent audio player bar --------------------------- */
/* Hidden until the first song plays; shown by adding `uw-playing`
* to <html>. The bar itself carries `hx-preserve` so the <audio>
* keeps playing while htmx swaps the page around it. */
#uw-player { display: none; }
.uw-playing #uw-player {
display: block;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 80;
background: oklch(var(--b2));
border-top: 3px solid oklch(var(--p));
box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.6);
}
.uw-playing body { padding-bottom: 6.75rem; }
.uw-player-inner {
display: flex;
align-items: center;
gap: 1.15rem;
width: 100%;
max-width: 72rem;
margin: 0 auto;
padding: 1rem 1.5rem;
}
.uw-player-tag {
flex: none;
font-size: 0.98rem;
font-weight: 700;
letter-spacing: 0.02em;
color: oklch(var(--p));
white-space: nowrap;
}
.uw-player-title {
flex: none;
max-width: 24rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.1rem;
color: oklch(var(--bc));
}
#uw-audio {
flex: 1;
min-width: 9rem;
width: auto;
height: 3.25rem;
margin: 0;
}
.uw-player-close {
flex: none;
width: 2.85rem;
height: 2.85rem;
font-size: 1.05rem;
background: transparent;
border: 1px solid oklch(var(--b3));
color: oklch(var(--bc) / 0.7);
cursor: pointer;
line-height: 1;
}
.uw-player-close:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
/* transport + playlist toggle buttons in the player bar */
.uw-player-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
min-width: 2.85rem;
height: 2.85rem;
padding: 0 0.6rem;
font-size: 1.05rem;
background: transparent;
border: 1px solid oklch(var(--b3));
color: oklch(var(--bc) / 0.78);
cursor: pointer;
line-height: 1;
}
.uw-player-btn:hover { color: oklch(var(--p)); border-color: oklch(var(--p)); }
.uw-queue-badge {
font-size: 0.72rem;
font-weight: 700;
min-width: 1.25rem;
padding: 0.05rem 0.3rem;
background: oklch(var(--p));
color: oklch(var(--pc));
}
#uw-player:not(.uw-has-queue) .uw-queue-badge { display: none; }
/* --- the SoundCloud-style playlist panel ------------------- */
.uw-queue {
width: 100%;
max-width: 72rem;
margin: 0 auto;
border-bottom: 1px solid oklch(var(--b3));
}
.uw-queue[hidden] { display: none; }
.uw-queue-head {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 1.5rem;
border-bottom: 1px solid oklch(var(--b3));
}
.uw-queue-title { font-weight: 700; font-size: 0.95rem; color: oklch(var(--p)); }
.uw-queue-meta { font-size: 0.8rem; color: oklch(var(--bc) / 0.6); }
.uw-queue-clear {
margin-left: auto;
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
background: transparent;
border: 1px solid oklch(var(--b3));
color: oklch(var(--bc) / 0.7);
cursor: pointer;
}
.uw-queue-clear:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
.uw-queue-list {
list-style: none;
margin: 0;
padding: 0.35rem 0;
max-height: 15rem;
overflow-y: auto;
}
.uw-queue-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.4rem 1.5rem;
}
.uw-queue-item:hover { background: oklch(var(--b3) / 0.5); }
.uw-queue-item.is-current { background: oklch(var(--p) / 0.12); }
.uw-queue-jump {
flex: none;
width: 1.85rem;
height: 1.85rem;
font-size: 0.8rem;
background: transparent;
border: 1px solid oklch(var(--b3));
color: oklch(var(--bc) / 0.7);
cursor: pointer;
line-height: 1;
}
.uw-queue-item.is-current .uw-queue-jump { color: oklch(var(--p)); border-color: oklch(var(--p)); }
.uw-queue-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
cursor: pointer;
}
.uw-queue-item.is-current .uw-queue-name { color: oklch(var(--p)); font-weight: 600; }
.uw-queue-remove {
flex: none;
width: 1.85rem;
height: 1.85rem;
font-size: 0.8rem;
background: transparent;
border: 1px solid transparent;
color: oklch(var(--bc) / 0.5);
cursor: pointer;
line-height: 1;
}
.uw-queue-remove:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
@media (max-width: 640px) {
.uw-player-tag { display: none; }
.uw-player-title { max-width: 7rem; font-size: 0.95rem; }
.uw-player-inner { padding: 0.75rem 0.95rem; gap: 0.6rem; }
.uw-playing body { padding-bottom: 5.75rem; }
.uw-player-btn { min-width: 2.4rem; height: 2.4rem; padding: 0 0.4rem; font-size: 0.95rem; }
.uw-player-close { width: 2.4rem; height: 2.4rem; }
#uw-audio { height: 2.7rem; min-width: 7rem; }
.uw-queue-head, .uw-queue-item { padding-left: 0.95rem; padding-right: 0.95rem; }
}
/* --- 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; }
/* --- blog editor ------------------------------------------- */
.blog-editor {
min-height: 24rem;
background: oklch(var(--b1));
}
.blog-editor .ql-editor {
min-height: 24rem;
font-size: 1rem;
line-height: 1.7;
}
.ql-toolbar.ql-snow,
.ql-container.ql-snow {
border-color: oklch(var(--b3));
}
.ql-toolbar.ql-snow {
background: oklch(var(--b2));
}
.ql-snow .ql-stroke,
.ql-snow .ql-stroke-miter {
stroke: oklch(var(--bc));
}
.ql-snow .ql-fill,
.ql-snow .ql-stroke.ql-fill {
fill: oklch(var(--bc));
}
.ql-snow .ql-picker,
.ql-snow .ql-picker-options {
color: oklch(var(--bc));
}
.ql-snow .ql-picker-options {
background: oklch(var(--b1));
border-color: oklch(var(--b3));
}
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label,
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options {
border-color: oklch(var(--b3));
}
/* active / hover toolbar state -> gruvbox accent */
.ql-snow.ql-toolbar button:hover,
.ql-snow.ql-toolbar button:focus,
.ql-snow.ql-toolbar button.ql-active,
.ql-snow.ql-toolbar .ql-picker-label:hover,
.ql-snow.ql-toolbar .ql-picker-label.ql-active,
.ql-snow.ql-toolbar .ql-picker-item:hover,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected,
.ql-snow .ql-picker.ql-expanded .ql-picker-label {
color: oklch(var(--p));
}
.ql-snow.ql-toolbar button:hover .ql-stroke,
.ql-snow.ql-toolbar button:focus .ql-stroke,
.ql-snow.ql-toolbar button.ql-active .ql-stroke,
.ql-snow.ql-toolbar button:hover .ql-stroke-miter,
.ql-snow.ql-toolbar button.ql-active .ql-stroke-miter,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke {
stroke: oklch(var(--p));
}
.ql-snow.ql-toolbar button:hover .ql-fill,
.ql-snow.ql-toolbar button:focus .ql-fill,
.ql-snow.ql-toolbar button.ql-active .ql-fill,
.ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill,
.ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill,
.ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill {
fill: oklch(var(--p));
}
/* link tooltip popup */
.ql-snow .ql-tooltip {
background-color: oklch(var(--b1));
border-color: oklch(var(--b3));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
color: oklch(var(--bc));
}
.ql-snow .ql-tooltip input[type=text] {
background: oklch(var(--b2));
border-color: oklch(var(--b3));
color: oklch(var(--bc));
}
.ql-snow .ql-tooltip a {
color: oklch(var(--p));
}
.ql-snow .ql-tooltip a.ql-action::after {
border-color: oklch(var(--b3));
}
.ql-snow .ql-editor a {
color: oklch(var(--p));
}
.blog-image-size-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.875rem;
}
.blog-image-size-controls.hidden {
display: none;
}
.blog-image-size-controls button {
border: 1px solid oklch(var(--b3));
background: oklch(var(--b2));
padding: 0.3rem 0.65rem;
line-height: 1;
}
.blog-image-size-controls button:hover {
border-color: oklch(var(--p));
color: oklch(var(--p));
}
.blog-image-size-controls label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.blog-image-size-controls input {
width: 5rem;
}
.blog-editor img,
.blog-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1rem auto;
border-radius: 0.25rem;
}
.blog-editor img {
cursor: pointer;
}
.blog-image-small {
width: min(100%, 18rem);
}
.blog-image-medium {
width: min(100%, 34rem);
}
.blog-image-full {
width: 100%;
}
.blog-content {
line-height: 1.75;
}
.blog-content h2 {
margin: 1.5rem 0 0.75rem;
font-size: 1.35rem;
font-weight: 700;
}
.blog-content h3 {
margin: 1.25rem 0 0.5rem;
font-size: 1.15rem;
font-weight: 700;
}
.blog-content p,
.blog-content ul,
.blog-content ol {
margin: 0.75rem 0;
}
.blog-content ul {
list-style: disc;
padding-left: 1.4rem;
}
.blog-content ol {
list-style: decimal;
padding-left: 1.4rem;
}
.blog-content a {
color: oklch(var(--p));
text-decoration: underline;
}
/* --- small screens ----------------------------------------- */
@media (max-width: 767px) {
.term-nav { gap: 0.5rem; }
.term-title { font-size: 1.4rem; }
}

View File

@@ -0,0 +1,149 @@
(function () {
function setImageSize(image, size) {
image.classList.remove('blog-image-small', 'blog-image-medium', 'blog-image-full');
image.style.removeProperty('width');
image.style.removeProperty('height');
image.classList.add('blog-image-' + size);
}
function setImageWidth(image, width) {
var px = parseInt(width, 10);
if (!Number.isFinite(px) || px < 40) return;
image.classList.remove('blog-image-small', 'blog-image-medium', 'blog-image-full');
image.style.width = Math.min(px, 1200) + 'px';
image.style.height = 'auto';
}
function normalizeEditorImages(root) {
root.querySelectorAll('img').forEach(function (image) {
if (
!image.classList.contains('blog-image-small')
&& !image.classList.contains('blog-image-medium')
&& !image.classList.contains('blog-image-full')
) {
image.classList.add('blog-image-full');
}
});
}
function initEditor(form) {
var editorEl = form.querySelector('[data-rich-editor]');
var contentInput = form.querySelector('[data-rich-content]');
var status = form.querySelector('[data-rich-status]');
var imageControls = form.querySelector('[data-image-size-controls]');
var imageWidthInput = form.querySelector('[data-image-width]');
if (!editorEl || !contentInput || !window.Quill) return;
var selectedImage = null;
var toolbar = [
[{ header: [2, 3, false] }],
['bold', 'italic'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'],
['clean']
];
var editor = new Quill(editorEl, {
modules: { toolbar: toolbar },
placeholder: '',
theme: 'snow'
});
var initialContent = contentInput.value.trim();
if (initialContent) {
if (initialContent.indexOf('<') >= 0) editor.clipboard.dangerouslyPasteHTML(initialContent);
else editor.setText(initialContent);
normalizeEditorImages(editor.root);
}
function syncContent() {
normalizeEditorImages(editor.root);
contentInput.value = editor.root.innerHTML;
}
function setStatus(message) {
if (status) status.textContent = message || '';
}
function chooseImageFile() {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/webp,image/gif';
input.addEventListener('change', function () {
var file = input.files && input.files[0];
if (!file) return;
uploadImage(file);
});
input.click();
}
async function uploadImage(file) {
var formData = new FormData();
formData.append('file', file);
setStatus(status ? status.dataset.uploading : '');
try {
var response = await fetch('/images/upload', {
method: 'POST',
body: formData,
credentials: 'same-origin'
});
if (!response.ok) throw new Error('upload failed');
var result = await response.json();
var range = editor.getSelection(true);
editor.insertEmbed(range.index, 'image', result.url, 'user');
editor.setSelection(range.index + 1, 0, 'silent');
window.setTimeout(function () {
var images = editor.root.querySelectorAll('img');
var image = images[images.length - 1];
if (image) {
setImageSize(image, 'full');
selectedImage = image;
if (imageControls) imageControls.classList.remove('hidden');
}
syncContent();
}, 0);
setStatus(status ? status.dataset.uploaded : '');
} catch (_error) {
setStatus(status ? status.dataset.error : '');
}
}
editor.getModule('toolbar').addHandler('image', chooseImageFile);
editor.root.addEventListener('click', function (event) {
if (event.target && event.target.tagName === 'IMG') {
selectedImage = event.target;
if (imageWidthInput) imageWidthInput.value = parseInt(selectedImage.style.width, 10) || '';
if (imageControls) imageControls.classList.remove('hidden');
}
});
if (imageControls) {
imageControls.addEventListener('click', function (event) {
var button = event.target.closest('[data-image-size]');
if (button && selectedImage) {
setImageSize(selectedImage, button.dataset.imageSize);
if (imageWidthInput) imageWidthInput.value = '';
syncContent();
}
});
}
if (imageWidthInput) {
imageWidthInput.addEventListener('change', function () {
if (!selectedImage) return;
setImageWidth(selectedImage, imageWidthInput.value);
syncContent();
});
}
editor.on('text-change', syncContent);
form.addEventListener('submit', syncContent);
syncContent();
}
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-rich-editor]').forEach(function (editorEl) {
var form = editorEl.closest('form');
if (form) initEditor(form);
});
});
})();

31
assets/static/vendor/quill/LICENSE vendored Normal file
View File

@@ -0,0 +1,31 @@
Copyright (c) 2017-2024, Slab
Copyright (c) 2014, Jason Chen
Copyright (c) 2013, salesforce.com
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

3
assets/static/vendor/quill/quill.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
/*!
* Quill Editor v2.0.3
* https://quilljs.com
* Copyright (c) 2017-2024, Slab
* Copyright (c) 2014, Jason Chen
* Copyright (c) 2013, salesforce.com
*/

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +1,36 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% block title %}Edit About{% endblock title %} {% block title %}{{ t(key="edit-about", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %} {% block content %}
<h1>Edit About</h1> <div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ t(key="edit-about", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm opacity-70">{{ t(key="update-about-page", lang=lang | default(value='sk')) }}</p>
</div>
<a href="/about" class="btn btn-ghost btn-sm">{{ t(key="view-page", lang=lang | default(value='sk')) }}</a>
</div>
<form method="post" action="/admin/about"> <div class="card border border-base-300 bg-base-100 shadow-sm">
<label> <div class="card-body">
Title <form method="post" action="/admin/about" class="space-y-2">
<input type="text" name="title" value="{{ page.title }}" required> <div class="form-control">
</label> <label class="label"><span class="label-text">{{ t(key="title", lang=lang | default(value='sk')) }}</span></label>
<label> <input type="text" name="title" value="{{ page.title }}" required class="input input-bordered w-full">
Content </div>
<textarea name="content" rows="16" required>{{ page.content }}</textarea>
</label> <div class="form-control">
<button type="submit">Save</button> <label class="label"><span class="label-text">{{ t(key="content", lang=lang | default(value='sk')) }}</span></label>
</form> <textarea name="content" rows="16" required class="textarea textarea-bordered w-full">{{ page.content }}</textarea>
</div>
<div class="flex flex-wrap gap-2 pt-2">
<button type="submit" class="btn btn-neutral btn-sm">{{ t(key="save", lang=lang | default(value='sk')) }}</button>
<a href="/admin/dashboard" class="btn btn-ghost btn-sm">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
</div>
</form>
</div>
</div>
</div>
{% endblock content %} {% endblock content %}

View File

@@ -0,0 +1,81 @@
{% extends "admin/base.html" %}
{% block title %}{{ t(key="albums-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}audio/albums{% endblock crumb %}
{% block content %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="albums-title", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">{{ t(key="admin-albums-desc", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
<a href="/admin/audio/albums/create" class="btn btn-primary btn-sm">{{ t(key="new-album", lang=lang | default(value='sk')) }}</a>
<a href="/admin/audio/tracks" class="btn btn-outline btn-sm">{{ t(key="songs-title", lang=lang | default(value='sk')) }}</a>
</div>
</header>
<div class="term-note">
<p class="term-note-title">{{ t(key="admin-albums-before", lang=lang | default(value='sk')) }}</p>
<div class="term-step">
<span class="term-step-n">[1]</span>
<span>{{ t(key="admin-albums-step-upload", lang=lang | default(value='sk')) }}</span>
</div>
<div class="term-step">
<span class="term-step-n">[2]</span>
<span>{{ t(key="admin-albums-step-create", lang=lang | default(value='sk')) }}</span>
</div>
</div>
<div class="card">
<div class="term-head">
<span class="term-head-name">~/audio/albums/</span>
<span class="term-head-meta term-tag is-purple">{{ albums | length }} {{ t(key="albums-title", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
{% if albums | length > 0 %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{{ t(key="album", lang=lang | default(value='sk')) }}</th>
<th>{{ t(key="status", lang=lang | default(value='sk')) }}</th>
<th>{{ t(key="songs-title", lang=lang | default(value='sk')) }}</th>
<th class="text-right">{{ t(key="actions", lang=lang | default(value='sk')) }}</th>
</tr>
</thead>
<tbody>
{% for row in albums %}
<tr>
<td class="font-medium">{{ row.album.title }}</td>
<td>
{% if row.album.published %}
<span class="term-tag is-green">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
{% else %}
<span class="term-tag">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
{% endif %}
</td>
<td>{{ row.track_count }}</td>
<td>
<div class="flex flex-wrap gap-2">
<a href="/admin/audio/albums/{{ row.album.id }}/tracks" class="btn btn-primary btn-sm">{{ t(key="open-edit", lang=lang | default(value='sk')) }}</a>
<a href="/audio/albums/{{ row.album.slug }}" class="btn btn-ghost btn-sm">{{ t(key="view", lang=lang | default(value='sk')) }}</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="term-empty">
<p class="font-medium">{{ t(key="admin-no-albums", lang=lang | default(value='sk')) }}</p>
<p class="term-empty-cmd">{{ t(key="admin-create-album-empty", lang=lang | default(value='sk')) }}</p>
<div class="pt-2">
<a href="/admin/audio/albums/create" class="btn btn-primary btn-sm">{{ t(key="new-album", lang=lang | default(value='sk')) }}</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,93 @@
{% extends "admin/base.html" %}
{% block title %}{{ t(key="new-album", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}audio/new-album{% endblock crumb %}
{% block content %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="new-album", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">{{ t(key="admin-new-album-desc", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
<a href="/admin/audio/albums" class="btn btn-outline btn-sm">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
</div>
</header>
<div class="card">
<div class="term-head">
<span class="term-head-name">~/audio/albums/new</span>
</div>
<div class="card-body">
<form method="post" action="/admin/audio/albums/create" enctype="multipart/form-data" class="space-y-2">
<div class="form-control">
<label class="label"><span class="label-text t-green">{{ t(key="album-title-label", lang=lang | default(value='sk')) }}</span></label>
<input type="text" name="title" required class="input input-bordered w-full">
</div>
<div class="form-control">
<label class="label"><span class="label-text t-green">{{ t(key="artist", lang=lang | default(value='sk')) }}</span></label>
<input type="text" name="artist" class="input input-bordered w-full">
</div>
<div class="form-control">
<label class="label"><span class="label-text t-green">{{ t(key="release-date", lang=lang | default(value='sk')) }}</span></label>
<input type="date" name="release_date" class="input input-bordered w-full">
</div>
<div class="form-control">
<label class="label"><span class="label-text t-green">{{ t(key="cover-image", lang=lang | default(value='sk')) }}</span></label>
<input type="file" name="cover" accept="image/png,image/jpeg,image/webp,image/gif" class="file-input file-input-bordered w-full">
<p class="term-help">{{ t(key="cover-help", lang=lang | default(value='sk')) }}</p>
</div>
<div class="form-control">
<label class="label"><span class="label-text t-green">{{ t(key="description", lang=lang | default(value='sk')) }}</span></label>
<textarea name="description" rows="5" class="textarea textarea-bordered w-full"></textarea>
</div>
<div class="term-formdiv"></div>
<div class="form-control">
<label class="label"><span class="label-text t-green">{{ t(key="songs-in-album", lang=lang | default(value='sk')) }}</span></label>
{% if available_tracks | length > 0 %}
<div class="term-picklist">
{% for song in available_tracks %}
<label class="term-pick">
<input type="checkbox" name="track_ids" value="{{ song.id }}" class="checkbox checkbox-sm">
<span class="min-w-0 flex-1 font-medium">{{ song.title }}</span>
{% if song.published %}
<span class="term-tag is-green">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
{% else %}
<span class="term-tag">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
{% endif %}
</label>
{% endfor %}
</div>
<p class="term-help">{{ t(key="free-songs-help", lang=lang | default(value='sk')) }}</p>
{% else %}
<div class="term-picklist">
<div class="term-pick">
<span class="term-help" style="margin:0">
{{ t(key="no-free-songs", lang=lang | default(value='sk')) }}
<a href="/admin/audio/tracks/upload" class="t-blue">{{ t(key="upload-song-first", lang=lang | default(value='sk')) }}</a>,
{{ t(key="create-empty-add-later", lang=lang | default(value='sk')) }}
</span>
</div>
</div>
{% endif %}
</div>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="published" class="checkbox checkbox-sm">
<span class="label-text">{{ t(key="publish-album-now", lang=lang | default(value='sk')) }}</span>
</label>
<div class="flex flex-wrap gap-2 pt-2">
<button type="submit" class="btn btn-primary btn-sm">{{ t(key="create-album", lang=lang | default(value='sk')) }}</button>
<a href="/admin/audio/albums" class="btn btn-ghost btn-sm">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
</div>
</form>
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,99 @@
{% extends "admin/base.html" %}
{% block title %}{{ t(key="songs-title-admin", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}audio/songs{% endblock crumb %}
{% block content %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="songs-title-admin", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">{{ t(key="admin-songs-desc", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
<a href="/admin/audio/tracks/upload" class="btn btn-primary btn-sm">{{ t(key="upload-song", lang=lang | default(value='sk')) }}</a>
<a href="/admin/audio/albums" class="btn btn-outline btn-sm">{{ t(key="albums-title", lang=lang | default(value='sk')) }}</a>
</div>
</header>
<div class="term-note">
<p class="term-note-title">{{ t(key="admin-audio-how", lang=lang | default(value='sk')) }}</p>
<div class="term-step">
<span class="term-step-n">[1]</span>
<span>{{ t(key="admin-audio-step-upload", lang=lang | default(value='sk')) }}</span>
</div>
<div class="term-step">
<span class="term-step-n">[2]</span>
<span>{{ t(key="admin-audio-step-album", lang=lang | default(value='sk')) }}</span>
</div>
<p class="term-note-foot">{{ t(key="admin-audio-note", lang=lang | default(value='sk')) }}</p>
</div>
<div class="card">
<div class="term-head">
<span class="term-head-name">~/audio/songs/</span>
<span class="term-head-meta term-tag is-green">{{ tracks | length }} {{ t(key="songs-title", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
{% if tracks | length > 0 %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{{ t(key="song", lang=lang | default(value='sk')) }}</th>
<th>{{ t(key="where", lang=lang | default(value='sk')) }}</th>
<th>{{ t(key="status", lang=lang | default(value='sk')) }}</th>
<th class="text-right">{{ t(key="actions", lang=lang | default(value='sk')) }}</th>
</tr>
</thead>
<tbody>
{% for track in tracks %}
<tr>
<td class="font-medium">{{ track.title }}</td>
<td>
{% if track.album_id %}
<span class="term-tag is-purple">{{ t(key="in-album", lang=lang | default(value='sk')) }}</span>
{% else %}
<span class="term-tag is-blue">{{ t(key="single", lang=lang | default(value='sk')) }}</span>
{% endif %}
</td>
<td>
{% if track.published %}
<span class="term-tag is-green">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
{% else %}
<span class="term-tag">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
{% endif %}
</td>
<td>
<div class="flex flex-wrap gap-2">
<a href="/audio/tracks/{{ track.id }}/stream" class="btn btn-ghost btn-sm">{{ t(key="play", lang=lang | default(value='sk')) }}</a>
{% if track.published %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/unpublish">
<button type="submit" class="btn btn-ghost btn-sm">{{ t(key="unpublish", lang=lang | default(value='sk')) }}</button>
</form>
{% else %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/publish">
<button type="submit" class="btn btn-ghost btn-sm t-green">{{ t(key="publish", lang=lang | default(value='sk')) }}</button>
</form>
{% endif %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/delete">
<button type="submit" class="btn btn-ghost btn-sm t-red">{{ t(key="delete", lang=lang | default(value='sk')) }}</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="term-empty">
<p class="font-medium">{{ t(key="admin-no-songs", lang=lang | default(value='sk')) }}</p>
<p class="term-empty-cmd">{{ t(key="admin-upload-first-song", lang=lang | default(value='sk')) }}</p>
<div class="pt-2">
<a href="/admin/audio/tracks/upload" class="btn btn-primary btn-sm">{{ t(key="upload-song", lang=lang | default(value='sk')) }}</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,123 @@
{% extends "admin/base.html" %}
{% block title %}{{ album.title }} - {{ t(key="admin-tracklist", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}audio/{{ album.slug }}{% endblock crumb %}
{% block content %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ album.title }}</h1>
<p class="term-sub">
{{ t(key="album", lang=lang | default(value='sk')) }} &middot; {{ tracks | length }} {{ t(key="songs-title", lang=lang | default(value='sk')) }} &middot;
{% if album.published %}<span class="t-green">{{ t(key="published", lang=lang | default(value='sk')) }}</span>{% else %}<span class="t-yellow">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>{% endif %}
</p>
</div>
<div class="term-cmd-actions">
<a href="/admin/audio/albums/{{ album.id }}/tracks/upload" class="btn btn-primary btn-sm">{{ t(key="upload-song-into-album", lang=lang | default(value='sk')) }}</a>
<a href="/audio/albums/{{ album.slug }}" class="btn btn-outline btn-sm">{{ t(key="view", lang=lang | default(value='sk')) }}</a>
<a href="/admin/audio/albums" class="btn btn-outline btn-sm">{{ t(key="albums-title", lang=lang | default(value='sk')) }}</a>
</div>
</header>
<div class="term-note">
<p class="term-note-title">{{ t(key="admin-two-ways-title", lang=lang | default(value='sk')) }}</p>
<div class="term-step">
<span class="term-step-n">[a]</span>
<span>{{ t(key="admin-two-ways-upload", lang=lang | default(value='sk')) }}</span>
</div>
<div class="term-step">
<span class="term-step-n">[b]</span>
<span>{{ t(key="admin-two-ways-pick", lang=lang | default(value='sk')) }}</span>
</div>
</div>
<div class="card">
<div class="term-head">
<span class="term-head-name">~/audio/{{ album.slug }}/tracklist</span>
<span class="term-head-meta term-tag is-purple">{{ tracks | length }} {{ t(key="songs-title", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
{% if available_tracks | length > 0 %}
<form method="post" action="/admin/audio/albums/{{ album.id }}/tracks/add" class="space-y-2">
<div class="form-control">
<label class="label"><span class="label-text t-green">{{ t(key="admin-add-existing-song", lang=lang | default(value='sk')) }}</span></label>
<select name="track_id" required class="select select-bordered w-full">
{% for song in available_tracks %}
<option value="{{ song.id }}">{{ song.title }}</option>
{% endfor %}
</select>
<p class="term-help">{{ t(key="admin-existing-song-help", lang=lang | default(value='sk')) }}</p>
</div>
<button type="submit" class="btn btn-outline btn-sm">{{ t(key="admin-add-to-album", lang=lang | default(value='sk')) }}</button>
</form>
<div class="term-formdiv"></div>
{% endif %}
{% if tracks | length > 0 %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>#</th>
<th>{{ t(key="song", lang=lang | default(value='sk')) }}</th>
<th>{{ t(key="status", lang=lang | default(value='sk')) }}</th>
<th>{{ t(key="featured", lang=lang | default(value='sk')) }}</th>
<th class="text-right">{{ t(key="actions", lang=lang | default(value='sk')) }}</th>
</tr>
</thead>
<tbody>
{% for track in tracks %}
<tr>
<td class="t-dim">{% if track.track_number %}{{ track.track_number }}{% else %}&mdash;{% endif %}</td>
<td class="font-medium">{{ track.title }}</td>
<td>
{% if track.published %}
<span class="term-tag is-green">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
{% else %}
<span class="term-tag">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
{% endif %}
</td>
<td>
{% if track.featured %}
<span class="term-tag is-aqua">{{ t(key="featured", lang=lang | default(value='sk')) }}</span>
{% else %}
<span class="t-dim"></span>
{% endif %}
</td>
<td>
<div class="flex flex-wrap gap-2">
<a href="/audio/tracks/{{ track.id }}/stream" class="btn btn-ghost btn-sm">{{ t(key="play", lang=lang | default(value='sk')) }}</a>
{% if track.published %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/unpublish">
<button type="submit" class="btn btn-ghost btn-sm">{{ t(key="unpublish", lang=lang | default(value='sk')) }}</button>
</form>
{% else %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/publish">
<button type="submit" class="btn btn-ghost btn-sm t-green">{{ t(key="publish", lang=lang | default(value='sk')) }}</button>
</form>
{% endif %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/remove-from-album">
<button type="submit" class="btn btn-ghost btn-sm">{{ t(key="remove-from-album", lang=lang | default(value='sk')) }}</button>
</form>
<form method="post" action="/admin/audio/tracks/{{ track.id }}/delete">
<button type="submit" class="btn btn-ghost btn-sm t-red">{{ t(key="delete", lang=lang | default(value='sk')) }}</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="term-empty">
<p class="font-medium">{{ t(key="admin-album-empty", lang=lang | default(value='sk')) }}</p>
<p class="term-empty-cmd">{{ t(key="admin-album-empty-help", lang=lang | default(value='sk')) }}</p>
<div class="pt-2">
<a href="/admin/audio/albums/{{ album.id }}/tracks/upload" class="btn btn-primary btn-sm">{{ t(key="upload-song-into-album", lang=lang | default(value='sk')) }}</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,76 @@
{% extends "admin/base.html" %}
{% block title %}{{ t(key="upload-song-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}audio/upload{% endblock crumb %}
{% block content %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="upload-song-title", lang=lang | default(value='sk')) }}</h1>
{% if album %}
<p class="term-sub">{{ t(key="upload-into-album-help", lang=lang | default(value='sk')) }} "{{ album.title }}".</p>
{% else %}
<p class="term-sub">{{ t(key="upload-single-help", lang=lang | default(value='sk')) }}</p>
{% endif %}
</div>
<div class="term-cmd-actions">
{% if album %}
<a href="/admin/audio/albums/{{ album.id }}/tracks" class="btn btn-outline btn-sm">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
{% else %}
<a href="/admin/audio/tracks" class="btn btn-outline btn-sm">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
{% endif %}
</div>
</header>
<div class="card">
<div class="term-head">
<span class="term-head-name">{% if album %}~/audio/{{ album.slug }}/upload{% else %}~/audio/songs/upload{% endif %}</span>
</div>
<div class="card-body">
{% if album %}
<form method="post" action="/admin/audio/albums/{{ album.id }}/tracks/upload-file" enctype="multipart/form-data" class="space-y-2">
{% else %}
<form method="post" action="/admin/audio/tracks/upload-file" enctype="multipart/form-data" class="space-y-2">
{% endif %}
<div class="form-control">
<label class="label"><span class="label-text t-green">1. {{ t(key="audio-file", lang=lang | default(value='sk')) }}</span></label>
<input type="file" name="file" accept="audio/mpeg,audio/wav,audio/ogg,audio/flac,audio/aac,audio/mp4,audio/webm" required class="file-input file-input-bordered w-full">
<p class="term-help">{{ t(key="audio-file-help", lang=lang | default(value='sk')) }}</p>
</div>
<div class="form-control">
<label class="label"><span class="label-text t-green">2. {{ t(key="title", lang=lang | default(value='sk')) }}</span></label>
<input type="text" name="title" class="input input-bordered w-full">
<p class="term-help">{{ t(key="title-help", lang=lang | default(value='sk')) }}</p>
</div>
{% if album %}
<div class="form-control">
<label class="label"><span class="label-text t-green">3. {{ t(key="track-number", lang=lang | default(value='sk')) }}</span></label>
<input type="number" name="track_number" min="1" class="input input-bordered w-full" placeholder="1">
<p class="term-help">{{ t(key="track-number-help", lang=lang | default(value='sk')) }}</p>
</div>
{% endif %}
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="featured" class="checkbox checkbox-sm">
<span class="label-text">{{ t(key="featured-help", lang=lang | default(value='sk')) }}</span>
</label>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="published" class="checkbox checkbox-sm">
<span class="label-text">{{ t(key="publish-song-now", lang=lang | default(value='sk')) }}</span>
</label>
<div class="flex flex-wrap gap-2 pt-2">
<button type="submit" class="btn btn-primary btn-sm">{{ t(key="upload-song", lang=lang | default(value='sk')) }}</button>
{% if album %}
<a href="/admin/audio/albums/{{ album.id }}/tracks" class="btn btn-ghost btn-sm">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
{% else %}
<a href="/admin/audio/tracks" class="btn btn-ghost btn-sm">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
{% endif %}
</div>
</form>
</div>
</div>
{% endblock content %}

View File

@@ -1,9 +1,9 @@
<!doctype html> <!doctype html>
<html lang="en" data-theme="light"> <html lang="{{ lang | default(value='sk') }}" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Admin{% endblock title %}</title> <title>{% block title %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock title %}</title>
<script> <script>
function applyTheme(t) { function applyTheme(t) {
var dark = t === 'dark' var dark = t === 'dark'
@@ -23,18 +23,27 @@
applyTheme(t); applyTheme(t);
highlightTheme(t); highlightTheme(t);
} }
applyTheme(localStorage.getItem('theme') || 'system'); applyTheme(localStorage.getItem('theme') || 'dark');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { 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 () { 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> </script>
<link href="/static/css/app.css" rel="stylesheet" type="text/css"> <link href="/static/css/app.css" rel="stylesheet" type="text/css">
{% block head %}{% endblock head %}
<link href="/static/css/theme.css" rel="stylesheet" type="text/css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script> <script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style> <style>
.btn { --animation-btn: 0; --btn-focus-scale: 1; } @media (min-width: 768px) {
.nav-menu { flex-direction: row; }
}
#nav-backdrop { display: none; } #nav-backdrop { display: none; }
@media (max-width: 767px) { @media (max-width: 767px) {
#nav-backdrop { #nav-backdrop {
@@ -42,12 +51,12 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 40; z-index: 40;
background-color: rgba(0, 0, 0, 0.25); background-color: rgba(0, 0, 0, 0.5);
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.2s; 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; opacity: 1;
visibility: visible; visibility: visible;
transition: opacity 0.15s ease, visibility 0s; transition: opacity 0.15s ease, visibility 0s;
@@ -55,40 +64,46 @@
} }
</style> </style>
</head> </head>
<body class="min-h-screen bg-base-200 font-sans text-base-content antialiased"> <body class="flex min-h-screen flex-col bg-base-100 text-base-content antialiased">
<header class="navbar bg-base-100 shadow-sm"> <header class="term-titlebar">
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4"> <nav class="term-nav">
<a href="/admin/dashboard" class="min-w-0 truncate text-lg font-bold">Admin</a> <a href="/admin/dashboard" class="term-brand">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a>
<div class="hidden items-center gap-1 md:flex"> <ul class="nav-menu term-navlinks menu menu-sm hidden items-center md:flex">
<a href="/admin/dashboard" class="btn btn-ghost btn-sm">Dashboard</a> <li><a href="/admin/dashboard" data-nav="/admin/dashboard">{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}</a></li>
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">Blog</a> <li><a href="/admin/blog/articles" data-nav="/admin/blog">{{ t(key="admin-blog", lang=lang | default(value='sk')) }}</a></li>
<a href="/admin/about" class="btn btn-ghost btn-sm">About</a> <li><a href="/admin/audio/albums" data-nav="/admin/audio">{{ t(key="admin-audio", lang=lang | default(value='sk')) }}</a></li>
<a href="/" class="btn btn-ghost btn-sm">View site</a> <li><a href="/admin/about" data-nav="/admin/about">{{ t(key="admin-about", lang=lang | default(value='sk')) }}</a></li>
<form method="post" action="/admin/logout"> <li><a href="/" class="t-blue">{{ t(key="admin-exit", lang=lang | default(value='sk')) }}</a></li>
<button type="submit" class="btn btn-ghost btn-sm">Logout</button> <li>
</form> <form method="post" action="/admin/logout">
</div> <button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
<div class="flex items-center gap-1"> </form>
</li>
</ul>
<div class="term-nav-right">
<div class="dropdown dropdown-end md:hidden"> <div class="dropdown dropdown-end md:hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Menu"> <div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5"> stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg> </svg>
</div> </div>
<div tabindex="0" <ul tabindex="0"
class="dropdown-content z-50 mt-3 flex w-52 flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"> class="menu dropdown-content z-50 mt-3 w-52 border border-base-300 bg-base-200 p-2 shadow-lg">
<a href="/admin/dashboard" class="btn btn-ghost btn-sm justify-start">Dashboard</a> <li><a href="/admin/dashboard">{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}</a></li>
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm justify-start">Blog</a> <li><a href="/admin/blog/articles">{{ t(key="admin-blog", lang=lang | default(value='sk')) }}</a></li>
<a href="/admin/about" class="btn btn-ghost btn-sm justify-start">About</a> <li><a href="/admin/audio/albums">{{ t(key="admin-audio", lang=lang | default(value='sk')) }}</a></li>
<a href="/" class="btn btn-ghost btn-sm justify-start">View site</a> <li><a href="/admin/about">{{ t(key="admin-about", lang=lang | default(value='sk')) }}</a></li>
<form method="post" action="/admin/logout"> <li><a href="/" class="t-blue">{{ t(key="admin-exit", lang=lang | default(value='sk')) }}</a></li>
<button type="submit" class="btn btn-ghost btn-sm w-full justify-start">Logout</button> <li>
</form> <form method="post" action="/admin/logout">
</div> <button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</li>
</ul>
</div> </div>
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Settings" title="Settings"> <div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}" title="{{ t(key='settings', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5"> stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
@@ -96,18 +111,37 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg> </svg>
</div> </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"> <form method="post" action="/lang" hx-boost="false">
<li class="menu-title">Theme</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><button type="button" data-theme-opt="system" onclick="setTheme('system')">System <span class="opt-check ml-auto hidden"></span></button></li> <li class="menu-title">{{ t(key="settings-language", lang=lang | default(value='sk')) }}</li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">Light <span class="opt-check ml-auto hidden"></span></button></li> <li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">Dark <span class="opt-check ml-auto hidden"></span></button></li> <button type="submit" name="lang" value="en" class="{% if lang | default(value='sk') == 'en' %}active{% endif %}">
</ul> {{ t(key="language-en", lang=lang | default(value='sk')) }}
{% if lang | default(value='sk') == 'en' %}
<span class="ml-auto"></span>
{% endif %}
</button>
</li>
<li>
<button type="submit" name="lang" value="sk" class="{% if lang | default(value='sk') == 'sk' %}active{% endif %}">
{{ t(key="language-sk", lang=lang | default(value='sk')) }}
{% if lang | default(value='sk') == 'sk' %}
<span class="ml-auto"></span>
{% endif %}
</button>
</li>
<li class="menu-title">{{ t(key="settings-theme", lang=lang | default(value='sk')) }}</li>
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">{{ t(key="theme-system", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">{{ t(key="theme-light", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">{{ t(key="theme-dark", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
</ul>
</form>
</div> </div>
</div> </div>
</nav> </nav>
</header> </header>
<div id="nav-backdrop" aria-hidden="true"></div> <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 %} {% block content %}{% endblock content %}
</main> </main>
</body> </body>

View File

@@ -1,31 +1,63 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% block title %}Edit Article{% endblock title %} {% block title %}{{ t(key="edit-article", lang=lang | default(value='sk')) }}{% endblock title %}
{% block head %}
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet" type="text/css">
{% endblock head %}
{% block content %} {% block content %}
<h1>Edit Article</h1> <div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ t(key="edit-article", lang=lang | default(value='sk')) }}</h1>
</div>
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">{{ t(key="back-to-articles", lang=lang | default(value='sk')) }}</a>
</div>
<form method="post" action="/admin/blog/articles/{{ article.id }}"> <div class="card border border-base-300 bg-base-100 shadow-sm">
<label> <div class="card-body">
Title <form method="post" action="/admin/blog/articles/{{ article.id }}" class="space-y-2">
<input type="text" name="title" value="{{ article.title }}" required> <div class="form-control">
</label> <label class="label"><span class="label-text">{{ t(key="title", lang=lang | default(value='sk')) }}</span></label>
<label> <input type="text" name="title" value="{{ article.title }}" required class="input input-bordered w-full">
Excerpt </div>
<textarea name="excerpt" rows="4">{% if article.excerpt %}{{ article.excerpt }}{% endif %}</textarea>
</label> <div class="form-control">
<label> <label class="label"><span class="label-text">{{ t(key="excerpt", lang=lang | default(value='sk')) }}</span></label>
Content <textarea name="excerpt" rows="4" class="textarea textarea-bordered w-full">{% if article.excerpt %}{{ article.excerpt }}{% endif %}</textarea>
<textarea name="content" rows="18" required>{{ article.content }}</textarea> </div>
</label>
<label> <div class="form-control">
Featured image id <label class="label"><span class="label-text">{{ t(key="content", lang=lang | default(value='sk')) }}</span></label>
<input type="text" name="featured_image_id" value="{% if article.featured_image_id %}{{ article.featured_image_id }}{% endif %}"> <textarea name="content" data-rich-content class="hidden">{{ article.content }}</textarea>
</label> <input type="hidden" name="featured_image_id" data-featured-image-id value="{% if article.featured_image_id %}{{ article.featured_image_id }}{% endif %}">
<label> <div data-rich-editor class="blog-editor"></div>
<input type="checkbox" name="published" {% if article.published %}checked{% endif %}> <div data-image-size-controls class="blog-image-size-controls hidden">
Published <span>{{ t(key="image-size", lang=lang | default(value='sk')) }}</span>
</label> <button type="button" data-image-size="small">{{ t(key="image-size-small", lang=lang | default(value='sk')) }}</button>
<button type="submit">Save</button> <button type="button" data-image-size="medium">{{ t(key="image-size-medium", lang=lang | default(value='sk')) }}</button>
</form> <button type="button" data-image-size="full">{{ t(key="image-size-full", lang=lang | default(value='sk')) }}</button>
<label>
<span>{{ t(key="image-width-px", lang=lang | default(value='sk')) }}</span>
<input type="number" min="40" max="1200" step="10" data-image-width class="input input-bordered input-sm">
</label>
</div>
<p class="text-sm opacity-70" data-rich-status data-uploading='{{ t(key="image-uploading", lang=lang | default(value='sk')) }}' data-uploaded='{{ t(key="image-uploaded", lang=lang | default(value='sk')) }}' data-error='{{ t(key="image-upload-error", lang=lang | default(value='sk')) }}'></p>
</div>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="published" class="checkbox checkbox-sm" {% if article.published %}checked{% endif %}>
<span class="label-text">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
</label>
<div class="flex flex-wrap gap-2 pt-2">
<button type="submit" class="btn btn-neutral btn-sm">{{ t(key="save", lang=lang | default(value='sk')) }}</button>
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
</div>
</form>
</div>
</div>
</div>
<script src="/static/vendor/quill/quill.js"></script>
<script src="/static/js/blog-editor.js"></script>
{% endblock content %} {% endblock content %}

View File

@@ -1,36 +1,63 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% block title %}Blog Articles{% endblock title %} {% block title %}{{ t(key="admin-blog-articles", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %} {% block content %}
<h1>Blog Articles</h1> <div class="space-y-2">
<p><a href="/admin/blog/articles/new">New article</a></p> <div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ t(key="admin-blog-articles", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm opacity-70">{{ t(key="admin-blog-index-desc", lang=lang | default(value='sk')) }}</p>
</div>
<a href="/admin/blog/articles/new" class="btn btn-neutral btn-sm">{{ t(key="new-article", lang=lang | default(value='sk')) }}</a>
</div>
{% if articles | length > 0 %} <div class="card border border-base-300 bg-base-100 shadow-sm">
<table> <div class="card-body">
<thead> {% if articles | length > 0 %}
<tr> <div class="overflow-x-auto">
<th>Title</th> <table class="table">
<th>Status</th> <thead>
<th>Actions</th> <tr>
</tr> <th>{{ t(key="title", lang=lang | default(value='sk')) }}</th>
</thead> <th>{{ t(key="status", lang=lang | default(value='sk')) }}</th>
<tbody> <th class="text-right">{{ t(key="actions", lang=lang | default(value='sk')) }}</th>
{% for article in articles %} </tr>
<tr> </thead>
<td>{{ article.title }}</td> <tbody>
<td>{% if article.published %}Published{% else %}Draft{% endif %}</td> {% for article in articles %}
<td> <tr>
<a href="/admin/blog/articles/{{ article.id }}/edit">Edit</a> <td class="font-medium">{{ article.title }}</td>
<form method="post" action="/admin/blog/articles/{{ article.id }}/delete"> <td>
<button type="submit">Delete</button> {% if article.published %}
</form> <span class="badge">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
</td> {% else %}
</tr> <span class="badge opacity-70">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
{% endfor %} {% endif %}
</tbody> </td>
</table> <td>
{% else %} <div class="flex gap-2">
<p>No articles yet.</p> <a href="/admin/blog/articles/{{ article.id }}/edit" class="btn btn-ghost btn-sm">{{ t(key="edit", lang=lang | default(value='sk')) }}</a>
{% endif %} <form method="post" action="/admin/blog/articles/{{ article.id }}/delete">
<button type="submit" class="btn btn-ghost btn-sm">{{ t(key="delete", lang=lang | default(value='sk')) }}</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center">
<p class="font-medium">{{ t(key="admin-no-articles", lang=lang | default(value='sk')) }}</p>
<p class="text-sm opacity-70">{{ t(key="admin-create-first-post", lang=lang | default(value='sk')) }}</p>
<div class="pt-2">
<a href="/admin/blog/articles/new" class="btn btn-neutral btn-sm">{{ t(key="new-article", lang=lang | default(value='sk')) }}</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock content %} {% endblock content %}

View File

@@ -1,31 +1,64 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% block title %}New Article{% endblock title %} {% block title %}{{ t(key="new-article", lang=lang | default(value='sk')) }}{% endblock title %}
{% block head %}
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet" type="text/css">
{% endblock head %}
{% block content %} {% block content %}
<h1>New Article</h1> <div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ t(key="new-article", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm opacity-70">{{ t(key="admin-blog-create-desc", lang=lang | default(value='sk')) }}</p>
</div>
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">{{ t(key="back-to-articles", lang=lang | default(value='sk')) }}</a>
</div>
<form method="post" action="/admin/blog/articles"> <div class="card border border-base-300 bg-base-100 shadow-sm">
<label> <div class="card-body">
Title <form method="post" action="/admin/blog/articles" class="space-y-2">
<input type="text" name="title" required> <div class="form-control">
</label> <label class="label"><span class="label-text">{{ t(key="title", lang=lang | default(value='sk')) }}</span></label>
<label> <input type="text" name="title" required class="input input-bordered w-full">
Excerpt </div>
<textarea name="excerpt" rows="4"></textarea>
</label> <div class="form-control">
<label> <label class="label"><span class="label-text">{{ t(key="excerpt", lang=lang | default(value='sk')) }}</span></label>
Content <textarea name="excerpt" rows="4" class="textarea textarea-bordered w-full"></textarea>
<textarea name="content" rows="18" required></textarea> </div>
</label>
<label> <div class="form-control">
Featured image id <label class="label"><span class="label-text">{{ t(key="content", lang=lang | default(value='sk')) }}</span></label>
<input type="text" name="featured_image_id"> <textarea name="content" data-rich-content class="hidden"></textarea>
</label> <input type="hidden" name="featured_image_id" data-featured-image-id>
<label> <div data-rich-editor class="blog-editor"></div>
<input type="checkbox" name="published"> <div data-image-size-controls class="blog-image-size-controls hidden">
Published <span>{{ t(key="image-size", lang=lang | default(value='sk')) }}</span>
</label> <button type="button" data-image-size="small">{{ t(key="image-size-small", lang=lang | default(value='sk')) }}</button>
<button type="submit">Create</button> <button type="button" data-image-size="medium">{{ t(key="image-size-medium", lang=lang | default(value='sk')) }}</button>
</form> <button type="button" data-image-size="full">{{ t(key="image-size-full", lang=lang | default(value='sk')) }}</button>
<label>
<span>{{ t(key="image-width-px", lang=lang | default(value='sk')) }}</span>
<input type="number" min="40" max="1200" step="10" data-image-width class="input input-bordered input-sm">
</label>
</div>
<p class="text-sm opacity-70" data-rich-status data-uploading='{{ t(key="image-uploading", lang=lang | default(value='sk')) }}' data-uploaded='{{ t(key="image-uploaded", lang=lang | default(value='sk')) }}' data-error='{{ t(key="image-upload-error", lang=lang | default(value='sk')) }}'></p>
</div>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="published" class="checkbox checkbox-sm">
<span class="label-text">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
</label>
<div class="flex flex-wrap gap-2 pt-2">
<button type="submit" class="btn btn-neutral btn-sm">{{ t(key="create", lang=lang | default(value='sk')) }}</button>
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
</div>
</form>
</div>
</div>
</div>
<script src="/static/vendor/quill/quill.js"></script>
<script src="/static/js/blog-editor.js"></script>
{% endblock content %} {% endblock content %}

View File

@@ -1,13 +1,60 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% block title %}Admin{% endblock title %} {% block title %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}dashboard{% endblock crumb %}
{% block content %} {% block content %}
<h1>Admin</h1> <header class="term-cmd">
<p>Logged in as {{ admin.email }}</p> <div>
<h1 class="term-title">{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">{{ t(key="admin-session", lang=lang | default(value='sk')) }}: {{ admin.email }}</p>
</div>
<div class="term-cmd-actions">
<a href="/" class="btn btn-outline btn-sm">[ {{ t(key="view-site", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
<ul> <div class="term-grid">
<li><a href="/admin/blog/articles">Manage blog articles</a></li> <article class="card">
<li><a href="/admin/about">Edit about page</a></li> <div class="term-head">
</ul> <span class="term-head-name">/admin/blog</span>
<span class="term-head-meta term-tag">{{ t(key="manage", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<h2 class="card-title text-base">{{ t(key="blog-title", lang=lang | default(value='sk')) }}</h2>
<p class="text-sm opacity-70">{{ t(key="admin-blog-desc", lang=lang | default(value='sk')) }}</p>
<div class="pt-2">
<a href="/admin/blog/articles" class="btn btn-primary btn-sm">[ {{ t(key="blog-manage", lang=lang | default(value='sk')) }} → ]</a>
</div>
</div>
</article>
<article class="card">
<div class="term-head">
<span class="term-head-name">/admin/about</span>
<span class="term-head-meta term-tag is-blue">{{ t(key="single", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<h2 class="card-title text-base">{{ t(key="about-sub", lang=lang | default(value='sk')) }}</h2>
<p class="text-sm opacity-70">{{ t(key="admin-about-desc", lang=lang | default(value='sk')) }}</p>
<div class="pt-2">
<a href="/admin/about" class="btn btn-primary btn-sm">[ {{ t(key="edit", lang=lang | default(value='sk')) }} → ]</a>
</div>
</div>
</article>
<article class="card">
<div class="term-head">
<span class="term-head-name">/admin/audio</span>
<span class="term-head-meta term-tag is-purple">{{ t(key="album", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<h2 class="card-title text-base">{{ t(key="audio-title", lang=lang | default(value='sk')) }}</h2>
<p class="text-sm opacity-70">{{ t(key="admin-audio-desc", lang=lang | default(value='sk')) }}</p>
<div class="pt-2">
<a href="/admin/audio/albums" class="btn btn-primary btn-sm">[ {{ t(key="manage", lang=lang | default(value='sk')) }} → ]</a>
</div>
</div>
</article>
</div>
{% endblock content %} {% endblock content %}

View File

@@ -1,27 +1,32 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Admin login{% endblock title %} {% block title %}{{ t(key="login-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}admin/login{% endblock crumb %}
{% block content %} {% block content %}
<div class="mx-auto mt-8 max-w-sm"> <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-head-name">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</span>
<span class="term-head-meta term-tag is-red">{{ t(key="auth", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body"> <div class="card-body">
<h1 class="card-title">Admin login</h1> <h1 class="term-title">{{ t(key="login-auth", lang=lang | default(value='sk')) }}</h1>
{% if error %} {% if error %}
<div class="alert alert-error"> <div class="alert alert-error mt-2">
<span>Invalid email or password.</span> <span>✗ {{ t(key="login-error", lang=lang | default(value='sk')) }}</span>
</div> </div>
{% endif %} {% endif %}
<form method="post" action="/admin/login" class="space-y-2"> <form method="post" action="/admin/login" hx-boost="false" class="space-y-2">
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text">Email</span></label> <label class="label"><span class="label-text t-green">{{ t(key="login-email", lang=lang | default(value='sk')) }}:</span></label>
<input type="email" name="email" required class="input input-bordered w-full"> <input type="email" name="email" required autofocus class="input input-bordered w-full">
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text">Password</span></label> <label class="label"><span class="label-text t-green">{{ t(key="login-password", lang=lang | default(value='sk')) }}:</span></label>
<input type="password" name="password" required class="input input-bordered w-full"> <input type="password" name="password" required class="input input-bordered w-full">
</div> </div>
<button class="btn btn-neutral mt-2 w-full">Login</button> <button class="btn btn-primary mt-2 w-full">[ {{ t(key="login-auth", lang=lang | default(value='sk')) }} ]</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,140 @@
{% extends "base.html" %}
{% block title %}{{ album.title }}{% endblock title %}
{% block crumb %}audio/{{ album.slug }}{% endblock crumb %}
{% block content %}
{% if logged_in_admin %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ album.title }}</h1>
{% if album.artist %}
<p class="term-sub">// {{ t(key="album-by", lang=lang | default(value='sk')) }} {{ album.artist }}</p>
{% endif %}
</div>
<div class="term-cmd-actions">
{% if tracks | length > 0 %}
<button type="button" class="uw-play-album btn btn-primary btn-sm"
data-tracks-from="#uw-album-tracks">{{ t(key="album-play-full", lang=lang | default(value='sk')) }}</button>
{% endif %}
<a href="/audio/albums" class="btn btn-outline btn-sm">[ {{ t(key="cd-up", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
{% if album.cover_image_id %}
<div class="card mb-6">
<div class="term-head">
<span class="term-head-name">~/audio/{{ album.slug }}/cover.png</span>
</div>
<div class="card-body">
<img src="/images/{{ album.cover_image_id }}" alt="">
</div>
</div>
{% endif %}
{% if album.description %}
<div class="card mb-6">
<div class="term-head">
<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-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" id="uw-album-tracks">
{% if tracks | length > 0 %}
<div class="term-track-bar">
<button type="button" class="uw-play-album btn btn-primary btn-sm"
data-tracks-from="#uw-album-tracks">{{ t(key="album-play-full", lang=lang | default(value='sk')) }}</button>
<span class="term-track-name t-dim">// {{ t(key="album-queue-all", lang=lang | default(value='sk')) }}</span>
</div>
{% for track in tracks %}
<div class="term-track">
<button type="button" class="uw-play btn btn-primary btn-sm"
data-src="/audio/tracks/{{ track.id }}/stream" data-title="{{ track.title }}">&#9654; play</button>
<span 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 }}
</span>
</div>
{% endfor %}
{% else %}
<p class="term-empty-cmd">{{ t(key="album-no-tracks", lang=lang | default(value='sk')) }}</p>
{% endif %}
</div>
</div>
{% else %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ album.title }}</h1>
{% if album.artist %}
<p class="term-sub">{{ t(key="album-by", lang=lang | default(value='sk')) }} {{ album.artist }}</p>
{% endif %}
</div>
<div class="term-cmd-actions">
{% if tracks | length > 0 %}
<button type="button" class="uw-play-album btn btn-primary btn-sm"
data-tracks-from="#uw-album-tracks">{{ t(key="album-play-full", lang=lang | default(value='sk')) }}</button>
{% endif %}
<a href="/audio/albums" class="btn btn-outline btn-sm">[ {{ t(key="cd-up", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
{% if album.cover_image_id %}
<div class="card mb-6">
<div class="term-head">
<span class="term-head-name">~/audio/{{ album.slug }}/cover.png</span>
</div>
<div class="card-body">
<img src="/images/{{ album.cover_image_id }}" alt="">
</div>
</div>
{% endif %}
{% if album.description %}
<div class="card mb-6">
<div class="term-head">
<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-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" id="uw-album-tracks">
{% if tracks | length > 0 %}
<div class="term-track-bar">
<button type="button" class="uw-play-album btn btn-primary btn-sm"
data-tracks-from="#uw-album-tracks">{{ t(key="album-play-full", lang=lang | default(value='sk')) }}</button>
<span class="term-track-name t-dim">// {{ t(key="album-queue-all", lang=lang | default(value='sk')) }}</span>
</div>
{% for track in tracks %}
<div class="term-track">
<button type="button" class="uw-play btn btn-primary btn-sm"
data-src="/audio/tracks/{{ track.id }}/stream" data-title="{{ track.title }}">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<span 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 }}
</span>
</div>
{% endfor %}
{% else %}
<p class="term-empty-cmd">{{ t(key="album-no-tracks", lang=lang | default(value='sk')) }}</p>
{% endif %}
</div>
</div>
{% endif %}
{% endblock content %}

View File

@@ -0,0 +1,96 @@
{% extends "base.html" %}
{% block title %}{{ t(key="audio-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}audio{% endblock crumb %}
{% block content %}
{% if logged_in_admin %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="audio-title", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">// {{ albums | length }} {{ t(key="audio-sub", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
<a href="/audio/tracks" class="btn btn-outline btn-sm">[ {{ t(key="audio-all-songs", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
{% if albums | length > 0 %}
<div class="term-grid">
{% for album in albums %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/audio/{{ album.slug }}/</span>
<span class="term-head-meta term-tag is-purple">{{ t(key="album", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
{% if album.cover_image_id %}
<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 t-aqua">{{ album.artist }}</p>
{% endif %}
{% if album.description %}
<p class="term-prose text-sm opacity-80">{{ album.description }}</p>
{% endif %}
<div class="flex flex-wrap gap-2 pt-2">
<button type="button" class="uw-play-album-remote btn btn-primary btn-sm"
data-album-tracks-url="/audio/albums/{{ album.slug }}/tracks">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<a href="/audio/albums/{{ album.slug }}" class="btn btn-outline btn-sm">{{ t(key="audio-open", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="term-empty">
<p class="font-medium">{{ t(key="audio-no-albums", lang=lang | default(value='sk')) }}</p>
</div>
{% endif %}
{% else %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="audio-title", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">{{ albums | length }} {{ t(key="audio-sub", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
<a href="/audio/tracks" class="btn btn-outline btn-sm">[ {{ t(key="audio-all-songs", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
{% if albums | length > 0 %}
<div class="term-grid">
{% for album in albums %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/audio/{{ album.slug }}/</span>
<span class="term-head-meta term-tag is-purple">{{ t(key="album", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
{% if album.cover_image_id %}
<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 t-aqua">{{ album.artist }}</p>
{% endif %}
{% if album.description %}
<p class="term-prose text-sm opacity-80">{{ album.description }}</p>
{% endif %}
<div class="flex flex-wrap gap-2 pt-2">
<button type="button" class="uw-play-album-remote btn btn-primary btn-sm"
data-album-tracks-url="/audio/albums/{{ album.slug }}/tracks">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<a href="/audio/albums/{{ album.slug }}" class="btn btn-outline btn-sm">{{ t(key="audio-open", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="term-empty">
<p class="font-medium">{{ t(key="audio-no-albums", lang=lang | default(value='sk')) }}</p>
</div>
{% endif %}
{% endif %}
{% endblock content %}

View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}{{ t(key="songs-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}audio/tracks{% endblock crumb %}
{% block content %}
{% if logged_in_admin %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="songs-title", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">// {{ tracks | length }} {{ t(key="songs-sub", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
{% if tracks | length > 0 %}
<button type="button" class="uw-play-album btn btn-primary btn-sm"
data-tracks-from="#uw-songs-list">{{ t(key="songs-play-all", lang=lang | default(value='sk')) }}</button>
{% endif %}
<a href="/audio/albums" class="btn btn-outline btn-sm">[ {{ t(key="songs-albums", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
<div class="card">
<div class="term-head">
<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" id="uw-songs-list">
{% if tracks | length > 0 %}
{% for track in tracks %}
<div class="term-track">
<button type="button" class="uw-play btn btn-primary btn-sm"
data-src="/audio/tracks/{{ track.id }}/stream" data-title="{{ track.title }}">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<span class="term-track-name"><span class="t-green"></span> {{ track.title }}</span>
</div>
{% endfor %}
{% else %}
<p class="term-empty-cmd">{{ t(key="songs-no-tracks", lang=lang | default(value='sk')) }}</p>
{% endif %}
</div>
</div>
{% else %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="songs-title", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">{{ tracks | length }} {{ t(key="songs-sub", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
{% if tracks | length > 0 %}
<button type="button" class="uw-play-album btn btn-primary btn-sm"
data-tracks-from="#uw-songs-list">{{ t(key="songs-play-all", lang=lang | default(value='sk')) }}</button>
{% endif %}
<a href="/audio/albums" class="btn btn-outline btn-sm">[ {{ t(key="songs-albums", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
<div class="card">
<div class="term-head">
<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" id="uw-songs-list">
{% if tracks | length > 0 %}
{% for track in tracks %}
<div class="term-track">
<button type="button" class="uw-play btn btn-primary btn-sm"
data-src="/audio/tracks/{{ track.id }}/stream" data-title="{{ track.title }}">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<span class="term-track-name">{{ track.title }}</span>
</div>
{% endfor %}
{% else %}
<p class="term-empty-cmd">{{ t(key="songs-no-tracks", lang=lang | default(value='sk')) }}</p>
{% endif %}
</div>
</div>
{% endif %}
{% endblock content %}

View File

@@ -1,9 +1,9 @@
<!doctype html> <!doctype html>
<html lang="en" data-theme="light"> <html lang="{{ lang | default(value='sk') }}" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Universal Web{% endblock title %}</title> <title>{% block title %}{{ t(key="brand", lang=lang | default(value='sk')) }}{% endblock title %}</title>
<script> <script>
function applyTheme(t) { function applyTheme(t) {
var dark = t === 'dark' var dark = t === 'dark'
@@ -23,18 +23,210 @@
applyTheme(t); applyTheme(t);
highlightTheme(t); highlightTheme(t);
} }
applyTheme(localStorage.getItem('theme') || 'system'); applyTheme(localStorage.getItem('theme') || 'dark');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { 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 () { function markActiveNav() {
highlightTheme(localStorage.getItem('theme') || 'system'); var path = location.pathname;
document.querySelectorAll('.term-navlinks a[data-nav]').forEach(function (a) {
var h = a.getAttribute('data-nav');
a.classList.toggle('is-active', h === path || (h !== '/' && path.indexOf(h) === 0));
});
}
function initPage() {
highlightTheme(localStorage.getItem('theme') || 'dark');
markActiveNav();
}
// --- persistent audio player with playlist queue ----------
// Survives htmx-boosted navigation: window state persists and
// #uw-player carries hx-preserve so <audio> keeps playing.
var uwQueue = []; // [{ src, title }]
var uwIndex = -1; // index of the current track, -1 when empty
function uwSave() {
try {
sessionStorage.setItem('uwQueue', JSON.stringify({ q: uwQueue, i: uwIndex }));
} catch (e) {}
}
function uwRestore() {
try {
var d = JSON.parse(sessionStorage.getItem('uwQueue') || 'null');
if (d && d.q) { uwQueue = d.q; uwIndex = (typeof d.i === 'number' ? d.i : -1); }
} catch (e) {}
}
function uwRenderQueue() {
var player = document.getElementById('uw-player');
if (player) player.classList.toggle('uw-has-queue', uwQueue.length > 0);
var badge = document.getElementById('uw-queue-badge');
if (badge) badge.textContent = uwQueue.length;
var count = document.getElementById('uw-queue-count');
if (count) count.textContent = uwQueue.length + (uwQueue.length === 1 ? ' track' : ' tracks');
var list = document.getElementById('uw-queue-list');
if (!list) return;
list.innerHTML = '';
uwQueue.forEach(function (t, idx) {
var li = document.createElement('li');
li.className = 'uw-queue-item' + (idx === uwIndex ? ' is-current' : '');
var jump = document.createElement('button');
jump.type = 'button';
jump.className = 'uw-queue-jump';
jump.setAttribute('data-uw-jump', idx);
jump.textContent = (idx === uwIndex ? '▸' : (idx + 1));
var name = document.createElement('span');
name.className = 'uw-queue-name';
name.setAttribute('data-uw-jump', idx);
name.textContent = t.title || 'unknown track';
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'uw-queue-remove';
rm.setAttribute('data-uw-remove', idx);
rm.setAttribute('aria-label', 'Remove from playlist');
rm.textContent = '✕';
li.appendChild(jump);
li.appendChild(name);
li.appendChild(rm);
list.appendChild(li);
});
}
// Point <audio> at the current queue entry; play it when asked.
function uwLoad(autoplay) {
var audio = document.getElementById('uw-audio');
var now = document.getElementById('uw-now');
if (!audio) return;
var t = uwQueue[uwIndex];
if (!t) {
if (now) now.textContent = '—';
audio.pause();
audio.removeAttribute('src');
document.documentElement.classList.remove('uw-playing');
uwRenderQueue();
uwSave();
return;
}
document.documentElement.classList.add('uw-playing');
if (now) now.textContent = t.title || 'unknown track';
if (audio.getAttribute('src') !== t.src) {
audio.setAttribute('src', t.src);
audio.load();
}
if (autoplay) {
var p = audio.play();
if (p && p.catch) p.catch(function () {});
}
uwRenderQueue();
uwSave();
}
// Replace the whole queue with a fresh set of tracks and play.
function uwPlayList(tracks) {
if (!tracks || !tracks.length) return;
uwQueue = tracks.slice();
uwIndex = 0;
uwLoad(true);
}
// Add one track: play it now if idle, otherwise queue it.
function uwAdd(src, title) {
var audio = document.getElementById('uw-audio');
var idle = uwIndex < 0 || !audio || audio.ended || !audio.getAttribute('src');
uwQueue.push({ src: src, title: title });
if (idle) { uwIndex = uwQueue.length - 1; uwLoad(true); }
else { uwRenderQueue(); uwSave(); }
}
function uwNext() {
if (uwIndex >= 0 && uwIndex < uwQueue.length - 1) { uwIndex++; uwLoad(true); }
}
function uwPrev() {
var audio = document.getElementById('uw-audio');
if (audio && audio.currentTime > 3) { audio.currentTime = 0; return; }
if (uwIndex > 0) { uwIndex--; uwLoad(true); }
else if (audio) audio.currentTime = 0;
}
function uwJump(idx) {
if (idx >= 0 && idx < uwQueue.length) { uwIndex = idx; uwLoad(true); }
}
function uwRemove(idx) {
if (idx < 0 || idx >= uwQueue.length) return;
var playing = document.documentElement.classList.contains('uw-playing');
uwQueue.splice(idx, 1);
if (idx < uwIndex) { uwIndex--; uwRenderQueue(); uwSave(); }
else if (idx > uwIndex) { uwRenderQueue(); uwSave(); }
else {
if (uwIndex >= uwQueue.length) uwIndex = uwQueue.length - 1;
uwLoad(playing);
}
}
function uwClear() {
uwQueue = [];
uwIndex = -1;
uwLoad(false);
var panel = document.getElementById('uw-queue');
if (panel) panel.hidden = true;
}
function uwInit() {
var audio = document.getElementById('uw-audio');
if (!audio || audio.dataset.uwBound) return;
audio.dataset.uwBound = '1';
uwRestore();
audio.addEventListener('ended', uwNext);
uwRenderQueue();
if (uwIndex >= 0 && uwQueue[uwIndex]) uwLoad(false);
}
document.addEventListener('DOMContentLoaded', function () { initPage(); uwInit(); });
document.addEventListener('htmx:afterSwap', initPage);
document.addEventListener('click', function (e) {
if (!e.target.closest) return;
var albumBtn = e.target.closest('.uw-play-album');
if (albumBtn) {
var sel = albumBtn.getAttribute('data-tracks-from');
var scope = (sel && document.querySelector(sel)) || document;
var tracks = [];
scope.querySelectorAll('.uw-play').forEach(function (b) {
tracks.push({ src: b.getAttribute('data-src'), title: b.getAttribute('data-title') });
});
uwPlayList(tracks);
return;
}
// Play an album straight from the listing: fetch its tracks first.
var remoteBtn = e.target.closest('.uw-play-album-remote');
if (remoteBtn) {
var rurl = remoteBtn.getAttribute('data-album-tracks-url');
if (rurl) {
remoteBtn.disabled = true;
fetch(rurl, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (d) { if (d && d.tracks) uwPlayList(d.tracks); })
.catch(function () {})
.then(function () { remoteBtn.disabled = false; });
}
return;
}
var playBtn = e.target.closest('.uw-play');
if (playBtn) {
uwAdd(playBtn.getAttribute('data-src'), playBtn.getAttribute('data-title'));
return;
}
var jumpEl = e.target.closest('[data-uw-jump]');
if (jumpEl) { uwJump(parseInt(jumpEl.getAttribute('data-uw-jump'), 10)); return; }
var rmEl = e.target.closest('[data-uw-remove]');
if (rmEl) { uwRemove(parseInt(rmEl.getAttribute('data-uw-remove'), 10)); return; }
if (e.target.closest('#uw-next')) { uwNext(); return; }
if (e.target.closest('#uw-prev')) { uwPrev(); return; }
if (e.target.closest('#uw-queue-clear')) { uwClear(); return; }
if (e.target.closest('#uw-queue-toggle')) {
var panel = document.getElementById('uw-queue');
if (panel) panel.hidden = !panel.hidden;
return;
}
if (e.target.closest('#uw-close')) { uwClear(); return; }
}); });
</script> </script>
<link href="/static/css/app.css" rel="stylesheet" type="text/css"> <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> <script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style> <style>
.btn { --animation-btn: 0; --btn-focus-scale: 1; } @media (min-width: 768px) {
.nav-menu { flex-direction: row; }
}
#nav-backdrop { display: none; } #nav-backdrop { display: none; }
@media (max-width: 767px) { @media (max-width: 767px) {
#nav-backdrop { #nav-backdrop {
@@ -42,12 +234,12 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 40; z-index: 40;
background-color: rgba(0, 0, 0, 0.25); background-color: rgba(0, 0, 0, 0.5);
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.2s; 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; opacity: 1;
visibility: visible; visibility: visible;
transition: opacity 0.15s ease, visibility 0s; transition: opacity 0.15s ease, visibility 0s;
@@ -55,34 +247,56 @@
} }
</style> </style>
</head> </head>
<body class="min-h-screen bg-base-200 font-sans text-base-content antialiased"> <body hx-boost="true" class="flex min-h-screen flex-col bg-base-100 text-base-content antialiased">
<header class="navbar bg-base-100 shadow-sm"> <header class="term-titlebar">
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4"> <nav class="term-nav">
<a href="/" class="min-w-0 truncate text-lg font-bold">Universal Web</a> <a href="/" class="term-brand">{{ t(key="brand", lang=lang | default(value='sk')) }}</a>
<div class="hidden items-center gap-1 md:flex"> <ul class="nav-menu term-navlinks menu menu-sm hidden items-center md:flex">
<a href="/" class="btn btn-ghost btn-sm">Home</a> <li><a href="/" data-nav="/">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
<a href="/about" class="btn btn-ghost btn-sm">About</a> <li><a href="/blog" data-nav="/blog">{{ t(key="nav-blog", lang=lang | default(value='sk')) }}</a></li>
<a href="/blog" class="btn btn-ghost btn-sm">Blog</a> <li><a href="/audio/albums" data-nav="/audio/albums">{{ t(key="nav-audio", lang=lang | default(value='sk')) }}</a></li>
<a href="/admin/login" class="btn btn-ghost btn-sm">Admin</a> <li><a href="/audio/tracks" data-nav="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li>
</div> <li><a href="/about" data-nav="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li>
<div class="flex items-center gap-1"> {% if logged_in_admin %}
<li><a href="/admin/dashboard" hx-boost="false" class="t-yellow" data-nav="/admin">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
<li>
<form method="post" action="/admin/logout" hx-boost="false">
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</li>
{% else %}
<li><a href="/admin/login" data-nav="/admin/login">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li>
{% endif %}
</ul>
<div class="term-nav-right">
<div class="dropdown dropdown-end md:hidden"> <div class="dropdown dropdown-end md:hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Menu"> <div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5"> stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg> </svg>
</div> </div>
<div tabindex="0" <ul tabindex="0"
class="dropdown-content z-50 mt-3 flex w-52 flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"> class="menu dropdown-content z-50 mt-3 w-52 border border-base-300 bg-base-200 p-2 shadow-lg">
<a href="/" class="btn btn-ghost btn-sm justify-start">Home</a> <li><a href="/">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
<a href="/about" class="btn btn-ghost btn-sm justify-start">About</a> <li><a href="/blog">{{ t(key="nav-blog", lang=lang | default(value='sk')) }}</a></li>
<a href="/blog" class="btn btn-ghost btn-sm justify-start">Blog</a> <li><a href="/audio/albums">{{ t(key="nav-audio", lang=lang | default(value='sk')) }}</a></li>
<a href="/admin/login" class="btn btn-ghost btn-sm justify-start">Admin</a> <li><a href="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li>
</div> <li><a href="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li>
{% if logged_in_admin %}
<li><a href="/admin/dashboard" hx-boost="false" class="t-yellow">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
<li>
<form method="post" action="/admin/logout">
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</li>
{% else %}
<li><a href="/admin/login">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li>
{% endif %}
</ul>
</div> </div>
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Settings" title="Settings"> <div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}" title="{{ t(key='settings', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5"> stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
@@ -90,19 +304,57 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg> </svg>
</div> </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"> <form method="post" action="/lang" hx-boost="false">
<li class="menu-title">Theme</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><button type="button" data-theme-opt="system" onclick="setTheme('system')">System <span class="opt-check ml-auto hidden"></span></button></li> <li class="menu-title">{{ t(key="settings-language", lang=lang | default(value='sk')) }}</li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">Light <span class="opt-check ml-auto hidden"></span></button></li> <li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">Dark <span class="opt-check ml-auto hidden"></span></button></li> <button type="submit" name="lang" value="en" class="{% if lang | default(value='sk') == 'en' %}active{% endif %}">
</ul> English
{% if lang | default(value='sk') == 'en' %}
<span class="ml-auto"></span>
{% endif %}
</button>
</li>
<li>
<button type="submit" name="lang" value="sk" class="{% if lang | default(value='sk') == 'sk' %}active{% endif %}">
Slovenčina
{% if lang | default(value='sk') == 'sk' %}
<span class="ml-auto"></span>
{% endif %}
</button>
</li>
<li class="menu-title">{{ t(key="settings-theme", lang=lang | default(value='sk')) }}</li>
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">{{ t(key="theme-system", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">{{ t(key="theme-light", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">{{ t(key="theme-dark", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
</ul>
</form>
</div> </div>
</div> </div>
</nav> </nav>
</header> </header>
<div id="nav-backdrop" aria-hidden="true"></div> <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 %} {% block content %}{% endblock content %}
</main> </main>
<div id="uw-player" hx-preserve="true">
<div id="uw-queue" class="uw-queue" hidden>
<div class="uw-queue-head">
<span class="uw-queue-title">&#9776; playlist</span>
<span id="uw-queue-count" class="uw-queue-meta">0 tracks</span>
<button type="button" id="uw-queue-clear" class="uw-queue-clear">clear</button>
</div>
<ol id="uw-queue-list" class="uw-queue-list"></ol>
</div>
<div class="uw-player-inner">
<span class="uw-player-tag">&#9654; now playing</span>
<span id="uw-now" class="uw-player-title">&mdash;</span>
<button type="button" id="uw-prev" class="uw-player-btn" aria-label="Previous track" title="Previous">&#9198;</button>
<audio id="uw-audio" controls preload="none"></audio>
<button type="button" id="uw-next" class="uw-player-btn" aria-label="Next track" title="Next">&#9197;</button>
<button type="button" id="uw-queue-toggle" class="uw-player-btn" aria-label="Toggle playlist" title="Playlist">&#9776;<span id="uw-queue-badge" class="uw-queue-badge">0</span></button>
<button type="button" id="uw-close" class="uw-player-close" aria-label="Stop playback" title="Stop">&#10005;</button>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -1,20 +1,81 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Blog{% endblock title %} {% block title %}{{ t(key="blog-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}blog{% endblock crumb %}
{% block content %} {% block content %}
<h1>Blog</h1> {% if logged_in_admin %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="blog-title", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">// {{ articles | length }} {{ t(key="blog-sub", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
<a href="/admin/blog/articles" hx-boost="false" class="btn btn-outline btn-sm">[ {{ t(key="blog-manage", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
{% if articles | length > 0 %} {% if articles | length > 0 %}
<ul> <div class="term-stack">
{% for article in articles %} {% for article in articles %}
<li> <article class="card">
<a href="/blog/{{ article.slug }}">{{ article.title }}</a> <div class="term-head">
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %} <span class="term-head-name">~/blog/{{ article.slug }}.txt</span>
</li> <span class="term-head-meta term-tag">{{ t(key="post", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<h2 class="card-title text-base">
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
</h2>
{% if article.excerpt %}
<p class="term-prose text-sm opacity-80">{{ article.excerpt }}</p>
{% endif %}
<div class="pt-2">
<a href="/blog/{{ article.slug }}" class="btn btn-primary btn-sm">{{ t(key="blog-read", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</article>
{% endfor %} {% endfor %}
</ul> </div>
{% else %} {% else %}
<p>No published posts yet.</p> <div class="term-empty">
<p class="font-medium">{{ t(key="blog-no-posts", lang=lang | default(value='sk')) }}</p>
</div>
{% endif %}
{% else %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="blog-title", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">{{ articles | length }} {{ t(key="blog-sub", lang=lang | default(value='sk')) }}</p>
</div>
</header>
{% if articles | length > 0 %}
<div class="term-stack">
{% for article in articles %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/blog/{{ article.slug }}.txt</span>
<span class="term-head-meta term-tag">{{ t(key="post", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<h2 class="card-title text-base">
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
</h2>
{% if article.excerpt %}
<p class="term-prose text-sm opacity-80">{{ article.excerpt }}</p>
{% endif %}
<div class="pt-2">
<a href="/blog/{{ article.slug }}" class="btn btn-primary btn-sm">{{ t(key="blog-read", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="term-empty">
<p class="font-medium">{{ t(key="blog-no-posts", lang=lang | default(value='sk')) }}</p>
</div>
{% endif %}
{% endif %} {% endif %}
{% endblock content %} {% endblock content %}

View File

@@ -1,12 +1,56 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ article.title }}{% endblock title %} {% block title %}{{ article.title }}{% endblock title %}
{% block crumb %}blog/{{ article.slug }}{% endblock crumb %}
{% block content %} {% block content %}
<article> {% if logged_in_admin %}
<h1>{{ article.title }}</h1> <header class="term-cmd">
<p>Views: {{ article.view_count }}</p> <div>
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %} <h1 class="term-title">{{ article.title }}</h1>
<div>{{ article.content | linebreaksbr | safe }}</div> <p class="term-sub">// {{ article.view_count }} {{ t(key="blog-views", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
<a href="/blog" class="btn btn-outline btn-sm">[ {{ t(key="cd-up", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
<article class="card">
<div class="term-head">
<span class="term-head-name">~/blog/{{ article.slug }}.txt</span>
<span class="term-head-meta term-tag is-blue">{{ t(key="readonly", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
{% if article.excerpt %}
<p class="term-prose t-yellow"># {{ article.excerpt }}</p>
<div class="border-t border-base-300 pt-4"></div>
{% endif %}
<div class="blog-content term-prose">{{ article.content | safe }}</div>
</div>
</article> </article>
{% else %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ article.title }}</h1>
<p class="term-sub">{{ article.view_count }} {{ t(key="blog-views", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
<a href="/blog" class="btn btn-outline btn-sm">[ {{ t(key="cd-up", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
<article class="card">
<div class="term-head">
<span class="term-head-name">~/blog/{{ article.slug }}.txt</span>
<span class="term-head-meta term-tag is-blue">{{ t(key="readonly", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
{% if article.excerpt %}
<p class="term-prose t-yellow"># {{ article.excerpt }}</p>
<div class="border-t border-base-300 pt-4"></div>
{% endif %}
<div class="blog-content term-prose">{{ article.content | safe }}</div>
</div>
</article>
{% endif %}
{% endblock content %} {% endblock content %}

View File

@@ -4,9 +4,9 @@
find this tera template at <code>assets/views/home/hello.html</code>: find this tera template at <code>assets/views/home/hello.html</code>:
<br/> <br/>
<br/> <br/>
{{ t(key="hello-world", lang="en-US") }}, {{ t(key="hello-world", lang="sk") }},
<br/> <br/>
{{ t(key="hello-world", lang="de-DE") }} {{ t(key="hello-world", lang="en") }}
</body></html> </body></html>

View File

@@ -1,23 +1,186 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Home{% endblock title %} {% block title %}{{ t(key="home-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{% endblock crumb %}
{% block content %} {% block content %}
<h1>Home</h1> {% if logged_in_admin %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="home-title", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">// {{ t(key="home-sub", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
<a href="/blog" class="btn btn-outline btn-sm">[ {{ t(key="home-all-posts", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
{% if featured_track or featured_album %}
<section class="mb-8">
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-picks", lang=lang | default(value='sk')) }}</p>
<div class="term-grid">
{% if featured_track %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/audio/tracks/{{ featured_track.slug }}</span>
<span class="term-head-meta term-tag is-green">{{ t(key="song", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<div class="term-track">
<button type="button" class="uw-play btn btn-primary btn-sm"
data-src="/audio/tracks/{{ featured_track.id }}/stream" data-title="{{ featured_track.title }}">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<span class="term-track-name"><span class="t-green"></span> {{ featured_track.title }}</span>
</div>
</div>
</article>
{% endif %}
{% if featured_album %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/audio/{{ featured_album.slug }}/</span>
<span class="term-head-meta term-tag is-purple">{{ t(key="album", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
{% if featured_album.cover_image_id %}
<img src="/images/{{ featured_album.cover_image_id }}" alt="" class="mb-3">
{% endif %}
<h2 class="card-title text-base">{{ featured_album.title }}</h2>
{% if featured_album.artist %}
<p class="text-sm t-aqua">{{ featured_album.artist }}</p>
{% endif %}
{% if featured_album.description %}
<p class="term-prose text-sm opacity-80">{{ featured_album.description }}</p>
{% endif %}
<div class="flex flex-wrap gap-2 pt-2">
<button type="button" class="uw-play-album-remote btn btn-primary btn-sm"
data-album-tracks-url="/audio/albums/{{ featured_album.slug }}/tracks">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<a href="/audio/albums/{{ featured_album.slug }}" class="btn btn-outline btn-sm">{{ t(key="audio-open", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</article>
{% endif %}
</div>
</section>
{% endif %}
<section> <section>
<h2>Latest posts</h2> <p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-recent", lang=lang | default(value='sk')) }} <span class="t-dim">({{ articles | length }})</span></p>
{% if articles | length > 0 %} {% if articles | length > 0 %}
<ul> <div class="term-stack">
{% for article in articles %} {% for article in articles %}
<li> <article class="card">
<a href="/blog/{{ article.slug }}">{{ article.title }}</a> <div class="term-head">
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %} <span class="term-head-name">~/blog/{{ article.slug }}.txt</span>
</li> <span class="term-head-meta term-tag">{{ t(key="post", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<h2 class="card-title text-base">
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
</h2>
{% if article.excerpt %}
<p class="term-prose text-sm opacity-80">{{ article.excerpt }}</p>
{% endif %}
<div class="pt-2">
<a href="/blog/{{ article.slug }}" class="btn btn-primary btn-sm">{{ t(key="blog-read", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</article>
{% endfor %} {% endfor %}
</ul> </div>
{% else %} {% else %}
<p>No published posts yet.</p> <div class="term-empty">
<p class="font-medium">{{ t(key="home-no-posts", lang=lang | default(value='sk')) }}</p>
</div>
{% endif %} {% endif %}
</section> </section>
{% else %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ t(key="home-title", lang=lang | default(value='sk')) }}</h1>
<p class="term-sub">{{ t(key="home-sub", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
<a href="/blog" class="btn btn-outline btn-sm">{{ t(key="home-all-posts", lang=lang | default(value='sk')) }}</a>
</div>
</header>
{% if featured_track or featured_album %}
<section class="mb-8">
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-picks", lang=lang | default(value='sk')) }}</p>
<div class="term-grid">
{% if featured_track %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/audio/tracks/{{ featured_track.slug }}</span>
<span class="term-head-meta term-tag is-green">{{ t(key="song", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<div class="term-track">
<button type="button" class="uw-play btn btn-primary btn-sm"
data-src="/audio/tracks/{{ featured_track.id }}/stream" data-title="{{ featured_track.title }}">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<span class="term-track-name"><span class="t-green"></span> {{ featured_track.title }}</span>
</div>
</div>
</article>
{% endif %}
{% if featured_album %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/audio/{{ featured_album.slug }}/</span>
<span class="term-head-meta term-tag is-purple">{{ t(key="album", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
{% if featured_album.cover_image_id %}
<img src="/images/{{ featured_album.cover_image_id }}" alt="" class="mb-3">
{% endif %}
<h2 class="card-title text-base">{{ featured_album.title }}</h2>
{% if featured_album.artist %}
<p class="text-sm t-aqua">{{ featured_album.artist }}</p>
{% endif %}
{% if featured_album.description %}
<p class="term-prose text-sm opacity-80">{{ featured_album.description }}</p>
{% endif %}
<div class="flex flex-wrap gap-2 pt-2">
<button type="button" class="uw-play-album-remote btn btn-primary btn-sm"
data-album-tracks-url="/audio/albums/{{ featured_album.slug }}/tracks">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<a href="/audio/albums/{{ featured_album.slug }}" class="btn btn-outline btn-sm">{{ t(key="audio-open", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</article>
{% endif %}
</div>
</section>
{% endif %}
<section>
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-recent", lang=lang | default(value='sk')) }} <span class="t-dim">({{ articles | length }})</span></p>
{% if articles | length > 0 %}
<div class="term-stack">
{% for article in articles %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/blog/{{ article.slug }}.txt</span>
<span class="term-head-meta term-tag">{{ t(key="post", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<h2 class="card-title text-base">
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
</h2>
{% if article.excerpt %}
<p class="text-sm opacity-80">{{ article.excerpt }}</p>
{% endif %}
<div class="pt-2">
<a href="/blog/{{ article.slug }}" class="btn btn-primary btn-sm">{{ t(key="blog-read", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="term-empty">
<p class="font-medium">{{ t(key="home-no-posts", lang=lang | default(value='sk')) }}</p>
</div>
{% endif %}
</section>
{% endif %}
{% endblock content %} {% endblock content %}

View File

@@ -1,10 +1,45 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ page.title }}{% endblock title %} {% block title %}{{ page.title }}{% endblock title %}
{% block crumb %}about{% endblock crumb %}
{% block content %} {% block content %}
<article> {% if logged_in_admin %}
<h1>{{ page.title }}</h1> <header class="term-cmd">
<div>{{ page.content | linebreaksbr | safe }}</div> <div>
<h1 class="term-title">{{ page.title }}</h1>
<p class="term-sub">// {{ t(key="about-sub", lang=lang | default(value='sk')) }}</p>
</div>
<div class="term-cmd-actions">
<a href="/admin/about" hx-boost="false" class="btn btn-outline btn-sm">[ {{ t(key="edit", lang=lang | default(value='sk')) }} ]</a>
</div>
</header>
<article class="card">
<div class="term-head">
<span class="term-head-name">~/about.txt</span>
<span class="term-head-meta term-tag is-blue">{{ t(key="readonly", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<div class="term-prose whitespace-pre-line">{{ page.content }}</div>
</div>
</article> </article>
{% else %}
<header class="term-cmd">
<div>
<h1 class="term-title">{{ page.title }}</h1>
<p class="term-sub">{{ t(key="about-sub", lang=lang | default(value='sk')) }}</p>
</div>
</header>
<article class="card">
<div class="term-head">
<span class="term-head-name">~/about.txt</span>
<span class="term-head-meta term-tag is-blue">{{ t(key="readonly", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<div class="term-prose whitespace-pre-line">{{ page.content }}</div>
</div>
</article>
{% endif %}
{% endblock content %} {% endblock content %}

View File

@@ -104,3 +104,4 @@ auth:
settings: settings:
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }} admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}
uploads_root: {{ get_env(name="UPLOADS_ROOT", default="uploads") }}

View File

@@ -101,3 +101,4 @@ auth:
settings: settings:
admin_email: admin@example.com admin_email: admin@example.com
uploads_root: uploads/test

View File

@@ -13,6 +13,7 @@ mod m20260517_000008_audio_track_tags;
mod m20260517_000009_simple_constraints; mod m20260517_000009_simple_constraints;
mod m20260517_000010_drop_user_roles; mod m20260517_000010_drop_user_roles;
mod m20260517_000011_site_pages; mod m20260517_000011_site_pages;
mod m20260517_000012_standalone_audio_tracks;
pub struct Migrator; pub struct Migrator;
@@ -32,6 +33,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260517_000009_simple_constraints::Migration), Box::new(m20260517_000009_simple_constraints::Migration),
Box::new(m20260517_000010_drop_user_roles::Migration), Box::new(m20260517_000010_drop_user_roles::Migration),
Box::new(m20260517_000011_site_pages::Migration), Box::new(m20260517_000011_site_pages::Migration),
Box::new(m20260517_000012_standalone_audio_tracks::Migration),
// inject-above (do not remove this comment) // inject-above (do not remove this comment)
] ]
} }

View File

@@ -33,8 +33,17 @@ impl MigrationTrait for Migration {
Table::create() Table::create()
.table(BlogArticles::Table) .table(BlogArticles::Table)
.if_not_exists() .if_not_exists()
.col(ColumnDef::new(BlogArticles::Id).uuid().not_null().primary_key()) .col(
.col(ColumnDef::new(BlogArticles::Title).string_len(500).not_null()) ColumnDef::new(BlogArticles::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(BlogArticles::Title)
.string_len(500)
.not_null(),
)
.col( .col(
ColumnDef::new(BlogArticles::Slug) ColumnDef::new(BlogArticles::Slug)
.string_len(500) .string_len(500)
@@ -42,7 +51,11 @@ impl MigrationTrait for Migration {
.unique_key(), .unique_key(),
) )
.col(ColumnDef::new(BlogArticles::Content).text().not_null()) .col(ColumnDef::new(BlogArticles::Content).text().not_null())
.col(ColumnDef::new(BlogArticles::Excerpt).string_len(1000).null()) .col(
ColumnDef::new(BlogArticles::Excerpt)
.string_len(1000)
.null(),
)
.col( .col(
ColumnDef::new(BlogArticles::Published) ColumnDef::new(BlogArticles::Published)
.boolean() .boolean()

View File

@@ -30,7 +30,12 @@ impl MigrationTrait for Migration {
Table::create() Table::create()
.table(AuditLogs::Table) .table(AuditLogs::Table)
.if_not_exists() .if_not_exists()
.col(ColumnDef::new(AuditLogs::Id).uuid().not_null().primary_key()) .col(
ColumnDef::new(AuditLogs::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(AuditLogs::AdminUserId).integer().not_null()) .col(ColumnDef::new(AuditLogs::AdminUserId).integer().not_null())
.col(ColumnDef::new(AuditLogs::Action).string_len(100).not_null()) .col(ColumnDef::new(AuditLogs::Action).string_len(100).not_null())
.col(ColumnDef::new(AuditLogs::TargetType).string_len(50).null()) .col(ColumnDef::new(AuditLogs::TargetType).string_len(50).null())

View File

@@ -34,8 +34,17 @@ impl MigrationTrait for Migration {
Table::create() Table::create()
.table(AudioAlbums::Table) .table(AudioAlbums::Table)
.if_not_exists() .if_not_exists()
.col(ColumnDef::new(AudioAlbums::Id).uuid().not_null().primary_key()) .col(
.col(ColumnDef::new(AudioAlbums::Title).string_len(500).not_null()) ColumnDef::new(AudioAlbums::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(AudioAlbums::Title)
.string_len(500)
.not_null(),
)
.col( .col(
ColumnDef::new(AudioAlbums::Slug) ColumnDef::new(AudioAlbums::Slug)
.string_len(500) .string_len(500)

View File

@@ -32,9 +32,18 @@ impl MigrationTrait for Migration {
Table::create() Table::create()
.table(AudioTracks::Table) .table(AudioTracks::Table)
.if_not_exists() .if_not_exists()
.col(ColumnDef::new(AudioTracks::Id).uuid().not_null().primary_key()) .col(
ColumnDef::new(AudioTracks::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(AudioTracks::AlbumId).uuid().not_null()) .col(ColumnDef::new(AudioTracks::AlbumId).uuid().not_null())
.col(ColumnDef::new(AudioTracks::Title).string_len(500).not_null()) .col(
ColumnDef::new(AudioTracks::Title)
.string_len(500)
.not_null(),
)
.col(ColumnDef::new(AudioTracks::Slug).string_len(500).not_null()) .col(ColumnDef::new(AudioTracks::Slug).string_len(500).not_null())
.col( .col(
ColumnDef::new(AudioTracks::AudioFileId) ColumnDef::new(AudioTracks::AudioFileId)

View File

@@ -19,7 +19,12 @@ impl MigrationTrait for Migration {
Table::create() Table::create()
.table(AudioTags::Table) .table(AudioTags::Table)
.if_not_exists() .if_not_exists()
.col(ColumnDef::new(AudioTags::Id).uuid().not_null().primary_key()) .col(
ColumnDef::new(AudioTags::Id)
.uuid()
.not_null()
.primary_key(),
)
.col( .col(
ColumnDef::new(AudioTags::Name) ColumnDef::new(AudioTags::Name)
.string_len(100) .string_len(100)

View File

@@ -21,7 +21,12 @@ impl MigrationTrait for Migration {
Table::create() Table::create()
.table(SitePages::Table) .table(SitePages::Table)
.if_not_exists() .if_not_exists()
.col(ColumnDef::new(SitePages::Id).uuid().not_null().primary_key()) .col(
ColumnDef::new(SitePages::Id)
.uuid()
.not_null()
.primary_key(),
)
.col( .col(
ColumnDef::new(SitePages::Slug) ColumnDef::new(SitePages::Slug)
.string_len(100) .string_len(100)

View File

@@ -0,0 +1,131 @@
use sea_orm_migration::{prelude::*, sea_query::Expr};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum AudioTracks {
Table,
AlbumId,
Published,
PublishedAt,
}
#[derive(DeriveIden)]
enum AudioAlbums {
Table,
Id,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_foreign_key(
ForeignKey::drop()
.name("fk-audio_tracks-album_id-to-audio_albums")
.table(AudioTracks::Table)
.to_owned(),
)
.await?;
m.alter_table(
Table::alter()
.table(AudioTracks::Table)
.modify_column(ColumnDef::new(AudioTracks::AlbumId).uuid().null())
.add_column(
ColumnDef::new(AudioTracks::Published)
.boolean()
.not_null()
.default(false),
)
.add_column(
ColumnDef::new(AudioTracks::PublishedAt)
.timestamp_with_time_zone()
.null(),
)
.to_owned(),
)
.await?;
m.create_foreign_key(
ForeignKey::create()
.name("fk-audio_tracks-album_id-to-audio_albums")
.from(AudioTracks::Table, AudioTracks::AlbumId)
.to(AudioAlbums::Table, AudioAlbums::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
m.get_connection()
.execute_unprepared(
r#"
UPDATE audio_tracks t
SET published = TRUE,
published_at = COALESCE(a.published_at, CURRENT_TIMESTAMP)
FROM audio_albums a
WHERE t.album_id = a.id
AND a.published = TRUE
"#,
)
.await?;
m.create_index(
Index::create()
.name("idx-audio_tracks-published")
.table(AudioTracks::Table)
.col(AudioTracks::Published)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_index(
Index::drop()
.name("idx-audio_tracks-published")
.table(AudioTracks::Table)
.to_owned(),
)
.await?;
m.drop_foreign_key(
ForeignKey::drop()
.name("fk-audio_tracks-album_id-to-audio_albums")
.table(AudioTracks::Table)
.to_owned(),
)
.await?;
m.alter_table(
Table::alter()
.table(AudioTracks::Table)
.drop_column(AudioTracks::PublishedAt)
.drop_column(AudioTracks::Published)
.modify_column(
ColumnDef::new(AudioTracks::AlbumId)
.uuid()
.not_null()
.default(Expr::cust("'00000000-0000-0000-0000-000000000000'::uuid")),
)
.to_owned(),
)
.await?;
m.create_foreign_key(
ForeignKey::create()
.name("fk-audio_tracks-album_id-to-audio_albums")
.from(AudioTracks::Table, AudioTracks::AlbumId)
.to(AudioAlbums::Table, AudioAlbums::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
Ok(())
}
}

View File

@@ -7,11 +7,12 @@ use loco_rs::{
controller::AppRoutes, controller::AppRoutes,
db::{self, truncate_table}, db::{self, truncate_table},
environment::Environment, environment::Environment,
storage::{self, Storage},
task::Tasks, task::Tasks,
Result, Result,
}; };
use migration::Migrator; use migration::Migrator;
use std::path::Path; use std::{path::Path, sync::Arc};
#[allow(unused_imports)] #[allow(unused_imports)]
use crate::{ use crate::{
@@ -43,10 +44,16 @@ impl Hooks for App {
create_app::<Self, Migrator>(mode, environment, config).await create_app::<Self, Migrator>(mode, environment, config).await
} }
async fn load_config(environment: &Environment) -> Result<Config> {
dotenvy::dotenv().ok();
environment.load()
}
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> { async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![Box::new( Ok(vec![
initializers::view_engine::ViewEngineInitializer, Box::new(initializers::view_engine::ViewEngineInitializer),
)]) Box::new(initializers::admin_seeder::AdminSeeder),
])
} }
fn routes(_ctx: &AppContext) -> AppRoutes { fn routes(_ctx: &AppContext) -> AppRoutes {
@@ -54,9 +61,24 @@ impl Hooks for App {
.add_route(controllers::auth::routes()) .add_route(controllers::auth::routes())
.add_route(controllers::admin::routes()) .add_route(controllers::admin::routes())
.add_route(controllers::blog::routes()) .add_route(controllers::blog::routes())
.add_route(controllers::i18n::routes())
.add_route(controllers::media::routes())
.add_route(controllers::pages::routes()) .add_route(controllers::pages::routes())
.add_route(controllers::frontend::routes()) .add_route(controllers::frontend::routes())
} }
async fn after_context(ctx: AppContext) -> Result<AppContext> {
let upload_root = crate::controllers::media::uploads_root(&ctx.config)?;
tokio::fs::create_dir_all(upload_root.join(controllers::media::AUDIO_STORAGE_DIR)).await?;
tokio::fs::create_dir_all(upload_root.join(controllers::media::IMAGE_STORAGE_DIR)).await?;
let driver = storage::drivers::local::new_with_prefix(&upload_root)?;
Ok(AppContext {
storage: Arc::new(Storage::single(driver)),
..ctx
})
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(DownloadWorker::build(ctx)).await?; queue.register(DownloadWorker::build(ctx)).await?;
Ok(()) Ok(())

View File

@@ -14,7 +14,7 @@ use std::sync::OnceLock;
use time::Duration as TimeDuration; use time::Duration as TimeDuration;
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new(); pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
const AUTH_COOKIE: &str = "auth_token"; pub(crate) const AUTH_COOKIE: &str = "auth_token";
fn get_allow_email_domain_re() -> &'static Regex { fn get_allow_email_domain_re() -> &'static Regex {
EMAIL_DOMAIN_RE.get_or_init(|| { EMAIL_DOMAIN_RE.get_or_init(|| {

View File

@@ -1,7 +1,4 @@
use crate::{ use crate::{controllers::admin, models::_entities::blog_articles};
controllers::admin,
models::_entities::blog_articles,
};
use chrono::Utc; use chrono::Utc;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
@@ -195,7 +192,11 @@ async fn admin_update(
} }
#[debug_handler] #[debug_handler]
async fn admin_delete(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> { async fn admin_delete(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?; admin::current_admin(auth, &ctx).await?;
let article = find_article_by_id(&ctx, id).await?; let article = find_article_by_id(&ctx, id).await?;
article.delete(&ctx.db).await?; article.delete(&ctx.db).await?;
@@ -203,7 +204,11 @@ async fn admin_delete(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<A
} }
#[debug_handler] #[debug_handler]
async fn admin_publish(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> { async fn admin_publish(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?; admin::current_admin(auth, &ctx).await?;
let mut article = find_article_by_id(&ctx, id).await?.into_active_model(); let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
article.published = Set(true); article.published = Set(true);
@@ -213,7 +218,11 @@ async fn admin_publish(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<
} }
#[debug_handler] #[debug_handler]
async fn admin_unpublish(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> { async fn admin_unpublish(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?; admin::current_admin(auth, &ctx).await?;
let mut article = find_article_by_id(&ctx, id).await?.into_active_model(); let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
article.published = Set(false); article.published = Set(false);

View File

@@ -1,14 +1,16 @@
use crate::{ use crate::{
controllers::{admin, auth as auth_controller}, controllers::{admin, auth as auth_controller, i18n::current_lang},
models::{ models::{
_entities::{blog_articles, site_pages}, _entities::{audio_albums, audio_tracks, blog_articles, site_pages},
users::{self, LoginParams}, users::{self, LoginParams},
}, },
}; };
use axum_extra::extract::cookie::CookieJar;
use chrono::Utc; use chrono::Utc;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, sea_query::Expr, ActiveModelTrait, ColumnTrait, EntityTrait, Order, QueryFilter, QueryOrder,
QuerySelect, Set,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
@@ -58,7 +60,9 @@ fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOff
} }
fn is_checked(value: &Option<String>) -> bool { fn is_checked(value: &Option<String>) -> bool {
value.as_deref().is_some_and(|value| value == "on" || value == "true") value
.as_deref()
.is_some_and(|value| value == "on" || value == "true")
} }
fn normalize_empty(value: Option<String>) -> Option<String> { fn normalize_empty(value: Option<String>) -> Option<String> {
@@ -87,8 +91,27 @@ async fn article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Mode
.ok_or_else(|| Error::NotFound) .ok_or_else(|| Error::NotFound)
} }
async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool {
let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else {
return false;
};
let Ok(jwt_config) = ctx.config.get_jwt_config() else {
return false;
};
let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
else {
return false;
};
let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
return false;
};
admin::is_admin(ctx, &user)
}
#[debug_handler] #[debug_handler]
async fn home( async fn home(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
@@ -99,19 +122,57 @@ async fn home(
.all(&ctx.db) .all(&ctx.db)
.await?; .await?;
format::view(&v, "home/index.html", json!({ "articles": articles })) // A random published song to suggest on the landing page.
let featured_track = audio_tracks::Entity::find()
.filter(audio_tracks::Column::Published.eq(true))
.order_by(Expr::cust("RANDOM()"), Order::Asc)
.one(&ctx.db)
.await?;
// A random published album, never the one the suggested song belongs to.
let mut album_query =
audio_albums::Entity::find().filter(audio_albums::Column::Published.eq(true));
if let Some(album_id) = featured_track.as_ref().and_then(|track| track.album_id) {
album_query = album_query.filter(audio_albums::Column::Id.ne(album_id));
}
let featured_album = album_query
.order_by(Expr::cust("RANDOM()"), Order::Asc)
.one(&ctx.db)
.await?;
format::view(
&v,
"home/index.html",
json!({
"articles": articles,
"featured_track": featured_track,
"featured_album": featured_album,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
} }
#[debug_handler] #[debug_handler]
async fn about( async fn about(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
format::view(&v, "pages/about.html", json!({ "page": about_page(&ctx).await? })) format::view(
&v,
"pages/about.html",
json!({
"page": about_page(&ctx).await?,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
} }
#[debug_handler] #[debug_handler]
async fn blog_index( async fn blog_index(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
@@ -121,11 +182,20 @@ async fn blog_index(
.all(&ctx.db) .all(&ctx.db)
.await?; .await?;
format::view(&v, "blog/index.html", json!({ "articles": articles })) format::view(
&v,
"blog/index.html",
json!({
"articles": articles,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
} }
#[debug_handler] #[debug_handler]
async fn blog_show( async fn blog_show(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
Path(slug): Path<String>, Path(slug): Path<String>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
@@ -142,26 +212,67 @@ async fn blog_show(
active.view_count = Set(next_count); active.view_count = Set(next_count);
let article = active.update(&ctx.db).await?; let article = active.update(&ctx.db).await?;
format::view(&v, "blog/show.html", json!({ "article": article })) format::view(
&v,
"blog/show.html",
json!({
"article": article,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
} }
#[debug_handler] #[debug_handler]
async fn admin_login_page(ViewEngine(v): ViewEngine<TeraView>) -> Result<Response> { async fn admin_login_page(
format::view(&v, "admin/login.html", json!({ "error": null })) jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if logged_in_admin(&ctx, &jar).await {
return format::redirect("/admin/dashboard");
}
format::view(
&v,
"admin/login.html",
json!({
"error": null,
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
)
} }
#[debug_handler] #[debug_handler]
async fn admin_login( async fn admin_login(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
Form(params): Form<LoginParams>, Form(params): Form<LoginParams>,
) -> Result<Response> { ) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else { let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" })); return format::view(
&v,
"admin/login.html",
json!({
"error": "Invalid credentials",
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
);
}; };
if !user.verify_password(&params.password) || !admin::is_admin(&ctx, &user) { if !user.verify_password(&params.password) || !admin::is_admin(&ctx, &user) {
return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" })); return format::view(
&v,
"admin/login.html",
json!({
"error": "Invalid credentials",
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
);
} }
let jwt_secret = ctx.config.get_jwt_config()?; let jwt_secret = ctx.config.get_jwt_config()?;
@@ -184,21 +295,31 @@ async fn admin_logout() -> Result<Response> {
#[debug_handler] #[debug_handler]
async fn admin_home( async fn admin_home(
auth: auth::JWT, auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?; let admin_user = admin::current_admin(auth, &ctx).await?;
format::view(&v, "admin/index.html", json!({ "admin": admin_user })) format::view(
&v,
"admin/index.html",
json!({ "admin": admin_user, "lang": current_lang(&jar) }),
)
} }
#[debug_handler] #[debug_handler]
async fn admin_about( async fn admin_about(
auth: auth::JWT, auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
admin::current_admin(auth, &ctx).await?; admin::current_admin(auth, &ctx).await?;
format::view(&v, "admin/about.html", json!({ "page": about_page(&ctx).await? })) format::view(
&v,
"admin/about.html",
json!({ "page": about_page(&ctx).await?, "lang": current_lang(&jar) }),
)
} }
#[debug_handler] #[debug_handler]
@@ -218,6 +339,7 @@ async fn admin_about_update(
#[debug_handler] #[debug_handler]
async fn admin_articles( async fn admin_articles(
auth: auth::JWT, auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
@@ -226,17 +348,26 @@ async fn admin_articles(
.order_by_desc(blog_articles::Column::CreatedAt) .order_by_desc(blog_articles::Column::CreatedAt)
.all(&ctx.db) .all(&ctx.db)
.await?; .await?;
format::view(&v, "admin/blog/index.html", json!({ "articles": articles })) format::view(
&v,
"admin/blog/index.html",
json!({ "articles": articles, "lang": current_lang(&jar) }),
)
} }
#[debug_handler] #[debug_handler]
async fn admin_article_new( async fn admin_article_new(
auth: auth::JWT, auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
admin::current_admin(auth, &ctx).await?; admin::current_admin(auth, &ctx).await?;
format::view(&v, "admin/blog/new.html", json!({})) format::view(
&v,
"admin/blog/new.html",
json!({ "lang": current_lang(&jar) }),
)
} }
#[debug_handler] #[debug_handler]
@@ -270,6 +401,7 @@ async fn admin_article_create(
#[debug_handler] #[debug_handler]
async fn admin_article_edit( async fn admin_article_edit(
auth: auth::JWT, auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
@@ -278,7 +410,7 @@ async fn admin_article_edit(
format::view( format::view(
&v, &v,
"admin/blog/edit.html", "admin/blog/edit.html",
json!({ "article": article_by_id(&ctx, id).await? }), json!({ "article": article_by_id(&ctx, id).await?, "lang": current_lang(&jar) }),
) )
} }
@@ -340,5 +472,8 @@ pub fn routes() -> Routes {
.add("/admin/blog/articles", post(admin_article_create)) .add("/admin/blog/articles", post(admin_article_create))
.add("/admin/blog/articles/{id}/edit", get(admin_article_edit)) .add("/admin/blog/articles/{id}/edit", get(admin_article_edit))
.add("/admin/blog/articles/{id}", post(admin_article_update)) .add("/admin/blog/articles/{id}", post(admin_article_update))
.add("/admin/blog/articles/{id}/delete", post(admin_article_delete)) .add(
"/admin/blog/articles/{id}/delete",
post(admin_article_delete),
)
} }

63
src/controllers/i18n.rs Normal file
View File

@@ -0,0 +1,63 @@
use axum::{
http::{header, HeaderMap},
response::Redirect,
};
use loco_rs::prelude::*;
use serde::Deserialize;
pub const LANG_COOKIE: &str = "lang";
#[derive(Debug, Deserialize)]
pub struct LangForm {
pub lang: String,
}
pub fn current_lang(jar: &axum_extra::extract::cookie::CookieJar) -> String {
match jar
.get(LANG_COOKIE)
.map(|cookie| cookie.value().to_string())
{
Some(ref lang) if lang == "en" => "en".to_string(),
_ => "sk".to_string(),
}
}
#[debug_handler]
async fn set_lang(headers: HeaderMap, Form(form): Form<LangForm>) -> Result<Response> {
let lang = if form.lang == "en" { "en" } else { "sk" };
let cookie = format!("{LANG_COOKIE}={lang}; Path=/; Max-Age=31536000; SameSite=Lax");
Ok((
[(header::SET_COOKIE, cookie)],
Redirect::to(&back_path(&headers)),
)
.into_response())
}
fn back_path(headers: &HeaderMap) -> String {
let raw = headers
.get(header::REFERER)
.and_then(|value| value.to_str().ok())
.unwrap_or("/");
if raw.starts_with('/') {
return raw.to_string();
}
if let Some(after_scheme) = raw.split_once("://").map(|(_, rest)| rest) {
if let Some(path_start) = after_scheme.find('/') {
let path = &after_scheme[path_start..];
return if path.starts_with('/') {
path.to_string()
} else {
"/".to_string()
};
}
}
"/".to_string()
}
pub fn routes() -> Routes {
Routes::new().add("/lang", post(set_lang))
}

1141
src/controllers/media.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,4 +2,6 @@ pub mod admin;
pub mod auth; pub mod auth;
pub mod blog; pub mod blog;
pub mod frontend; pub mod frontend;
pub mod i18n;
pub mod media;
pub mod pages; pub mod pages;

View File

@@ -1,7 +1,4 @@
use crate::{ use crate::{controllers::admin, models::_entities::site_pages};
controllers::admin,
models::_entities::site_pages,
};
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@@ -0,0 +1,36 @@
use async_trait::async_trait;
use loco_rs::prelude::*;
use crate::models::users::{self, RegisterParams};
pub struct AdminSeeder;
#[async_trait]
impl Initializer for AdminSeeder {
fn name(&self) -> String {
"admin-seeder".to_string()
}
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
let email = std::env::var("ADMIN_EMAIL").unwrap_or_default();
let password = std::env::var("ADMIN_PASSWORD").unwrap_or_default();
let name = std::env::var("ADMIN_NAME").unwrap_or_else(|_| "Admin".to_string());
if email.is_empty() || password.is_empty() {
tracing::warn!("ADMIN_EMAIL / ADMIN_PASSWORD not set in .env; admin not seeded");
} else if users::Model::find_by_email(&ctx.db, &email).await.is_err() {
users::Model::create_with_password(
&ctx.db,
&RegisterParams {
email: email.clone(),
password,
name,
},
)
.await?;
tracing::info!(admin = %email, "admin user seeded");
}
Ok(())
}
}

View File

@@ -1 +1,2 @@
pub mod admin_seeder;
pub mod view_engine; pub mod view_engine;

View File

@@ -25,7 +25,7 @@ impl Initializer for ViewEngineInitializer {
async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> { async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
let tera_engine = if std::path::Path::new(I18N_DIR).exists() { let tera_engine = if std::path::Path::new(I18N_DIR).exists() {
let arc = std::sync::Arc::new( let arc = std::sync::Arc::new(
ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US")) ArcLoader::builder(&I18N_DIR, unic_langid::langid!("sk"))
.shared_resources(Some(&[I18N_SHARED.into()])) .shared_resources(Some(&[I18N_SHARED.into()]))
.customize(|bundle| bundle.set_use_isolating(false)) .customize(|bundle| bundle.set_use_isolating(false))
.build() .build()

View File

@@ -8,16 +8,18 @@ use serde::{Deserialize, Serialize};
pub struct Model { pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, pub id: Uuid,
pub album_id: Uuid, pub album_id: Option<Uuid>,
pub title: String, pub title: String,
pub slug: String, pub slug: String,
pub audio_file_id: String, pub audio_file_id: String,
pub track_number: Option<i32>, pub track_number: Option<i32>,
pub duration: Option<i32>, pub duration: Option<i32>,
pub featured: bool, pub featured: bool,
pub published: bool,
pub play_count: i32, pub play_count: i32,
pub created_at: DateTimeWithTimeZone, pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone,
pub published_at: Option<DateTimeWithTimeZone>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -27,7 +29,7 @@ pub enum Relation {
from = "Column::AlbumId", from = "Column::AlbumId",
to = "super::audio_albums::Column::Id", to = "super::audio_albums::Column::Id",
on_update = "Cascade", on_update = "Cascade",
on_delete = "Cascade" on_delete = "SetNull"
)] )]
AudioAlbums, AudioAlbums,
#[sea_orm(has_many = "super::audio_track_tags::Entity")] #[sea_orm(has_many = "super::audio_track_tags::Entity")]

View File

@@ -1,5 +1,5 @@
pub use super::_entities::audio_albums::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
pub use super::_entities::audio_albums::{ActiveModel, Model, Entity};
pub type AudioAlbums = Entity; pub type AudioAlbums = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
pub use super::_entities::audio_tags::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
pub use super::_entities::audio_tags::{ActiveModel, Model, Entity};
pub type AudioTags = Entity; pub type AudioTags = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
pub use super::_entities::audio_track_tags::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
pub use super::_entities::audio_track_tags::{ActiveModel, Model, Entity};
pub type AudioTrackTags = Entity; pub type AudioTrackTags = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
pub use super::_entities::audio_tracks::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
pub use super::_entities::audio_tracks::{ActiveModel, Model, Entity};
pub type AudioTracks = Entity; pub type AudioTracks = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
pub use super::_entities::audit_logs::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
pub use super::_entities::audit_logs::{ActiveModel, Model, Entity};
pub type AuditLogs = Entity; pub type AuditLogs = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
pub use super::_entities::blog_articles::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
pub use super::_entities::blog_articles::{ActiveModel, Model, Entity};
pub type BlogArticles = Entity; pub type BlogArticles = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]

View File

@@ -1,9 +1,9 @@
pub mod _entities; pub mod _entities;
pub mod users; pub mod audio_albums;
pub mod audio_tags; pub mod audio_tags;
pub mod audio_tracks;
pub mod audio_track_tags; pub mod audio_track_tags;
pub mod audio_tracks;
pub mod audit_logs; pub mod audit_logs;
pub mod blog_articles; pub mod blog_articles;
pub mod audio_albums;
pub mod site_pages; pub mod site_pages;
pub mod users;

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::site_pages::{ActiveModel, Entity, Model}; pub use super::_entities::site_pages::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type SitePages = Entity; pub type SitePages = Entity;
#[async_trait::async_trait] #[async_trait::async_trait]