Compare commits
6 Commits
eaccfada73
...
ec5a3a3d73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec5a3a3d73 | ||
|
|
c1ecfa459d | ||
|
|
d164edf87c | ||
|
|
1d51a23bfb | ||
|
|
0a36e8839c | ||
|
|
046f7c04c8 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ target/
|
|||||||
*.sqlite-*
|
*.sqlite-*
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
|
uploads/
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ The old app ships these feature modules. Each must exist in the rewrite:
|
|||||||
- [ ] **Admin** — dashboard, user management, role assignment, audit log
|
- [ ] **Admin** — dashboard, user management, role assignment, audit log
|
||||||
- [ ] **Blog** — articles CRUD, publish workflow, public listing, view counts
|
- [ ] **Blog** — articles CRUD, publish workflow, public listing, view counts
|
||||||
- [ ] **Audio dashboard** — albums + tracks + tags CRUD, publish workflow
|
- [ ] **Audio dashboard** — albums + tracks + tags CRUD, publish workflow
|
||||||
- [ ] **Audio streaming** — range-aware track/file streaming, raw upload
|
- [x] **Audio streaming** — range-aware track/file streaming, raw upload
|
||||||
- [ ] **Audio player** — persistent bottom-bar player (frontend)
|
- [ ] **Audio player** — persistent bottom-bar player (frontend)
|
||||||
- [ ] **Images** — upload + serve, used as cover/featured images
|
- [x] **Images** — upload + serve, used as cover/featured images
|
||||||
- [ ] **Theme** — per-user light/dark preference
|
- [ ] **Theme** — per-user light/dark preference
|
||||||
- [ ] **Storage** — pluggable backend (fs default, S3/Azure/GCS capable)
|
- [x] **Storage** — pluggable backend (fs default, S3/Azure/GCS capable)
|
||||||
- [ ] **Home + layout** — landing page, dynamic navbar, footer
|
- [ ] **Home + layout** — landing page, dynamic navbar, footer
|
||||||
- [ ] **Swagger/OpenAPI** — API docs (optional, lower priority)
|
- [ ] **Swagger/OpenAPI** — API docs (optional, lower priority)
|
||||||
|
|
||||||
@@ -366,11 +366,11 @@ Already generated in this directory — reuse, don't rebuild:
|
|||||||
2. **Auth + sessions** — settle §3.1, get register/login/logout/me working,
|
2. **Auth + sessions** — settle §3.1, get register/login/logout/me working,
|
||||||
including admin-bootstrap.
|
including admin-bootstrap.
|
||||||
3. **RBAC** — roles/permissions, the permission-loading middleware, guard helpers.
|
3. **RBAC** — roles/permissions, the permission-loading middleware, guard helpers.
|
||||||
4. **Storage + images** — storage backend, image upload/serve (unblocks blog/audio
|
4. **Storage + images** — DONE: storage backend, image upload/serve (unblocks blog/audio
|
||||||
cover images).
|
cover images).
|
||||||
5. **Blog** — CRUD + publish + public pages.
|
5. **Blog** — CRUD + publish + public pages.
|
||||||
6. **Audio dashboard** — albums, tracks (multipart upload), tags.
|
6. **Audio dashboard** — albums, tracks (multipart upload), tags.
|
||||||
7. **Audio streaming + player** — range-aware endpoints, then the player in the GUI.
|
7. **Audio streaming + player** — DONE for range-aware endpoints; player remains GUI work.
|
||||||
8. **Admin** — dashboard, user management, role UI, audit log.
|
8. **Admin** — dashboard, user management, role UI, audit log.
|
||||||
9. **Theme**, **home/layout/navbar**.
|
9. **Theme**, **home/layout/navbar**.
|
||||||
10. **Swagger/OpenAPI** (optional), tests, polish.
|
10. **Swagger/OpenAPI** (optional), tests, polish.
|
||||||
|
|||||||
@@ -3,17 +3,34 @@
|
|||||||
{% block title %}Edit About{% endblock title %}
|
{% block title %}Edit About{% 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">Edit About</h1>
|
||||||
|
<p class="text-sm opacity-70">Update the public about page.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/about" class="btn btn-ghost btn-sm">View page</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">Title</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">Content</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">Save</button>
|
||||||
|
<a href="/admin/dashboard" class="btn btn-ghost btn-sm">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
66
assets/views/admin/audio/albums.html
Normal file
66
assets/views/admin/audio/albums.html
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Audio Albums{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Audio Albums</h1>
|
||||||
|
<p class="text-sm opacity-70">Create albums and upload audio tracks.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/admin/audio/tracks" class="btn btn-ghost btn-sm">Songs</a>
|
||||||
|
<a href="/admin/audio/albums/create" class="btn btn-neutral btn-sm">New album</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if albums | length > 0 %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Tracks</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in albums %}
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{{ row.album.title }}</td>
|
||||||
|
<td>
|
||||||
|
{% if row.album.published %}
|
||||||
|
<span class="badge">Published</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge opacity-70">Draft</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ row.track_count }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/admin/audio/albums/{{ row.album.id }}/tracks" class="btn btn-ghost btn-sm">Tracks</a>
|
||||||
|
<a href="/audio/albums/{{ row.album.slug }}" class="btn btn-ghost btn-sm">View</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="font-medium">No albums yet.</p>
|
||||||
|
<p class="text-sm opacity-70">Create an album before uploading tracks.</p>
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/admin/audio/albums/create" class="btn btn-neutral btn-sm">New album</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
77
assets/views/admin/audio/new_album.html
Normal file
77
assets/views/admin/audio/new_album.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}New Audio Album{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">New Audio Album</h1>
|
||||||
|
<p class="text-sm opacity-70">Create a container for uploaded tracks.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/audio/albums" class="btn btn-ghost btn-sm">Back to albums</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/audio/albums/create" class="space-y-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Title</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">Artist</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">Release date</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">Cover image id</span></label>
|
||||||
|
<input type="text" name="cover_image_id" class="input input-bordered w-full">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Description</span></label>
|
||||||
|
<textarea name="description" rows="6" class="textarea textarea-bordered w-full"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Songs</span></label>
|
||||||
|
{% if available_tracks | length > 0 %}
|
||||||
|
<div class="divide-y divide-base-300 rounded border border-base-300">
|
||||||
|
{% for song in available_tracks %}
|
||||||
|
<label class="flex cursor-pointer items-center gap-3 p-2 hover:bg-base-200">
|
||||||
|
<input type="checkbox" name="track_ids" value="{{ song.id }}" class="checkbox checkbox-sm">
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate font-medium">{{ song.title }}</span>
|
||||||
|
<span class="block truncate text-xs opacity-70">{{ song.audio_file_id }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded border border-base-300 p-2 text-sm opacity-70">
|
||||||
|
No ungrouped songs available.
|
||||||
|
</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">Published</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 pt-2">
|
||||||
|
<button type="submit" class="btn btn-neutral btn-sm">Create</button>
|
||||||
|
<a href="/admin/audio/albums" class="btn btn-ghost btn-sm">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
85
assets/views/admin/audio/songs.html
Normal file
85
assets/views/admin/audio/songs.html
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Songs{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Songs</h1>
|
||||||
|
<p class="text-sm opacity-70">Publish songs directly; albums only group them together.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/admin/audio/tracks/upload" class="btn btn-neutral btn-sm">Upload song</a>
|
||||||
|
<a href="/admin/audio/albums" class="btn btn-ghost btn-sm">Albums</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if tracks | length > 0 %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Song</th>
|
||||||
|
<th>Group</th>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for track in tracks %}
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{{ track.title }}</td>
|
||||||
|
<td>
|
||||||
|
{% if track.album_id %}
|
||||||
|
<span class="badge">Album</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge opacity-70">Ungrouped</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">{{ track.audio_file_id }}</td>
|
||||||
|
<td>
|
||||||
|
{% if track.published %}
|
||||||
|
<span class="badge">Published</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge opacity-70">Draft</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/audio/tracks/{{ track.id }}/stream" class="btn btn-ghost btn-sm">Play</a>
|
||||||
|
{% if track.published %}
|
||||||
|
<form method="post" action="/admin/audio/tracks/{{ track.id }}/unpublish">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Unpublish</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/admin/audio/tracks/{{ track.id }}/publish">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Publish</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/admin/audio/tracks/{{ track.id }}/delete">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="font-medium">No songs yet.</p>
|
||||||
|
<p class="text-sm opacity-70">Upload a song, then group it into an album when needed.</p>
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/admin/audio/tracks/upload" class="btn btn-neutral btn-sm">Upload song</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
104
assets/views/admin/audio/tracks.html
Normal file
104
assets/views/admin/audio/tracks.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ album.title }} Tracks{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">{{ album.title }}</h1>
|
||||||
|
<p class="text-sm opacity-70">Uploaded tracks for this album.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/admin/audio/albums/{{ album.id }}/tracks/upload" class="btn btn-neutral btn-sm">Upload track</a>
|
||||||
|
<a href="/admin/audio/albums" class="btn btn-ghost btn-sm">Back to albums</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if available_tracks | length > 0 %}
|
||||||
|
<form method="post" action="/admin/audio/albums/{{ album.id }}/tracks/add" class="mb-4 flex flex-wrap items-end gap-2">
|
||||||
|
<div class="form-control flex-1">
|
||||||
|
<label class="label"><span class="label-text">Add existing song</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>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-neutral btn-sm">Add to album</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tracks | length > 0 %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Featured</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for track in tracks %}
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">
|
||||||
|
{% if track.track_number %}{{ track.track_number }}. {% endif %}{{ track.title }}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">{{ track.audio_file_id }}</td>
|
||||||
|
<td>
|
||||||
|
{% if track.published %}
|
||||||
|
<span class="badge">Published</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge opacity-70">Draft</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if track.featured %}
|
||||||
|
<span class="badge">Yes</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge opacity-70">No</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/audio/tracks/{{ track.id }}/stream" class="btn btn-ghost btn-sm">Play</a>
|
||||||
|
{% if track.published %}
|
||||||
|
<form method="post" action="/admin/audio/tracks/{{ track.id }}/unpublish">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Unpublish</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/admin/audio/tracks/{{ track.id }}/publish">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Publish</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/admin/audio/tracks/{{ track.id }}/remove-from-album">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Remove</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/audio/tracks/{{ track.id }}/delete">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="font-medium">No tracks yet.</p>
|
||||||
|
<p class="text-sm opacity-70">Upload the first audio file for this album.</p>
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/admin/audio/albums/{{ album.id }}/tracks/upload" class="btn btn-neutral btn-sm">Upload track</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
67
assets/views/admin/audio/upload_track.html
Normal file
67
assets/views/admin/audio/upload_track.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Upload Track{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Upload Track</h1>
|
||||||
|
{% if album %}
|
||||||
|
<p class="text-sm opacity-70">{{ album.title }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm opacity-70">Ungrouped song</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if album %}
|
||||||
|
<a href="/admin/audio/albums/{{ album.id }}/tracks" class="btn btn-ghost btn-sm">Back to tracks</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/admin/audio/tracks" class="btn btn-ghost btn-sm">Back to songs</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<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">Audio file</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">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Title</span></label>
|
||||||
|
<input type="text" name="title" class="input input-bordered w-full">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Track number</span></label>
|
||||||
|
<input type="number" name="track_number" min="1" class="input input-bordered w-full">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="label cursor-pointer justify-start gap-2">
|
||||||
|
<input type="checkbox" name="featured" class="checkbox checkbox-sm">
|
||||||
|
<span class="label-text">Featured</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="label cursor-pointer justify-start gap-2">
|
||||||
|
<input type="checkbox" name="published" class="checkbox checkbox-sm">
|
||||||
|
<span class="label-text">Published</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 pt-2">
|
||||||
|
<button type="submit" class="btn btn-neutral btn-sm">Upload</button>
|
||||||
|
{% if album %}
|
||||||
|
<a href="/admin/audio/albums/{{ album.id }}/tracks" class="btn btn-ghost btn-sm">Cancel</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/admin/audio/tracks" class="btn btn-ghost btn-sm">Cancel</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -35,6 +35,9 @@
|
|||||||
<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; }
|
.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 {
|
||||||
@@ -59,15 +62,19 @@
|
|||||||
<header class="navbar bg-base-100 shadow-sm">
|
<header class="navbar bg-base-100 shadow-sm">
|
||||||
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4">
|
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4">
|
||||||
<a href="/admin/dashboard" class="min-w-0 truncate text-lg font-bold">Admin</a>
|
<a href="/admin/dashboard" class="min-w-0 truncate text-lg font-bold">Admin</a>
|
||||||
<div class="hidden items-center gap-1 md:flex">
|
<ul class="nav-menu menu menu-sm hidden items-center gap-1 md:flex">
|
||||||
<a href="/admin/dashboard" class="btn btn-ghost btn-sm">Dashboard</a>
|
<li><a href="/admin/dashboard">Dashboard</a></li>
|
||||||
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">Blog</a>
|
<li><a href="/admin/blog/articles">Blog</a></li>
|
||||||
<a href="/admin/about" class="btn btn-ghost btn-sm">About</a>
|
<li><a href="/admin/audio/albums">Audio</a></li>
|
||||||
<a href="/" class="btn btn-ghost btn-sm">View site</a>
|
<li><a href="/admin/images">Images</a></li>
|
||||||
<form method="post" action="/admin/logout">
|
<li><a href="/admin/about">About</a></li>
|
||||||
<button type="submit" class="btn btn-ghost btn-sm">Logout</button>
|
<li><a href="/">View site</a></li>
|
||||||
</form>
|
<li>
|
||||||
</div>
|
<form method="post" action="/admin/logout">
|
||||||
|
<button type="submit" class="w-full">Logout</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<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="Menu">
|
||||||
@@ -76,16 +83,20 @@
|
|||||||
<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 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
|
||||||
<a href="/admin/dashboard" class="btn btn-ghost btn-sm justify-start">Dashboard</a>
|
<li><a href="/admin/dashboard">Dashboard</a></li>
|
||||||
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm justify-start">Blog</a>
|
<li><a href="/admin/blog/articles">Blog</a></li>
|
||||||
<a href="/admin/about" class="btn btn-ghost btn-sm justify-start">About</a>
|
<li><a href="/admin/audio/albums">Audio</a></li>
|
||||||
<a href="/" class="btn btn-ghost btn-sm justify-start">View site</a>
|
<li><a href="/admin/images">Images</a></li>
|
||||||
<form method="post" action="/admin/logout">
|
<li><a href="/admin/about">About</a></li>
|
||||||
<button type="submit" class="btn btn-ghost btn-sm w-full justify-start">Logout</button>
|
<li><a href="/">View site</a></li>
|
||||||
</form>
|
<li>
|
||||||
</div>
|
<form method="post" action="/admin/logout">
|
||||||
|
<button type="submit" class="w-full">Logout</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="Settings" title="Settings">
|
||||||
|
|||||||
@@ -3,34 +3,61 @@
|
|||||||
{% block title %}Blog Articles{% endblock title %}
|
{% block title %}Blog Articles{% 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">Blog Articles</h1>
|
||||||
|
<p class="text-sm opacity-70">Create, edit, and remove blog posts.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/blog/articles/new" class="btn btn-neutral btn-sm">New article</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>Title</th>
|
||||||
</thead>
|
<th>Status</th>
|
||||||
<tbody>
|
<th class="text-right">Actions</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">Published</span>
|
||||||
</td>
|
{% else %}
|
||||||
</tr>
|
<span class="badge opacity-70">Draft</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">Edit</a>
|
||||||
{% endif %}
|
<form method="post" action="/admin/blog/articles/{{ article.id }}/delete">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="font-medium">No articles yet.</p>
|
||||||
|
<p class="text-sm opacity-70">Create the first blog post.</p>
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/admin/blog/articles/new" class="btn btn-neutral btn-sm">New article</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -3,29 +3,49 @@
|
|||||||
{% block title %}New Article{% endblock title %}
|
{% block title %}New Article{% endblock title %}
|
||||||
|
|
||||||
{% 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">New Article</h1>
|
||||||
|
<p class="text-sm opacity-70">Create a blog post for the public site.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">Back to articles</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">Title</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">Excerpt</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">Content</span></label>
|
||||||
<input type="text" name="featured_image_id">
|
<textarea name="content" rows="18" required class="textarea textarea-bordered w-full"></textarea>
|
||||||
</label>
|
</div>
|
||||||
<label>
|
|
||||||
<input type="checkbox" name="published">
|
<div class="form-control">
|
||||||
Published
|
<label class="label"><span class="label-text">Featured image id</span></label>
|
||||||
</label>
|
<input type="text" name="featured_image_id" class="input input-bordered w-full">
|
||||||
<button type="submit">Create</button>
|
</div>
|
||||||
</form>
|
|
||||||
|
<label class="label cursor-pointer justify-start gap-2">
|
||||||
|
<input type="checkbox" name="published" class="checkbox checkbox-sm">
|
||||||
|
<span class="label-text">Published</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 pt-2">
|
||||||
|
<button type="submit" class="btn btn-neutral btn-sm">Create</button>
|
||||||
|
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
38
assets/views/admin/images/index.html
Normal file
38
assets/views/admin/images/index.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Images{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Images</h1>
|
||||||
|
<p class="text-sm opacity-70">Upload images for blog posts and audio covers.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/dashboard" class="btn btn-ghost btn-sm">Back to dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if uploaded %}
|
||||||
|
<div class="alert mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Uploaded image id: {{ uploaded }}</p>
|
||||||
|
<p class="text-sm opacity-70">URL: {{ uploaded_url }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/admin/images/upload" enctype="multipart/form-data" class="space-y-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Image file</span></label>
|
||||||
|
<input type="file" name="file" accept="image/jpeg,image/png,image/webp,image/gif" required class="file-input file-input-bordered w-full">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2 pt-2">
|
||||||
|
<button type="submit" class="btn btn-neutral btn-sm">Upload</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -3,11 +3,67 @@
|
|||||||
{% block title %}Admin{% endblock title %}
|
{% block title %}Admin{% endblock title %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Admin</h1>
|
<div class="space-y-2">
|
||||||
<p>Logged in as {{ admin.email }}</p>
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||||
|
<p class="text-sm opacity-70">Logged in as {{ admin.email }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn btn-ghost btn-sm">View site</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul>
|
<div class="grid grid-cols-2 gap-4 pt-4">
|
||||||
<li><a href="/admin/blog/articles">Manage blog articles</a></li>
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
<li><a href="/admin/about">Edit about page</a></li>
|
<div class="card-body">
|
||||||
</ul>
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h2 class="card-title text-base">Blog</h2>
|
||||||
|
<span class="badge">Content</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm opacity-70">Create and update blog articles.</p>
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/admin/blog/articles" class="btn btn-neutral btn-sm">Manage blog</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h2 class="card-title text-base">About page</h2>
|
||||||
|
<span class="badge">Page</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm opacity-70">Edit the public about page content.</p>
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/admin/about" class="btn btn-neutral btn-sm">Edit about</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h2 class="card-title text-base">Audio</h2>
|
||||||
|
<span class="badge">Media</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm opacity-70">Create albums and upload tracks.</p>
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/admin/audio/albums" class="btn btn-neutral btn-sm">Manage audio</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h2 class="card-title text-base">Images</h2>
|
||||||
|
<span class="badge">Uploads</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm opacity-70">Upload images for covers and articles.</p>
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/admin/images" class="btn btn-neutral btn-sm">Upload image</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
48
assets/views/audio/album.html
Normal file
48
assets/views/audio/album.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ album.title }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">{{ album.title }}</h1>
|
||||||
|
{% if album.artist %}
|
||||||
|
<p class="text-sm opacity-70">{{ album.artist }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="/audio/albums" class="btn btn-ghost btn-sm">Back to albums</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if album.cover_image_id %}
|
||||||
|
<img src="/images/{{ album.cover_image_id }}" alt="" class="mb-4 rounded">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if album.description %}
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="whitespace-pre-line">{{ album.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if tracks | length > 0 %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for track in tracks %}
|
||||||
|
<div class="border-t border-base-300 pt-2">
|
||||||
|
<p class="font-medium">{% if track.track_number %}{{ track.track_number }}. {% endif %}{{ track.title }}</p>
|
||||||
|
<audio controls preload="metadata" class="mt-2 w-full">
|
||||||
|
<source src="/audio/tracks/{{ track.id }}/stream">
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center font-medium">No tracks yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
42
assets/views/audio/albums.html
Normal file
42
assets/views/audio/albums.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Audio{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Audio</h1>
|
||||||
|
<p class="text-sm opacity-70">Published albums.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if albums | length > 0 %}
|
||||||
|
<div class="grid grid-cols-2 gap-4 pt-4">
|
||||||
|
{% for album in albums %}
|
||||||
|
<article class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if album.cover_image_id %}
|
||||||
|
<img src="/images/{{ album.cover_image_id }}" alt="" class="mb-3 rounded">
|
||||||
|
{% endif %}
|
||||||
|
<h2 class="card-title text-base">{{ album.title }}</h2>
|
||||||
|
{% if album.artist %}
|
||||||
|
<p class="text-sm opacity-70">{{ album.artist }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if album.description %}
|
||||||
|
<p class="text-sm opacity-80">{{ album.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/audio/albums/{{ album.slug }}" class="btn btn-neutral btn-sm">Open album</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<p class="font-medium">No published albums yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
34
assets/views/audio/tracks.html
Normal file
34
assets/views/audio/tracks.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Songs{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Songs</h1>
|
||||||
|
<p class="text-sm opacity-70">Published songs from every album and ungrouped uploads.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/audio/albums" class="btn btn-ghost btn-sm">Albums</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if tracks | length > 0 %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for track in tracks %}
|
||||||
|
<div class="border-t border-base-300 pt-2">
|
||||||
|
<p class="font-medium">{{ track.title }}</p>
|
||||||
|
<audio controls preload="metadata" class="mt-2 w-full">
|
||||||
|
<source src="/audio/tracks/{{ track.id }}/stream">
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center font-medium">No published songs yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -35,6 +35,9 @@
|
|||||||
<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; }
|
.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 {
|
||||||
@@ -59,12 +62,23 @@
|
|||||||
<header class="navbar bg-base-100 shadow-sm">
|
<header class="navbar bg-base-100 shadow-sm">
|
||||||
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4">
|
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4">
|
||||||
<a href="/" class="min-w-0 truncate text-lg font-bold">Universal Web</a>
|
<a href="/" class="min-w-0 truncate text-lg font-bold">Universal Web</a>
|
||||||
<div class="hidden items-center gap-1 md:flex">
|
<ul class="nav-menu menu menu-sm hidden items-center gap-1 md:flex">
|
||||||
<a href="/" class="btn btn-ghost btn-sm">Home</a>
|
<li><a href="/">Home</a></li>
|
||||||
<a href="/about" class="btn btn-ghost btn-sm">About</a>
|
<li><a href="/about">About</a></li>
|
||||||
<a href="/blog" class="btn btn-ghost btn-sm">Blog</a>
|
<li><a href="/blog">Blog</a></li>
|
||||||
<a href="/admin/login" class="btn btn-ghost btn-sm">Admin</a>
|
<li><a href="/audio/albums">Audio</a></li>
|
||||||
</div>
|
<li><a href="/audio/tracks">Songs</a></li>
|
||||||
|
{% if logged_in_admin %}
|
||||||
|
<li><a href="/admin/dashboard">Dashboard</a></li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/admin/logout">
|
||||||
|
<button type="submit" class="w-full">Logout</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="/admin/login">Admin</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<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="Menu">
|
||||||
@@ -73,13 +87,24 @@
|
|||||||
<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 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
|
||||||
<a href="/" class="btn btn-ghost btn-sm justify-start">Home</a>
|
<li><a href="/">Home</a></li>
|
||||||
<a href="/about" class="btn btn-ghost btn-sm justify-start">About</a>
|
<li><a href="/about">About</a></li>
|
||||||
<a href="/blog" class="btn btn-ghost btn-sm justify-start">Blog</a>
|
<li><a href="/blog">Blog</a></li>
|
||||||
<a href="/admin/login" class="btn btn-ghost btn-sm justify-start">Admin</a>
|
<li><a href="/audio/albums">Audio</a></li>
|
||||||
</div>
|
<li><a href="/audio/tracks">Songs</a></li>
|
||||||
|
{% if logged_in_admin %}
|
||||||
|
<li><a href="/admin/dashboard">Dashboard</a></li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/admin/logout">
|
||||||
|
<button type="submit" class="w-full">Logout</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="/admin/login">Admin</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="Settings" title="Settings">
|
||||||
|
|||||||
@@ -3,18 +3,45 @@
|
|||||||
{% block title %}Blog{% endblock title %}
|
{% block title %}Blog{% endblock title %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Blog</h1>
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Blog</h1>
|
||||||
|
<p class="text-sm opacity-70">Published articles.</p>
|
||||||
|
</div>
|
||||||
|
{% if logged_in_admin %}
|
||||||
|
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">Manage blog</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if articles | length > 0 %}
|
{% if articles | length > 0 %}
|
||||||
<ul>
|
<div class="grid gap-4 pt-4">
|
||||||
{% for article in articles %}
|
{% for article in articles %}
|
||||||
<li>
|
<article class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
|
<div class="card-body">
|
||||||
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
</li>
|
<h2 class="card-title text-base">
|
||||||
{% endfor %}
|
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
|
||||||
</ul>
|
</h2>
|
||||||
{% else %}
|
<span class="badge">Post</span>
|
||||||
<p>No published posts yet.</p>
|
</div>
|
||||||
{% endif %}
|
{% if article.excerpt %}
|
||||||
|
<p class="text-sm leading-relaxed opacity-80">{{ article.excerpt }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/blog/{{ article.slug }}" class="btn btn-neutral btn-sm">Read</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<p class="font-medium">No published posts yet.</p>
|
||||||
|
<p class="text-sm opacity-70">Published articles will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -3,10 +3,23 @@
|
|||||||
{% block title %}{{ article.title }}{% endblock title %}
|
{% block title %}{{ article.title }}{% endblock title %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<article>
|
<article class="space-y-2">
|
||||||
<h1>{{ article.title }}</h1>
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p>Views: {{ article.view_count }}</p>
|
<div>
|
||||||
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
|
<h1 class="text-2xl font-bold">{{ article.title }}</h1>
|
||||||
<div>{{ article.content | linebreaksbr | safe }}</div>
|
<p class="text-sm opacity-70">Views: {{ article.view_count }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="/blog" class="btn btn-ghost btn-sm">Back to blog</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if article.excerpt %}
|
||||||
|
<p class="text-base leading-relaxed opacity-80">{{ article.excerpt }}</p>
|
||||||
|
<div class="border-t border-base-300 pt-4"></div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="leading-relaxed whitespace-pre-line">{{ article.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -3,21 +3,45 @@
|
|||||||
{% block title %}Home{% endblock title %}
|
{% block title %}Home{% endblock title %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Home</h1>
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Universal Web</h1>
|
||||||
|
<p class="text-sm opacity-70">Latest updates from the site.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/blog" class="btn btn-ghost btn-sm">All posts</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section>
|
<section class="pt-4">
|
||||||
<h2>Latest posts</h2>
|
{% if articles | length > 0 %}
|
||||||
{% if articles | length > 0 %}
|
<div class="grid gap-4">
|
||||||
<ul>
|
{% for article in articles %}
|
||||||
{% for article in articles %}
|
<article class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
<li>
|
<div class="card-body">
|
||||||
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
|
<h2 class="card-title text-base">
|
||||||
</li>
|
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
|
||||||
{% endfor %}
|
</h2>
|
||||||
</ul>
|
<span class="badge">Post</span>
|
||||||
{% else %}
|
</div>
|
||||||
<p>No published posts yet.</p>
|
{% if article.excerpt %}
|
||||||
{% endif %}
|
<p class="text-sm leading-relaxed opacity-80">{{ article.excerpt }}</p>
|
||||||
</section>
|
{% endif %}
|
||||||
|
<div class="pt-2">
|
||||||
|
<a href="/blog/{{ article.slug }}" class="btn btn-neutral btn-sm">Read</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<p class="font-medium">No published posts yet.</p>
|
||||||
|
<p class="text-sm opacity-70">Check back later.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -3,8 +3,21 @@
|
|||||||
{% block title %}{{ page.title }}{% endblock title %}
|
{% block title %}{{ page.title }}{% endblock title %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<article>
|
<article class="space-y-2">
|
||||||
<h1>{{ page.title }}</h1>
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>{{ page.content | linebreaksbr | safe }}</div>
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">{{ page.title }}</h1>
|
||||||
|
<p class="text-sm opacity-70">About this site.</p>
|
||||||
|
</div>
|
||||||
|
{% if logged_in_admin %}
|
||||||
|
<a href="/admin/about" class="btn btn-ghost btn-sm">Edit page</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="leading-relaxed whitespace-pre-line">{{ page.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -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") }}
|
||||||
|
|||||||
@@ -101,3 +101,4 @@ auth:
|
|||||||
|
|
||||||
settings:
|
settings:
|
||||||
admin_email: admin@example.com
|
admin_email: admin@example.com
|
||||||
|
uploads_root: uploads/test
|
||||||
|
|||||||
@@ -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)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
131
migration/src/m20260517_000012_standalone_audio_tracks.rs
Normal file
131
migration/src/m20260517_000012_standalone_audio_tracks.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app.rs
29
src/app.rs
@@ -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,23 @@ 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::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(())
|
||||||
|
|||||||
@@ -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(|| {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::{
|
|||||||
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::{
|
||||||
@@ -87,8 +88,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 +119,32 @@ async fn home(
|
|||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
format::view(&v, "home/index.html", json!({ "articles": articles }))
|
format::view(
|
||||||
|
&v,
|
||||||
|
"home/index.html",
|
||||||
|
json!({ "articles": articles, "logged_in_admin": logged_in_admin(&ctx, &jar).await }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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 +154,16 @@ 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 }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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,12 +180,28 @@ 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 }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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 }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
@@ -157,11 +211,19 @@ async fn admin_login(
|
|||||||
Form(params): Form<LoginParams>,
|
Form(params): Form<LoginParams>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.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 }),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if !user.verify_password(¶ms.password) || !admin::is_admin(&ctx, &user) {
|
if !user.verify_password(¶ms.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 }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let jwt_secret = ctx.config.get_jwt_config()?;
|
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||||
|
|||||||
937
src/controllers/media.rs
Normal file
937
src/controllers/media.rs
Normal file
@@ -0,0 +1,937 @@
|
|||||||
|
use crate::{
|
||||||
|
controllers::{admin, auth as auth_controller},
|
||||||
|
models::{
|
||||||
|
_entities::{audio_albums, audio_tracks},
|
||||||
|
users,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{DefaultBodyLimit, Multipart},
|
||||||
|
http::{
|
||||||
|
header::{self, HeaderMap},
|
||||||
|
StatusCode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use chrono::{NaiveDate, Utc};
|
||||||
|
use loco_rs::{config::Config, prelude::*};
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
||||||
|
QueryOrder, Set,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::{Path as StdPath, PathBuf},
|
||||||
|
str::FromStr,
|
||||||
|
};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const AUDIO_MAX_BYTES: usize = 50 * 1024 * 1024;
|
||||||
|
const IMAGE_MAX_BYTES: usize = 10 * 1024 * 1024;
|
||||||
|
pub const AUDIO_STORAGE_DIR: &str = "audio";
|
||||||
|
pub const IMAGE_STORAGE_DIR: &str = "images";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AlbumForm {
|
||||||
|
title: String,
|
||||||
|
description: Option<String>,
|
||||||
|
cover_image_id: Option<String>,
|
||||||
|
artist: Option<String>,
|
||||||
|
release_date: Option<String>,
|
||||||
|
published: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
track_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AlbumSongForm {
|
||||||
|
track_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct UploadResponse {
|
||||||
|
filename: String,
|
||||||
|
url: String,
|
||||||
|
size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uploads_root(config: &Config) -> Result<PathBuf> {
|
||||||
|
config
|
||||||
|
.settings
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|settings| settings.get("uploads_root"))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| Error::string("settings.uploads_root must be configured"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slugify(title: &str) -> String {
|
||||||
|
let mut slug = String::new();
|
||||||
|
let mut last_was_dash = false;
|
||||||
|
|
||||||
|
for ch in title.chars().flat_map(char::to_lowercase) {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
slug.push(ch);
|
||||||
|
last_was_dash = false;
|
||||||
|
} else if !last_was_dash && !slug.is_empty() {
|
||||||
|
slug.push('-');
|
||||||
|
last_was_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let slug = slug.trim_matches('-').to_string();
|
||||||
|
if slug.is_empty() {
|
||||||
|
Uuid::new_v4().to_string()
|
||||||
|
} else {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|value| {
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
if value.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_checked(value: &Option<String>) -> bool {
|
||||||
|
value.as_deref().is_some_and(|value| value == "on" || value == "true" || value == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_filename(filename: &str) -> Result<&str> {
|
||||||
|
if filename.is_empty()
|
||||||
|
|| filename.contains('/')
|
||||||
|
|| filename.contains('\\')
|
||||||
|
|| filename.contains("..")
|
||||||
|
{
|
||||||
|
return Err(Error::BadRequest("invalid filename".to_string()));
|
||||||
|
}
|
||||||
|
Ok(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audio_content_type(extension: &str) -> &'static str {
|
||||||
|
match extension {
|
||||||
|
"aac" => "audio/aac",
|
||||||
|
"flac" => "audio/flac",
|
||||||
|
"m4a" => "audio/mp4",
|
||||||
|
"ogg" => "audio/ogg",
|
||||||
|
"wav" => "audio/wav",
|
||||||
|
"webm" => "audio/webm",
|
||||||
|
_ => "audio/mpeg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_content_type(extension: &str) -> &'static str {
|
||||||
|
match extension {
|
||||||
|
"gif" => "image/gif",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"png" => "image/png",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_audio_extension(data: &[u8]) -> Result<&'static str> {
|
||||||
|
if data.len() < 12 {
|
||||||
|
return Err(Error::BadRequest("audio file is too small".to_string()));
|
||||||
|
}
|
||||||
|
if data.starts_with(b"ID3") || (data[0] == 0xff && (data[1] & 0xe0) == 0xe0) {
|
||||||
|
return Ok("mp3");
|
||||||
|
}
|
||||||
|
if data.starts_with(b"RIFF") && &data[8..12] == b"WAVE" {
|
||||||
|
return Ok("wav");
|
||||||
|
}
|
||||||
|
if data.starts_with(b"OggS") {
|
||||||
|
return Ok("ogg");
|
||||||
|
}
|
||||||
|
if data.starts_with(b"fLaC") {
|
||||||
|
return Ok("flac");
|
||||||
|
}
|
||||||
|
if data.len() >= 12 && &data[4..8] == b"ftyp" {
|
||||||
|
return Ok("m4a");
|
||||||
|
}
|
||||||
|
if data.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
|
||||||
|
return Ok("webm");
|
||||||
|
}
|
||||||
|
if data.starts_with(&[0xff, 0xf1]) || data.starts_with(&[0xff, 0xf9]) {
|
||||||
|
return Ok("aac");
|
||||||
|
}
|
||||||
|
Err(Error::BadRequest("unsupported audio format".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_image_extension(data: &[u8]) -> Result<&'static str> {
|
||||||
|
if data.len() < 12 {
|
||||||
|
return Err(Error::BadRequest("image file is too small".to_string()));
|
||||||
|
}
|
||||||
|
if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
|
||||||
|
return Ok("png");
|
||||||
|
}
|
||||||
|
if data.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||||
|
return Ok("jpg");
|
||||||
|
}
|
||||||
|
if data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" {
|
||||||
|
return Ok("webp");
|
||||||
|
}
|
||||||
|
if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
|
||||||
|
return Ok("gif");
|
||||||
|
}
|
||||||
|
Err(Error::BadRequest("unsupported image format".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_multipart_file(mut multipart: Multipart, max_bytes: usize) -> Result<Vec<u8>> {
|
||||||
|
while let Some(mut field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
|
||||||
|
{
|
||||||
|
if field.name() != Some("file") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut data = Vec::new();
|
||||||
|
while let Some(chunk) = field
|
||||||
|
.chunk()
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
|
||||||
|
{
|
||||||
|
data.extend_from_slice(&chunk);
|
||||||
|
if data.len() > max_bytes {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"file is larger than {} MB",
|
||||||
|
max_bytes / 1024 / 1024
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.is_empty() {
|
||||||
|
return Err(Error::BadRequest("empty file upload".to_string()));
|
||||||
|
}
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::BadRequest("multipart field `file` is required".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_track_upload(
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<(Vec<u8>, Option<String>, Option<i32>, bool, bool)> {
|
||||||
|
let mut data = None;
|
||||||
|
let mut title = None;
|
||||||
|
let mut track_number = None;
|
||||||
|
let mut featured = false;
|
||||||
|
let mut published = false;
|
||||||
|
|
||||||
|
while let Some(mut field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
|
||||||
|
{
|
||||||
|
let name = field.name().unwrap_or("").to_string();
|
||||||
|
if name == "file" {
|
||||||
|
let mut file = Vec::new();
|
||||||
|
while let Some(chunk) = field
|
||||||
|
.chunk()
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
|
||||||
|
{
|
||||||
|
file.extend_from_slice(&chunk);
|
||||||
|
if file.len() > AUDIO_MAX_BYTES {
|
||||||
|
return Err(Error::BadRequest("file is larger than 50 MB".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data = Some(file);
|
||||||
|
} else {
|
||||||
|
let value = field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
|
||||||
|
match name.as_str() {
|
||||||
|
"title" => title = normalize_empty(Some(value)),
|
||||||
|
"track_number" => {
|
||||||
|
track_number = value.trim().parse::<i32>().ok().filter(|number| *number > 0)
|
||||||
|
}
|
||||||
|
"featured" => featured = value == "on" || value == "true" || value == "1",
|
||||||
|
"published" => published = value == "on" || value == "true" || value == "1",
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = data.ok_or_else(|| Error::BadRequest("multipart field `file` is required".to_string()))?;
|
||||||
|
if data.is_empty() {
|
||||||
|
return Err(Error::BadRequest("empty file upload".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((data, title, track_number, featured, published))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unique_album_slug(ctx: &AppContext, title: &str) -> Result<String> {
|
||||||
|
let base = slugify(title);
|
||||||
|
let mut slug = base.clone();
|
||||||
|
let mut suffix = 2;
|
||||||
|
|
||||||
|
while audio_albums::Entity::find()
|
||||||
|
.filter(audio_albums::Column::Slug.eq(&slug))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?
|
||||||
|
> 0
|
||||||
|
{
|
||||||
|
slug = format!("{base}-{suffix}");
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unique_track_slug(ctx: &AppContext, album_id: Option<Uuid>, title: &str) -> Result<String> {
|
||||||
|
let base = slugify(title);
|
||||||
|
let mut slug = base.clone();
|
||||||
|
let mut suffix = 2;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut query = audio_tracks::Entity::find().filter(audio_tracks::Column::Slug.eq(&slug));
|
||||||
|
query = if let Some(album_id) = album_id {
|
||||||
|
query.filter(audio_tracks::Column::AlbumId.eq(album_id))
|
||||||
|
} else {
|
||||||
|
query.filter(audio_tracks::Column::AlbumId.is_null())
|
||||||
|
};
|
||||||
|
|
||||||
|
if query.count(&ctx.db).await? == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
slug = format!("{base}-{suffix}");
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn album_by_id(ctx: &AppContext, id: Uuid) -> Result<audio_albums::Model> {
|
||||||
|
audio_albums::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn track_by_id(ctx: &AppContext, id: Uuid) -> Result<audio_tracks::Model> {
|
||||||
|
audio_tracks::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_upload(ctx: &AppContext, folder: &str, extension: &str, data: Vec<u8>) -> Result<String> {
|
||||||
|
let filename = format!("{}.{}", Uuid::new_v4(), extension);
|
||||||
|
let key = format!("{folder}/{filename}");
|
||||||
|
ctx.storage
|
||||||
|
.upload(StdPath::new(&key), &Bytes::from(data))
|
||||||
|
.await?;
|
||||||
|
Ok(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn image_upload(auth: auth::JWT, State(ctx): State<AppContext>, multipart: Multipart) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let data = read_multipart_file(multipart, IMAGE_MAX_BYTES).await?;
|
||||||
|
let extension = detect_image_extension(&data)?;
|
||||||
|
let size = data.len();
|
||||||
|
let filename = store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?;
|
||||||
|
|
||||||
|
format::json(UploadResponse {
|
||||||
|
url: format!("/images/{filename}"),
|
||||||
|
filename,
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_images(
|
||||||
|
auth: auth::JWT,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Query(query): Query<HashMap<String, String>>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let uploaded = query.get("uploaded");
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/images/index.html",
|
||||||
|
json!({
|
||||||
|
"uploaded": uploaded,
|
||||||
|
"uploaded_url": uploaded.map(|filename| format!("/images/{filename}")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_image_upload(auth: auth::JWT, State(ctx): State<AppContext>, multipart: Multipart) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let data = read_multipart_file(multipart, IMAGE_MAX_BYTES).await?;
|
||||||
|
let extension = detect_image_extension(&data)?;
|
||||||
|
let filename = store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?;
|
||||||
|
format::redirect(&format!("/admin/images?uploaded={filename}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn image_serve(Path(filename): Path<String>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
let filename = safe_filename(&filename)?;
|
||||||
|
let extension = filename.rsplit('.').next().unwrap_or("");
|
||||||
|
let key = format!("{IMAGE_STORAGE_DIR}/{filename}");
|
||||||
|
let body: Vec<u8> = ctx.storage.download(StdPath::new(&key)).await?;
|
||||||
|
Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, image_content_type(extension))
|
||||||
|
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||||
|
.body(Body::from(body))
|
||||||
|
.map_err(Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn audio_upload(auth: auth::JWT, State(ctx): State<AppContext>, multipart: Multipart) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let data = read_multipart_file(multipart, AUDIO_MAX_BYTES).await?;
|
||||||
|
let extension = detect_audio_extension(&data)?;
|
||||||
|
let size = data.len();
|
||||||
|
let filename = store_upload(&ctx, AUDIO_STORAGE_DIR, extension, data).await?;
|
||||||
|
|
||||||
|
format::json(UploadResponse {
|
||||||
|
url: format!("/audio/stream/{filename}"),
|
||||||
|
filename,
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn public_albums(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let albums = audio_albums::Entity::find()
|
||||||
|
.filter(audio_albums::Column::Published.eq(true))
|
||||||
|
.order_by_desc(audio_albums::Column::PublishedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"audio/albums.html",
|
||||||
|
json!({ "albums": albums, "logged_in_admin": logged_in_admin(&ctx, &jar).await }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn public_album(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let album = audio_albums::Entity::find()
|
||||||
|
.filter(audio_albums::Column::Slug.eq(slug))
|
||||||
|
.filter(audio_albums::Column::Published.eq(true))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
let mut active = album.clone().into_active_model();
|
||||||
|
active.view_count = Set(album.view_count + 1);
|
||||||
|
let album = active.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
let tracks = audio_tracks::Entity::find()
|
||||||
|
.filter(audio_tracks::Column::AlbumId.eq(album.id))
|
||||||
|
.filter(audio_tracks::Column::Published.eq(true))
|
||||||
|
.order_by_asc(audio_tracks::Column::TrackNumber)
|
||||||
|
.order_by_asc(audio_tracks::Column::Title)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"audio/album.html",
|
||||||
|
json!({
|
||||||
|
"album": album,
|
||||||
|
"tracks": tracks,
|
||||||
|
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn public_tracks(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let tracks = audio_tracks::Entity::find()
|
||||||
|
.filter(audio_tracks::Column::Published.eq(true))
|
||||||
|
.order_by_desc(audio_tracks::Column::PublishedAt)
|
||||||
|
.order_by_desc(audio_tracks::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"audio/tracks.html",
|
||||||
|
json!({ "tracks": tracks, "logged_in_admin": logged_in_admin(&ctx, &jar).await }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_albums(
|
||||||
|
auth: auth::JWT,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let albums = audio_albums::Entity::find()
|
||||||
|
.order_by_desc(audio_albums::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for album in albums {
|
||||||
|
let track_count = audio_tracks::Entity::find()
|
||||||
|
.filter(audio_tracks::Column::AlbumId.eq(album.id))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
rows.push(json!({ "album": album, "track_count": track_count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
format::view(&v, "admin/audio/albums.html", json!({ "albums": rows }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_tracks(
|
||||||
|
auth: auth::JWT,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let tracks = audio_tracks::Entity::find()
|
||||||
|
.order_by_desc(audio_tracks::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::view(&v, "admin/audio/songs.html", json!({ "tracks": tracks }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_album_new(auth: auth::JWT, ViewEngine(v): ViewEngine<TeraView>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let available_tracks = audio_tracks::Entity::find()
|
||||||
|
.filter(audio_tracks::Column::AlbumId.is_null())
|
||||||
|
.order_by_asc(audio_tracks::Column::Title)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
format::view(&v, "admin/audio/new_album.html", json!({ "available_tracks": available_tracks }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_album_create(
|
||||||
|
auth: auth::JWT,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(params): Form<AlbumForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let admin_user = admin::current_admin(auth, &ctx).await?;
|
||||||
|
let published = is_checked(¶ms.published);
|
||||||
|
let release_date = normalize_empty(params.release_date)
|
||||||
|
.and_then(|date| NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok());
|
||||||
|
|
||||||
|
let album = audio_albums::ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
title: Set(params.title.clone()),
|
||||||
|
slug: Set(unique_album_slug(&ctx, ¶ms.title).await?),
|
||||||
|
description: Set(normalize_empty(params.description)),
|
||||||
|
cover_image_id: Set(normalize_empty(params.cover_image_id)),
|
||||||
|
artist: Set(normalize_empty(params.artist)),
|
||||||
|
release_date: Set(release_date),
|
||||||
|
published: Set(published),
|
||||||
|
uploader_id: Set(admin_user.id),
|
||||||
|
view_count: Set(0),
|
||||||
|
published_at: Set(published.then(|| Utc::now().into())),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for track_id in params.track_ids {
|
||||||
|
let track = track_by_id(&ctx, track_id).await?;
|
||||||
|
if track.album_id.is_some() {
|
||||||
|
return Err(Error::BadRequest("selected song already belongs to an album".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut active = track.into_active_model();
|
||||||
|
active.album_id = Set(Some(album.id));
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
format::redirect("/admin/audio/albums")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_album_tracks(
|
||||||
|
auth: auth::JWT,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(album_id): Path<Uuid>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let album = album_by_id(&ctx, album_id).await?;
|
||||||
|
let tracks = audio_tracks::Entity::find()
|
||||||
|
.filter(audio_tracks::Column::AlbumId.eq(album_id))
|
||||||
|
.order_by_asc(audio_tracks::Column::TrackNumber)
|
||||||
|
.order_by_asc(audio_tracks::Column::Title)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let available_tracks = audio_tracks::Entity::find()
|
||||||
|
.filter(audio_tracks::Column::AlbumId.is_null())
|
||||||
|
.order_by_asc(audio_tracks::Column::Title)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/audio/tracks.html",
|
||||||
|
json!({ "album": album, "tracks": tracks, "available_tracks": available_tracks }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_album_add_track(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(album_id): Path<Uuid>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(params): Form<AlbumSongForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
album_by_id(&ctx, album_id).await?;
|
||||||
|
let track = track_by_id(&ctx, params.track_id).await?;
|
||||||
|
|
||||||
|
if track.album_id.is_some() {
|
||||||
|
return Err(Error::BadRequest("song already belongs to an album".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut active = track.into_active_model();
|
||||||
|
active.album_id = Set(Some(album_id));
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_track_remove_from_album(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let track = track_by_id(&ctx, id).await?;
|
||||||
|
let album_id = track.album_id;
|
||||||
|
let mut active = track.into_active_model();
|
||||||
|
active.album_id = Set(None);
|
||||||
|
active.track_number = Set(None);
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
if let Some(album_id) = album_id {
|
||||||
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
||||||
|
} else {
|
||||||
|
format::redirect("/admin/audio/tracks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_track_upload_form(
|
||||||
|
auth: auth::JWT,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(album_id): Path<Uuid>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/audio/upload_track.html",
|
||||||
|
json!({ "album": album_by_id(&ctx, album_id).await? }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_song_upload_form(
|
||||||
|
auth: auth::JWT,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
format::view(&v, "admin/audio/upload_track.html", json!({ "album": null }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_uploaded_track(
|
||||||
|
ctx: &AppContext,
|
||||||
|
album_id: Option<Uuid>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<audio_tracks::Model> {
|
||||||
|
let (data, title, track_number, featured, published) = read_track_upload(multipart).await?;
|
||||||
|
let extension = detect_audio_extension(&data)?;
|
||||||
|
let filename = store_upload(ctx, AUDIO_STORAGE_DIR, extension, data).await?;
|
||||||
|
let title = title.unwrap_or_else(|| filename.trim_end_matches(&format!(".{extension}")).to_string());
|
||||||
|
|
||||||
|
audio_tracks::ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
album_id: Set(album_id),
|
||||||
|
title: Set(title.clone()),
|
||||||
|
slug: Set(unique_track_slug(ctx, album_id, &title).await?),
|
||||||
|
audio_file_id: Set(filename),
|
||||||
|
track_number: Set(track_number),
|
||||||
|
duration: Set(None),
|
||||||
|
featured: Set(featured),
|
||||||
|
published: Set(published),
|
||||||
|
play_count: Set(0),
|
||||||
|
published_at: Set(published.then(|| Utc::now().into())),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_track_upload(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(album_id): Path<Uuid>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
album_by_id(&ctx, album_id).await?;
|
||||||
|
|
||||||
|
create_uploaded_track(&ctx, Some(album_id), multipart).await?;
|
||||||
|
|
||||||
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_song_upload(
|
||||||
|
auth: auth::JWT,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
create_uploaded_track(&ctx, None, multipart).await?;
|
||||||
|
format::redirect("/admin/audio/tracks")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_track_delete(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let track = track_by_id(&ctx, id).await?;
|
||||||
|
let album_id = track.album_id;
|
||||||
|
let _ = ctx
|
||||||
|
.storage
|
||||||
|
.delete(StdPath::new(&format!("{AUDIO_STORAGE_DIR}/{}", track.audio_file_id)))
|
||||||
|
.await;
|
||||||
|
track.delete(&ctx.db).await?;
|
||||||
|
if let Some(album_id) = album_id {
|
||||||
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
||||||
|
} else {
|
||||||
|
format::redirect("/admin/audio/tracks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_track_publish(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let track = track_by_id(&ctx, id).await?;
|
||||||
|
let album_id = track.album_id;
|
||||||
|
let mut active = track.into_active_model();
|
||||||
|
active.published = Set(true);
|
||||||
|
active.published_at = Set(Some(Utc::now().into()));
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
if let Some(album_id) = album_id {
|
||||||
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
||||||
|
} else {
|
||||||
|
format::redirect("/admin/audio/tracks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_track_unpublish(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let track = track_by_id(&ctx, id).await?;
|
||||||
|
let album_id = track.album_id;
|
||||||
|
let mut active = track.into_active_model();
|
||||||
|
active.published = Set(false);
|
||||||
|
active.published_at = Set(None);
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
if let Some(album_id) = album_id {
|
||||||
|
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
||||||
|
} else {
|
||||||
|
format::redirect("/admin/audio/tracks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_audio_file(config: &Config, filename: &str, headers: &HeaderMap) -> Result<Response> {
|
||||||
|
let filename = safe_filename(filename)?;
|
||||||
|
let path = uploads_root(config)?.join(AUDIO_STORAGE_DIR).join(filename);
|
||||||
|
let mut file = tokio::fs::File::open(&path).await.map_err(|_| Error::NotFound)?;
|
||||||
|
let total_len = file.metadata().await?.len();
|
||||||
|
let extension = filename.rsplit('.').next().unwrap_or("mp3");
|
||||||
|
let content_type = audio_content_type(extension);
|
||||||
|
|
||||||
|
let (status, start, end) = parse_range(headers, total_len)?;
|
||||||
|
let len = end.saturating_sub(start) + 1;
|
||||||
|
file.seek(SeekFrom::Start(start)).await?;
|
||||||
|
|
||||||
|
let mut body = vec![0; len as usize];
|
||||||
|
file.read_exact(&mut body).await?;
|
||||||
|
|
||||||
|
let mut builder = Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header(header::CONTENT_TYPE, content_type)
|
||||||
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
|
.header(header::CONTENT_LENGTH, len.to_string());
|
||||||
|
|
||||||
|
if status == StatusCode::PARTIAL_CONTENT {
|
||||||
|
builder = builder.header(
|
||||||
|
header::CONTENT_RANGE,
|
||||||
|
format!("bytes {start}-{end}/{total_len}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.body(Body::from(body)).map_err(Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_range(headers: &HeaderMap, total_len: u64) -> Result<(StatusCode, u64, u64)> {
|
||||||
|
if total_len == 0 {
|
||||||
|
return Ok((StatusCode::OK, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(range_header) = headers.get(header::RANGE) else {
|
||||||
|
return Ok((StatusCode::OK, 0, total_len - 1));
|
||||||
|
};
|
||||||
|
let range = range_header
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| Error::BadRequest("invalid range header".to_string()))?;
|
||||||
|
let Some(range) = range.strip_prefix("bytes=") else {
|
||||||
|
return Err(Error::BadRequest("invalid range header".to_string()));
|
||||||
|
};
|
||||||
|
let Some((start, end)) = range.split_once('-') else {
|
||||||
|
return Err(Error::BadRequest("invalid range header".to_string()));
|
||||||
|
};
|
||||||
|
|
||||||
|
let suffix_range = start.is_empty();
|
||||||
|
let start = if suffix_range {
|
||||||
|
let suffix = u64::from_str(end).map_err(|_| Error::BadRequest("invalid range header".to_string()))?;
|
||||||
|
total_len.saturating_sub(suffix)
|
||||||
|
} else {
|
||||||
|
u64::from_str(start).map_err(|_| Error::BadRequest("invalid range header".to_string()))?
|
||||||
|
};
|
||||||
|
let end = if suffix_range || end.is_empty() {
|
||||||
|
total_len - 1
|
||||||
|
} else {
|
||||||
|
u64::from_str(end).map_err(|_| Error::BadRequest("invalid range header".to_string()))?
|
||||||
|
};
|
||||||
|
|
||||||
|
if start >= total_len || end >= total_len || start > end {
|
||||||
|
return Err(Error::CustomError(
|
||||||
|
StatusCode::RANGE_NOT_SATISFIABLE,
|
||||||
|
loco_rs::controller::ErrorDetail::new("range-not-satisfiable", "range not satisfiable"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((StatusCode::PARTIAL_CONTENT, start, end))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn raw_audio_stream(
|
||||||
|
Path(filename): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
stream_audio_file(&ctx.config, &filename, &headers).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn track_stream(
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let track = track_by_id(&ctx, id).await?;
|
||||||
|
if !track.published {
|
||||||
|
return Err(Error::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut active = track.clone().into_active_model();
|
||||||
|
active.play_count = Set(track.play_count + 1);
|
||||||
|
let track = active.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
stream_audio_file(&ctx.config, &track.audio_file_id, &headers).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/images/upload", post(image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)))
|
||||||
|
.add("/images/{filename}", get(image_serve))
|
||||||
|
.add("/audio/upload", post(audio_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)))
|
||||||
|
.add("/audio/stream/{filename}", get(raw_audio_stream))
|
||||||
|
.add("/audio/albums", get(public_albums))
|
||||||
|
.add("/audio/albums/{slug}", get(public_album))
|
||||||
|
.add("/audio/tracks", get(public_tracks))
|
||||||
|
.add("/audio/tracks/{id}/stream", get(track_stream))
|
||||||
|
.add("/admin/images", get(admin_images))
|
||||||
|
.add("/admin/images/upload", post(admin_image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)))
|
||||||
|
.add("/admin/audio/albums", get(admin_albums))
|
||||||
|
.add("/admin/audio/albums/create", get(admin_album_new))
|
||||||
|
.add("/admin/audio/albums/create", post(admin_album_create))
|
||||||
|
.add("/admin/audio/tracks", get(admin_tracks))
|
||||||
|
.add("/admin/audio/tracks/upload", get(admin_song_upload_form))
|
||||||
|
.add("/admin/audio/tracks/upload-file", post(admin_song_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)))
|
||||||
|
.add("/admin/audio/albums/{album_id}/tracks", get(admin_album_tracks))
|
||||||
|
.add("/admin/audio/albums/{album_id}/tracks/add", post(admin_album_add_track))
|
||||||
|
.add("/admin/audio/albums/{album_id}/tracks/upload", get(admin_track_upload_form))
|
||||||
|
.add("/admin/audio/albums/{album_id}/tracks/upload-file", post(admin_track_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)))
|
||||||
|
.add("/admin/audio/tracks/{id}/publish", post(admin_track_publish))
|
||||||
|
.add("/admin/audio/tracks/{id}/unpublish", post(admin_track_unpublish))
|
||||||
|
.add("/admin/audio/tracks/{id}/remove-from-album", post(admin_track_remove_from_album))
|
||||||
|
.add("/admin/audio/tracks/{id}/delete", post(admin_track_delete))
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@ pub mod admin;
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod blog;
|
pub mod blog;
|
||||||
pub mod frontend;
|
pub mod frontend;
|
||||||
|
pub mod media;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
|
|||||||
36
src/initializers/admin_seeder.rs
Normal file
36
src/initializers/admin_seeder.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
pub mod admin_seeder;
|
||||||
pub mod view_engine;
|
pub mod view_engine;
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
Reference in New Issue
Block a user