7 Commits

Author SHA1 Message Date
Priec
9ce07e8c23 better project structure
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-16 22:48:10 +02:00
Priec
b255e95051 sidebar and shit 2026-06-16 22:02:07 +02:00
Priec
f0a6f97609 more webshop 2026-06-16 19:27:29 +02:00
Priec
baf7522273 eshop
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-16 16:35:50 +02:00
Priec
c4f60dd8d7 login page with penguin ui
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-16 13:39:41 +02:00
Priec
e3b99b0fd8 using penguiui
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-16 12:53:11 +02:00
Priec
635cb34810 cleaned 2026-06-16 12:44:31 +02:00
140 changed files with 5032 additions and 5187 deletions

4
.gitignore vendored
View File

@@ -22,3 +22,7 @@ target/
uploads/
*.report.html
favicon_io.zip
# Tailwind standalone binary (downloaded via `make tailwind`)
bin/tailwindcss
node_modules/

View File

@@ -1,6 +1,33 @@
COMPOSE = docker compose -f docker-compose.prod.yml --env-file .env.production
.PHONY: up down restart logs build ps
# --- Frontend (Tailwind v4 + PenguinUI) -----------------------------
# Uses the Tailwind v4 standalone binary (no Node required at runtime).
# The compiled assets/static/css/app.css is committed and served by loco.
TW = bin/tailwindcss
CSS_IN = assets/css/app.css
CSS_OUT = assets/static/css/app.css
UNAME_M := $(shell uname -m)
ifeq ($(UNAME_M),aarch64)
TW_TARGET = tailwindcss-linux-arm64
else
TW_TARGET = tailwindcss-linux-x64
endif
.PHONY: up down restart logs build ps css css-watch tailwind
$(TW):
@mkdir -p bin
curl -fsSL -o $(TW) \
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/$(TW_TARGET)"
chmod +x $(TW)
tailwind: $(TW)
css: $(TW)
$(TW) -i $(CSS_IN) -o $(CSS_OUT) --minify
css-watch: $(TW)
$(TW) -i $(CSS_IN) -o $(CSS_OUT) --watch
up:
$(COMPOSE) up -d --build

73
assets/css/app.css Normal file
View File

@@ -0,0 +1,73 @@
/* ============================================================
* Tailwind v4 source — built into assets/static/css/app.css
* ------------------------------------------------------------
* Stack: Tailwind CSS v4 + Alpine.js v3 + PenguinUI components
* (https://www.penguinui.com). PenguinUI is copy-paste: paste a
* component's markup into a template and it picks up the design
* tokens defined in the @theme block below.
*
* Build: make css (one-off, minified)
* make css-watch (rebuild on change while developing)
*
* The compiled output assets/static/css/app.css IS committed, so
* the Docker image / loco static server need no Node at runtime.
* ============================================================ */
@import "tailwindcss";
/* Scan every template so used utility classes are emitted. */
@source "../views";
/* PenguinUI toggles dark styles with a `dark:` variant. This app
* already sets <html data-theme="dark|light"> (see base.html), so
* key the variant off that attribute instead of the OS setting. */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/* === PenguinUI design tokens ================================
* "Modern" starting palette. Swap any line for another Tailwind
* color (e.g. --color-primary: var(--color-emerald-600)) or grab
* a ready-made theme from https://www.penguinui.com/theme.
* Components reference these as bg-primary, text-on-surface,
* dark:bg-surface-dark, border-outline, etc.
* ============================================================ */
@theme {
/* light mode */
--color-surface: var(--color-white);
--color-surface-alt: var(--color-slate-100);
--color-on-surface: var(--color-slate-700);
--color-on-surface-strong: var(--color-slate-900);
--color-primary: var(--color-indigo-600);
--color-on-primary: var(--color-white);
--color-secondary: var(--color-slate-600);
--color-on-secondary: var(--color-white);
--color-outline: var(--color-slate-300);
--color-outline-strong: var(--color-slate-800);
/* dark mode */
--color-surface-dark: var(--color-slate-900);
--color-surface-dark-alt: var(--color-slate-800);
--color-on-surface-dark: var(--color-slate-300);
--color-on-surface-dark-strong: var(--color-white);
--color-primary-dark: var(--color-indigo-400);
--color-on-primary-dark: var(--color-slate-950);
--color-secondary-dark: var(--color-slate-300);
--color-on-secondary-dark: var(--color-slate-950);
--color-outline-dark: var(--color-slate-700);
--color-outline-dark-strong: var(--color-slate-300);
/* shared status colors (same in both modes) */
--color-info: var(--color-sky-500);
--color-on-info: var(--color-white);
--color-success: var(--color-green-600);
--color-on-success: var(--color-white);
--color-warning: var(--color-amber-500);
--color-on-warning: var(--color-white);
--color-danger: var(--color-red-600);
--color-on-danger: var(--color-white);
/* shared design tokens */
--radius-radius: 0.375rem;
}
/* Hide Alpine x-cloak elements until Alpine initializes. */
[x-cloak] { display: none !important; }

View File

@@ -172,3 +172,94 @@ 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.
# --- eshop: catalog, shop, cart, orders ---
nav-shop = Shop
admin-products = Products
admin-products-desc = manage the products you sell.
admin-categories = Categories
admin-categories-desc = organise products into categories.
admin-orders = Orders
admin-no-products = No products yet.
admin-no-categories = No categories yet.
new-product = New product
edit-product = Edit product
new-category = New category
edit-category = Edit category
product = Product
name = Name
price = Price
stock = Stock
sku = SKU
currency = Currency
category = Category
no-category = No category
image = Image
slug = URL slug
slug-auto = generated automatically
position = Position
parent-category = Parent category
no-parent = — None (top level) —
quantity = Quantity
add-to-cart = Add to cart
in-stock = In stock
out-of-stock = Out of stock
confirm-delete = Delete this for good?
shop-title = Shop
shop-subtitle = browse our products.
shop-empty = There are no products here yet.
categories = Categories
all-products = All products
cart-title = Cart
cart-empty = Your cart is empty.
cart-total = Total
cart-checkout = Proceed to checkout
cart-remove = Remove
cart-update = Update
cart-continue = Continue shopping
checkout-title = Checkout
checkout-contact = Contact details
checkout-shipping = Shipping address
checkout-email = Email
checkout-name = Full name
checkout-address = Address
checkout-city = City
checkout-zip = Postal code
checkout-country = Country
checkout-note = Order note
checkout-place-order = Place order
checkout-summary = Order summary
order-confirmed-title = Thank you for your order!
order-confirmed-sub = We have received your order.
order-number = Order number
order-status = Status
order-total = Total
order-items = Items
order-date = Date
order-customer = Customer
admin-no-orders = No orders yet.
order-status-pending = Pending
order-status-paid = Paid
order-status-shipped = Shipped
order-status-cancelled = Cancelled
order-update-status = Update status
# --- eshop: shipping & payment ---
checkout-carrier = Delivery
checkout-payment = Payment method
checkout-subtotal = Subtotal
checkout-shipping-cost = Shipping
checkout-pick-point = Choose pickup point
checkout-chosen-point = Chosen point
checkout-pickup-point = Pickup point
payment-cod = Cash on delivery
payment-bank = Bank transfer
payment-bank-instructions = Please transfer the amount to our account:
payment-cod-note = You will pay for the goods on delivery.
payment-bank-note = We will ship once the payment arrives.
bank-account-name = Account holder
bank-variable-symbol = Variable symbol
bank-amount = Amount
admin-shipping = Shipping
admin-shipping-desc = set carrier prices and availability.
shipping-enabled = Active

View File

@@ -172,3 +172,94 @@ 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.
# --- eshop: catalog, shop, cart, orders ---
nav-shop = Obchod
admin-products = Produkty
admin-products-desc = spravovať produkty v ponuke.
admin-categories = Kategórie
admin-categories-desc = usporiadať produkty do kategórií.
admin-orders = Objednávky
admin-no-products = Zatiaľ žiadne produkty.
admin-no-categories = Zatiaľ žiadne kategórie.
new-product = Nový produkt
edit-product = Upraviť produkt
new-category = Nová kategória
edit-category = Upraviť kategóriu
product = Produkt
name = Názov
price = Cena
stock = Sklad
sku = Kód (SKU)
currency = Mena
category = Kategória
no-category = Bez kategórie
image = Obrázok
slug = URL adresa
slug-auto = vygeneruje sa automaticky
position = Poradie
parent-category = Nadradená kategória
no-parent = — Žiadna (najvyššia úroveň) —
quantity = Množstvo
add-to-cart = Pridať do košíka
in-stock = Na sklade
out-of-stock = Vypredané
confirm-delete = Naozaj zmazať?
shop-title = Obchod
shop-subtitle = prezrite si našu ponuku produktov.
shop-empty = Zatiaľ tu nie sú žiadne produkty.
categories = Kategórie
all-products = Všetky produkty
cart-title = Košík
cart-empty = Váš košík je prázdny.
cart-total = Spolu
cart-checkout = Pokračovať k pokladni
cart-remove = Odstrániť
cart-update = Aktualizovať
cart-continue = Pokračovať v nákupe
checkout-title = Pokladňa
checkout-contact = Kontaktné údaje
checkout-shipping = Dodacia adresa
checkout-email = E-mail
checkout-name = Meno a priezvisko
checkout-address = Adresa
checkout-city = Mesto
checkout-zip = PSČ
checkout-country = Krajina
checkout-note = Poznámka k objednávke
checkout-place-order = Odoslať objednávku
checkout-summary = Súhrn objednávky
order-confirmed-title = Ďakujeme za objednávku!
order-confirmed-sub = Vašu objednávku sme prijali.
order-number = Číslo objednávky
order-status = Stav
order-total = Spolu
order-items = Položky
order-date = Dátum
order-customer = Zákazník
admin-no-orders = Zatiaľ žiadne objednávky.
order-status-pending = Čaká na spracovanie
order-status-paid = Zaplatené
order-status-shipped = Odoslané
order-status-cancelled = Zrušené
order-update-status = Zmeniť stav
# --- eshop: shipping & payment ---
checkout-carrier = Doprava
checkout-payment = Spôsob platby
checkout-subtotal = Medzisúčet
checkout-shipping-cost = Doprava
checkout-pick-point = Vybrať výdajné miesto
checkout-chosen-point = Vybrané miesto
checkout-pickup-point = Výdajné miesto
payment-cod = Dobierka (platba pri prevzatí)
payment-bank = Bankový prevod
payment-bank-instructions = Sumu uhraďte prevodom na náš účet:
payment-cod-note = Za tovar zaplatíte pri jeho prevzatí.
payment-bank-note = Po prijatí platby objednávku odošleme.
bank-account-name = Príjemca
bank-variable-symbol = Variabilný symbol
bank-amount = Suma
admin-shipping = Doprava
admin-shipping-desc = nastaviť cenu a dostupnosť dopravcov.
shipping-enabled = Aktívne

File diff suppressed because one or more lines are too long

View File

