initial commit of gitara site

This commit is contained in:
Priec
2026-06-16 12:12:25 +02:00
commit 29eac1ffcd
156 changed files with 16165 additions and 0 deletions

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
-something = foo

3
assets/static/404.html Normal file
View File

@@ -0,0 +1,3 @@
<html><body>
not found :-(
</body></html>

File diff suppressed because one or more lines are too long

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

@@ -0,0 +1,781 @@
/* ============================================================
* Terminal theme
* ------------------------------------------------------------
* Project-owned styling. The vendored `app.css` (a pre-compiled
* Tailwind + DaisyUI bundle) is NOT edited. This file loads
* after it (see base.html / admin/base.html) and provides:
*
* 1. Catppuccin Latte for DaisyUI's `light` theme
* and Gruvbox for DaisyUI's `dark` theme
* 2. square corners (terminals have none)
* 3. a terminal look & feel: monospace, window chrome,
* vim-style statusline, CRT scanlines
* 4. `.term-*` building blocks used by the templates
*
* Why CSS classes and not utility classes: `app.css` is frozen
* and only contains the utilities the original project used, so
* new Tailwind classes would not exist. The DaisyUI *components*
* (card/btn/badge/menu/...) do exist and are reused; everything
* else is defined here as real, themeable CSS.
*
* Palettes:
* - https://github.com/catppuccin/catppuccin (Latte)
* - https://github.com/morhetz/gruvbox (dark, bright)
* DaisyUI color vars are OKLch "L% C H" triplets; this file can
* therefore tint anything with `oklch(var(--x) / <alpha>)`.
* ============================================================ */
/* === 1. Theme palettes ====================================== */
/* Catppuccin Latte. */
[data-theme="light"] {
--b1: 95.78% 0.006 264.5; /* #eff1f5 base */
--b2: 93.35% 0.009 264.5; /* #e6e9ef mantle */
--b3: 90.60% 0.012 264.5; /* #dce0e8 crust */
--bc: 43.55% 0.043 279.3; /* #4c4f69 text */
--n: 80.83% 0.017 271.2; /* #bcc0cc surface1 */
--nc: 43.55% 0.043 279.3; /* #4c4f69 text */
--p: 55.86% 0.226 262.1; /* #1e66f5 blue primary */
--pc: 95.78% 0.006 264.5; /* #eff1f5 text on primary */
--s: 55.47% 0.250 297.0; /* #8839ef mauve secondary */
--sc: 95.78% 0.006 264.5; /* #eff1f5 text on secondary */
--a: 60.23% 0.098 201.1; /* #179299 teal accent */
--ac: 95.78% 0.006 264.5; /* #eff1f5 text on accent */
--in: 68.20% 0.145 235.4; /* #04a5e5 sky info */
--su: 62.50% 0.177 140.4; /* #40a02b green success */
--wa: 71.40% 0.149 67.8; /* #df8e1d yellow warning */
--er: 55.05% 0.216 19.8; /* #d20f39 red error */
--inc: 43.55% 0.043 279.3; /* #4c4f69 text on status */
--suc: 95.78% 0.006 264.5;
--wac: 95.78% 0.006 264.5;
--erc: 95.78% 0.006 264.5;
}
/* Source hex noted per line. To retune: change hex, reconvert
* to OKLch, update the value. */
[data-theme="dark"] {
--b1: 27.69% 0 0; /* #282828 bg0 screen background */
--b2: 31.10% 0.003 49.7; /* #32302f bg0_s panels / chrome */
--b3: 34.40% 0.0066 48.7; /* #3c3836 bg1 borders */
--bc: 89.42% 0.0566 89.5; /* #ebdbb2 fg body text */
--n: 34.40% 0.0066 48.7; /* #3c3836 bg1 */
--nc: 89.42% 0.0566 89.5; /* #ebdbb2 fg */
--p: 73.10% 0.182 51.7; /* #fe8019 bright orange primary */
--pc: 27.69% 0 0; /* #282828 text on primary */
--s: 70.54% 0.097 2.3; /* #d3869b bright purple secondary */
--sc: 27.69% 0 0; /* #282828 text on secondary */
--a: 75.57% 0.108 137.6; /* #8ec07c bright aqua accent */
--ac: 27.69% 0 0; /* #282828 text on accent */
--in: 69.26% 0.042 169.8; /* #83a598 bright blue info */
--su: 76.52% 0.158 110.8; /* #b8bb26 bright green success */
--wa: 83.49% 0.160 83.6; /* #fabd2f bright yellow warning */
--er: 65.97% 0.217 30.4; /* #fb4934 bright red error */
--inc: 24.07% 0.005 220.9; /* #1d2021 bg0_h text on status */
--suc: 24.07% 0.005 220.9;
--wac: 24.07% 0.005 220.9;
--erc: 24.07% 0.005 220.9;
}
/* === 2. Square corners ====================================== */
/* `[data-theme]` matches the same <html> element as the vendored
* `[data-theme=dark|light]` rules with equal specificity, and
* wins by load order. Applies to both themes. */
[data-theme] {
--rounded-box: 0;
--rounded-btn: 0;
--rounded-badge: 0;
--tab-radius: 0;
--animation-btn: 0;
--animation-input: 0;
}
/* === 3. Terminal look & feel ================================ */
/* Root font-size drives every rem in this file and in app.css.
* Bump this to scale the whole UI; drop to shrink. */
html { font-size: 19px; }
body {
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code",
ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 1rem;
line-height: 1.6;
}
/* Text selection + scrollbars */
[data-theme="light"] ::selection { background: #acb0be; color: #4c4f69; }
[data-theme="light"] { scrollbar-color: #bcc0cc #eff1f5; }
[data-theme="light"] ::-webkit-scrollbar { width: 12px; height: 12px; }
[data-theme="light"] ::-webkit-scrollbar-track { background: #eff1f5; }
[data-theme="light"] ::-webkit-scrollbar-thumb {
background: #bcc0cc; border: 3px solid #eff1f5;
}
[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: #acb0be; }
[data-theme="dark"] ::selection { background: #fe8019; color: #282828; }
[data-theme="dark"] { scrollbar-color: #504945 #282828; }
[data-theme="dark"] ::-webkit-scrollbar { width: 12px; height: 12px; }
[data-theme="dark"] ::-webkit-scrollbar-track { background: #282828; }
[data-theme="dark"] ::-webkit-scrollbar-thumb {
background: #504945; border: 3px solid #282828;
}
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #665c54; }
/* Faint CRT scanlines — dark only. Remove this block to drop it. */
[data-theme="dark"] body::before {
content: "";
position: fixed;
inset: 0;
z-index: 90;
pointer-events: none;
background: repeating-linear-gradient(
0deg, rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15) 1px,
transparent 1px, transparent 3px);
opacity: 0.45;
}
/* --- color helpers (theme-adaptive: gruvbox in dark) -------- */
.t-orange { color: oklch(var(--p)); }
.t-purple { color: oklch(var(--s)); }
.t-aqua { color: oklch(var(--a)); }
.t-blue { color: oklch(var(--in)); }
.t-green { color: oklch(var(--su)); }
.t-yellow { color: oklch(var(--wa)); }
.t-red { color: oklch(var(--er)); }
.t-dim { color: oklch(var(--bc) / 0.75); }
/* --- window titlebar (the header) -------------------------- */
.term-titlebar {
position: sticky;
top: 0;
z-index: 50;
background: oklch(var(--b2));
border-bottom: 1px solid oklch(var(--b3));
}
.term-nav {
display: flex;
align-items: center;
gap: 0.85rem;
width: 100%;
max-width: 72rem;
margin: 0 auto;
padding: 0.5rem 1rem;
}
.term-dots { display: inline-flex; gap: 0.45rem; flex: none; }
.term-dot {
width: 0.72rem;
height: 0.72rem;
border-radius: 9999px;
display: block;
}
.term-dot.r { background: oklch(var(--er)); }
.term-dot.y { background: oklch(var(--wa)); }
.term-dot.g { background: oklch(var(--su)); }
.term-brand {
font-size: 0.85rem;
white-space: nowrap;
text-decoration: none;
}
.term-brand:hover { text-decoration: none; }
.term-nav-right { margin-left: auto; display: flex; align-items: center; gap: 0.25rem; }
/* horizontal nav links */
.term-navlinks { padding: 0; gap: 0; }
.term-navlinks li > a,
.term-navlinks li > form > button {
padding: 0.2rem 0.55rem;
font-size: 0.85rem;
border-radius: 0;
}
.term-navlinks li > a::before { content: ""; }
.term-navlinks li > a:hover,
.term-navlinks li > form > button:hover {
background: transparent;
color: oklch(var(--p));
}
.term-navlinks a.is-active {
color: oklch(var(--p));
background: oklch(var(--p) / 0.12);
}
.term-navlinks a.is-active::before {
content: "▸ ";
color: oklch(var(--p));
}
/* --- page body layout -------------------------------------- */
.term-main {
flex: 1 1 auto;
width: 100%;
max-width: 72rem;
margin: 0 auto;
padding: 2.25rem 1rem 3rem;
}
/* --- command-prompt page heading --------------------------- */
.term-cmd {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.75rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid oklch(var(--b3));
}
.term-cmd-line { font-size: 0.8rem; color: oklch(var(--bc) / 0.85); }
.term-title {
margin-top: 0.4rem;
font-size: 1.7rem;
font-weight: 700;
line-height: 1.15;
color: oklch(var(--p));
}
.term-sub { margin-top: 0.2rem; font-size: 0.85rem; color: oklch(var(--bc) / 0.8); }
.term-cmd-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
/* --- responsive card grid ---------------------------------- */
.term-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
}
.term-stack > * + * { margin-top: 1rem; }
/* --- cards as terminal windows ----------------------------- */
.card {
background: oklch(var(--b2));
border: 1px solid oklch(var(--b3));
box-shadow: none;
}
.card:hover { border-color: oklch(var(--p) / 0.5); }
/* filename strip at the top of a card */
.term-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.85rem;
font-size: 0.74rem;
color: oklch(var(--bc) / 0.6);
background: oklch(var(--b1));
border-bottom: 1px solid oklch(var(--b3));
}
.term-head .term-dots .term-dot { width: 0.55rem; height: 0.55rem; }
.term-head-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.term-head-meta { margin-left: auto; }
.card-title a { color: oklch(var(--p)); text-decoration: none; }
.card-title a:hover { text-decoration: underline; }
/* --- inline tags ------------------------------------------- */
.term-tag {
display: inline-block;
padding: 0.02rem 0.45rem;
font-size: 0.7rem;
letter-spacing: 0.03em;
border: 1px solid oklch(var(--p) / 0.55);
color: oklch(var(--p));
}
.term-tag.is-aqua { border-color: oklch(var(--a) / 0.55); color: oklch(var(--a)); }
.term-tag.is-purple { border-color: oklch(var(--s) / 0.55); color: oklch(var(--s)); }
.term-tag.is-blue { border-color: oklch(var(--in) / 0.55); color: oklch(var(--in)); }
.term-tag.is-green { border-color: oklch(var(--su) / 0.55); color: oklch(var(--su)); }
/* --- empty / "no results" state ---------------------------- */
.term-empty {
padding: 2.25rem 1rem;
text-align: center;
color: oklch(var(--bc) / 0.6);
border: 1px dashed oklch(var(--b3));
}
.term-empty-cmd { font-size: 0.8rem; color: oklch(var(--bc) / 0.45); }
/* --- how-it-works note + form helpers (admin) -------------- */
.term-note {
margin-bottom: 1.5rem;
padding: 0.9rem 1.1rem;
background: oklch(var(--b2));
border: 1px solid oklch(var(--b3));
border-left: 3px solid oklch(var(--a));
}
.term-note-title { margin-bottom: 0.55rem; font-size: 0.8rem; color: oklch(var(--a)); }
.term-step { display: flex; gap: 0.55rem; font-size: 0.88rem; }
.term-step + .term-step { margin-top: 0.3rem; }
.term-step-n { flex: none; color: oklch(var(--p)); }
.term-note-foot { margin-top: 0.6rem; font-size: 0.8rem; color: oklch(var(--bc) / 0.6); }
.term-help { margin-top: 0.2rem; font-size: 0.76rem; color: oklch(var(--bc) / 0.55); }
.term-picklist {
border: 1px solid oklch(var(--b3));
background: oklch(var(--b1));
max-height: 18rem;
overflow-y: auto;
}
.term-pick {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.5rem 0.7rem;
border-top: 1px solid oklch(var(--b3));
cursor: pointer;
}
.term-pick:first-child { border-top: 0; }
.term-pick:hover { background: oklch(var(--b2)); }
.term-formdiv { margin: 1.25rem 0; border-top: 1px dashed oklch(var(--b3)); }
/* --- terminal session block (mockup-code substitute) ------- */
.term-screen {
background: oklch(var(--b1));
border: 1px solid oklch(var(--b3));
padding: 0.85rem 1rem;
font-size: 0.85rem;
overflow-x: auto;
}
.term-screen .line { white-space: pre-wrap; }
.term-screen .line::before {
content: attr(data-p) " ";
color: oklch(var(--su));
}
.term-screen .line.out::before { content: ""; }
.term-screen .line.out { color: oklch(var(--bc) / 0.8); }
/* --- prose (article / about bodies) ------------------------ */
.term-prose { line-height: 1.7; }
.term-prose a { color: oklch(var(--in)); }
/* --- audio rows -------------------------------------------- */
.term-track {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.5rem 0;
border-top: 1px solid oklch(var(--b3));
}
.term-track:first-child { border-top: 0; }
.term-track .btn { flex: none; }
.term-track-name { font-size: 0.9rem; }
/* "play album" row sitting above the per-track list */
.term-track-bar {
display: flex;
align-items: center;
gap: 0.7rem;
padding-bottom: 0.65rem;
margin-bottom: 0.15rem;
border-bottom: 1px solid oklch(var(--b3));
}
.term-track-bar .btn { flex: none; }
/* --- persistent audio player bar --------------------------- */
/* Hidden until the first song plays; shown by adding `uw-playing`
* to <html>. The bar itself carries `hx-preserve` so the <audio>
* keeps playing while htmx swaps the page around it. */
#uw-player { display: none; }
.uw-playing #uw-player {
display: block;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 80;
background: oklch(var(--b2));
border-top: 3px solid oklch(var(--p));
box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.6);
}
.uw-playing body { padding-bottom: 6.75rem; }
.uw-player-inner {
display: flex;
align-items: center;
gap: 1.15rem;
width: 100%;
max-width: 72rem;
margin: 0 auto;
padding: 1rem 1.5rem;
}
.uw-player-tag {
flex: none;
font-size: 0.98rem;
font-weight: 700;
letter-spacing: 0.02em;
color: oklch(var(--p));
white-space: nowrap;
}
.uw-player-title {
flex: none;
max-width: 24rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.1rem;
color: oklch(var(--bc));
}
#uw-audio {
flex: 1;
min-width: 9rem;
width: auto;
height: 3.25rem;
margin: 0;
}
.uw-player-close {
flex: none;
width: 2.85rem;
height: 2.85rem;
font-size: 1.05rem;
background: transparent;
border: 1px solid oklch(var(--b3));
color: oklch(var(--bc) / 0.7);
cursor: pointer;
line-height: 1;
}
.uw-player-close:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
/* transport + playlist toggle buttons in the player bar */
.uw-player-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
min-width: 2.85rem;
height: 2.85rem;
padding: 0 0.6rem;
font-size: 1.05rem;
background: transparent;
border: 1px solid oklch(var(--b3));
color: oklch(var(--bc) / 0.78);
cursor: pointer;
line-height: 1;
}
.uw-player-btn:hover { color: oklch(var(--p)); border-color: oklch(var(--p)); }
.uw-queue-badge {
font-size: 0.72rem;
font-weight: 700;
min-width: 1.25rem;
padding: 0.05rem 0.3rem;
background: oklch(var(--p));
color: oklch(var(--pc));
}
#uw-player:not(.uw-has-queue) .uw-queue-badge { display: none; }
/* --- the SoundCloud-style playlist panel ------------------- */
.uw-queue {
width: 100%;
max-width: 72rem;
margin: 0 auto;
border-bottom: 1px solid oklch(var(--b3));
}
.uw-queue[hidden] { display: none; }
.uw-queue-head {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 1.5rem;
border-bottom: 1px solid oklch(var(--b3));
}
.uw-queue-title { font-weight: 700; font-size: 0.95rem; color: oklch(var(--p)); }
.uw-queue-meta { font-size: 0.8rem; color: oklch(var(--bc) / 0.6); }
.uw-queue-clear {
margin-left: auto;
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
background: transparent;
border: 1px solid oklch(var(--b3));
color: oklch(var(--bc) / 0.7);
cursor: pointer;
}
.uw-queue-clear:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
.uw-queue-list {
list-style: none;
margin: 0;
padding: 0.35rem 0;
max-height: 15rem;
overflow-y: auto;
}
.uw-queue-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.4rem 1.5rem;
}
.uw-queue-item:hover { background: oklch(var(--b3) / 0.5); }
.uw-queue-item.is-current { background: oklch(var(--p) / 0.12); }
.uw-queue-jump {
flex: none;
width: 1.85rem;
height: 1.85rem;
font-size: 0.8rem;
background: transparent;
border: 1px solid oklch(var(--b3));
color: oklch(var(--bc) / 0.7);
cursor: pointer;
line-height: 1;
}
.uw-queue-item.is-current .uw-queue-jump { color: oklch(var(--p)); border-color: oklch(var(--p)); }
.uw-queue-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
cursor: pointer;
}
.uw-queue-item.is-current .uw-queue-name { color: oklch(var(--p)); font-weight: 600; }
.uw-queue-remove {
flex: none;
width: 1.85rem;
height: 1.85rem;
font-size: 0.8rem;
background: transparent;
border: 1px solid transparent;
color: oklch(var(--bc) / 0.5);
cursor: pointer;
line-height: 1;
}
.uw-queue-remove:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
@media (max-width: 640px) {
/* Two-row layout: [title][prev][next][queue][close] on top,
* full-width <audio> scrubber underneath. */
.uw-player-tag { display: none; }
.uw-player-inner {
flex-wrap: wrap;
padding: 0.6rem 0.75rem;
gap: 0.4rem;
row-gap: 0.5rem;
}
.uw-player-title {
flex: 1 1 auto;
min-width: 0;
max-width: none;
font-size: 0.95rem;
}
.uw-player-btn {
min-width: 2.2rem;
height: 2.2rem;
padding: 0 0.35rem;
font-size: 0.9rem;
}
.uw-player-close { width: 2.2rem; height: 2.2rem; font-size: 0.95rem; }
#uw-audio {
order: 99;
flex: 1 1 100%;
width: 100%;
min-width: 0;
height: 2.4rem;
}
.uw-playing body { padding-bottom: 8.25rem; }
.uw-queue-head, .uw-queue-item { padding-left: 0.95rem; padding-right: 0.95rem; }
}
/* --- vim-style statusline (the footer) --------------------- */
.term-statusline {
display: flex;
flex-wrap: wrap;
align-items: stretch;
font-size: 0.72rem;
border-top: 1px solid oklch(var(--b3));
}
.term-seg {
display: flex;
align-items: center;
padding: 0.25rem 0.8rem;
background: oklch(var(--b3));
color: oklch(var(--bc));
white-space: nowrap;
}
.term-seg.is-mode {
background: oklch(var(--p));
color: oklch(var(--pc));
font-weight: 700;
letter-spacing: 0.06em;
}
.term-seg.is-alt {
background: oklch(var(--s));
color: oklch(var(--sc));
font-weight: 700;
}
.term-seg.is-fill {
flex: 1 1 8rem;
background: oklch(var(--b2));
color: oklch(var(--bc) / 0.55);
}
/* --- square the icon buttons ------------------------------- */
.btn-circle { border-radius: 0; }
/* --- blog editor ------------------------------------------- */
.blog-editor {
min-height: 24rem;
background: oklch(var(--b1));
}
.blog-editor .ql-editor {
min-height: 24rem;
font-size: 1rem;
line-height: 1.7;
}
.ql-toolbar.ql-snow,
.ql-container.ql-snow {
border-color: oklch(var(--b3));
}
.ql-toolbar.ql-snow {
background: oklch(var(--b2));
}
.ql-snow .ql-stroke,
.ql-snow .ql-stroke-miter {
stroke: oklch(var(--bc));
}
.ql-snow .ql-fill,
.ql-snow .ql-stroke.ql-fill {
fill: oklch(var(--bc));
}
.ql-snow .ql-picker,
.ql-snow .ql-picker-options {
color: oklch(var(--bc));
}
.ql-snow .ql-picker-options {
background: oklch(var(--b1));
border-color: oklch(var(--b3));
}
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label,
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options {
border-color: oklch(var(--b3));
}
/* active / hover toolbar state -> gruvbox accent */
.ql-snow.ql-toolbar button:hover,
.ql-snow.ql-toolbar button:focus,
.ql-snow.ql-toolbar button.ql-active,
.ql-snow.ql-toolbar .ql-picker-label:hover,
.ql-snow.ql-toolbar .ql-picker-label.ql-active,
.ql-snow.ql-toolbar .ql-picker-item:hover,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected,
.ql-snow .ql-picker.ql-expanded .ql-picker-label {
color: oklch(var(--p));
}
.ql-snow.ql-toolbar button:hover .ql-stroke,
.ql-snow.ql-toolbar button:focus .ql-stroke,
.ql-snow.ql-toolbar button.ql-active .ql-stroke,
.ql-snow.ql-toolbar button:hover .ql-stroke-miter,
.ql-snow.ql-toolbar button.ql-active .ql-stroke-miter,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke {
stroke: oklch(var(--p));
}
.ql-snow.ql-toolbar button:hover .ql-fill,
.ql-snow.ql-toolbar button:focus .ql-fill,
.ql-snow.ql-toolbar button.ql-active .ql-fill,
.ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill,
.ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill,
.ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill {
fill: oklch(var(--p));
}
/* link tooltip popup */
.ql-snow .ql-tooltip {
background-color: oklch(var(--b1));
border-color: oklch(var(--b3));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
color: oklch(var(--bc));
}
.ql-snow .ql-tooltip input[type=text] {
background: oklch(var(--b2));
border-color: oklch(var(--b3));
color: oklch(var(--bc));
}
.ql-snow .ql-tooltip a {
color: oklch(var(--p));
}
.ql-snow .ql-tooltip a.ql-action::after {
border-color: oklch(var(--b3));
}
.ql-snow .ql-editor a {
color: oklch(var(--p));
}
.blog-image-size-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.875rem;
}
.blog-image-size-controls.hidden {
display: none;
}
.blog-image-size-controls button {
border: 1px solid oklch(var(--b3));
background: oklch(var(--b2));
padding: 0.3rem 0.65rem;
line-height: 1;
}
.blog-image-size-controls button:hover {
border-color: oklch(var(--p));
color: oklch(var(--p));
}
.blog-image-size-controls label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.blog-image-size-controls input {
width: 5rem;
}
.blog-editor img,
.blog-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1rem auto;
border-radius: 0.25rem;
}
.blog-editor img {
cursor: pointer;
}
.blog-image-small {
width: min(100%, 18rem);
}
.blog-image-medium {
width: min(100%, 34rem);
}
.blog-image-full {
width: 100%;
}
.blog-content {
line-height: 1.75;
}
.blog-content h2 {
margin: 1.5rem 0 0.75rem;
font-size: 1.35rem;
font-weight: 700;
}
.blog-content h3 {
margin: 1.25rem 0 0.5rem;
font-size: 1.15rem;
font-weight: 700;
}
.blog-content p,
.blog-content ul,
.blog-content ol {
margin: 0.75rem 0;
}
.blog-content ul {
list-style: disc;
padding-left: 1.4rem;
}
.blog-content ol {
list-style: decimal;
padding-left: 1.4rem;
}
.blog-content a {
color: oklch(var(--p));
text-decoration: underline;
}
/* --- small screens ----------------------------------------- */
@media (max-width: 767px) {
.term-nav { gap: 0.5rem; }
.term-title { font-size: 1.4rem; }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/static/favicon/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/favicon/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

BIN
assets/static/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View File

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

File diff suppressed because one or more lines are too long

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

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

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,154 @@
<!doctype html>
<html lang="{{ lang | default(value='sk') }}" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock title %}</title>
<meta name="description" content="{% block meta_description %}{{ t(key="meta-description", lang=lang | default(value='sk')) }}{% endblock meta_description %}">
<link rel="icon" type="image/x-icon" href="/static/favicon/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png">
<link rel="manifest" href="/static/favicon/site.webmanifest">
<script>
function applyTheme(t) {
var dark = t === 'dark'
|| (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
}
function highlightTheme(t) {
document.querySelectorAll('[data-theme-opt]').forEach(function (b) {
var on = b.getAttribute('data-theme-opt') === t;
b.classList.toggle('active', on);
var chk = b.querySelector('.opt-check');
if (chk) chk.classList.toggle('hidden', !on);
});
}
function setTheme(t) {
localStorage.setItem('theme', t);
applyTheme(t);
highlightTheme(t);
}
applyTheme(localStorage.getItem('theme') || 'dark');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if ((localStorage.getItem('theme') || 'dark') === 'system') applyTheme('system');
});
document.addEventListener('DOMContentLoaded', function () {
highlightTheme(localStorage.getItem('theme') || 'dark');
var path = location.pathname;
document.querySelectorAll('.term-navlinks a[data-nav]').forEach(function (a) {
var h = a.getAttribute('data-nav');
if (h === path || (h !== '/' && path.indexOf(h) === 0)) a.classList.add('is-active');
});
});
</script>
<link href="/static/css/app.css?v=2026-05-20b" rel="stylesheet" type="text/css">
{% block head %}{% endblock head %}
<link href="/static/css/theme.css?v=2026-05-20b" rel="stylesheet" type="text/css">
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
<style>
@media (min-width: 768px) {
.nav-menu { flex-direction: row; }
}
#nav-backdrop { display: none; }
@media (max-width: 767px) {
#nav-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 40;
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.2s;
}
.term-titlebar:has(.dropdown:focus-within) ~ #nav-backdrop {
opacity: 1;
visibility: visible;
transition: opacity 0.15s ease, visibility 0s;
}
}
</style>
</head>
<body class="flex min-h-screen flex-col bg-base-100 text-base-content antialiased">
<header class="term-titlebar">
<nav class="term-nav">
<a href="/admin/dashboard" class="term-brand">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a>
<ul class="nav-menu term-navlinks menu menu-sm hidden items-center md:flex">
<li><a href="/admin/dashboard" data-nav="/admin/dashboard">{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/admin/blog/articles" data-nav="/admin/blog">{{ t(key="admin-blog", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/admin/audio/albums" data-nav="/admin/audio">{{ t(key="admin-audio", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/admin/about" data-nav="/admin/about">{{ t(key="admin-about", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/" class="t-blue">{{ t(key="admin-exit", lang=lang | default(value='sk')) }}</a></li>
<li>
<form method="post" action="/admin/logout">
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</li>
</ul>
<div class="term-nav-right">
<div class="dropdown dropdown-end md:hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</div>
<ul tabindex="0"
class="menu dropdown-content z-50 mt-3 w-52 border border-base-300 bg-base-200 p-2 shadow-lg">
<li><a href="/admin/dashboard">{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/admin/blog/articles">{{ t(key="admin-blog", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/admin/audio/albums">{{ t(key="admin-audio", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/admin/about">{{ t(key="admin-about", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/" class="t-blue">{{ t(key="admin-exit", lang=lang | default(value='sk')) }}</a></li>
<li>
<form method="post" action="/admin/logout">
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</li>
</ul>
</div>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}" title="{{ t(key='settings', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
<form method="post" action="/lang" hx-boost="false">
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 border border-base-300 bg-base-200 p-2 shadow-lg">
<li class="menu-title">{{ t(key="settings-language", lang=lang | default(value='sk')) }}</li>
<li>
<button type="submit" name="lang" value="en" class="{% if lang | default(value='sk') == 'en' %}active{% endif %}">
{{ t(key="language-en", lang=lang | default(value='sk')) }}
{% if lang | default(value='sk') == 'en' %}
<span class="ml-auto"></span>
{% endif %}
</button>
</li>
<li>
<button type="submit" name="lang" value="sk" class="{% if lang | default(value='sk') == 'sk' %}active{% endif %}">
{{ t(key="language-sk", lang=lang | default(value='sk')) }}
{% if lang | default(value='sk') == 'sk' %}
<span class="ml-auto"></span>
{% endif %}
</button>
</li>
<li class="menu-title">{{ t(key="settings-theme", lang=lang | default(value='sk')) }}</li>
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">{{ t(key="theme-system", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">{{ t(key="theme-light", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">{{ t(key="theme-dark", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
</ul>
</form>
</div>
</div>
</nav>
</header>
<div id="nav-backdrop" aria-hidden="true"></div>
<main class="term-main">
{% block content %}{% endblock content %}
</main>
</body>
</html>

View File

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

View File

@@ -0,0 +1,63 @@
{% extends "admin/base.html" %}
{% block title %}{{ t(key="admin-blog-articles", lang=lang | default(value='sk')) }}{% 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">{{ t(key="admin-blog-articles", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm opacity-70">{{ t(key="admin-blog-index-desc", lang=lang | default(value='sk')) }}</p>
</div>
<a href="/admin/blog/articles/new" class="btn btn-neutral btn-sm">{{ t(key="new-article", lang=lang | default(value='sk')) }}</a>
</div>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body">
{% if articles | length > 0 %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{{ t(key="title", lang=lang | default(value='sk')) }}</th>
<th>{{ t(key="status", lang=lang | default(value='sk')) }}</th>
<th class="text-right">{{ t(key="actions", lang=lang | default(value='sk')) }}</th>
</tr>
</thead>
<tbody>
{% for article in articles %}
<tr>
<td class="font-medium">{{ article.title }}</td>
<td>
{% if article.published %}
<span class="badge">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
{% else %}
<span class="badge opacity-70">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
{% endif %}
</td>
<td>
<div class="flex gap-2">
<a href="/admin/blog/articles/{{ article.id }}/edit" class="btn btn-ghost btn-sm">{{ t(key="edit", lang=lang | default(value='sk')) }}</a>
<form method="post" action="/admin/blog/articles/{{ article.id }}/delete">
<button type="submit" class="btn btn-ghost btn-sm">{{ t(key="delete", lang=lang | default(value='sk')) }}</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center">
<p class="font-medium">{{ t(key="admin-no-articles", lang=lang | default(value='sk')) }}</p>
<p class="text-sm opacity-70">{{ t(key="admin-create-first-post", lang=lang | default(value='sk')) }}</p>
<div class="pt-2">
<a href="/admin/blog/articles/new" class="btn btn-neutral btn-sm">{{ t(key="new-article", lang=lang | default(value='sk')) }}</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock content %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

366
assets/views/base.html Normal file
View File

@@ -0,0 +1,366 @@
<!doctype html>
<html lang="{{ lang | default(value='sk') }}" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ t(key="brand", lang=lang | default(value='sk')) }}{% endblock title %}</title>
<meta name="description" content="{% block meta_description %}{{ t(key="meta-description", lang=lang | default(value='sk')) }}{% endblock meta_description %}">
<link rel="icon" type="image/x-icon" href="/static/favicon/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png">
<link rel="manifest" href="/static/favicon/site.webmanifest">
<script>
function applyTheme(t) {
var dark = t === 'dark'
|| (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
}
function highlightTheme(t) {
document.querySelectorAll('[data-theme-opt]').forEach(function (b) {
var on = b.getAttribute('data-theme-opt') === t;
b.classList.toggle('active', on);
var chk = b.querySelector('.opt-check');
if (chk) chk.classList.toggle('hidden', !on);
});
}
function setTheme(t) {
localStorage.setItem('theme', t);
applyTheme(t);
highlightTheme(t);
}
applyTheme(localStorage.getItem('theme') || 'dark');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if ((localStorage.getItem('theme') || 'dark') === 'system') applyTheme('system');
});
function markActiveNav() {
var path = location.pathname;
document.querySelectorAll('.term-navlinks a[data-nav]').forEach(function (a) {
var h = a.getAttribute('data-nav');
a.classList.toggle('is-active', h === path || (h !== '/' && path.indexOf(h) === 0));
});
}
function initPage() {
highlightTheme(localStorage.getItem('theme') || 'dark');
markActiveNav();
}
// --- persistent audio player with playlist queue ----------
// Survives htmx-boosted navigation: window state persists and
// #uw-player carries hx-preserve so <audio> keeps playing.
var uwQueue = []; // [{ src, title }]
var uwIndex = -1; // index of the current track, -1 when empty
function uwSave() {
try {
sessionStorage.setItem('uwQueue', JSON.stringify({ q: uwQueue, i: uwIndex }));
} catch (e) {}
}
function uwRestore() {
try {
var d = JSON.parse(sessionStorage.getItem('uwQueue') || 'null');
if (d && d.q) { uwQueue = d.q; uwIndex = (typeof d.i === 'number' ? d.i : -1); }
} catch (e) {}
}
function uwRenderQueue() {
var player = document.getElementById('uw-player');
if (player) player.classList.toggle('uw-has-queue', uwQueue.length > 0);
var badge = document.getElementById('uw-queue-badge');
if (badge) badge.textContent = uwQueue.length;
var count = document.getElementById('uw-queue-count');
if (count) count.textContent = uwQueue.length + (uwQueue.length === 1 ? ' track' : ' tracks');
var list = document.getElementById('uw-queue-list');
if (!list) return;
list.innerHTML = '';
uwQueue.forEach(function (t, idx) {
var li = document.createElement('li');
li.className = 'uw-queue-item' + (idx === uwIndex ? ' is-current' : '');
var jump = document.createElement('button');
jump.type = 'button';
jump.className = 'uw-queue-jump';
jump.setAttribute('data-uw-jump', idx);
jump.textContent = (idx === uwIndex ? '▸' : (idx + 1));
var name = document.createElement('span');
name.className = 'uw-queue-name';
name.setAttribute('data-uw-jump', idx);
name.textContent = t.title || 'unknown track';
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'uw-queue-remove';
rm.setAttribute('data-uw-remove', idx);
rm.setAttribute('aria-label', 'Remove from playlist');
rm.textContent = '✕';
li.appendChild(jump);
li.appendChild(name);
li.appendChild(rm);
list.appendChild(li);
});
}
// Point <audio> at the current queue entry; play it when asked.
function uwLoad(autoplay) {
var audio = document.getElementById('uw-audio');
var now = document.getElementById('uw-now');
if (!audio) return;
var t = uwQueue[uwIndex];
if (!t) {
if (now) now.textContent = '—';
audio.pause();
audio.removeAttribute('src');
document.documentElement.classList.remove('uw-playing');
uwRenderQueue();
uwSave();
return;
}
document.documentElement.classList.add('uw-playing');
if (now) now.textContent = t.title || 'unknown track';
if (audio.getAttribute('src') !== t.src) {
audio.setAttribute('src', t.src);
audio.load();
}
if (autoplay) {
var p = audio.play();
if (p && p.catch) p.catch(function () {});
}
uwRenderQueue();
uwSave();
}
// Replace the whole queue with a fresh set of tracks and play.
function uwPlayList(tracks) {
if (!tracks || !tracks.length) return;
uwQueue = tracks.slice();
uwIndex = 0;
uwLoad(true);
}
// Add one track: play it now if idle, otherwise queue it.
function uwAdd(src, title) {
var audio = document.getElementById('uw-audio');
var idle = uwIndex < 0 || !audio || audio.ended || !audio.getAttribute('src');
uwQueue.push({ src: src, title: title });
if (idle) { uwIndex = uwQueue.length - 1; uwLoad(true); }
else { uwRenderQueue(); uwSave(); }
}
function uwNext() {
if (uwIndex >= 0 && uwIndex < uwQueue.length - 1) { uwIndex++; uwLoad(true); }
}
function uwPrev() {
var audio = document.getElementById('uw-audio');
if (audio && audio.currentTime > 3) { audio.currentTime = 0; return; }
if (uwIndex > 0) { uwIndex--; uwLoad(true); }
else if (audio) audio.currentTime = 0;
}
function uwJump(idx) {
if (idx >= 0 && idx < uwQueue.length) { uwIndex = idx; uwLoad(true); }
}
function uwRemove(idx) {
if (idx < 0 || idx >= uwQueue.length) return;
var playing = document.documentElement.classList.contains('uw-playing');
uwQueue.splice(idx, 1);
if (idx < uwIndex) { uwIndex--; uwRenderQueue(); uwSave(); }
else if (idx > uwIndex) { uwRenderQueue(); uwSave(); }
else {
if (uwIndex >= uwQueue.length) uwIndex = uwQueue.length - 1;
uwLoad(playing);
}
}
function uwClear() {
uwQueue = [];
uwIndex = -1;
uwLoad(false);
var panel = document.getElementById('uw-queue');
if (panel) panel.hidden = true;
}
function uwInit() {
var audio = document.getElementById('uw-audio');
if (!audio || audio.dataset.uwBound) return;
audio.dataset.uwBound = '1';
uwRestore();
audio.addEventListener('ended', uwNext);
uwRenderQueue();
if (uwIndex >= 0 && uwQueue[uwIndex]) uwLoad(false);
}
document.addEventListener('DOMContentLoaded', function () { initPage(); uwInit(); });
document.addEventListener('htmx:afterSwap', initPage);
document.addEventListener('click', function (e) {
if (!e.target.closest) return;
var albumBtn = e.target.closest('.uw-play-album');
if (albumBtn) {
var sel = albumBtn.getAttribute('data-tracks-from');
var scope = (sel && document.querySelector(sel)) || document;
var tracks = [];
scope.querySelectorAll('.uw-play').forEach(function (b) {
tracks.push({ src: b.getAttribute('data-src'), title: b.getAttribute('data-title') });
});
uwPlayList(tracks);
return;
}
// Play an album straight from the listing: fetch its tracks first.
var remoteBtn = e.target.closest('.uw-play-album-remote');
if (remoteBtn) {
var rurl = remoteBtn.getAttribute('data-album-tracks-url');
if (rurl) {
remoteBtn.disabled = true;
fetch(rurl, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (d) { if (d && d.tracks) uwPlayList(d.tracks); })
.catch(function () {})
.then(function () { remoteBtn.disabled = false; });
}
return;
}
var playBtn = e.target.closest('.uw-play');
if (playBtn) {
uwAdd(playBtn.getAttribute('data-src'), playBtn.getAttribute('data-title'));
return;
}
var jumpEl = e.target.closest('[data-uw-jump]');
if (jumpEl) { uwJump(parseInt(jumpEl.getAttribute('data-uw-jump'), 10)); return; }
var rmEl = e.target.closest('[data-uw-remove]');
if (rmEl) { uwRemove(parseInt(rmEl.getAttribute('data-uw-remove'), 10)); return; }
if (e.target.closest('#uw-next')) { uwNext(); return; }
if (e.target.closest('#uw-prev')) { uwPrev(); return; }
if (e.target.closest('#uw-queue-clear')) { uwClear(); return; }
if (e.target.closest('#uw-queue-toggle')) {
var panel = document.getElementById('uw-queue');
if (panel) panel.hidden = !panel.hidden;
return;
}
if (e.target.closest('#uw-close')) { uwClear(); return; }
});
</script>
<link href="/static/css/app.css?v=2026-05-20b" rel="stylesheet" type="text/css">
<link href="/static/css/theme.css?v=2026-05-20b" rel="stylesheet" type="text/css">
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
<style>
@media (min-width: 768px) {
.nav-menu { flex-direction: row; }
}
#nav-backdrop { display: none; }
@media (max-width: 767px) {
#nav-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 40;
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.2s;
}
.term-titlebar:has(.dropdown:focus-within) ~ #nav-backdrop {
opacity: 1;
visibility: visible;
transition: opacity 0.15s ease, visibility 0s;
}
}
</style>
</head>
<body hx-boost="true" class="flex min-h-screen flex-col bg-base-100 text-base-content antialiased">
<header class="term-titlebar">
<nav class="term-nav">
<a href="/" class="term-brand">{{ t(key="brand", lang=lang | default(value='sk')) }}</a>
<ul class="nav-menu term-navlinks menu menu-sm hidden items-center md:flex">
<li><a href="/" data-nav="/">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/blog" data-nav="/blog">{{ t(key="nav-blog", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/audio/albums" data-nav="/audio/albums">{{ t(key="nav-audio", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/audio/tracks" data-nav="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/about" data-nav="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li>
{% if logged_in_admin %}
<li><a href="/admin/dashboard" hx-boost="false" class="t-yellow" data-nav="/admin">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
<li>
<form method="post" action="/admin/logout" hx-boost="false">
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</li>
{% else %}
<li><a href="/admin/login" data-nav="/admin/login">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li>
{% endif %}
</ul>
<div class="term-nav-right">
<div class="dropdown dropdown-end md:hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</div>
<ul tabindex="0"
class="menu dropdown-content z-50 mt-3 w-52 border border-base-300 bg-base-200 p-2 shadow-lg">
<li><a href="/">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/blog">{{ t(key="nav-blog", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/audio/albums">{{ t(key="nav-audio", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li>
{% if logged_in_admin %}
<li><a href="/admin/dashboard" hx-boost="false" class="t-yellow">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
<li>
<form method="post" action="/admin/logout">
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</li>
{% else %}
<li><a href="/admin/login">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li>
{% endif %}
</ul>
</div>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}" title="{{ t(key='settings', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
<form method="post" action="/lang" hx-boost="false">
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 border border-base-300 bg-base-200 p-2 shadow-lg">
<li class="menu-title">{{ t(key="settings-language", lang=lang | default(value='sk')) }}</li>
<li>
<button type="submit" name="lang" value="en" class="{% if lang | default(value='sk') == 'en' %}active{% endif %}">
English
{% if lang | default(value='sk') == 'en' %}
<span class="ml-auto"></span>
{% endif %}
</button>
</li>
<li>
<button type="submit" name="lang" value="sk" class="{% if lang | default(value='sk') == 'sk' %}active{% endif %}">
Slovenčina
{% if lang | default(value='sk') == 'sk' %}
<span class="ml-auto"></span>
{% endif %}
</button>
</li>
<li class="menu-title">{{ t(key="settings-theme", lang=lang | default(value='sk')) }}</li>
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">{{ t(key="theme-system", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">{{ t(key="theme-light", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">{{ t(key="theme-dark", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden"></span></button></li>
</ul>
</form>
</div>
</div>
</nav>
</header>
<div id="nav-backdrop" aria-hidden="true"></div>
<main class="term-main">
{% block content %}{% endblock content %}
</main>
<div id="uw-player" hx-preserve="true">
<div id="uw-queue" class="uw-queue" hidden>
<div class="uw-queue-head">
<span class="uw-queue-title">&#9776; playlist</span>
<span id="uw-queue-count" class="uw-queue-meta">0 tracks</span>
<button type="button" id="uw-queue-clear" class="uw-queue-clear">clear</button>
</div>
<ol id="uw-queue-list" class="uw-queue-list"></ol>
</div>
<div class="uw-player-inner">
<span class="uw-player-tag">&#9654; now playing</span>
<span id="uw-now" class="uw-player-title">&mdash;</span>
<button type="button" id="uw-prev" class="uw-player-btn" aria-label="Previous track" title="Previous">&#9198;</button>
<audio id="uw-audio" controls preload="none"></audio>
<button type="button" id="uw-next" class="uw-player-btn" aria-label="Next track" title="Next">&#9197;</button>
<button type="button" id="uw-queue-toggle" class="uw-player-btn" aria-label="Toggle playlist" title="Playlist">&#9776;<span id="uw-queue-badge" class="uw-queue-badge">0</span></button>
<button type="button" id="uw-close" class="uw-player-close" aria-label="Stop playback" title="Stop">&#10005;</button>
</div>
</div>
</body>
</html>

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
<html><body>
<img src="/static/image.png" width="200"/>
<br/>
find this tera template at <code>assets/views/home/hello.html</code>:
<br/>
<br/>
{{ t(key="hello-world", lang="sk") }},
<br/>
{{ t(key="hello-world", lang="en") }}
</body></html>

View File

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

View File

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