@@ -1,781 +0,0 @@
/* ============================================================
* 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; }
}

View File

@@ -1,149 +0,0 @@
(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

View File

@@ -1,31 +0,0 @@
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.

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -1,36 +0,0 @@
{% 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

@@ -1,81 +0,0 @@
{% 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

@@ -1,93 +0,0 @@
{% 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

@@ -1,99 +0,0 @@
{% 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

@@ -1,123 +0,0 @@
{% 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

@@ -1,76 +0,0 @@
{% 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

@@ -16,139 +16,159 @@
|| (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);
document.dispatchEvent(new CustomEvent('theme:changed', { detail: t }));
}
applyTheme(localStorage.getItem('theme') || 'dark');
function currentTheme() { return localStorage.getItem('theme') || 'dark'; }
applyTheme(currentTheme());
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if ((localStorage.getItem('theme') || 'dark') === 'system') applyTheme('system');
if (currentTheme() === 'system') applyTheme('system');
});
document.addEventListener('DOMContentLoaded', function () {
highlightTheme(localStorage.getItem('theme') || 'dark');
function markActiveNav() {
var path = location.pathname;
document.querySelectorAll('.term-navlinks a[data-nav]').forEach(function (a) {
document.querySelectorAll('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');
var on = h === path || (h !== '/' && path.indexOf(h) === 0);
if (on) a.setAttribute('aria-current', 'page');
else a.removeAttribute('aria-current');
});
});
}
document.addEventListener('DOMContentLoaded', markActiveNav);
document.addEventListener('htmx:afterSwap', markActiveNav);
</script>
<link href="/static/css/app.css?v=2026-05-20b" rel="stylesheet" type="text/css">
<link href="/static/css/app.css?v=2026-06-16" 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>
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
</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>
<body
x-data="{ showSidebar: false }"
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
<!-- dark overlay for the open sidebar on small screens -->
<div x-cloak x-show="showSidebar" x-transition.opacity aria-hidden="true"
@click="showSidebar = false"
class="fixed inset-0 z-30 bg-black/50 md:hidden"></div>
<!-- sidebar -->
<nav aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
x-bind:class="showSidebar ? 'translate-x-0' : '-translate-x-60'"
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-outline bg-surface-alt transition-transform duration-300 md:translate-x-0 dark:border-outline-dark dark:bg-surface-dark-alt">
<a href="/admin/dashboard"
class="flex h-16 items-center gap-2 border-b border-outline px-6 text-lg font-bold tracking-tight text-on-surface-strong dark:border-outline-dark dark:text-on-surface-dark-strong">
{{ t(key="admin-title", lang=lang | default(value='sk')) }}
</a>
<div class="flex flex-1 flex-col gap-1 overflow-y-auto p-4">
<a href="/admin/dashboard" data-nav="/admin/dashboard"
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/catalog/products" data-nav="/admin/catalog/products"
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
{{ t(key="admin-products", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/catalog/categories" data-nav="/admin/catalog/categories"
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
{{ t(key="admin-categories", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/orders" data-nav="/admin/orders"
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/shipping" data-nav="/admin/shipping"
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
</a>
</div>
<div class="border-t border-outline p-4 dark:border-outline-dark">
<a href="/" class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-info transition hover:bg-surface dark:hover:bg-surface-dark">
{{ t(key="admin-exit", lang=lang | default(value='sk')) }}
</a>
<form method="post" action="/admin/logout">
<button type="submit" class="flex w-full items-center gap-3 rounded-radius px-3 py-2 text-left text-sm font-medium text-danger transition hover:bg-surface dark:hover:bg-surface-dark">
{{ t(key="logout", lang=lang | default(value='sk')) }}
</button>
</form>
</div>
</nav>
<!-- content column -->
<div class="flex min-h-screen flex-col md:ml-60">
<header class="sticky top-0 z-20 flex h-16 items-center gap-4 border-b border-outline bg-surface/95 px-4 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
<button type="button" @click="showSidebar = !showSidebar" :aria-expanded="showSidebar"
aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt md:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<span class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">
{% block crumb %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock crumb %}
</span>
<!-- settings (language + theme) dropdown -->
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative ml-auto">
<button type="button" @click="open = !open" :aria-expanded="open"
aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}"
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-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>
</button>
<div x-show="open" x-cloak @click.outside="open = false" x-transition.origin.top.right
class="absolute right-0 mt-2 w-56 rounded-radius border border-outline bg-surface p-2 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
<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>
<p class="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
{{ t(key="settings-language", lang=lang | default(value='sk')) }}
</p>
<button type="submit" name="lang" value="en"
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
<span>English</span>
{% if lang | default(value='sk') == "en" %}<span class="text-primary dark:text-primary-dark"></span>{% endif %}
</button>
<button type="submit" name="lang" value="sk"
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
<span>Slovenčina</span>
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark"></span>{% endif %}
</button>
</form>
<p class="mt-1 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
</p>
<div x-data="{ theme: currentTheme() }" @theme:changed.document="theme = $event.detail">
<button type="button" @click="setTheme('system')"
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
<span>{{ t(key="theme-system", lang=lang | default(value='sk')) }}</span>
<span x-show="theme === 'system'" class="text-primary dark:text-primary-dark"></span>
</button>
<button type="button" @click="setTheme('light')"
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
<span>{{ t(key="theme-light", lang=lang | default(value='sk')) }}</span>
<span x-show="theme === 'light'" class="text-primary dark:text-primary-dark"></span>
</button>
<button type="button" @click="setTheme('dark')"
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
<span>{{ t(key="theme-dark", lang=lang | default(value='sk')) }}</span>
<span x-show="theme === 'dark'" class="text-primary dark:text-primary-dark"></span>
</button>
</div>
</div>
</div>
</nav>
</header>
<div id="nav-backdrop" aria-hidden="true"></div>
<main class="term-main">
{% block content %}{% endblock content %}
</main>
</header>
<main class="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
{% block content %}{% endblock content %}
</main>
</div>
</body>
</html>

View File

@@ -1,63 +0,0 @@
{% 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

@@ -1,63 +0,0 @@
{% 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

@@ -1,64 +0,0 @@
{% 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,70 @@
{% extends "admin/base.html" %}
{% block title %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-categories", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-categories-desc", lang=lang | default(value='sk')) }}</p>
</div>
<a href="/admin/catalog/categories/new"
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="new-category", lang=lang | default(value='sk')) }}
</a>
</div>
<div class="mt-6 overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
{% if categories | length > 0 %}
<table class="w-full text-left text-sm">
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
<tr>
<th class="px-4 py-3 font-semibold">{{ t(key="name", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="status", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ t(key="actions", lang=lang | default(value='sk')) }}</th>
</tr>
</thead>
<tbody class="divide-y divide-outline dark:divide-outline-dark">
{% for row in categories %}
<tr class="hover:bg-surface-alt dark:hover:bg-surface-dark-alt">
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
<span style="padding-left: {{ row.depth * 20 }}px" class="inline-flex items-center gap-1.5">
{% if row.depth > 0 %}<span class="text-on-surface/40 dark:text-on-surface-dark/40"></span>{% endif %}
{{ row.category.name }}
</span>
</td>
<td class="px-4 py-3 tabular-nums">{{ row.product_count }}</td>
<td class="px-4 py-3">
{% if row.category.published %}
<span class="inline-flex rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
{% else %}
<span class="inline-flex rounded-full bg-surface-alt px-2 py-0.5 text-xs font-medium text-on-surface/70 dark:bg-surface-dark-alt dark:text-on-surface-dark/70">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
{% endif %}
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap justify-end gap-2">
<a href="/admin/catalog/categories/{{ row.category.id }}/edit"
class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="edit", lang=lang | default(value='sk')) }}</a>
<form method="post" action="/admin/catalog/categories/{{ row.category.id }}/delete"
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
<button type="submit" class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-danger transition hover:bg-danger/10 dark:border-outline-dark">{{ t(key="delete", lang=lang | default(value='sk')) }}</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-categories", lang=lang | default(value='sk')) }}</p>
<a href="/admin/catalog/categories/new"
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="new-category", lang=lang | default(value='sk')) }}
</a>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,83 @@
{% extends "admin/base.html" %}
{% set editing = category %}
{% block title %}{% if editing %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block crumb %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<div class="flex items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{% if editing %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}
</h1>
<a href="/admin/catalog/categories"
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
</div>
<form method="post" enctype="multipart/form-data"
action="{% if editing %}/admin/catalog/categories/{{ category.id }}{% else %}/admin/catalog/categories{% endif %}"
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="space-y-1.5">
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
<input id="name" name="name" type="text" required value="{% if editing %}{{ category.name }}{% endif %}"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div class="space-y-1.5">
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label>
<input id="slug" name="slug" type="text" value="{% if editing %}{{ category.slug }}{% endif %}"
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<div class="space-y-1.5">
<label for="position" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="position", lang=lang | default(value='sk')) }}</label>
<input id="position" name="position" type="number" value="{% if editing %}{{ category.position }}{% else %}0{% endif %}"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
</div>
<div class="space-y-1.5">
<label for="parent_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="parent-category", lang=lang | default(value='sk')) }}</label>
<select id="parent_id" name="parent_id"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
<option value="">{{ t(key="no-parent", lang=lang | default(value='sk')) }}</option>
{% for parent in parents %}
<option value="{{ parent.id }}" {% if editing and category.parent_id == parent.id %}selected{% endif %}>
{% if parent.depth > 0 %}{% for _ in range(end=parent.depth) %}—&nbsp;{% endfor %}{% endif %}{{ parent.name }}
</option>
{% endfor %}
</select>
</div>
<div class="space-y-1.5">
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
<textarea id="description" name="description" rows="4"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if editing and category.description %}{{ category.description }}{% endif %}</textarea>
</div>
<div class="space-y-1.5">
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
{% if editing and category.image_id %}
<img src="/images/{{ category.image_id }}" alt="" class="size-24 rounded-radius object-cover">
{% endif %}
<input id="image" name="image" type="file" accept="image/*"
class="block w-full text-sm text-on-surface file:mr-3 file:rounded-radius file:border-0 file:bg-primary file:px-3 file:py-2 file:text-sm file:font-medium file:text-on-primary dark:text-on-surface-dark dark:file:bg-primary-dark dark:file:text-on-primary-dark">
</div>
<label class="flex items-center gap-2">
<input type="checkbox" name="published" value="on" {% if editing and category.published %}checked{% endif %}
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
</label>
<div class="flex gap-3 pt-2">
<button type="submit"
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="save", lang=lang | default(value='sk')) }}
</button>
<a href="/admin/catalog/categories"
class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
</div>
</form>
{% endblock content %}

View File

@@ -0,0 +1,101 @@
{% extends "admin/base.html" %}
{% set editing = product %}
{% block title %}{% if editing %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<div class="flex items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{% if editing %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}
</h1>
<a href="/admin/catalog/products"
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
</div>
<form method="post" enctype="multipart/form-data"
action="{% if editing %}/admin/catalog/products/{{ product.id }}{% else %}/admin/catalog/products{% endif %}"
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="space-y-1.5">
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
<input id="name" name="name" type="text" required value="{% if editing %}{{ product.name }}{% endif %}"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div class="space-y-1.5">
<label for="price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
<input id="price" name="price" type="text" inputmode="decimal" required value="{% if editing %}{{ product.price }}{% endif %}"
placeholder="0.00"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<div class="space-y-1.5">
<label for="currency" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="currency", lang=lang | default(value='sk')) }}</label>
<input id="currency" name="currency" type="text" maxlength="3" value="{% if editing %}{{ product.currency }}{% else %}EUR{% endif %}"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm uppercase text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div class="space-y-1.5">
<label for="stock" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
<input id="stock" name="stock" type="number" min="0" value="{% if editing %}{{ product.stock }}{% else %}0{% endif %}"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<div class="space-y-1.5">
<label for="sku" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
<input id="sku" name="sku" type="text" value="{% if editing and product.sku %}{{ product.sku }}{% endif %}"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
</div>
<div class="space-y-1.5">
<label for="category_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="category", lang=lang | default(value='sk')) }}</label>
<select id="category_id" name="category_id"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
<option value="">{{ t(key="no-category", lang=lang | default(value='sk')) }}</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if editing and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="space-y-1.5">
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label>
<input id="slug" name="slug" type="text" value="{% if editing %}{{ product.slug }}{% endif %}"
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<div class="space-y-1.5">
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
<textarea id="description" name="description" rows="5"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if editing and product.description %}{{ product.description }}{% endif %}</textarea>
</div>
<div class="space-y-1.5">
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
{% if editing and product.image %}
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
{% endif %}
<input id="image" name="image" type="file" accept="image/*"
class="block w-full text-sm text-on-surface file:mr-3 file:rounded-radius file:border-0 file:bg-primary file:px-3 file:py-2 file:text-sm file:font-medium file:text-on-primary dark:text-on-surface-dark dark:file:bg-primary-dark dark:file:text-on-primary-dark">
</div>
<label class="flex items-center gap-2">
<input type="checkbox" name="published" value="on" {% if editing and product.published %}checked{% endif %}
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
</label>
<div class="flex gap-3 pt-2">
<button type="submit"
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="save", lang=lang | default(value='sk')) }}
</button>
<a href="/admin/catalog/products"
class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
</div>
</form>
{% endblock content %}

View File

@@ -0,0 +1,81 @@
{% extends "admin/base.html" %}
{% block title %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-products-desc", lang=lang | default(value='sk')) }}</p>
</div>
<a href="/admin/catalog/products/new"
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="new-product", lang=lang | default(value='sk')) }}
</a>
</div>
<div class="mt-6 overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
{% if products | length > 0 %}
<table class="w-full text-left text-sm">
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
<tr>
<th class="px-4 py-3 font-semibold">{{ t(key="product", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="price", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="stock", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="status", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ t(key="actions", lang=lang | default(value='sk')) }}</th>
</tr>
</thead>
<tbody class="divide-y divide-outline dark:divide-outline-dark">
{% for product in products %}
<tr class="hover:bg-surface-alt dark:hover:bg-surface-dark-alt">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
{% if product.image %}
<img src="/images/{{ product.image }}" alt="" class="size-10 rounded-radius object-cover">
{% else %}
<div class="size-10 rounded-radius bg-surface-alt dark:bg-surface-dark-alt"></div>
{% endif %}
<div>
<div class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</div>
{% if product.category_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ product.category_name }}</div>{% endif %}
</div>
</div>
</td>
<td class="px-4 py-3 tabular-nums">{{ product.price }} {{ product.currency }}</td>
<td class="px-4 py-3 tabular-nums">{{ product.stock }}</td>
<td class="px-4 py-3">
{% if product.published %}
<span class="inline-flex rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
{% else %}
<span class="inline-flex rounded-full bg-surface-alt px-2 py-0.5 text-xs font-medium text-on-surface/70 dark:bg-surface-dark-alt dark:text-on-surface-dark/70">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
{% endif %}
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap justify-end gap-2">
<a href="/admin/catalog/products/{{ product.id }}/edit"
class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="edit", lang=lang | default(value='sk')) }}</a>
<a href="/shop/{{ product.slug }}"
class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="view", lang=lang | default(value='sk')) }}</a>
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
<button type="submit" class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-danger transition hover:bg-danger/10 dark:border-outline-dark">{{ t(key="delete", lang=lang | default(value='sk')) }}</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
<a href="/admin/catalog/products/new"
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="new-product", lang=lang | default(value='sk')) }}
</a>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -1,60 +1,29 @@
{% extends "admin/base.html" %}
{% block title %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}dashboard{% endblock crumb %}
{% block crumb %}{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}{% 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 class="space-y-1">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ admin.email }}</p>
</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 class="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<a href="/admin/catalog/products"
class="flex flex-col gap-2 rounded-radius border border-outline bg-surface p-5 transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
<h2 class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h2>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-products-desc", lang=lang | default(value='sk')) }}</p>
</a>
<a href="/admin/catalog/categories"
class="flex flex-col gap-2 rounded-radius border border-outline bg-surface p-5 transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
<h2 class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-categories", lang=lang | default(value='sk')) }}</h2>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-categories-desc", lang=lang | default(value='sk')) }}</p>
</a>
<a href="/admin/orders"
class="flex flex-col gap-2 rounded-radius border border-outline bg-surface p-5 transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
<h2 class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</h2>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</p>
</a>
</div>
{% endblock content %}

View File

@@ -1,32 +1,60 @@
{% 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
class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<div
class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="nav-admin", lang=lang | default(value='sk')) }}
</span>
<span
class="rounded-radius border border-danger/40 px-2 py-0.5 text-xs font-medium text-danger">
{{ 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>
<div class="p-5">
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ 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
class="mt-3 rounded-radius border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
role="alert">
✗ {{ t(key="login-error", lang=lang | default(value='sk')) }}
</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">
<form method="post" action="/admin/login" hx-boost="false" class="mt-4 flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="email"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-email", lang=lang | default(value='sk')) }}
</label>
<input type="email" id="email" name="email" required autofocus
autocomplete="email"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark dark:focus:ring-primary-dark">
</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 class="flex flex-col gap-1">
<label for="password"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-password", lang=lang | default(value='sk')) }}
</label>
<input type="password" id="password" name="password" required
autocomplete="current-password"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark dark:focus:ring-primary-dark">
</div>
<button class="btn btn-primary mt-2 w-full">[ {{ t(key="login-auth", lang=lang | default(value='sk')) }} ]</button>
<button type="submit"
class="mt-1 w-full rounded-radius bg-primary px-4 py-2 text-sm font-semibold text-on-primary transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-surface-alt dark:bg-primary-dark dark:text-on-primary-dark dark:focus:ring-primary-dark dark:focus:ring-offset-surface-dark-alt">
{{ t(key="login-auth", lang=lang | default(value='sk')) }}
</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,41 @@
{% extends "admin/base.html" %}
{% block title %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</h1>
<div class="mt-6 overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
{% if orders | length > 0 %}
<table class="w-full text-left text-sm">
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
<tr>
<th class="px-4 py-3 font-semibold">{{ t(key="order-number", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="order-customer", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="order-status", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-outline dark:divide-outline-dark">
{% for order in orders %}
<tr class="hover:bg-surface-alt dark:hover:bg-surface-dark-alt">
<td class="px-4 py-3 font-mono font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</td>
<td class="px-4 py-3">{{ order.email }}</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full bg-surface-alt px-2 py-0.5 text-xs font-medium text-on-surface/80 dark:bg-surface-dark-alt dark:text-on-surface-dark/80">{{ t(key="order-status-" ~ order.status, lang=lang | default(value='sk')) }}</span>
</td>
<td class="px-4 py-3 text-right tabular-nums">{{ order.total }} {{ order.currency }}</td>
<td class="px-4 py-3 text-right">
<a href="/admin/orders/{{ order.id }}" class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="view", lang=lang | default(value='sk')) }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="px-4 py-16 text-center text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-orders", lang=lang | default(value='sk')) }}</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,84 @@
{% extends "admin/base.html" %}
{% block title %}{{ order.order_number }}{% endblock title %}
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="font-mono text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</h1>
<a href="/admin/orders" class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</a>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-3">
<div class="space-y-6 lg:col-span-2">
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
<table class="w-full text-left text-sm">
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
<tr>
<th class="px-4 py-3 font-semibold">{{ t(key="product", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="quantity", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</th>
</tr>
</thead>
<tbody class="divide-y divide-outline dark:divide-outline-dark">
{% for item in items %}
<tr>
<td class="px-4 py-3">{{ item.product_name }}</td>
<td class="px-4 py-3 tabular-nums">{{ item.quantity }}</td>
<td class="px-4 py-3 text-right tabular-nums">{{ item.line_total }} {{ order.currency }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="border-t border-outline dark:border-outline-dark">
<tr>
<td colspan="2" class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</td>
<td class="px-4 py-3 text-right font-bold tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
<aside class="space-y-6">
<div class="space-y-3 rounded-radius border border-outline bg-surface p-5 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<div>
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="order-customer", lang=lang | default(value='sk')) }}</p>
<p class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.customer_name }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.email }}</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}<br>{{ order.zip }} {{ order.city }}<br>{{ order.country }}</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.carrier_name }} — {{ order.shipping }} {{ order.currency }}</p>
{% if order.pickup_point_name %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.pickup_point_name }}</p>{% endif %}
</div>
<div>
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">
{% if order.payment_method == "bank_transfer" %}{{ t(key="payment-bank", lang=lang | default(value='sk')) }} · VS {{ order.variable_symbol }}{% else %}{{ t(key="payment-cod", lang=lang | default(value='sk')) }}{% endif %}
</p>
</div>
{% if order.note %}
<div>
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.note }}</p>
</div>
{% endif %}
</div>
<form method="post" action="/admin/orders/{{ order.id }}/status" class="space-y-3 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<label for="status" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-status", lang=lang | default(value='sk')) }}</label>
<select id="status" name="status"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
{% for status in statuses %}
<option value="{{ status }}" {% if order.status == status %}selected{% endif %}>{{ t(key="order-status-" ~ status, lang=lang | default(value='sk')) }}</option>
{% endfor %}
</select>
<button type="submit" class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="order-update-status", lang=lang | default(value='sk')) }}</button>
</form>
</aside>
</div>
{% endblock content %}

View File

@@ -0,0 +1,37 @@
{% extends "admin/base.html" %}
{% block title %}{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<header class="space-y-1">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-shipping-desc", lang=lang | default(value='sk')) }}</p>
</header>
<div class="mt-6 space-y-4">
{% for method in methods %}
<form method="post" action="/admin/shipping/{{ method.id }}"
class="flex flex-wrap items-end gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="min-w-40">
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ method.name }}</p>
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.code }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}</p>
</div>
<div class="space-y-1.5">
<label for="price-{{ method.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
<input id="price-{{ method.id }}" name="price" type="text" inputmode="decimal" value="{{ method.price }}"
class="w-28 rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<label class="flex items-center gap-2 pb-2">
<input type="checkbox" name="enabled" value="on" {% if method.enabled %}checked{% endif %}
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shipping-enabled", lang=lang | default(value='sk')) }}</span>
</label>
<button type="submit"
class="ml-auto inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="save", lang=lang | default(value='sk')) }}
</button>
</form>
{% endfor %}
</div>
{% endblock content %}

View File

@@ -1,140 +0,0 @@
{% 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

@@ -1,96 +0,0 @@
{% 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

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

View File

@@ -11,356 +11,191 @@
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png">
<link rel="manifest" href="/static/favicon/site.webmanifest">
<script>
// Apply the saved theme before first paint to avoid a flash.
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);
document.dispatchEvent(new CustomEvent('theme:changed', { detail: t }));
}
applyTheme(localStorage.getItem('theme') || 'dark');
function currentTheme() { return localStorage.getItem('theme') || 'dark'; }
applyTheme(currentTheme());
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if ((localStorage.getItem('theme') || 'dark') === 'system') applyTheme('system');
if (currentTheme() === 'system') applyTheme('system');
});
// Mark the active top-nav link via aria-current (styled with Tailwind).
function markActiveNav() {
var path = location.pathname;
document.querySelectorAll('.term-navlinks a[data-nav]').forEach(function (a) {
document.querySelectorAll('a[data-nav]').forEach(function (a) {
var h = a.getAttribute('data-nav');
a.classList.toggle('is-active', h === path || (h !== '/' && path.indexOf(h) === 0));
var on = h === path || (h !== '/' && path.indexOf(h) === 0);
if (on) a.setAttribute('aria-current', 'page');
else a.removeAttribute('aria-current');
});
}
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; }
});
document.addEventListener('DOMContentLoaded', markActiveNav);
document.addEventListener('htmx:afterSwap', markActiveNav);
</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">
<link href="/static/css/app.css?v=2026-06-16" 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>
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
</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>
<body hx-boost="true"
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }"
x-init="window.matchMedia('(min-width: 1024px)').addEventListener('change', e => lg = e.matches)"
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
<header
class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
<nav x-data="{ mobile: false }" class="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3">
<!-- category sidebar toggle (mobile only) -->
<button type="button" @click="cats = !cats" :aria-expanded="cats"
aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt lg:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<a href="/"
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="brand", lang=lang | default(value='sk')) }}
</a>
<!-- desktop links -->
<ul class="ml-2 hidden items-center gap-1 md:flex">
<li><a href="/" data-nav="/" class="rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary aria-[current=page]:text-primary aria-[current=page]:font-semibold dark:text-on-surface-dark dark:hover:bg-surface-dark-alt dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/shop" data-nav="/shop" class="rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary aria-[current=page]:text-primary aria-[current=page]:font-semibold dark:text-on-surface-dark dark:hover:bg-surface-dark-alt dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-shop", 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><a href="/admin/dashboard" hx-boost="false" data-nav="/admin" class="rounded-radius px-3 py-1.5 text-sm font-medium text-warning transition hover:bg-surface-alt dark:hover:bg-surface-dark-alt">{{ 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>
<button type="submit" class="rounded-radius px-3 py-1.5 text-sm font-medium text-danger transition hover:bg-surface-alt dark:hover:bg-surface-dark-alt">{{ 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>
<li><a href="/admin/login" data-nav="/admin/login" class="rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary aria-[current=page]:text-primary aria-[current=page]:font-semibold dark:text-on-surface-dark dark:hover:bg-surface-dark-alt dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ 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')) }}">
<!-- right side: cart + settings + mobile toggle -->
<div class="ml-auto flex items-center gap-1">
<!-- cart with live item-count badge read from the `cart` cookie -->
<a href="/cart" data-nav="/cart"
x-data="{ count: 0 }"
x-init="count = (function(){ var m = document.cookie.split('; ').find(function(c){return c.indexOf('cart=')===0}); if(!m) return 0; var v = decodeURIComponent(m.split('=')[1]||''); if(!v) return 0; return v.split(',').reduce(function(s,e){ return s + (parseInt(e.split(':')[1])||0) }, 0) })()"
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
class="relative inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
<span x-show="count > 0" x-cloak x-text="count"
class="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-on-primary dark:bg-primary-dark dark:text-on-primary-dark"></span>
</a>
<!-- settings (language + theme) dropdown -->
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative">
<button type="button" @click="open = !open" :aria-expanded="open"
aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}"
title="{{ t(key='settings', lang=lang | default(value='sk')) }}"
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
<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">
stroke="currentColor" class="size-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>
</button>
<div x-show="open" x-cloak @click.outside="open = false"
x-transition.origin.top.right
class="absolute right-0 mt-2 w-56 rounded-radius border border-outline bg-surface p-2 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
<form method="post" action="/lang" hx-boost="false">
<p class="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
{{ t(key="settings-language", lang=lang | default(value='sk')) }}
</p>
<button type="submit" name="lang" value="en"
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
<span>English</span>
{% if lang | default(value='sk') == "en" %}<span class="text-primary dark:text-primary-dark"></span>{% endif %}
</button>
<button type="submit" name="lang" value="sk"
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
<span>Slovenčina</span>
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark"></span>{% endif %}
</button>
</form>
<p class="mt-1 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
</p>
<div x-data="{ theme: currentTheme() }" @theme:changed.document="theme = $event.detail">
<button type="button" @click="setTheme('system')"
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
<span>{{ t(key="theme-system", lang=lang | default(value='sk')) }}</span>
<span x-show="theme === 'system'" class="text-primary dark:text-primary-dark"></span>
</button>
<button type="button" @click="setTheme('light')"
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
<span>{{ t(key="theme-light", lang=lang | default(value='sk')) }}</span>
<span x-show="theme === 'light'" class="text-primary dark:text-primary-dark"></span>
</button>
<button type="button" @click="setTheme('dark')"
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
<span>{{ t(key="theme-dark", lang=lang | default(value='sk')) }}</span>
<span x-show="theme === 'dark'" class="text-primary dark:text-primary-dark"></span>
</button>
</div>
</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>
<!-- mobile hamburger -->
<button type="button" @click="mobile = !mobile" :aria-expanded="mobile"
aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt md:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
</div>
<!-- mobile menu panel -->
<ul x-show="mobile" x-cloak @click.outside="mobile = false" x-transition
class="absolute inset-x-0 top-full mx-4 mt-2 flex flex-col gap-1 rounded-radius border border-outline bg-surface p-2 shadow-lg md:hidden dark:border-outline-dark dark:bg-surface-dark-alt">
<li><a href="/" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/shop" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a></li>
{% if logged_in_admin %}
<li><a href="/admin/dashboard" hx-boost="false" class="block rounded-radius px-3 py-2 text-sm font-medium text-warning hover:bg-surface-alt dark:hover:bg-surface-dark">{{ 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="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger hover:bg-surface-alt dark:hover:bg-surface-dark">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</li>
{% else %}
<li><a href="/admin/login" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li>
{% endif %}
</ul>
</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>
<!-- dark overlay behind the category drawer on small screens -->
<div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true"
class="fixed inset-0 z-30 bg-black/50 lg:hidden"></div>
<div class="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
<!-- persistent category sidebar (off-canvas drawer on mobile).
hx-preserve keeps this node across boosted page swaps, so it is
fetched once (hx-trigger=load) and never reloaded on navigation. -->
<aside id="category-sidebar" hx-preserve="true"
x-cloak x-show="cats || lg" aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
hx-get="/partials/categories" hx-trigger="load"
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:static lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
</aside>
<main class="min-w-0 flex-1">
{% block content %}{% endblock content %}
</main>
</div>
</body>
</html>

View File

@@ -1,81 +0,0 @@
{% 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

@@ -1,56 +0,0 @@
{% 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

@@ -1,186 +1,29 @@
{% extends "base.html" %}
{% block title %}{{ t(key="home-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{% endblock crumb %}
{% block title %}{{ t(key="brand", lang=lang | default(value='sk')) }}{% endblock title %}
{% 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>
<div class="space-y-12">
<!-- hero -->
<section class="rounded-radius border border-outline bg-surface-alt px-6 py-12 text-center dark:border-outline-dark dark:bg-surface-dark-alt">
<h1 class="text-4xl font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1>
<p class="mx-auto mt-3 max-w-xl text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
<a href="/shop" class="mt-6 inline-flex items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
</section>
{% 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>
<!-- featured products -->
{% if products | length > 0 %}
<section class="space-y-5">
<div class="flex items-end justify-between">
<h2 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h2>
<a href="/shop" class="text-sm font-medium text-primary dark:text-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }} →</a>
</div>
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
{% else %}
<div class="term-empty">
<p class="font-medium">{{ t(key="home-no-posts", lang=lang | default(value='sk')) }}</p>
</div>
</section>
{% 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 %}
</div>
{% endblock content %}

View File

@@ -1,45 +0,0 @@
{% 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 %}

View File

@@ -0,0 +1,12 @@
<a href="/shop/{{ product.slug }}"
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
<div class="aspect-square overflow-hidden bg-surface-alt dark:bg-surface-dark">
{% if product.image %}
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition group-hover:scale-105">
{% endif %}
</div>
<div class="flex flex-1 flex-col gap-1 p-4">
<h3 class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
<p class="mt-auto pt-2 font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
</div>
</a>

View File

@@ -0,0 +1,27 @@
{# Site-wide category sidebar contents, served as an htmx partial and swapped
into the <aside> in base.html. `category_tree` is a depth-ordered flat list
of { name, slug, depth }; nesting is shown via left indentation. Active state
is set client-side by markActiveNav() via data-nav + aria-current. #}
<p class="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
{{ t(key="categories", lang=lang | default(value='sk')) }}
</p>
<ul class="flex flex-col gap-0.5">
<li>
<a href="/shop" data-nav="/shop"
class="block rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
{{ t(key="all-products", lang=lang | default(value='sk')) }}
</a>
</li>
{% for item in category_tree %}
<li>
<a href="/category/{{ item.slug }}" data-nav="/category/{{ item.slug }}" style="padding-left: {{ 12 + item.depth * 16 }}px"
class="flex items-center gap-1.5 rounded-radius py-1.5 pr-3 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
{% if item.depth > 0 %}<span class="text-on-surface/40 dark:text-on-surface-dark/40"></span>{% endif %}
<span class="truncate">{{ item.name }}</span>
</a>
</li>
{% endfor %}
</ul>
{% if category_tree | length == 0 %}
<p class="px-3 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
{% endif %}

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}{{ t(key="cart-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="space-y-6">
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-title", lang=lang | default(value='sk')) }}</h1>
{% if items | length > 0 %}
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
<table class="w-full text-left text-sm">
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
<tr>
<th class="px-4 py-3 font-semibold">{{ t(key="product", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="price", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="quantity", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-outline dark:divide-outline-dark">
{% for item in items %}
<tr>
<td class="px-4 py-3">
<a href="/shop/{{ item.slug }}" class="font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
</td>
<td class="px-4 py-3 tabular-nums">{{ item.price }} {{ item.currency }}</td>
<td class="px-4 py-3">
<form method="post" action="/cart/update" hx-boost="false" class="flex items-center gap-2">
<input type="hidden" name="product_id" value="{{ item.id }}">
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
class="w-20 rounded-radius border border-outline bg-surface px-2 py-1 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
<button type="submit" class="rounded-radius border border-outline px-2 py-1 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-update", lang=lang | default(value='sk')) }}</button>
</form>
</td>
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ item.currency }}</td>
<td class="px-4 py-3 text-right">
<form method="post" action="/cart/remove" hx-boost="false">
<input type="hidden" name="product_id" value="{{ item.id }}">
<button type="submit" class="text-xs font-medium text-danger hover:underline">{{ t(key="cart-remove", lang=lang | default(value='sk')) }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="border-t border-outline dark:border-outline-dark">
<tr>
<td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td>
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div class="flex flex-wrap justify-between gap-3">
<a href="/shop" class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
<a href="/checkout" class="inline-flex items-center justify-center rounded-radius bg-primary px-5 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-checkout", lang=lang | default(value='sk')) }}</a>
</div>
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center dark:border-outline-dark">
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="cart-empty", lang=lang | default(value='sk')) }}</p>
<a href="/shop" class="mt-4 inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}{{ category.name }}{% endblock title %}
{% block content %}
<div class="space-y-8">
<header class="space-y-2">
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
{% for crumb in breadcrumbs %}
<span class="px-1">/</span>
<a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a>
{% endfor %}
<span class="px-1">/</span>
<span>{{ category.name }}</span>
</nav>
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1>
{% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %}
{% if children | length > 0 %}
<div class="flex flex-wrap gap-2 pt-1">
{% for child in children %}
<a href="/category/{{ child.slug }}"
class="rounded-full border border-outline px-3 py-1 text-sm font-medium text-on-surface transition hover:border-primary hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:border-primary-dark dark:hover:text-primary-dark">{{ child.name }}</a>
{% endfor %}
</div>
{% endif %}
</header>
{% if products | length > 0 %}
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
{% if packeta_api_key %}<script src="https://widget.packeta.com/v6/www/js/library.js"></script>{% endif %}
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-title", lang=lang | default(value='sk')) }}</h1>
<form method="post" action="/checkout" hx-boost="false"
x-data="{
paymentMethod: '',
carrier: '',
carrierPrice: 0,
requiresPoint: false,
pointId: '',
pointName: '',
subtotal: {{ subtotal_cents }},
packetaKey: '{{ packeta_api_key }}',
fmt(c) { return (c / 100).toFixed(2) },
pickPoint() {
Packeta.Widget.pick(this.packetaKey, (point) => {
if (point) { this.pointId = String(point.id); this.pointName = point.formatedValue || point.name }
})
},
get canSubmit() {
return this.paymentMethod && this.carrier && (!this.requiresPoint || this.pointId)
}
}"
class="mt-6 grid gap-8 lg:grid-cols-3">
<div class="space-y-6 lg:col-span-2">
<!-- contact -->
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-contact", lang=lang | default(value='sk')) }}</legend>
<div class="space-y-1.5">
<label for="email" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-email", lang=lang | default(value='sk')) }}</label>
<input id="email" name="email" type="email" required
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<div class="space-y-1.5">
<label for="customer_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-name", lang=lang | default(value='sk')) }}</label>
<input id="customer_name" name="customer_name" type="text" required
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
</fieldset>
<!-- shipping address -->
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
<div class="space-y-1.5">
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}</label>
<input id="address" name="address" type="text" required
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="space-y-1.5">
<label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}</label>
<input id="city" name="city" type="text" required
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<div class="space-y-1.5">
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}</label>
<input id="zip" name="zip" type="text" required
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<div class="space-y-1.5">
<label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}</label>
<input id="country" name="country" type="text" required value="Slovensko"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
</div>
</fieldset>
<!-- carrier -->
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</legend>
{% for m in shipping_methods %}
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
<span class="flex items-center gap-3">
<input type="radio" name="carrier_code" value="{{ m.code }}" required
@change="carrier='{{ m.code }}'; carrierPrice={{ m.price_cents }}; requiresPoint={{ m.requires_pickup_point }}; pointId=''; pointName=''"
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</span>
</span>
<span class="tabular-nums text-on-surface/80 dark:text-on-surface-dark/80">{{ m.price }} {{ currency }}</span>
</label>
{% endfor %}
<!-- pickup point (carriers that need one, e.g. Packeta) -->
<div x-show="requiresPoint" x-cloak class="space-y-2 pt-1">
<input type="hidden" name="pickup_point_id" x-model="pointId">
<input type="hidden" name="pickup_point_name" x-model="pointName">
{% if packeta_api_key %}
<button type="button" @click="pickPoint()"
class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
{{ t(key="checkout-pick-point", lang=lang | default(value='sk')) }}
</button>
<p x-show="pointName" x-cloak class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">
<span class="font-medium">{{ t(key="checkout-chosen-point", lang=lang | default(value='sk')) }}:</span> <span x-text="pointName"></span>
</p>
{% else %}
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}</label>
<input type="text" x-model="pointName" @input="pointId = pointName"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
{% endif %}
</div>
</fieldset>
<!-- payment -->
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}</legend>
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
<input type="radio" name="payment_method" value="cod" required x-model="paymentMethod"
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-cod", lang=lang | default(value='sk')) }}</span>
</label>
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
<input type="radio" name="payment_method" value="bank_transfer" required x-model="paymentMethod"
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank", lang=lang | default(value='sk')) }}</span>
</label>
</fieldset>
<div class="space-y-1.5">
<label for="note" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</label>
<textarea id="note" name="note" rows="3"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark"></textarea>
</div>
</div>
<!-- summary -->
<aside class="h-fit space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-summary", lang=lang | default(value='sk')) }}</h2>
<ul class="space-y-2 text-sm">
{% for item in items %}
<li class="flex justify-between gap-2">
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.name }} × {{ item.quantity }}</span>
<span class="tabular-nums">{{ item.line_total }} {{ item.currency }}</span>
</li>
{% endfor %}
</ul>
<div class="space-y-1 border-t border-outline pt-3 text-sm dark:border-outline-dark">
<div class="flex justify-between">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums">{{ subtotal }} {{ currency }}</span>
</div>
<div class="flex justify-between">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-shipping-cost", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums" x-text="fmt(carrierPrice) + ' {{ currency }}'"></span>
</div>
</div>
<div class="flex justify-between border-t border-outline pt-3 text-base font-bold dark:border-outline-dark">
<span>{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' {{ currency }}'"></span>
</div>
<button type="submit" :disabled="!canSubmit"
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="checkout-place-order", lang=lang | default(value='sk')) }}
</button>
</aside>
</form>
{% endblock content %}

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="space-y-8">
<header class="space-y-2">
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1>
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
</header>
{% if products | length > 0 %}
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="mx-auto max-w-2xl space-y-6">
<div class="text-center">
<div class="mx-auto flex size-14 items-center justify-center rounded-full bg-success/15 text-success">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<h1 class="mt-3 text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}</h1>
<p class="mt-1 text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-confirmed-sub", lang=lang | default(value='sk')) }}</p>
</div>
<div class="rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="flex flex-wrap justify-between gap-2 border-b border-outline pb-3 dark:border-outline-dark">
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-number", lang=lang | default(value='sk')) }}</span>
<span class="font-mono font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</span>
</div>
<ul class="space-y-2 py-3 text-sm">
{% for item in items %}
<li class="flex justify-between gap-2">
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }} × {{ item.quantity }}</span>
<span class="tabular-nums">{{ item.line_total }} {{ order.currency }}</span>
</li>
{% endfor %}
</ul>
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark">
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} {{ order.currency }}</span></div>
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} {{ order.currency }}</span></div>
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
</div>
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</span>
</div>
</div>
{% if order.payment_method == "bank_transfer" %}
<div class="space-y-2 rounded-radius border border-primary/40 bg-primary/5 p-6 text-sm dark:border-primary-dark/40">
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank-instructions", lang=lang | default(value='sk')) }}</p>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} {{ order.currency }}</span>
</div>
</div>
{% else %}
<div class="rounded-radius border border-outline bg-surface p-4 text-sm text-on-surface/80 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/80">
{{ t(key="payment-cod-note", lang=lang | default(value='sk')) }}
</div>
{% endif %}
<div class="text-center">
<a href="/shop" class="inline-flex items-center justify-center rounded-radius border border-outline px-5 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}{{ product.name }}{% endblock title %}
{% block content %}
<div class="grid gap-10 lg:grid-cols-2">
<!-- gallery -->
<div x-data="{ active: 0 }" class="space-y-4">
<div class="aspect-square overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt">
{% if images | length > 0 %}
{% for image in images %}
<img x-show="active === {{ loop.index0 }}" src="/images/{{ image }}" alt="{{ product.name }}" class="size-full object-cover">
{% endfor %}
{% endif %}
</div>
{% if images | length > 1 %}
<div class="flex flex-wrap gap-2">
{% for image in images %}
<button type="button" @click="active = {{ loop.index0 }}"
:class="active === {{ loop.index0 }} ? 'border-primary dark:border-primary-dark' : 'border-outline dark:border-outline-dark'"
class="size-16 overflow-hidden rounded-radius border">
<img src="/images/{{ image }}" alt="" class="size-full object-cover">
</button>
{% endfor %}
</div>
{% endif %}
</div>
<!-- details -->
<div class="space-y-6">
{% if category %}
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
{% endif %}
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
<p class="text-2xl font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
{% if product.description %}
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
{% endif %}
{% if product.stock > 0 %}
<form method="post" action="/cart/add" hx-boost="false" class="flex flex-wrap items-end gap-3">
<input type="hidden" name="product_id" value="{{ product.id }}">
<div class="space-y-1.5">
<label for="quantity" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="quantity", lang=lang | default(value='sk')) }}</label>
<input id="quantity" name="quantity" type="number" min="1" max="{{ product.stock }}" value="1"
class="w-24 rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<button type="submit"
class="inline-flex items-center justify-center rounded-radius bg-primary px-5 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}
</button>
</form>
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
{% else %}
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
{% endif %}
</div>
</div>
{% endblock content %}

View File

@@ -105,3 +105,9 @@ auth:
settings:
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}
uploads_root: {{ get_env(name="UPLOADS_ROOT", default="uploads") }}
# Packeta (Zásilkovna) web API key for the pickup-point picker widget.
# Empty falls back to a plain text field for the pickup point.
packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }}
# Bank-transfer payment details shown on the order confirmation.
bank_iban: {{ get_env(name="BANK_IBAN", default="SK00 0000 0000 0000 0000 0000") }}
bank_account_name: {{ get_env(name="BANK_ACCOUNT_NAME", default="Kompress s.r.o.") }}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -15,6 +15,18 @@ mod m20260517_000010_drop_user_roles;
mod m20260517_000011_site_pages;
mod m20260517_000012_standalone_audio_tracks;
mod m20260616_123506_categories;
mod m20260616_123524_products;
mod m20260616_123550_product_images;
mod m20260616_123611_product_tags;
mod m20260616_123957_create_join_table_products_and_product_tags;
mod m20260616_130610_orders;
mod m20260616_130628_order_items;
mod m20260616_131000_drop_audio_tables;
mod m20260616_132000_drop_blog_and_pages;
mod m20260616_150755_shipping_methods;
mod m20260616_150812_add_shipping_fields_to_orders;
mod m20260616_160000_add_parent_to_categories;
pub struct Migrator;
#[async_trait::async_trait]
@@ -34,7 +46,19 @@ impl MigratorTrait for Migrator {
Box::new(m20260517_000010_drop_user_roles::Migration),
Box::new(m20260517_000011_site_pages::Migration),
Box::new(m20260517_000012_standalone_audio_tracks::Migration),
Box::new(m20260616_123506_categories::Migration),
Box::new(m20260616_123524_products::Migration),
Box::new(m20260616_123550_product_images::Migration),
Box::new(m20260616_123611_product_tags::Migration),
Box::new(m20260616_123957_create_join_table_products_and_product_tags::Migration),
Box::new(m20260616_130610_orders::Migration),
Box::new(m20260616_130628_order_items::Migration),
Box::new(m20260616_131000_drop_audio_tables::Migration),
Box::new(m20260616_132000_drop_blog_and_pages::Migration),
Box::new(m20260616_150755_shipping_methods::Migration),
Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration),
Box::new(m20260616_160000_add_parent_to_categories::Migration),
// inject-above (do not remove this comment)
]
}
}
}

View File

@@ -0,0 +1,30 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "categories",
&[
("id", ColType::PkAuto),
("name", ColType::String),
("slug", ColType::StringUniq),
("description", ColType::TextNull),
("image_id", ColType::StringNull),
("position", ColType::IntegerWithDefault(0)),
("published", ColType::BooleanWithDefault(false)),
],
&[
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "categories").await
}
}

View File

@@ -0,0 +1,35 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "products",
&[
("id", ColType::PkAuto),
("name", ColType::String),
("slug", ColType::StringUniq),
("description", ColType::TextNull),
("price_cents", ColType::BigInteger),
("currency", ColType::StringWithDefault("EUR".to_string())),
("sku", ColType::StringNull),
("stock", ColType::IntegerWithDefault(0)),
("view_count", ColType::IntegerWithDefault(0)),
("published", ColType::BooleanWithDefault(false)),
("published_at", ColType::TimestampWithTimeZoneNull),
],
&[
("category?", ""),
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "products").await
}
}

View File

@@ -0,0 +1,28 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "product_images",
&[
("id", ColType::PkAuto),
("image_id", ColType::String),
("position", ColType::IntegerWithDefault(0)),
("alt", ColType::StringNull),
],
&[
("product", ""),
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "product_images").await
}
}

View File

@@ -0,0 +1,26 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "product_tags",
&[
("id", ColType::PkAuto),
("name", ColType::String),
("slug", ColType::StringUniq),
],
&[
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "product_tags").await
}
}

View File

@@ -0,0 +1,23 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_join_table_without_timestamps(m, "product_product_tags",
&[
],
&[
("product", ""),
("product_tag", ""),
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "product_product_tags").await
}
}

View File

@@ -0,0 +1,35 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "orders",
&[
("id", ColType::PkAuto),
("order_number", ColType::StringUniq),
("email", ColType::String),
("customer_name", ColType::StringNull),
("status", ColType::StringWithDefault("pending".to_string())),
("total_cents", ColType::BigInteger),
("currency", ColType::StringWithDefault("EUR".to_string())),
("address", ColType::StringNull),
("city", ColType::StringNull),
("zip", ColType::StringNull),
("country", ColType::StringNull),
("note", ColType::TextNull),
],
&[
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "orders").await
}
}

View File

@@ -0,0 +1,29 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "order_items",
&[
("id", ColType::PkAuto),
("product_name", ColType::String),
("unit_price_cents", ColType::BigInteger),
("quantity", ColType::IntegerWithDefault(1)),
],
&[
("order", ""),
("product?", ""),
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "order_items").await
}
}

View File

@@ -0,0 +1,42 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum AudioTrackTags {
Table,
}
#[derive(DeriveIden)]
enum AudioTracks {
Table,
}
#[derive(DeriveIden)]
enum AudioTags {
Table,
}
#[derive(DeriveIden)]
enum AudioAlbums {
Table,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
// Drop child tables before parents to satisfy foreign keys.
m.drop_table(Table::drop().table(AudioTrackTags::Table).if_exists().to_owned())
.await?;
m.drop_table(Table::drop().table(AudioTracks::Table).if_exists().to_owned())
.await?;
m.drop_table(Table::drop().table(AudioTags::Table).if_exists().to_owned())
.await?;
m.drop_table(Table::drop().table(AudioAlbums::Table).if_exists().to_owned())
.await?;
Ok(())
}
async fn down(&self, _m: &SchemaManager) -> Result<(), DbErr> {
// The music domain has been retired; recreating it is out of scope.
Ok(())
}
}

View File

@@ -0,0 +1,29 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum BlogArticles {
Table,
}
#[derive(DeriveIden)]
enum SitePages {
Table,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_table(Table::drop().table(BlogArticles::Table).if_exists().to_owned())
.await?;
m.drop_table(Table::drop().table(SitePages::Table).if_exists().to_owned())
.await?;
Ok(())
}
async fn down(&self, _m: &SchemaManager) -> Result<(), DbErr> {
// The blog and static-pages domains have been retired.
Ok(())
}
}

View File

@@ -0,0 +1,30 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "shipping_methods",
&[
("id", ColType::PkAuto),
("code", ColType::StringUniq),
("name", ColType::String),
("price_cents", ColType::BigIntegerWithDefault(0)),
("requires_pickup_point", ColType::BooleanWithDefault(false)),
("enabled", ColType::BooleanWithDefault(true)),
("position", ColType::IntegerWithDefault(0)),
],
&[
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "shipping_methods").await
}
}

View File

@@ -0,0 +1,28 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
add_column(m, "orders", "payment_method", ColType::StringNull).await?;
add_column(m, "orders", "carrier_code", ColType::StringNull).await?;
add_column(m, "orders", "carrier_name", ColType::StringNull).await?;
add_column(m, "orders", "shipping_cents", ColType::BigIntegerWithDefault(0)).await?;
add_column(m, "orders", "pickup_point_id", ColType::StringNull).await?;
add_column(m, "orders", "pickup_point_name", ColType::StringNull).await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "orders", "payment_method").await?;
remove_column(m, "orders", "carrier_code").await?;
remove_column(m, "orders", "carrier_name").await?;
remove_column(m, "orders", "shipping_cents").await?;
remove_column(m, "orders", "pickup_point_id").await?;
remove_column(m, "orders", "pickup_point_name").await?;
Ok(())
}
}

View File

@@ -0,0 +1,55 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum Categories {
Table,
Id,
ParentId,
}
const FK_NAME: &str = "fk_categories_parent_id";
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.alter_table(
Table::alter()
.table(Categories::Table)
.add_column(ColumnDef::new(Categories::ParentId).integer().null())
.to_owned(),
)
.await?;
m.create_foreign_key(
ForeignKey::create()
.name(FK_NAME)
.from(Categories::Table, Categories::ParentId)
.to(Categories::Table, Categories::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade)
.to_owned(),
)
.await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_foreign_key(
ForeignKey::drop()
.name(FK_NAME)
.table(Categories::Table)
.to_owned(),
)
.await?;
m.alter_table(
Table::alter()
.table(Categories::Table)
.drop_column(Categories::ParentId)
.to_owned(),
)
.await
}
}

View File

@@ -1,10 +1,11 @@
pub mod models;
pub mod view;
use crate::{
account::models::users::{self, LoginParams, RegisterParams},
account::view::{CurrentResponse, LoginResponse},
mailers::auth::AuthMailer,
models::{
_entities::users,
users::{LoginParams, RegisterParams},
},
views::auth::{CurrentResponse, LoginResponse},
shared::guard::is_admin,
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use loco_rs::prelude::*;
@@ -22,18 +23,6 @@ fn get_allow_email_domain_re() -> &'static Regex {
})
}
fn admin_email(ctx: &AppContext) -> Option<&str> {
ctx.config
.settings
.as_ref()
.and_then(|settings| settings.get("admin_email"))
.and_then(|email| email.as_str())
}
fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
}
pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
Cookie::build((AUTH_COOKIE, token.to_string()))
.path("/")

View File

@@ -0,0 +1 @@
pub mod users;

View File

@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Map;
use uuid::Uuid;
pub use super::_entities::users::{self, ActiveModel, Entity, Model};
pub use crate::models::_entities::users::{self, ActiveModel, Entity, Model};
pub const MAGIC_LINK_LENGTH: i8 = 32;
pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5;
@@ -41,7 +41,7 @@ impl Validatable for ActiveModel {
}
#[async_trait::async_trait]
impl ActiveModelBehavior for super::_entities::users::ActiveModel {
impl ActiveModelBehavior for crate::models::_entities::users::ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
where
C: ConnectionTrait,

269
src/admin/categories.rs Normal file
View File

@@ -0,0 +1,269 @@
//! Admin category CRUD, including parent/child hierarchy management.
use std::collections::HashSet;
use axum::extract::{DefaultBodyLimit, Multipart};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, Set,
};
use serde_json::json;
use crate::{
admin::form::{read_multipart_form, store_image, MultipartForm},
i18n::current_lang,
media::IMAGE_MAX_BYTES,
shared::{
guard,
slug::{slugify, unique_slug},
},
shop::models::{categories, products},
};
async fn category_by_id(ctx: &AppContext, id: i32) -> Result<categories::Model> {
categories::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
/// Fields parsed from a category form.
struct CategoryFields {
name: String,
slug: String,
description: Option<String>,
position: i32,
published: bool,
parent_id: Option<i32>,
}
async fn parse_category_fields(
ctx: &AppContext,
form: &MultipartForm,
current_id: Option<i32>,
) -> Result<CategoryFields> {
let name = form
.text("name")
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
let description = form.text("description");
let position = form
.text("position")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let published = form.checked("published");
// Resolve the chosen parent, rejecting cycles: a category may not be its
// own parent nor be re-parented under one of its descendants.
let parent_id = match form.text("parent_id").and_then(|s| s.parse::<i32>().ok()) {
Some(parent_id) => {
categories::Entity::find_by_id(parent_id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::BadRequest("parent category not found".to_string()))?;
if let Some(id) = current_id {
if parent_id == id {
return Err(Error::BadRequest(
"a category cannot be its own parent".to_string(),
));
}
if categories::descendant_ids(&categories::all(ctx).await?, id).contains(&parent_id)
{
return Err(Error::BadRequest(
"a category cannot be moved under its own descendant".to_string(),
));
}
}
Some(parent_id)
}
None => None,
};
let desired = form
.text("slug")
.map(|s| slugify(&s))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| slugify(&name));
let slug = unique_slug(&desired, |candidate| {
let ctx = ctx.clone();
async move {
let mut query =
categories::Entity::find().filter(categories::Column::Slug.eq(candidate));
if let Some(id) = current_id {
query = query.filter(categories::Column::Id.ne(id));
}
Ok(query.count(&ctx.db).await? > 0)
}
})
.await?;
Ok(CategoryFields {
name,
slug,
description,
position,
published,
parent_id,
})
}
/// Build the parent-category dropdown options for the category form, as a
/// depth-ordered list of `{ id, name, depth }`. When editing, the category
/// itself and all of its descendants are excluded to keep the tree acyclic.
async fn form_context(
ctx: &AppContext,
jar: &CookieJar,
editing: Option<i32>,
) -> Result<serde_json::Value> {
let all = categories::all(ctx).await?;
let blocked: HashSet<i32> = match editing {
Some(id) => {
let mut set = categories::descendant_ids(&all, id);
set.insert(id);
set
}
None => HashSet::new(),
};
let parents: Vec<serde_json::Value> = categories::tree(&all)
.into_iter()
.filter(|(category, _)| !blocked.contains(&category.id))
.map(|(category, depth)| json!({ "id": category.id, "name": category.name, "depth": depth }))
.collect();
Ok(json!({ "parents": parents, "lang": current_lang(jar) }))
}
#[debug_handler]
async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let list = categories::all(&ctx).await?;
let mut rows = Vec::new();
for (category, depth) in categories::tree(&list) {
let product_count = products::Entity::find()
.filter(products::Column::CategoryId.eq(category.id))
.count(&ctx.db)
.await?;
rows.push(json!({ "category": category, "depth": depth, "product_count": product_count }));
}
format::view(
&v,
"admin/catalog/categories.html",
json!({ "categories": rows, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn new(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let mut context = form_context(&ctx, &jar, None).await?;
context["category"] = serde_json::Value::Null;
format::view(&v, "admin/catalog/category_form.html", context)
}
#[debug_handler]
async fn create(
auth: auth::JWT,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let form = read_multipart_form(multipart).await?;
let fields = parse_category_fields(&ctx, &form, None).await?;
let image_id = match form.image {
Some(data) => Some(store_image(&ctx, data).await?),
None => None,
};
categories::ActiveModel {
name: Set(fields.name),
slug: Set(fields.slug),
description: Set(fields.description),
image_id: Set(image_id),
position: Set(fields.position),
published: Set(fields.published),
parent_id: Set(fields.parent_id),
..Default::default()
}
.insert(&ctx.db)
.await?;
format::redirect("/admin/catalog/categories")
}
#[debug_handler]
async fn edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let mut context = form_context(&ctx, &jar, Some(id)).await?;
context["category"] = json!(category_by_id(&ctx, id).await?);
format::view(&v, "admin/catalog/category_form.html", context)
}
#[debug_handler]
async fn update(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let existing = category_by_id(&ctx, id).await?;
let form = read_multipart_form(multipart).await?;
let fields = parse_category_fields(&ctx, &form, Some(id)).await?;
let mut category = existing.into_active_model();
category.name = Set(fields.name);
category.slug = Set(fields.slug);
category.description = Set(fields.description);
category.position = Set(fields.position);
category.published = Set(fields.published);
category.parent_id = Set(fields.parent_id);
if let Some(data) = form.image {
category.image_id = Set(Some(store_image(&ctx, data).await?));
}
category.update(&ctx.db).await?;
format::redirect("/admin/catalog/categories")
}
#[debug_handler]
async fn delete(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
category_by_id(&ctx, id).await?.delete(&ctx.db).await?;
format::redirect("/admin/catalog/categories")
}
pub fn routes() -> Routes {
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
Routes::new()
.add("/admin/catalog/categories", get(index))
.add("/admin/catalog/categories/new", get(new))
.add(
"/admin/catalog/categories",
post(create).layer(image_limit.clone()),
)
.add("/admin/catalog/categories/{id}/edit", get(edit))
.add(
"/admin/catalog/categories/{id}",
post(update).layer(image_limit),
)
.add("/admin/catalog/categories/{id}/delete", post(delete))
}

87
src/admin/form.rs Normal file
View File

@@ -0,0 +1,87 @@
//! Multipart form handling shared by the product and category admin forms.
//!
//! Both forms submit a mix of text fields and an optional `image` file part;
//! this collects them into an easy-to-query [`MultipartForm`] and stores any
//! uploaded image through the configured storage driver.
use std::collections::HashMap;
use axum::extract::Multipart;
use loco_rs::prelude::*;
use crate::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR};
fn normalize_empty(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
})
}
/// Collected multipart form: text fields keyed by name, plus the raw bytes of
/// an `image` file part if one was uploaded (an empty file input is ignored).
pub(crate) struct MultipartForm {
fields: HashMap<String, String>,
pub(crate) image: Option<Vec<u8>>,
}
impl MultipartForm {
/// Trimmed value of a text field, `None` when missing or blank.
pub(crate) fn text(&self, key: &str) -> Option<String> {
normalize_empty(self.fields.get(key).cloned())
}
/// Whether a checkbox-style field is checked.
pub(crate) fn checked(&self, key: &str) -> bool {
matches!(
self.fields.get(key).map(String::as_str),
Some("on" | "true" | "1")
)
}
}
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
let mut fields = HashMap::new();
let mut image = None;
while let Some(mut field) = multipart
.next_field()
.await
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
{
let name = field.name().unwrap_or("").to_string();
if name == "image" {
let mut data = Vec::new();
while let Some(chunk) = field
.chunk()
.await
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
{
data.extend_from_slice(&chunk);
if data.len() > IMAGE_MAX_BYTES {
return Err(Error::BadRequest(format!(
"image is larger than {} MB",
IMAGE_MAX_BYTES / 1024 / 1024
)));
}
}
if !data.is_empty() {
image = Some(data);
}
} else {
let value = field
.text()
.await
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
fields.insert(name, value);
}
}
Ok(MultipartForm { fields, image })
}
/// Store an uploaded image's bytes and return its generated filename.
pub(crate) async fn store_image(ctx: &AppContext, data: Vec<u8>) -> Result<String> {
let extension = detect_image_extension(&data)?;
store_upload(ctx, IMAGE_STORAGE_DIR, extension, data).await
}

85
src/admin/login.rs Normal file
View File

@@ -0,0 +1,85 @@
//! Cookie-based admin login/logout pages (separate from the JSON `/api/auth`
//! flow used by the SPA/API).
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{
account::{self as auth_controller, models::users::{self, LoginParams}},
i18n::current_lang,
shared::guard,
};
fn login_error(v: &TeraView, jar: &CookieJar) -> Result<Response> {
format::view(
v,
"admin/login.html",
json!({
"error": "Invalid credentials",
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn login_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if guard::logged_in(&ctx, &jar).await {
return format::redirect("/admin/dashboard");
}
format::view(
&v,
"admin/login.html",
json!({
"error": null,
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn login(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<LoginParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
return login_error(&v, &jar);
};
if !user.verify_password(&params.password) || !guard::is_admin(&ctx, &user) {
return login_error(&v, &jar);
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.redirect("/admin/dashboard")
}
#[debug_handler]
async fn logout() -> Result<Response> {
format::render()
.cookies(&[auth_controller::clear_auth_cookie()])?
.redirect("/admin/login")
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin", get(login_page))
.add("/admin/login", get(login_page))
.add("/admin/login", post(login))
.add("/admin/logout", post(logout))
}

63
src/admin/mod.rs Normal file
View File

@@ -0,0 +1,63 @@
//! Admin area. Each surface lives in its own submodule; this module holds the
//! dashboard (HTML home + JSON stats) and is the entry point for admin routes.
pub mod categories;
pub mod form;
pub mod login;
pub mod models;
pub mod orders;
pub mod products;
pub mod shipping;
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{EntityTrait, PaginatorTrait};
use serde::Serialize;
use serde_json::json;
use crate::{i18n::current_lang, models::_entities, shared::guard};
#[derive(Debug, Serialize)]
struct DashboardResponse {
users: u64,
products: u64,
categories: u64,
orders: u64,
audit_logs: u64,
}
/// JSON dashboard stats, served under `/api/admin`.
#[debug_handler]
async fn dashboard_json(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
format::json(DashboardResponse {
users: _entities::users::Entity::find().count(&ctx.db).await?,
products: _entities::products::Entity::find().count(&ctx.db).await?,
categories: _entities::categories::Entity::find().count(&ctx.db).await?,
orders: _entities::orders::Entity::find().count(&ctx.db).await?,
audit_logs: _entities::audit_logs::Entity::find().count(&ctx.db).await?,
})
}
/// HTML admin home page.
#[debug_handler]
async fn dashboard_page(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let admin_user = guard::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/index.html",
json!({ "admin": admin_user, "lang": current_lang(&jar) }),
)
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/dashboard", get(dashboard_page))
.add("/api/admin/dashboard", get(dashboard_json))
}

View File

@@ -1,4 +1,4 @@
pub use super::_entities::audit_logs::{ActiveModel, Entity, Model};
pub use crate::models::_entities::audit_logs::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type AuditLogs = Entity;

1
src/admin/models/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod audit_logs;

106
src/admin/orders.rs Normal file
View File

@@ -0,0 +1,106 @@
//! Admin order list, detail, and status updates.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::Deserialize;
use serde_json::json;
use crate::{
checkout::{
models::{order_items, orders},
view,
},
i18n::current_lang,
shared::{guard, settings},
};
pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
#[derive(Debug, Deserialize)]
struct StatusForm {
status: String,
}
#[debug_handler]
async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let list = orders::Entity::find()
.order_by_desc(orders::Column::CreatedAt)
.all(&ctx.db)
.await?;
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
format::view(
&v,
"admin/orders/index.html",
json!({ "orders": rows, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn show(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let order = orders::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let items = order_items::Entity::find()
.filter(order_items::Column::OrderId.eq(order.id))
.all(&ctx.db)
.await?;
format::view(
&v,
"admin/orders/show.html",
json!({
"order": view::detail(
&order,
settings::get(&ctx, "bank_iban").unwrap_or(""),
settings::get(&ctx, "bank_account_name").unwrap_or(""),
),
"items": view::items(&items),
"statuses": ORDER_STATUSES,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn update_status(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<StatusForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
if !ORDER_STATUSES.contains(&form.status.as_str()) {
return Err(Error::BadRequest("invalid status".to_string()));
}
let order = orders::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = order.into_active_model();
active.status = Set(form.status);
active.update(&ctx.db).await?;
format::redirect(&format!("/admin/orders/{id}"))
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/orders", get(index))
.add("/admin/orders/{id}", get(show))
.add("/admin/orders/{id}/status", post(update_status))
}

286
src/admin/products.rs Normal file
View File

@@ -0,0 +1,286 @@
//! Admin product CRUD.
use axum::extract::{DefaultBodyLimit, Multipart};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use serde_json::json;
use crate::{
admin::form::{read_multipart_form, store_image, MultipartForm},
i18n::current_lang,
media::IMAGE_MAX_BYTES,
shared::{
guard,
money::parse_price_to_cents,
slug::{slugify, unique_slug},
},
shop::{
models::{categories, product_images, products},
view,
},
};
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
products::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
/// Fields parsed from a product form, ready to apply to an active model.
struct ProductFields {
name: String,
slug: String,
description: Option<String>,
price_cents: i64,
currency: String,
sku: Option<String>,
stock: i32,
category_id: Option<i32>,
published: bool,
}
async fn parse_product_fields(
ctx: &AppContext,
form: &MultipartForm,
current_id: Option<i32>,
) -> Result<ProductFields> {
let name = form
.text("name")
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
let price_cents = parse_price_to_cents(
form.text("price")
.ok_or_else(|| Error::BadRequest("price is required".to_string()))?
.as_str(),
)?;
let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string());
let description = form.text("description");
let sku = form.text("sku");
let stock = form
.text("stock")
.and_then(|s| s.parse::<i32>().ok())
.filter(|n| *n >= 0)
.unwrap_or(0);
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
let published = form.checked("published");
let desired = form
.text("slug")
.map(|s| slugify(&s))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| slugify(&name));
let slug = unique_slug(&desired, |candidate| {
let ctx = ctx.clone();
async move {
let mut query = products::Entity::find().filter(products::Column::Slug.eq(candidate));
if let Some(id) = current_id {
query = query.filter(products::Column::Id.ne(id));
}
Ok(query.count(&ctx.db).await? > 0)
}
})
.await?;
Ok(ProductFields {
name,
slug,
description,
price_cents,
currency,
sku,
stock,
category_id,
published,
})
}
async fn form_context(ctx: &AppContext, jar: &CookieJar) -> Result<serde_json::Value> {
let categories = categories::Entity::find()
.order_by_asc(categories::Column::Position)
.order_by_asc(categories::Column::Name)
.all(&ctx.db)
.await?;
Ok(json!({ "categories": categories, "lang": current_lang(jar) }))
}
#[debug_handler]
async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let list = products::Entity::find()
.order_by_desc(products::Column::CreatedAt)
.all(&ctx.db)
.await?;
let mut rows = Vec::new();
for product in list {
let image = product_images::first_for(&ctx, product.id).await?;
let category_name = match product.category_id {
Some(id) => categories::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.map(|c| c.name),
None => None,
};
rows.push(view::product_card(&product, image, category_name));
}
format::view(
&v,
"admin/catalog/products.html",
json!({ "products": rows, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn new(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let mut context = form_context(&ctx, &jar).await?;
context["product"] = serde_json::Value::Null;
format::view(&v, "admin/catalog/product_form.html", context)
}
#[debug_handler]
async fn create(
auth: auth::JWT,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let form = read_multipart_form(multipart).await?;
let fields = parse_product_fields(&ctx, &form, None).await?;
let product = products::ActiveModel {
name: Set(fields.name),
slug: Set(fields.slug),
description: Set(fields.description),
price_cents: Set(fields.price_cents),
currency: Set(fields.currency),
sku: Set(fields.sku),
stock: Set(fields.stock),
view_count: Set(0),
published: Set(fields.published),
published_at: Set(fields.published.then(|| chrono::Utc::now().into())),
category_id: Set(fields.category_id),
..Default::default()
}
.insert(&ctx.db)
.await?;
if let Some(data) = form.image {
let filename = store_image(&ctx, data).await?;
product_images::ActiveModel {
product_id: Set(product.id),
image_id: Set(filename),
position: Set(0),
alt: Set(None),
..Default::default()
}
.insert(&ctx.db)
.await?;
}
format::redirect("/admin/catalog/products")
}
#[debug_handler]
async fn edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
let image = product_images::first_for(&ctx, id).await?;
let mut context = form_context(&ctx, &jar).await?;
context["product"] = view::product_form(&product, image);
format::view(&v, "admin/catalog/product_form.html", context)
}
#[debug_handler]
async fn update(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let existing = product_by_id(&ctx, id).await?;
let was_published = existing.published;
let form = read_multipart_form(multipart).await?;
let fields = parse_product_fields(&ctx, &form, Some(id)).await?;
let mut product = existing.into_active_model();
product.name = Set(fields.name);
product.slug = Set(fields.slug);
product.description = Set(fields.description);
product.price_cents = Set(fields.price_cents);
product.currency = Set(fields.currency);
product.sku = Set(fields.sku);
product.stock = Set(fields.stock);
product.category_id = Set(fields.category_id);
product.published = Set(fields.published);
if fields.published && !was_published {
product.published_at = Set(Some(chrono::Utc::now().into()));
} else if !fields.published {
product.published_at = Set(None);
}
product.update(&ctx.db).await?;
if let Some(data) = form.image {
let filename = store_image(&ctx, data).await?;
let next_position = product_images::count_for(&ctx, id).await?;
product_images::ActiveModel {
product_id: Set(id),
image_id: Set(filename),
position: Set(next_position),
alt: Set(None),
..Default::default()
}
.insert(&ctx.db)
.await?;
}
format::redirect("/admin/catalog/products")
}
#[debug_handler]
async fn delete(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
product_by_id(&ctx, id).await?.delete(&ctx.db).await?;
format::redirect("/admin/catalog/products")
}
pub fn routes() -> Routes {
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
Routes::new()
.add("/admin/catalog/products", get(index))
.add("/admin/catalog/products/new", get(new))
.add(
"/admin/catalog/products",
post(create).layer(image_limit.clone()),
)
.add("/admin/catalog/products/{id}/edit", get(edit))
.add(
"/admin/catalog/products/{id}",
post(update).layer(image_limit),
)
.add("/admin/catalog/products/{id}/delete", post(delete))
}

79
src/admin/shipping.rs Normal file
View File

@@ -0,0 +1,79 @@
//! Admin management of shipping methods (price + enabled toggle).
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
use serde::Deserialize;
use serde_json::json;
use crate::{
checkout::models::shipping_methods,
i18n::current_lang,
shared::{
guard,
money::{format_price, parse_price_to_cents},
},
};
#[derive(Debug, Deserialize)]
struct ShippingForm {
price: String,
enabled: Option<String>,
}
#[debug_handler]
async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let methods = shipping_methods::Entity::find()
.order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db)
.await?;
let rows: Vec<serde_json::Value> = methods
.iter()
.map(|m| {
json!({
"id": m.id,
"code": m.code,
"name": m.name,
"price": format_price(m.price_cents),
"requires_pickup_point": m.requires_pickup_point,
"enabled": m.enabled,
})
})
.collect();
format::view(
&v,
"admin/shipping/index.html",
json!({ "methods": rows, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn update(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<ShippingForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let method = shipping_methods::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = method.into_active_model();
active.price_cents = Set(parse_price_to_cents(&form.price)?);
active.enabled = Set(matches!(form.enabled.as_deref(), Some("on" | "true" | "1")));
active.update(&ctx.db).await?;
format::redirect("/admin/shipping")
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/shipping", get(index))
.add("/admin/shipping/{id}", post(update))
}

View File

@@ -16,7 +16,8 @@ use std::{path::Path, sync::Arc};
#[allow(unused_imports)]
use crate::{
controllers, initializers, models::_entities::users, tasks, workers::downloader::DownloadWorker,
account, admin, cart, checkout, home, i18n, initializers, media,
models::_entities::users, shop, tasks, workers::downloader::DownloadWorker,
};
pub struct App;
@@ -53,24 +54,33 @@ impl Hooks for App {
Ok(vec![
Box::new(initializers::view_engine::ViewEngineInitializer),
Box::new(initializers::admin_seeder::AdminSeeder),
Box::new(initializers::shipping_seeder::ShippingSeeder),
])
}
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes() // controller routes below
.add_route(controllers::auth::routes())
.add_route(controllers::admin::routes())
.add_route(controllers::blog::routes())
.add_route(controllers::i18n::routes())
.add_route(controllers::media::routes())
.add_route(controllers::pages::routes())
.add_route(controllers::frontend::routes())
AppRoutes::with_default_routes() // feature routes below
// public
.add_route(home::routes())
.add_route(shop::routes())
.add_route(cart::routes())
.add_route(checkout::routes())
// cross-cutting
.add_route(account::routes())
.add_route(i18n::routes())
.add_route(media::routes())
// admin
.add_route(admin::routes())
.add_route(admin::login::routes())
.add_route(admin::products::routes())
.add_route(admin::categories::routes())
.add_route(admin::orders::routes())
.add_route(admin::shipping::routes())
}
async fn after_context(ctx: AppContext) -> Result<AppContext> {
let upload_root = crate::controllers::media::uploads_root(&ctx.config)?;
tokio::fs::create_dir_all(upload_root.join(controllers::media::AUDIO_STORAGE_DIR)).await?;
tokio::fs::create_dir_all(upload_root.join(controllers::media::IMAGE_STORAGE_DIR)).await?;
let upload_root = media::uploads_root(&ctx.config)?;
tokio::fs::create_dir_all(upload_root.join(media::IMAGE_STORAGE_DIR)).await?;
let driver = storage::drivers::local::new_with_prefix(&upload_root)?;
Ok(AppContext {

200
src/cart/mod.rs Normal file
View File

@@ -0,0 +1,200 @@
use crate::{i18n::current_lang, shared::money::format_price, shop::models::products};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Deserialize;
use serde_json::json;
use time::Duration as TimeDuration;
pub(crate) const CART_COOKIE: &str = "cart";
const CART_MAX_AGE_DAYS: i64 = 30;
#[derive(Debug, Deserialize)]
struct AddForm {
product_id: i32,
quantity: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct UpdateForm {
product_id: i32,
quantity: i32,
}
#[derive(Debug, Deserialize)]
struct RemoveForm {
product_id: i32,
}
/// Parse the `cart` cookie ("id:qty,id:qty") into `(product_id, quantity)`
/// pairs, silently dropping malformed or non-positive entries.
pub(crate) fn parse_cart(jar: &CookieJar) -> Vec<(i32, i32)> {
let Some(cookie) = jar.get(CART_COOKIE) else {
return Vec::new();
};
cookie
.value()
.split(',')
.filter_map(|entry| {
let (id, qty) = entry.split_once(':')?;
let id = id.trim().parse::<i32>().ok()?;
let qty = qty.trim().parse::<i32>().ok()?;
(qty > 0).then_some((id, qty))
})
.collect()
}
fn serialize_cart(items: &[(i32, i32)]) -> String {
items
.iter()
.map(|(id, qty)| format!("{id}:{qty}"))
.collect::<Vec<_>>()
.join(",")
}
fn cart_cookie(value: String) -> Cookie<'static> {
Cookie::build((CART_COOKIE, value))
.path("/")
.same_site(SameSite::Lax)
.max_age(TimeDuration::days(CART_MAX_AGE_DAYS))
.build()
}
/// Look up a published product, returning its current stock cap.
async fn published_product(ctx: &AppContext, id: i32) -> Result<Option<products::Model>> {
Ok(products::Entity::find_by_id(id)
.filter(products::Column::Published.eq(true))
.one(&ctx.db)
.await?)
}
#[debug_handler]
async fn add(
jar: CookieJar,
State(ctx): State<AppContext>,
Form(form): Form<AddForm>,
) -> Result<Response> {
let Some(product) = published_product(&ctx, form.product_id).await? else {
return Err(Error::NotFound);
};
let mut items = parse_cart(&jar);
let add_qty = form.quantity.unwrap_or(1).max(1);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == product.id) {
entry.1 = (entry.1 + add_qty).min(product.stock);
} else {
items.push((product.id, add_qty.min(product.stock)));
}
items.retain(|(_, qty)| *qty > 0);
format::render()
.cookies(&[cart_cookie(serialize_cart(&items))])?
.redirect("/cart")
}
#[debug_handler]
async fn update(
jar: CookieJar,
State(ctx): State<AppContext>,
Form(form): Form<UpdateForm>,
) -> Result<Response> {
let stock = published_product(&ctx, form.product_id)
.await?
.map(|p| p.stock)
.unwrap_or(0);
let mut items = parse_cart(&jar);
let clamped = form.quantity.clamp(0, stock);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.product_id) {
entry.1 = clamped;
}
items.retain(|(_, qty)| *qty > 0);
format::render()
.cookies(&[cart_cookie(serialize_cart(&items))])?
.redirect("/cart")
}
#[debug_handler]
async fn remove(jar: CookieJar, Form(form): Form<RemoveForm>) -> Result<Response> {
let mut items = parse_cart(&jar);
items.retain(|(id, _)| *id != form.product_id);
format::render()
.cookies(&[cart_cookie(serialize_cart(&items))])?
.redirect("/cart")
}
/// Resolve the cart cookie into priced line items, dropping anything that is no
/// longer purchasable and clamping quantities to current stock. Returns the
/// (re-validated) lines, the rebuilt cookie value, and the total in cents.
pub(crate) async fn resolve_cart(
ctx: &AppContext,
jar: &CookieJar,
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
let mut lines = Vec::new();
let mut valid = Vec::new();
let mut total: i64 = 0;
for (id, qty) in parse_cart(jar) {
let Some(product) = published_product(ctx, id).await? else {
continue;
};
let qty = qty.clamp(0, product.stock);
if qty == 0 {
continue;
}
let line_total = product.price_cents * i64::from(qty);
total += line_total;
valid.push((product.id, qty));
lines.push(json!({
"id": product.id,
"name": product.name,
"slug": product.slug,
"price": format_price(product.price_cents),
"currency": product.currency,
"quantity": qty,
"stock": product.stock,
"line_total": format_price(line_total),
}));
}
Ok((lines, valid, total))
}
#[debug_handler]
async fn show(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
// Drop any now-invalid lines from the cookie so the badge stays accurate.
let rebuilt = serialize_cart(&valid);
let response = format::view(
&v,
"shop/cart.html",
json!({
"items": lines,
"total": format_price(total),
"currency": currency,
"lang": current_lang(&jar),
}),
)?;
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
}
pub fn routes() -> Routes {
Routes::new()
.add("/cart", get(show))
.add("/cart/add", post(add))
.add("/cart/update", post(update))
.add("/cart/remove", post(remove))
}

206
src/checkout/mod.rs Normal file
View File

@@ -0,0 +1,206 @@
//! Public checkout flow: the checkout form, placing an order, and the order
//! confirmation page.
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use serde::Deserialize;
use serde_json::json;
use time::Duration as TimeDuration;
pub mod models;
pub mod view;
use crate::{
cart::{resolve_cart, CART_COOKIE},
checkout::models::{
order_items,
orders::{self, Checkout},
shipping_methods,
},
i18n::current_lang,
shared::{money::format_price, settings},
};
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
#[derive(Debug, Deserialize)]
struct CheckoutForm {
email: String,
customer_name: String,
address: String,
city: String,
zip: String,
country: String,
note: Option<String>,
payment_method: String,
carrier_code: String,
pickup_point_id: Option<String>,
pickup_point_name: Option<String>,
}
fn trimmed(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn cleared_cart_cookie() -> Cookie<'static> {
Cookie::build((CART_COOKIE, ""))
.path("/")
.same_site(SameSite::Lax)
.max_age(TimeDuration::seconds(0))
.build()
}
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
Ok(shipping_methods::Entity::find()
.filter(shipping_methods::Column::Enabled.eq(true))
.order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db)
.await?)
}
#[debug_handler]
async fn checkout_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?;
if lines.is_empty() {
return format::redirect("/cart");
}
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
.await?
.iter()
.map(|m| {
json!({
"code": m.code,
"name": m.name,
"price_cents": m.price_cents,
"price": format_price(m.price_cents),
"requires_pickup_point": m.requires_pickup_point,
})
})
.collect();
format::view(
&v,
"shop/checkout.html",
json!({
"items": lines,
"subtotal": format_price(subtotal),
"subtotal_cents": subtotal,
"currency": currency,
"shipping_methods": methods,
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn place_order(
jar: CookieJar,
State(ctx): State<AppContext>,
Form(form): Form<CheckoutForm>,
) -> Result<Response> {
let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?;
if valid.is_empty() {
return format::redirect("/cart");
}
let email =
trimmed(&form.email).ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) {
return Err(Error::BadRequest("invalid payment method".to_string()));
}
// Resolve the chosen carrier from the enabled methods (price is taken from
// the DB, never the form, so the customer can't pick their own fee).
let method = shipping_methods::Entity::find()
.filter(shipping_methods::Column::Code.eq(&form.carrier_code))
.filter(shipping_methods::Column::Enabled.eq(true))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?;
let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
let id = form
.pickup_point_id
.as_deref()
.and_then(trimmed)
.ok_or_else(|| Error::BadRequest("a pickup point is required".to_string()))?;
(Some(id), form.pickup_point_name.as_deref().and_then(trimmed))
} else {
(None, None)
};
let order = orders::place(
&ctx,
&valid,
Checkout {
email,
customer_name: trimmed(&form.customer_name),
address: trimmed(&form.address),
city: trimmed(&form.city),
zip: trimmed(&form.zip),
country: trimmed(&form.country),
note: form.note.as_deref().and_then(trimmed),
payment_method: form.payment_method,
method,
pickup_point_id,
pickup_point_name,
},
)
.await?;
format::render()
.cookies(&[cleared_cart_cookie()])?
.redirect(&format!("/orders/{}", order.order_number))
}
#[debug_handler]
async fn order_confirmation(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(order_number): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let order = orders::Entity::find()
.filter(orders::Column::OrderNumber.eq(order_number))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let items = order_items::Entity::find()
.filter(order_items::Column::OrderId.eq(order.id))
.all(&ctx.db)
.await?;
format::view(
&v,
"shop/order_confirmed.html",
json!({
"order": view::detail(
&order,
settings::get(&ctx, "bank_iban").unwrap_or(""),
settings::get(&ctx, "bank_account_name").unwrap_or(""),
),
"items": view::items(&items),
"lang": current_lang(&jar),
}),
)
}
pub fn routes() -> Routes {
Routes::new()
.add("/checkout", get(checkout_page))
.add("/checkout", post(place_order))
.add("/orders/{order_number}", get(order_confirmation))
}

View File

@@ -0,0 +1,3 @@
pub mod order_items;
pub mod orders;
pub mod shipping_methods;

View File

@@ -1,6 +1,6 @@
pub use super::_entities::audio_albums::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type AudioAlbums = Entity;
pub use crate::models::_entities::order_items::{ActiveModel, Column, Entity, Model};
pub type OrderItems = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {

View File

@@ -0,0 +1,127 @@
use loco_rs::prelude::*;
use sea_orm::entity::prelude::*;
use sea_orm::{Set, TransactionTrait};
use uuid::Uuid;
use crate::models::_entities::{order_items, products, shipping_methods};
pub use crate::models::_entities::orders::{ActiveModel, Column, Entity, Model};
pub type Orders = Entity;
/// The customer-supplied and carrier details needed to place an order. Prices
/// and product names are never taken from here — they are snapshotted from the
/// database inside [`place`] so the customer cannot influence what they pay.
pub struct Checkout {
pub email: String,
pub customer_name: Option<String>,
pub address: Option<String>,
pub city: Option<String>,
pub zip: Option<String>,
pub country: Option<String>,
pub note: Option<String>,
pub payment_method: String,
pub method: shipping_methods::Model,
pub pickup_point_id: Option<String>,
pub pickup_point_name: Option<String>,
}
fn generate_order_number() -> String {
let suffix = Uuid::new_v4().simple().to_string()[..8].to_uppercase();
format!("ORD-{suffix}")
}
/// Atomically place an order for the given `(product_id, quantity)` lines:
/// snapshot each product's price/name, decrement stock (re-checking inside the
/// transaction so an item can't oversell between cart and pay), then write the
/// order and its line items. Returns the persisted order.
pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) -> Result<Model> {
let txn = ctx.db.begin().await?;
let mut subtotal: i64 = 0;
let mut currency = "EUR".to_string();
let mut snapshots = Vec::new();
for (product_id, qty) in items {
let product = products::Entity::find_by_id(*product_id)
.filter(products::Column::Published.eq(true))
.one(&txn)
.await?
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
if product.stock < *qty {
return Err(Error::BadRequest(format!(
"not enough stock for {}",
product.name
)));
}
currency = product.currency.clone();
subtotal += product.price_cents * i64::from(*qty);
let mut active = product.clone().into_active_model();
active.stock = Set(product.stock - *qty);
active.update(&txn).await?;
snapshots.push((product.id, product.name, product.price_cents, *qty));
}
let order = ActiveModel {
order_number: Set(generate_order_number()),
email: Set(details.email),
customer_name: Set(details.customer_name),
status: Set("pending".to_string()),
total_cents: Set(subtotal + details.method.price_cents),
currency: Set(currency),
address: Set(details.address),
city: Set(details.city),
zip: Set(details.zip),
country: Set(details.country),
note: Set(details.note),
payment_method: Set(Some(details.payment_method)),
carrier_code: Set(Some(details.method.code)),
carrier_name: Set(Some(details.method.name)),
shipping_cents: Set(details.method.price_cents),
pickup_point_id: Set(details.pickup_point_id),
pickup_point_name: Set(details.pickup_point_name),
..Default::default()
}
.insert(&txn)
.await?;
for (product_id, name, unit_price_cents, qty) in snapshots {
order_items::ActiveModel {
order_id: Set(order.id),
product_id: Set(Some(product_id)),
product_name: Set(name),
unit_price_cents: Set(unit_price_cents),
quantity: Set(qty),
..Default::default()
}
.insert(&txn)
.await?;
}
txn.commit().await?;
Ok(order)
}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
if !insert && self.updated_at.is_unchanged() {
let mut this = self;
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
Ok(this)
} else {
Ok(self)
}
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}

View File

@@ -1,6 +1,6 @@
pub use super::_entities::site_pages::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type SitePages = Entity;
pub use crate::models::_entities::shipping_methods::{ActiveModel, Column, Entity, Model};
pub type ShippingMethods = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
@@ -18,6 +18,11 @@ impl ActiveModelBehavior for ActiveModel {
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}

64
src/checkout/view.rs Normal file
View File

@@ -0,0 +1,64 @@
//! JSON shaping for order confirmation and admin order templates.
use serde_json::{json, Value};
use crate::models::_entities::{order_items, orders};
use crate::shared::money::format_price;
/// Line items of an order, shaped for templates.
pub fn items(items: &[order_items::Model]) -> Vec<Value> {
items
.iter()
.map(|item| {
json!({
"product_name": item.product_name,
"quantity": item.quantity,
"unit_price": format_price(item.unit_price_cents),
"line_total": format_price(item.unit_price_cents * i64::from(item.quantity)),
})
})
.collect()
}
/// Full order detail for the confirmation and admin show pages. `bank_iban` and
/// `bank_account_name` come from settings and are embedded for bank-transfer
/// payment instructions.
pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -> Value {
json!({
"id": order.id,
"order_number": order.order_number,
"email": order.email,
"customer_name": order.customer_name,
"status": order.status,
"subtotal": format_price(order.total_cents - order.shipping_cents),
"shipping": format_price(order.shipping_cents),
"total": format_price(order.total_cents),
"currency": order.currency,
"address": order.address,
"city": order.city,
"zip": order.zip,
"country": order.country,
"note": order.note,
"payment_method": order.payment_method,
"carrier_name": order.carrier_name,
"pickup_point_name": order.pickup_point_name,
// Numeric, sequential order id doubles as the bank variable symbol.
"variable_symbol": order.id,
"bank_iban": bank_iban,
"bank_account_name": bank_account_name,
"created_at": order.created_at.to_rfc3339(),
})
}
/// Compact row for the admin orders list.
pub fn summary(order: &orders::Model) -> Value {
json!({
"id": order.id,
"order_number": order.order_number,
"email": order.email,
"status": order.status,
"total": format_price(order.total_cents),
"currency": order.currency,
"created_at": order.created_at.to_rfc3339(),
})
}

View File

@@ -1,57 +0,0 @@
use crate::models::{
_entities::{audio_albums, audio_tracks, audit_logs, blog_articles, users},
users as users_model,
};
use loco_rs::prelude::*;
use sea_orm::{EntityTrait, PaginatorTrait};
use serde::Serialize;
#[derive(Debug, Serialize)]
struct DashboardResponse {
users: u64,
blog_articles: u64,
audio_albums: u64,
audio_tracks: u64,
audit_logs: u64,
}
pub(crate) fn admin_email(ctx: &AppContext) -> Option<&str> {
ctx.config
.settings
.as_ref()
.and_then(|settings| settings.get("admin_email"))
.and_then(|email| email.as_str())
}
pub(crate) fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
}
pub(crate) async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::Model> {
let user = users_model::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
if !is_admin(ctx, &user) {
return unauthorized("admin only");
}
Ok(user)
}
#[debug_handler]
async fn dashboard(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
current_admin(auth, &ctx).await?;
format::json(DashboardResponse {
users: users::Entity::find().count(&ctx.db).await?,
blog_articles: blog_articles::Entity::find().count(&ctx.db).await?,
audio_albums: audio_albums::Entity::find().count(&ctx.db).await?,
audio_tracks: audio_tracks::Entity::find().count(&ctx.db).await?,
audit_logs: audit_logs::Entity::find().count(&ctx.db).await?,
})
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/admin")
.add("/dashboard", get(dashboard))
}

View File

@@ -1,245 +0,0 @@
use crate::{controllers::admin, models::_entities::blog_articles};
use chrono::Utc;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
struct ArticleParams {
title: String,
content: String,
excerpt: Option<String>,
published: Option<bool>,
featured_image_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct ArticleResponse {
id: Uuid,
title: String,
slug: String,
content: String,
excerpt: Option<String>,
published: bool,
author_id: i32,
featured_image_id: Option<String>,
view_count: i32,
created_at: chrono::DateTime<chrono::FixedOffset>,
updated_at: chrono::DateTime<chrono::FixedOffset>,
published_at: Option<chrono::DateTime<chrono::FixedOffset>>,
}
#[derive(Debug, Serialize)]
struct ArticleListResponse {
articles: Vec<ArticleResponse>,
}
impl From<blog_articles::Model> for ArticleResponse {
fn from(article: blog_articles::Model) -> Self {
Self {
id: article.id,
title: article.title,
slug: article.slug,
content: article.content,
excerpt: article.excerpt,
published: article.published,
author_id: article.author_id,
featured_image_id: article.featured_image_id,
view_count: article.view_count,
created_at: article.created_at,
updated_at: article.updated_at,
published_at: article.published_at,
}
}
}
fn slugify(title: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in title.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
last_was_dash = false;
} else if !last_was_dash && !slug.is_empty() {
slug.push('-');
last_was_dash = true;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
Uuid::new_v4().to_string()
} else {
slug
}
}
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
published.then(|| Utc::now().into())
}
async fn find_article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
blog_articles::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
#[debug_handler]
async fn public_index(State(ctx): State<AppContext>) -> Result<Response> {
let articles = blog_articles::Entity::find()
.filter(blog_articles::Column::Published.eq(true))
.order_by_desc(blog_articles::Column::PublishedAt)
.all(&ctx.db)
.await?
.into_iter()
.map(ArticleResponse::from)
.collect();
format::json(ArticleListResponse { articles })
}
#[debug_handler]
async fn public_show(Path(slug): Path<String>, State(ctx): State<AppContext>) -> Result<Response> {
let article = blog_articles::Entity::find()
.filter(blog_articles::Column::Slug.eq(slug))
.filter(blog_articles::Column::Published.eq(true))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = article.into_active_model();
let next_count = active.view_count.as_ref().to_owned() + 1;
active.view_count = Set(next_count);
let article = active.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_index(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let articles = blog_articles::Entity::find()
.order_by_desc(blog_articles::Column::CreatedAt)
.all(&ctx.db)
.await?
.into_iter()
.map(ArticleResponse::from)
.collect();
format::json(ArticleListResponse { articles })
}
#[debug_handler]
async fn admin_create(
auth: auth::JWT,
State(ctx): State<AppContext>,
Json(params): Json<ArticleParams>,
) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?;
let published = params.published.unwrap_or(false);
let article = blog_articles::ActiveModel {
id: Set(Uuid::new_v4()),
title: Set(params.title.clone()),
slug: Set(slugify(&params.title)),
content: Set(params.content),
excerpt: Set(params.excerpt),
published: Set(published),
author_id: Set(admin_user.id),
featured_image_id: Set(params.featured_image_id),
view_count: Set(0),
published_at: Set(published_at_for(published)),
..Default::default()
}
.insert(&ctx.db)
.await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_update(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
Json(params): Json<ArticleParams>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let existing = find_article_by_id(&ctx, id).await?;
let was_published = existing.published;
let published = params.published.unwrap_or(was_published);
let mut article = existing.into_active_model();
article.title = Set(params.title.clone());
article.slug = Set(slugify(&params.title));
article.content = Set(params.content);
article.excerpt = Set(params.excerpt);
article.published = Set(published);
article.featured_image_id = Set(params.featured_image_id);
if published && !was_published {
article.published_at = Set(published_at_for(true));
} else if !published {
article.published_at = Set(None);
}
let article = article.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_delete(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let article = find_article_by_id(&ctx, id).await?;
article.delete(&ctx.db).await?;
format::json(())
}
#[debug_handler]
async fn admin_publish(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
article.published = Set(true);
article.published_at = Set(published_at_for(true));
let article = article.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_unpublish(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
article.published = Set(false);
article.published_at = Set(None);
let article = article.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api")
.add("/blog", get(public_index))
.add("/blog/{slug}", get(public_show))
.add("/admin/blog/articles", get(admin_index))
.add("/admin/blog/articles", post(admin_create))
.add("/admin/blog/articles/{id}", put(admin_update))
.add("/admin/blog/articles/{id}", delete(admin_delete))
.add("/admin/blog/articles/{id}/publish", post(admin_publish))
.add("/admin/blog/articles/{id}/unpublish", post(admin_unpublish))
}

View File

@@ -1,479 +0,0 @@
use crate::{
controllers::{admin, auth as auth_controller, i18n::current_lang},
models::{
_entities::{audio_albums, audio_tracks, blog_articles, site_pages},
users::{self, LoginParams},
},
};
use axum_extra::extract::cookie::CookieJar;
use chrono::Utc;
use loco_rs::prelude::*;
use sea_orm::{
sea_query::Expr, ActiveModelTrait, ColumnTrait, EntityTrait, Order, QueryFilter, QueryOrder,
QuerySelect, Set,
};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
const ABOUT_SLUG: &str = "about";
#[derive(Debug, Deserialize)]
struct ArticleForm {
title: String,
content: String,
excerpt: Option<String>,
published: Option<String>,
featured_image_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AboutForm {
title: String,
content: String,
}
fn slugify(title: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in title.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
last_was_dash = false;
} else if !last_was_dash && !slug.is_empty() {
slug.push('-');
last_was_dash = true;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
Uuid::new_v4().to_string()
} else {
slug
}
}
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
published.then(|| Utc::now().into())
}
fn is_checked(value: &Option<String>) -> bool {
value
.as_deref()
.is_some_and(|value| value == "on" || value == "true")
}
fn normalize_empty(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() {
None
} else {
Some(value)
}
})
}
async fn about_page(ctx: &AppContext) -> Result<site_pages::Model> {
site_pages::Entity::find()
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
async fn article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
blog_articles::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool {
let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else {
return false;
};
let Ok(jwt_config) = ctx.config.get_jwt_config() else {
return false;
};
let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
else {
return false;
};
let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
return false;
};
admin::is_admin(ctx, &user)
}
#[debug_handler]
async fn home(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let articles = blog_articles::Entity::find()
.filter(blog_articles::Column::Published.eq(true))
.order_by_desc(blog_articles::Column::PublishedAt)
.limit(5)
.all(&ctx.db)
.await?;
// A random published song to suggest on the landing page.
let featured_track = audio_tracks::Entity::find()
.filter(audio_tracks::Column::Published.eq(true))
.order_by(Expr::cust("RANDOM()"), Order::Asc)
.one(&ctx.db)
.await?;
// A random published album, never the one the suggested song belongs to.
let mut album_query =
audio_albums::Entity::find().filter(audio_albums::Column::Published.eq(true));
if let Some(album_id) = featured_track.as_ref().and_then(|track| track.album_id) {
album_query = album_query.filter(audio_albums::Column::Id.ne(album_id));
}
let featured_album = album_query
.order_by(Expr::cust("RANDOM()"), Order::Asc)
.one(&ctx.db)
.await?;
format::view(
&v,
"home/index.html",
json!({
"articles": articles,
"featured_track": featured_track,
"featured_album": featured_album,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn about(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
format::view(
&v,
"pages/about.html",
json!({
"page": about_page(&ctx).await?,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn blog_index(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let articles = blog_articles::Entity::find()
.filter(blog_articles::Column::Published.eq(true))
.order_by_desc(blog_articles::Column::PublishedAt)
.all(&ctx.db)
.await?;
format::view(
&v,
"blog/index.html",
json!({
"articles": articles,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn blog_show(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(slug): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let article = blog_articles::Entity::find()
.filter(blog_articles::Column::Slug.eq(slug))
.filter(blog_articles::Column::Published.eq(true))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = article.into_active_model();
let next_count = active.view_count.as_ref().to_owned() + 1;
active.view_count = Set(next_count);
let article = active.update(&ctx.db).await?;
format::view(
&v,
"blog/show.html",
json!({
"article": article,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn admin_login_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if logged_in_admin(&ctx, &jar).await {
return format::redirect("/admin/dashboard");
}
format::view(
&v,
"admin/login.html",
json!({
"error": null,
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn admin_login(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<LoginParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
return format::view(
&v,
"admin/login.html",
json!({
"error": "Invalid credentials",
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
);
};
if !user.verify_password(&params.password) || !admin::is_admin(&ctx, &user) {
return format::view(
&v,
"admin/login.html",
json!({
"error": "Invalid credentials",
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
);
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.redirect("/admin/dashboard")
}
#[debug_handler]
async fn admin_logout() -> Result<Response> {
format::render()
.cookies(&[auth_controller::clear_auth_cookie()])?
.redirect("/admin/login")
}
#[debug_handler]
async fn admin_home(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/index.html",
json!({ "admin": admin_user, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn admin_about(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/about.html",
json!({ "page": about_page(&ctx).await?, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn admin_about_update(
auth: auth::JWT,
State(ctx): State<AppContext>,
Form(params): Form<AboutForm>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let mut page = about_page(&ctx).await?.into_active_model();
page.title = Set(params.title);
page.content = Set(params.content);
page.update(&ctx.db).await?;
format::redirect("/admin/about")
}
#[debug_handler]
async fn admin_articles(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let articles = blog_articles::Entity::find()
.order_by_desc(blog_articles::Column::CreatedAt)
.all(&ctx.db)
.await?;
format::view(
&v,
"admin/blog/index.html",
json!({ "articles": articles, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn admin_article_new(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/blog/new.html",
json!({ "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn admin_article_create(
auth: auth::JWT,
State(ctx): State<AppContext>,
Form(params): Form<ArticleForm>,
) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?;
let published = is_checked(&params.published);
blog_articles::ActiveModel {
id: Set(Uuid::new_v4()),
title: Set(params.title.clone()),
slug: Set(slugify(&params.title)),
content: Set(params.content),
excerpt: Set(normalize_empty(params.excerpt)),
published: Set(published),
author_id: Set(admin_user.id),
featured_image_id: Set(normalize_empty(params.featured_image_id)),
view_count: Set(0),
published_at: Set(published_at_for(published)),
..Default::default()
}
.insert(&ctx.db)
.await?;
format::redirect("/admin/blog/articles")
}
#[debug_handler]
async fn admin_article_edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/blog/edit.html",
json!({ "article": article_by_id(&ctx, id).await?, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn admin_article_update(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
Form(params): Form<ArticleForm>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let existing = article_by_id(&ctx, id).await?;
let was_published = existing.published;
let published = is_checked(&params.published);
let mut article = existing.into_active_model();
article.title = Set(params.title.clone());
article.slug = Set(slugify(&params.title));
article.content = Set(params.content);
article.excerpt = Set(normalize_empty(params.excerpt));
article.published = Set(published);
article.featured_image_id = Set(normalize_empty(params.featured_image_id));
if published && !was_published {
article.published_at = Set(published_at_for(true));
} else if !published {
article.published_at = Set(None);
}
article.update(&ctx.db).await?;
format::redirect("/admin/blog/articles")
}
#[debug_handler]
async fn admin_article_delete(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
article_by_id(&ctx, id).await?.delete(&ctx.db).await?;
format::redirect("/admin/blog/articles")
}
pub fn routes() -> Routes {
Routes::new()
.add("/", get(home))
.add("/about", get(about))
.add("/blog", get(blog_index))
.add("/blog/{slug}", get(blog_show))
.add("/admin/login", get(admin_login_page))
.add("/admin/login", post(admin_login))
.add("/admin/logout", post(admin_logout))
.add("/admin", get(admin_login_page))
.add("/admin/dashboard", get(admin_home))
.add("/admin/about", get(admin_about))
.add("/admin/about", post(admin_about_update))
.add("/admin/blog/articles", get(admin_articles))
.add("/admin/blog/articles/new", get(admin_article_new))
.add("/admin/blog/articles", post(admin_article_create))
.add("/admin/blog/articles/{id}/edit", get(admin_article_edit))
.add("/admin/blog/articles/{id}", post(admin_article_update))
.add(
"/admin/blog/articles/{id}/delete",
post(admin_article_delete),
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
pub mod admin;
pub mod auth;
pub mod blog;
pub mod frontend;
pub mod i18n;
pub mod media;
pub mod pages;

View File

@@ -1,86 +0,0 @@
use crate::{controllers::admin, models::_entities::site_pages};
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const ABOUT_SLUG: &str = "about";
#[derive(Debug, Deserialize)]
struct AboutParams {
title: String,
content: String,
}
#[derive(Debug, Serialize)]
struct PageResponse {
id: Uuid,
slug: String,
title: String,
content: String,
updated_at: chrono::DateTime<chrono::FixedOffset>,
}
impl From<site_pages::Model> for PageResponse {
fn from(page: site_pages::Model) -> Self {
Self {
id: page.id,
slug: page.slug,
title: page.title,
content: page.content,
updated_at: page.updated_at,
}
}
}
async fn find_about(ctx: &AppContext) -> Result<site_pages::Model> {
site_pages::Entity::find()
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
#[debug_handler]
async fn about(State(ctx): State<AppContext>) -> Result<Response> {
format::json(PageResponse::from(find_about(&ctx).await?))
}
#[debug_handler]
async fn update_about(
auth: auth::JWT,
State(ctx): State<AppContext>,
Json(params): Json<AboutParams>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let page = match find_about(&ctx).await {
Ok(page) => {
let mut page = page.into_active_model();
page.title = Set(params.title);
page.content = Set(params.content);
page.update(&ctx.db).await?
}
Err(Error::NotFound) => {
site_pages::ActiveModel {
id: Set(Uuid::new_v4()),
slug: Set(ABOUT_SLUG.to_string()),
title: Set(params.title),
content: Set(params.content),
..Default::default()
}
.insert(&ctx.db)
.await?
}
Err(err) => return Err(err),
};
format::json(PageResponse::from(page))
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api")
.add("/about", get(about))
.add("/admin/about", put(update_about))
}

30
src/home/mod.rs Normal file
View File

@@ -0,0 +1,30 @@
//! Public landing page.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{i18n::current_lang, shared::guard, shop};
#[debug_handler]
async fn index(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let products = shop::featured_products(&ctx, 8).await?;
format::view(
&v,
"home/index.html",
json!({
"products": products,
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
pub fn routes() -> Routes {
Routes::new().add("/", get(index))
}

View File

@@ -1,7 +1,7 @@
use async_trait::async_trait;
use loco_rs::prelude::*;
use crate::models::users::{self, RegisterParams};
use crate::account::models::users::{self, RegisterParams};
pub struct AdminSeeder;

View File

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

View File

@@ -0,0 +1,48 @@
use async_trait::async_trait;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use crate::models::_entities::shipping_methods;
/// (code, display name, price in cents, requires a pickup point)
const CARRIERS: [(&str, &str, i64, bool); 3] = [
("packeta", "Packeta", 300, true),
("dpd", "DPD", 450, false),
("dhl", "DHL", 500, false),
];
pub struct ShippingSeeder;
#[async_trait]
impl Initializer for ShippingSeeder {
fn name(&self) -> String {
"shipping-seeder".to_string()
}
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
for (position, (code, name, price_cents, requires_pickup_point)) in
CARRIERS.iter().enumerate()
{
let exists = shipping_methods::Entity::find()
.filter(shipping_methods::Column::Code.eq(*code))
.one(&ctx.db)
.await?
.is_some();
if exists {
continue;
}
shipping_methods::ActiveModel {
code: Set((*code).to_string()),
name: Set((*name).to_string()),
price_cents: Set(*price_cents),
requires_pickup_point: Set(*requires_pickup_point),
enabled: Set(true),
position: Set(position as i32),
..Default::default()
}
.insert(&ctx.db)
.await?;
}
Ok(())
}
}

View File

@@ -1,9 +1,22 @@
pub mod app;
pub mod controllers;
pub mod data;
pub mod initializers;
pub mod mailers;
pub mod models;
pub mod tasks;
pub mod views;
pub mod workers;
// Cross-cutting helpers shared by every feature.
pub mod shared;
// Feature slices: each owns its routes, handlers, view-shaping and the model
// methods/services specific to it. Generated sea-orm entities stay shared in
// `models::_entities`.
pub mod account;
pub mod admin;
pub mod cart;
pub mod checkout;
pub mod home;
pub mod i18n;
pub mod media;
pub mod shop;

View File

@@ -4,7 +4,7 @@
use loco_rs::prelude::*;
use serde_json::json;
use crate::models::users;
use crate::account::models::users;
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");

166
src/media/mod.rs Normal file
View File

@@ -0,0 +1,166 @@
use crate::shared::guard;
use axum::{
body::Body,
extract::{DefaultBodyLimit, Multipart},
http::header,
};
use bytes::Bytes;
use loco_rs::{config::Config, prelude::*};
use serde::Serialize;
use std::path::{Path as StdPath, PathBuf};
use uuid::Uuid;
pub(crate) const IMAGE_MAX_BYTES: usize = 10 * 1024 * 1024;
pub const IMAGE_STORAGE_DIR: &str = "images";
#[derive(Debug, Serialize)]
struct UploadResponse {
filename: String,
url: String,
size: usize,
}
pub fn uploads_root(config: &Config) -> Result<PathBuf> {
config
.settings
.as_ref()
.and_then(|settings| settings.get("uploads_root"))
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
.ok_or_else(|| Error::string("settings.uploads_root must be configured"))
}
fn safe_filename(filename: &str) -> Result<&str> {
if filename.is_empty()
|| filename.contains('/')
|| filename.contains('\\')
|| filename.contains("..")
{
return Err(Error::BadRequest("invalid filename".to_string()));
}
Ok(filename)
}
fn image_content_type(extension: &str) -> &'static str {
match extension {
"gif" => "image/gif",
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"webp" => "image/webp",
_ => "application/octet-stream",
}
}
pub(crate) fn detect_image_extension(data: &[u8]) -> Result<&'static str> {
if data.len() < 12 {
return Err(Error::BadRequest("image file is too small".to_string()));
}
if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
return Ok("png");
}
if data.starts_with(&[0xff, 0xd8, 0xff]) {
return Ok("jpg");
}
if data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" {
return Ok("webp");
}
if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
return Ok("gif");
}
Err(Error::BadRequest("unsupported image format".to_string()))
}
async fn read_multipart_file(mut multipart: Multipart, max_bytes: usize) -> Result<Vec<u8>> {
while let Some(mut field) = multipart
.next_field()
.await
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
{
if field.name() != Some("file") {
continue;
}
let mut data = Vec::new();
while let Some(chunk) = field
.chunk()
.await
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
{
data.extend_from_slice(&chunk);
if data.len() > max_bytes {
return Err(Error::BadRequest(format!(
"file is larger than {} MB",
max_bytes / 1024 / 1024
)));
}
}
if data.is_empty() {
return Err(Error::BadRequest("empty file upload".to_string()));
}
return Ok(data);
}
Err(Error::BadRequest(
"multipart field `file` is required".to_string(),
))
}
pub(crate) async fn store_upload(
ctx: &AppContext,
folder: &str,
extension: &str,
data: Vec<u8>,
) -> Result<String> {
let filename = format!("{}.{}", Uuid::new_v4(), extension);
let key = format!("{folder}/{filename}");
ctx.storage
.upload(StdPath::new(&key), &Bytes::from(data))
.await?;
Ok(filename)
}
#[debug_handler]
async fn image_upload(
auth: auth::JWT,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let data = read_multipart_file(multipart, IMAGE_MAX_BYTES).await?;
let extension = detect_image_extension(&data)?;
let size = data.len();
let filename = store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?;
format::json(UploadResponse {
url: format!("/images/{filename}"),
filename,
size,
})
}
#[debug_handler]
async fn image_serve(
Path(filename): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let filename = safe_filename(&filename)?;
let extension = filename.rsplit('.').next().unwrap_or("");
let key = format!("{IMAGE_STORAGE_DIR}/{filename}");
let body: Vec<u8> = ctx.storage.download(StdPath::new(&key)).await?;
Response::builder()
.header(header::CONTENT_TYPE, image_content_type(extension))
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.body(Body::from(body))
.map_err(Error::from)
}
pub fn routes() -> Routes {
Routes::new()
.add(
"/images/upload",
post(image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
)
.add("/images/{filename}", get(image_serve))
}

View File

@@ -1,51 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "audio_albums")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub title: String,
#[sea_orm(unique)]
pub slug: String,
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub cover_image_id: Option<String>,
pub artist: Option<String>,
pub release_date: Option<Date>,
pub published: bool,
pub uploader_id: i32,
pub view_count: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
pub published_at: Option<DateTimeWithTimeZone>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::audio_tracks::Entity")]
AudioTracks,
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UploaderId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::audio_tracks::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTracks.def()
}
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}

View File

@@ -1,37 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "audio_tags")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
#[sea_orm(unique)]
pub name: String,
#[sea_orm(unique)]
pub slug: String,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::audio_track_tags::Entity")]
AudioTrackTags,
}
impl Related<super::audio_track_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTrackTags.def()
}
}
impl Related<super::audio_tracks::Entity> for Entity {
fn to() -> RelationDef {
super::audio_track_tags::Relation::AudioTracks.def()
}
fn via() -> Option<RelationDef> {
Some(super::audio_track_tags::Relation::AudioTags.def().rev())
}
}

Some files were not shown because too many files have changed in this diff Show More