Compare commits
39 Commits
4e1722ce35
...
v0.1.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
996358be87 | ||
|
|
c6624e1b3d | ||
|
|
b9c1277876 | ||
|
|
42bab82960 | ||
|
|
7da4109584 | ||
|
|
ed607e3d27 | ||
|
|
7af0a48e92 | ||
|
|
1cd2b86b74 | ||
|
|
68381d558a | ||
|
|
36a5e7c5fc | ||
|
|
e8c6035eeb | ||
|
|
9a3c68eae5 | ||
|
|
ee944ed5ce | ||
|
|
0a619517b6 | ||
|
|
1538d870b9 | ||
|
|
ed2eb036ae | ||
|
|
ae99ec079f | ||
|
|
0754e014a3 | ||
|
|
1d747d9960 | ||
|
|
126b1eeb7e | ||
|
|
c401acb1cc | ||
|
|
67fd364761 | ||
|
|
7601fc704d | ||
|
|
7be1726f1b | ||
|
|
d18bdeaf6e | ||
|
|
cd7a756a54 | ||
|
|
e8c0362a54 | ||
|
|
43562e964a | ||
|
|
f54fd3d717 | ||
|
|
e4f63b3de9 | ||
|
|
95f195a204 | ||
|
|
b88c990873 | ||
|
|
9ce07e8c23 | ||
|
|
b255e95051 | ||
|
|
f0a6f97609 | ||
|
|
baf7522273 | ||
|
|
c4f60dd8d7 | ||
|
|
e3b99b0fd8 | ||
|
|
635cb34810 |
@@ -2,7 +2,7 @@ CONTAINER_NAME=universal-web
|
||||
REVERSE_PROXY_NETWORK=
|
||||
UPLOADS_VOLUME_NAME=universal_web_uploads
|
||||
|
||||
APP_HOST=https://gitara.farmeris.sk
|
||||
APP_HOST=https://eshop.example.com
|
||||
PORT=5150
|
||||
SERVER_BINDING=0.0.0.0
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,3 +22,7 @@ target/
|
||||
uploads/
|
||||
*.report.html
|
||||
favicon_io.zip
|
||||
|
||||
# Tailwind standalone binary (downloaded via `make tailwind`)
|
||||
bin/tailwindcss
|
||||
node_modules/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
gitara.farmeris.sk {
|
||||
eshop.example.com {
|
||||
encode gzip
|
||||
|
||||
@static path /static/*
|
||||
@@ -6,9 +6,9 @@ gitara.farmeris.sk {
|
||||
|
||||
rewrite /favicon.ico /static/favicon/favicon.ico
|
||||
|
||||
reverse_proxy gitara-web:5150
|
||||
reverse_proxy kompress:5150
|
||||
}
|
||||
|
||||
www.gitara.farmeris.sk {
|
||||
redir https://gitara.farmeris.sk{uri} permanent
|
||||
eshop.example.com {
|
||||
redir https://eshop.example.com{uri} permanent
|
||||
}
|
||||
|
||||
1726
Cargo.lock
generated
1726
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
24
Cargo.toml
@@ -1,11 +1,11 @@
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "gitara_web"
|
||||
name = "kompress_eshop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
default-run = "gitara_web-cli"
|
||||
default-run = "kompress-eshop-cli"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@@ -16,14 +16,14 @@ loco-rs = { version = "0.16" }
|
||||
loco-rs = { workspace = true }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1" }
|
||||
tokio = { version = "1.45", default-features = false, features = [
|
||||
tokio = { version = "1.52", default-features = false, features = [
|
||||
"rt-multi-thread",
|
||||
] }
|
||||
async-trait = { version = "0.1" }
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
tracing = { version = "0.1" }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
regex = { version = "1.11" }
|
||||
regex = { version = "1.12" }
|
||||
migration = { path = "migration" }
|
||||
sea-orm = { version = "1.1", features = [
|
||||
"sqlx-sqlite",
|
||||
@@ -35,22 +35,28 @@ chrono = { version = "0.4" }
|
||||
time = { version = "0.3" }
|
||||
dotenvy = { version = "0.15" }
|
||||
validator = { version = "0.20" }
|
||||
uuid = { version = "1.6", features = ["v4"] }
|
||||
uuid = { version = "1.23", features = ["v4"] }
|
||||
include_dir = { version = "0.7" }
|
||||
# outbound HTTP for carrier shipment APIs (Packeta / DPD / DHL)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
# view engine i18n
|
||||
fluent-templates = { version = "0.13", features = ["tera"] }
|
||||
unic-langid = { version = "0.9" }
|
||||
# /view engine
|
||||
axum-extra = { version = "0.10", features = ["form"] }
|
||||
bytes = { version = "1" }
|
||||
axum-casbin = "1.3.0"
|
||||
loco-oauth2 = "0.5.0"
|
||||
passwords = "3.1.16"
|
||||
tower-sessions = "0.14"
|
||||
|
||||
[[bin]]
|
||||
name = "gitara_web-cli"
|
||||
name = "kompress-eshop-cli"
|
||||
path = "src/bin/main.rs"
|
||||
required-features = []
|
||||
|
||||
[dev-dependencies]
|
||||
loco-rs = { workspace = true, features = ["testing"] }
|
||||
serial_test = { version = "3.1.1" }
|
||||
serial_test = { version = "3.5.0" }
|
||||
rstest = { version = "0.25" }
|
||||
insta = { version = "1.34", features = ["redactions", "yaml", "filters"] }
|
||||
insta = { version = "1.48", features = ["redactions", "yaml", "filters"] }
|
||||
|
||||
@@ -5,7 +5,7 @@ WORKDIR /usr/src
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN cargo build --release --bin gitara_web-cli
|
||||
RUN cargo build --release --bin kompress-cli
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -17,9 +17,9 @@ WORKDIR /usr/app
|
||||
|
||||
COPY --from=builder /usr/src/assets assets
|
||||
COPY --from=builder /usr/src/config config
|
||||
COPY --from=builder /usr/src/target/release/gitara_web-cli gitara_web-cli
|
||||
COPY --from=builder /usr/src/target/release/kompress-cli kompress-cli
|
||||
|
||||
ENV LOCO_ENV=production
|
||||
EXPOSE 5150
|
||||
ENTRYPOINT ["/usr/app/gitara_web-cli"]
|
||||
ENTRYPOINT ["/usr/app/kompress-cli"]
|
||||
CMD ["start"]
|
||||
|
||||
29
Makefile
29
Makefile
@@ -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
|
||||
|
||||
79
assets/css/app.css
Normal file
79
assets/css/app.css
Normal file
@@ -0,0 +1,79 @@
|
||||
/* ============================================================
|
||||
* 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-components/ is the read-only vendored PenguinUI library
|
||||
* (reference only — never {% include %}'d, never edited). Tailwind v4
|
||||
* auto-detects it from the project root, so exclude it explicitly or
|
||||
* its 177 files bloat the build with classes we never render. */
|
||||
@source not "../../penguinui-components";
|
||||
|
||||
/* 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; }
|
||||
@@ -1,6 +1,6 @@
|
||||
brand = My guitar
|
||||
brand = Kompress eshop
|
||||
hello-world = Hello world!
|
||||
meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place.
|
||||
meta-description = Kompress eshop
|
||||
nav-home = Home
|
||||
nav-about = About
|
||||
nav-blog = Blog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
brand = My guitar
|
||||
brand = Kompress eshop
|
||||
hello-world = Hello world!
|
||||
meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place.
|
||||
meta-description = Kompress eshop
|
||||
nav-home = Home
|
||||
nav-about = About
|
||||
nav-blog = Blog
|
||||
@@ -57,12 +57,31 @@ album-by = by
|
||||
album-play-full = Play full album
|
||||
album-queue-all = queue all tracks in order
|
||||
album-no-tracks = no tracks yet
|
||||
login-title = Admin login
|
||||
login-title = Sign in
|
||||
login-error = Access denied - invalid email or password.
|
||||
login-error-unverified = Your account isn't verified yet. Check your email and click the verification link.
|
||||
login-root = root
|
||||
login-auth = Authenticate
|
||||
login-auth = Sign in
|
||||
login-email = Email
|
||||
login-password = Password
|
||||
login-no-account = Don't have an account?
|
||||
login-have-account = Already have an account?
|
||||
auth-or = or
|
||||
auth-google = Continue with Google
|
||||
nav-login = Sign in
|
||||
nav-register = Register
|
||||
nav-profile = My profile
|
||||
register-title = Create account
|
||||
register-name = Name
|
||||
register-submit = Create account
|
||||
register-error-exists = An account with this email already exists.
|
||||
register-error-invalid = Please check the details you entered and try again.
|
||||
verify-sent-title = Check your email
|
||||
verify-sent-body = We've sent a verification link to
|
||||
verify-ok-title = Account verified
|
||||
verify-ok-body = Your account is verified. You can now sign in.
|
||||
verify-fail-title = Verification failed
|
||||
verify-fail-body = This link is invalid or has expired.
|
||||
auth = Auth
|
||||
admin-session = Session
|
||||
readonly = readonly
|
||||
@@ -172,3 +191,135 @@ 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
|
||||
cart-added = Added to cart
|
||||
in-stock = In stock
|
||||
out-of-stock = Out of stock
|
||||
gallery-prev = Previous image
|
||||
gallery-next = Next image
|
||||
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-remove-confirm = Remove this item from the cart?
|
||||
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-phone = Phone
|
||||
checkout-address = Address
|
||||
checkout-city = City
|
||||
checkout-zip = Postal code
|
||||
checkout-country = Country
|
||||
country-sk = Slovakia
|
||||
country-cz = Czechia
|
||||
country-at = Austria
|
||||
country-de = Germany
|
||||
country-pl = Poland
|
||||
country-hu = Hungary
|
||||
checkout-note = Order note
|
||||
checkout-save-profile = Save this address to my profile
|
||||
account-type = Account type
|
||||
account-personal = Individual
|
||||
account-company = Company
|
||||
account-company-details = Company details
|
||||
company-name = Company name
|
||||
company-ico = Company ID (IČO)
|
||||
company-dic = Tax ID (DIČ)
|
||||
company-icdph = VAT ID (IČ DPH)
|
||||
field-optional = optional
|
||||
checkout-place-order = Place order
|
||||
checkout-summary = Order summary
|
||||
profile-title = My profile
|
||||
profile-intro = We'll use these details to prefill checkout.
|
||||
profile-saved = Profile saved.
|
||||
profile-save = Save profile
|
||||
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 the price and availability of each delivery option.
|
||||
shipping-enabled = Active
|
||||
shipping-new = Add delivery option
|
||||
shipping-add = Add
|
||||
shipping-requires-pickup = Requires pickup point
|
||||
shipping-carrier = Carrier
|
||||
carrier-none = Manual (no API)
|
||||
carrier-packeta = Packeta
|
||||
carrier-dpd = DPD
|
||||
carrier-dhl = DHL
|
||||
order-fulfillment = Fulfillment
|
||||
order-shipped-via = Sent via
|
||||
order-tracking = Tracking
|
||||
order-label = Print label
|
||||
order-manual-fulfillment = Manual fulfilment — no carrier API for this option.
|
||||
order-send-hint = When the goods are ready, send this order to the carrier.
|
||||
order-send-to-carrier = Send to
|
||||
order-send-confirm = Send this order to the carrier now?
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
brand = Moja gitara
|
||||
brand = Kompress eshop
|
||||
hello-world = Ahoj svet!
|
||||
meta-description = Osobná stránka gitaristu. Novinky, blog, albumy a skladby na jednom mieste.
|
||||
meta-description = Kompress eshop
|
||||
nav-home = Domov
|
||||
nav-about = O mne
|
||||
nav-blog = Blog
|
||||
@@ -57,12 +57,31 @@ album-by = od
|
||||
album-play-full = Prehrať celý album
|
||||
album-queue-all = zoradiť všetky skladby v poradí
|
||||
album-no-tracks = zatiaľ žiadne skladby
|
||||
login-title = Prihlásenie admina
|
||||
login-title = Prihlásenie
|
||||
login-error = Prístup odmietnutý - nesprávny e-mail alebo heslo.
|
||||
login-error-unverified = Účet ešte nie je overený. Skontrolujte si e-mail a kliknite na overovací odkaz.
|
||||
login-root = root
|
||||
login-auth = Prihlásiť sa
|
||||
login-email = E-mail
|
||||
login-password = Heslo
|
||||
login-no-account = Nemáte účet?
|
||||
login-have-account = Už máte účet?
|
||||
auth-or = alebo
|
||||
auth-google = Pokračovať cez Google
|
||||
nav-login = Prihlásiť sa
|
||||
nav-register = Registrácia
|
||||
nav-profile = Môj profil
|
||||
register-title = Vytvoriť účet
|
||||
register-name = Meno
|
||||
register-submit = Zaregistrovať sa
|
||||
register-error-exists = Účet s týmto e-mailom už existuje.
|
||||
register-error-invalid = Skontrolujte zadané údaje a skúste to znova.
|
||||
verify-sent-title = Skontrolujte si e-mail
|
||||
verify-sent-body = Poslali sme overovací odkaz na adresu
|
||||
verify-ok-title = Účet overený
|
||||
verify-ok-body = Váš účet je overený. Teraz sa môžete prihlásiť.
|
||||
verify-fail-title = Overenie zlyhalo
|
||||
verify-fail-body = Tento odkaz je neplatný alebo mu vypršala platnosť.
|
||||
auth = Overenie
|
||||
admin-session = Relácia
|
||||
readonly = iba na čítanie
|
||||
@@ -172,3 +191,135 @@ 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
|
||||
cart-added = Pridané do košíka
|
||||
in-stock = Na sklade
|
||||
out-of-stock = Vypredané
|
||||
gallery-prev = Predchádzajúci obrázok
|
||||
gallery-next = Ďalší obrázok
|
||||
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-remove-confirm = Odstrániť túto položku z košíka?
|
||||
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-phone = Telefón
|
||||
checkout-address = Adresa
|
||||
checkout-city = Mesto
|
||||
checkout-zip = PSČ
|
||||
checkout-country = Krajina
|
||||
country-sk = Slovensko
|
||||
country-cz = Česko
|
||||
country-at = Rakúsko
|
||||
country-de = Nemecko
|
||||
country-pl = Poľsko
|
||||
country-hu = Maďarsko
|
||||
checkout-note = Poznámka k objednávke
|
||||
checkout-save-profile = Uložiť túto adresu do môjho profilu
|
||||
account-type = Typ účtu
|
||||
account-personal = Súkromná osoba
|
||||
account-company = Firma
|
||||
account-company-details = Firemné údaje
|
||||
company-name = Názov firmy
|
||||
company-ico = IČO
|
||||
company-dic = DIČ
|
||||
company-icdph = IČ DPH
|
||||
field-optional = nepovinné
|
||||
checkout-place-order = Odoslať objednávku
|
||||
checkout-summary = Súhrn objednávky
|
||||
profile-title = Môj profil
|
||||
profile-intro = Tieto údaje použijeme na predvyplnenie pokladne.
|
||||
profile-saved = Profil bol uložený.
|
||||
profile-save = Uložiť profil
|
||||
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ť jednotlivých možností dopravy.
|
||||
shipping-enabled = Aktívne
|
||||
shipping-new = Pridať možnosť dopravy
|
||||
shipping-add = Pridať
|
||||
shipping-requires-pickup = Vyžaduje výdajné miesto
|
||||
shipping-carrier = Dopravca
|
||||
carrier-none = Manuálne (bez API)
|
||||
carrier-packeta = Packeta
|
||||
carrier-dpd = DPD
|
||||
carrier-dhl = DHL
|
||||
order-fulfillment = Expedícia
|
||||
order-shipped-via = Odoslané cez
|
||||
order-tracking = Sledovanie
|
||||
order-label = Tlačiť štítok
|
||||
order-manual-fulfillment = Manuálne spracovanie — táto možnosť nemá API dopravcu.
|
||||
order-send-hint = Keď je tovar pripravený, odošlite objednávku dopravcovi.
|
||||
order-send-to-carrier = Odoslať dopravcovi
|
||||
order-send-confirm = Odoslať túto objednávku dopravcovi teraz?
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
5
assets/static/vendor/alpine/alpinejs-3.14.9.min.js
vendored
Normal file
5
assets/static/vendor/alpine/alpinejs-3.14.9.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
31
assets/static/vendor/quill/LICENSE
vendored
31
assets/static/vendor/quill/LICENSE
vendored
@@ -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.
|
||||
3
assets/static/vendor/quill/quill.js
vendored
3
assets/static/vendor/quill/quill.js
vendored
File diff suppressed because one or more lines are too long
@@ -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
|
||||
*/
|
||||
10
assets/static/vendor/quill/quill.snow.css
vendored
10
assets/static/vendor/quill/quill.snow.css
vendored
File diff suppressed because one or more lines are too long
153
assets/views/account/profile.html
Normal file
153
assets/views/account/profile.html
Normal file
@@ -0,0 +1,153 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="profile-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-title", lang=lang | default(value='sk')) }}</h1>
|
||||
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="profile-intro", lang=lang | default(value='sk')) }}</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="mt-4 rounded-radius border border-success bg-success/10 px-4 py-3 text-sm text-success" role="status">
|
||||
{{ t(key="profile-saved", lang=lang | default(value='sk')) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/account/profile" hx-boost="false" class="mt-6 space-y-6"
|
||||
x-data="{ accountType: '{{ account_type | default(value='personal') }}' }">
|
||||
<!-- personal vs company: an account is exactly one of the two -->
|
||||
<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="account-type", lang=lang | default(value='sk')) }}</legend>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<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">
|
||||
{{ ui::radio(name="account_type", value="personal", attrs='x-model="accountType"') }}
|
||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-personal", 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">
|
||||
{{ ui::radio(name="account_type", value="company", attrs='x-model="accountType"') }}
|
||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-company", lang=lang | default(value='sk')) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- company billing details (company accounts only) -->
|
||||
<fieldset x-show="accountType === 'company'" x-cloak 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="account-company-details", lang=lang | default(value='sk')) }}</legend>
|
||||
<div class="space-y-1.5">
|
||||
<label for="company_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-name", lang=lang | default(value='sk')) }}</label>
|
||||
{{ ui::input(name="company_name", id="company_name", value=company_name | default(value=''), autocomplete="organization") }}
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-1.5">
|
||||
<label for="company_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-ico", lang=lang | default(value='sk')) }}</label>
|
||||
{{ ui::input(name="company_id", id="company_id", value=company_id | default(value='')) }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="tax_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-dic", lang=lang | default(value='sk')) }}</label>
|
||||
{{ ui::input(name="tax_id", id="tax_id", value=tax_id | default(value='')) }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="vat_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-icdph", lang=lang | default(value='sk')) }} <span class="text-on-surface/50 dark:text-on-surface-dark/50">({{ t(key="field-optional", lang=lang | default(value='sk')) }})</span></label>
|
||||
{{ ui::input(name="vat_id", id="vat_id", value=vat_id | default(value='')) }}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- contact (name/email are managed by the login) -->
|
||||
<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 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>
|
||||
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">{{ name }}</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label 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>
|
||||
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">{{ email }}</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="phone" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-phone", lang=lang | default(value='sk')) }}</label>
|
||||
<div class="flex gap-2">
|
||||
<!-- editable combobox: type freely or pick from the dropdown -->
|
||||
<div class="relative w-28 shrink-0" @click.outside="prefixOpen = false"
|
||||
x-data="{ prefixOpen: false, prefix: '{{ phone_prefix | default(value='+421') }}', opts: [
|
||||
{ v: '+421', l: '🇸🇰 +421' }, { v: '+420', l: '🇨🇿 +420' },
|
||||
{ v: '+43', l: '🇦🇹 +43' }, { v: '+49', l: '🇩🇪 +49' },
|
||||
{ v: '+48', l: '🇵🇱 +48' }, { v: '+36', l: '🇭🇺 +36' },
|
||||
{ v: '+44', l: '🇬🇧 +44' }, { v: '+39', l: '🇮🇹 +39' }, { v: '+33', l: '🇫🇷 +33' }
|
||||
], get filtered() { return this.opts.filter(o => !this.prefix || o.v.includes(this.prefix)) } }">
|
||||
<input name="phone_prefix" type="text" x-model="prefix" @focus="prefixOpen = true" @input="prefixOpen = true"
|
||||
aria-label="{{ t(key='checkout-phone', lang=lang | default(value='sk')) }}" autocomplete="tel-country-code" inputmode="tel"
|
||||
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-7 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="button" tabindex="-1" @click="prefixOpen = !prefixOpen"
|
||||
class="absolute inset-y-0 right-0 flex w-7 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
||||
class="size-4 transition-transform" :class="prefixOpen && 'rotate-180'">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul x-show="prefixOpen" x-cloak x-transition
|
||||
class="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-radius border border-outline bg-surface p-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<template x-for="o in filtered" :key="o.v">
|
||||
<li><button type="button" @click="prefix = o.v; prefixOpen = false" x-text="o.l"
|
||||
class="block w-full rounded-radius px-3 py-1.5 text-left text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark"></button></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
{{ ui::input(name="phone", id="phone", type="tel", value=phone | default(value=''), autocomplete="tel", placeholder="900 000 000", attrs='inputmode="tel"') }}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- default 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>
|
||||
{{ ui::input(name="address", id="address", value=address | default(value=''), autocomplete="street-address") }}
|
||||
</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>
|
||||
{{ ui::input(name="city", id="city", value=city | default(value=''), autocomplete="address-level2") }}
|
||||
</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>
|
||||
{{ ui::input(name="zip", id="zip", value=zip | default(value=''), autocomplete="postal-code") }}
|
||||
</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>
|
||||
<div class="relative" @click.outside="countryOpen = false"
|
||||
x-data="{ countryOpen: false, country: '{{ country | default(value='') }}', opts: [
|
||||
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-de', lang=lang | default(value='sk')) }}', l: '🇩🇪 {{ t(key='country-de', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
|
||||
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
|
||||
<input id="country" name="country" type="text" x-model="country" @focus="countryOpen = true" @input="countryOpen = true"
|
||||
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 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="button" tabindex="-1" @click="countryOpen = !countryOpen"
|
||||
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
||||
class="size-4 transition-transform" :class="countryOpen && 'rotate-180'">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul x-show="countryOpen" x-cloak x-transition
|
||||
class="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-radius border border-outline bg-surface p-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<template x-for="o in filtered" :key="o.v">
|
||||
<li><button type="button" @click="country = o.v; countryOpen = false" x-text="o.l"
|
||||
class="block w-full rounded-radius px-3 py-1.5 text-left text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark"></button></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{{ ui::button(label=t(key="profile-save", lang=lang | default(value='sk')), type="submit", size="px-6 py-2.5 text-sm") }}
|
||||
</form>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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')) }} · {{ tracks | length }} {{ t(key="songs-title", lang=lang | default(value='sk')) }} ·
|
||||
{% 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 %}—{% 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 %}
|
||||
@@ -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 %}
|
||||
@@ -1,3 +1,4 @@
|
||||
{% import "macros/ui.html" as ui %}
|
||||
<!doctype html>
|
||||
<html lang="{{ lang | default(value='sk') }}" data-theme="dark">
|
||||
<head>
|
||||
@@ -16,139 +17,115 @@
|
||||
|| (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>
|
||||
<form method="post" action="/lang" hx-boost="false">
|
||||
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 border border-base-300 bg-base-200 p-2 shadow-lg">
|
||||
<li class="menu-title">{{ t(key="settings-language", lang=lang | default(value='sk')) }}</li>
|
||||
<li>
|
||||
<button type="submit" name="lang" value="en" class="{% if lang | default(value='sk') == 'en' %}active{% endif %}">
|
||||
{{ t(key="language-en", lang=lang | default(value='sk')) }}
|
||||
{% if lang | default(value='sk') == 'en' %}
|
||||
<span class="ml-auto">✓</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="submit" name="lang" value="sk" class="{% if lang | default(value='sk') == 'sk' %}active{% endif %}">
|
||||
{{ t(key="language-sk", lang=lang | default(value='sk')) }}
|
||||
{% if lang | default(value='sk') == 'sk' %}
|
||||
<span class="ml-auto">✓</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="menu-title">{{ t(key="settings-theme", lang=lang | default(value='sk')) }}</li>
|
||||
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">{{ t(key="theme-system", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">{{ t(key="theme-light", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">{{ t(key="theme-dark", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
||||
<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">
|
||||
|
||||
{# Sidebar nav links — adapted from the vendored Penguin UI component
|
||||
penguinui-components/sidebar/simple-sidebar.html: Penguin's link
|
||||
treatment (hover:bg-primary/5, focus-visible:underline) with the active
|
||||
state (bg-primary/10 + text-on-surface-strong) mapped onto our
|
||||
data-nav / aria-current so markActiveNav() keeps driving it. #}
|
||||
<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-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ 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-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ 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-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ t(key="admin-categories", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<a href="/admin/orders" data-nav="/admin/orders"
|
||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<a href="/admin/shipping" data-nav="/admin/shipping"
|
||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ 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-2 rounded-radius px-2 py-1.5 text-sm font-medium text-info underline-offset-2 transition hover:bg-info/5 focus:outline-hidden focus-visible:underline">
|
||||
{{ t(key="admin-exit", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="flex w-full items-center gap-2 rounded-radius px-2 py-1.5 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-danger/5 focus:outline-hidden focus-visible:underline">
|
||||
{{ 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">
|
||||
<!-- Penguin animated hamburger (bars ↔ X) in our ghost-square shell -->
|
||||
<button type="button" @click="showSidebar = !showSidebar" :aria-expanded="showSidebar" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 md:hidden dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
|
||||
{{ ui::icon(name="hamburger", size="size-6", attrs='x-show="!showSidebar"') }}
|
||||
{{ ui::icon(name="close", size="size-6", attrs='x-cloak x-show="showSidebar"') }}
|
||||
</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">
|
||||
{% include "partials/settings_dropdown.html" %}
|
||||
</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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
64
assets/views/admin/catalog/categories.html
Normal file
64
assets/views/admin/catalog/categories.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% 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>
|
||||
{{ ui::button(label=t(key="new-category", lang=lang | default(value='sk')), href="/admin/catalog/categories/new") }}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 {{ ui::table_wrap_cls() }}">
|
||||
{% if categories | length > 0 %}
|
||||
<table class="{{ ui::table_cls() }}">
|
||||
<thead class="{{ ui::thead_cls() }}">
|
||||
<tr>
|
||||
{{ ui::th(label=t(key="name", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="admin-products", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="status", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="{{ ui::tbody_cls() }}">
|
||||
{% for row in categories %}
|
||||
<tr class="{{ ui::row_cls() }}">
|
||||
<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 %}
|
||||
{{ ui::badge(label=t(key="published", lang=lang | default(value='sk')), variant="success") }}
|
||||
{% else %}
|
||||
{{ ui::badge(label=t(key="draft", lang=lang | default(value='sk')), variant="neutral") }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/categories/" ~ row.category.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
||||
<form method="post" action="/admin/catalog/categories/{{ row.category.id }}/delete"
|
||||
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
||||
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||
</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>
|
||||
{{ ui::button(label=t(key="new-category", lang=lang | default(value='sk')), href="/admin/catalog/categories/new") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
77
assets/views/admin/catalog/category_form.html
Normal file
77
assets/views/admin/catalog/category_form.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{% if category %}{{ 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 category %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}
|
||||
</h1>
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/categories", size="px-3 py-2 text-sm") }}
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data"
|
||||
action="{% if category %}/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">
|
||||
|
||||
{% if category %}
|
||||
{% set v_name = category.name %}{% set v_slug = category.slug %}{% set v_pos = category.position %}{% set v_desc = category.description | default(value="") %}{% set v_pub = category.published %}
|
||||
{% else %}
|
||||
{% set v_name = "" %}{% set v_slug = "" %}{% set v_pos = 0 %}{% set v_desc = "" %}{% set v_pub = false %}
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
|
||||
</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>
|
||||
{{ ui::input(name="slug", id="slug", value=v_slug, placeholder=t(key='slug-auto', lang=lang | default(value='sk'))) }}
|
||||
</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>
|
||||
{{ ui::input(name="position", id="position", type="number", value=v_pos) }}
|
||||
</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>
|
||||
<div class="relative">
|
||||
<select id="parent_id" name="parent_id"
|
||||
class="w-full appearance-none rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||
<option value="">{{ t(key="no-parent", lang=lang | default(value='sk')) }}</option>
|
||||
{% for parent in parents %}
|
||||
<option value="{{ parent.id }}" {% if category and category.parent_id == parent.id %}selected{% endif %}>
|
||||
{% if parent.depth > 0 %}{% for _ in range(end=parent.depth) %}— {% endfor %}{% endif %}{{ parent.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-on-surface/60 dark:text-on-surface-dark/60"><path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
</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>
|
||||
{{ ui::textarea(name="description", id="description", rows="4", value=v_desc) }}
|
||||
</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 category and category.image_id %}
|
||||
<img src="/images/{{ category.image_id }}" alt="" class="size-24 rounded-radius object-cover">
|
||||
{% endif %}
|
||||
{{ ui::file_input(name="image", id="image", accept="image/*") }}
|
||||
</div>
|
||||
|
||||
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/categories") }}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
91
assets/views/admin/catalog/product_form.html
Normal file
91
assets/views/admin/catalog/product_form.html
Normal file
@@ -0,0 +1,91 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{% if product %}{{ 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 product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}
|
||||
</h1>
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products", size="px-3 py-2 text-sm") }}
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data"
|
||||
action="{% if product %}/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">
|
||||
|
||||
{% if product %}
|
||||
{% set v_name = product.name %}{% set v_price = product.price %}{% set v_currency = product.currency %}{% set v_stock = product.stock %}{% set v_sku = product.sku | default(value="") %}{% set v_slug = product.slug %}{% set v_desc = product.description | default(value="") %}{% set v_pub = product.published %}
|
||||
{% else %}
|
||||
{% set v_name = "" %}{% set v_price = "" %}{% set v_currency = "EUR" %}{% set v_stock = 0 %}{% set v_sku = "" %}{% set v_slug = "" %}{% set v_desc = "" %}{% set v_pub = false %}
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
|
||||
</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>
|
||||
{{ ui::input(name="price", id="price", required=true, value=v_price, placeholder="0.00", attrs='inputmode="decimal"') }}
|
||||
</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>
|
||||
{{ ui::input(name="currency", id="currency", value=v_currency, attrs='maxlength="3"', extra="uppercase") }}
|
||||
</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>
|
||||
{{ ui::input(name="stock", id="stock", type="number", value=v_stock, attrs='min="0"') }}
|
||||
</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>
|
||||
{{ ui::input(name="sku", id="sku", value=v_sku) }}
|
||||
</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>
|
||||
<div class="relative">
|
||||
<select id="category_id" name="category_id"
|
||||
class="w-full appearance-none rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||
<option value="">{{ t(key="no-category", lang=lang | default(value='sk')) }}</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}" {% if product and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-on-surface/60 dark:text-on-surface-dark/60"><path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
</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>
|
||||
{{ ui::input(name="slug", id="slug", value=v_slug, placeholder=t(key='slug-auto', lang=lang | default(value='sk'))) }}
|
||||
</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>
|
||||
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }}
|
||||
</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 product and product.image %}
|
||||
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
|
||||
{% endif %}
|
||||
{{ ui::file_input(name="image", id="image", accept="image/*") }}
|
||||
</div>
|
||||
|
||||
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
74
assets/views/admin/catalog/products.html
Normal file
74
assets/views/admin/catalog/products.html
Normal file
@@ -0,0 +1,74 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% 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>
|
||||
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 {{ ui::table_wrap_cls() }}">
|
||||
{% if products | length > 0 %}
|
||||
<table class="{{ ui::table_cls() }}">
|
||||
<thead class="{{ ui::thead_cls() }}">
|
||||
<tr>
|
||||
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="price", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="stock", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="status", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="{{ ui::tbody_cls() }}">
|
||||
{% for product in products %}
|
||||
<tr class="{{ ui::row_cls() }}">
|
||||
<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 %}
|
||||
{{ ui::badge(label=t(key="published", lang=lang | default(value='sk')), variant="success") }}
|
||||
{% else %}
|
||||
{{ ui::badge(label=t(key="draft", lang=lang | default(value='sk')), variant="neutral") }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, size="px-3 py-1.5 text-xs") }}
|
||||
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
|
||||
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
||||
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||
</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>
|
||||
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t(key="login-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
{% block crumb %}admin/login{% endblock crumb %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto mt-8 max-w-sm">
|
||||
<div class="card">
|
||||
<div class="term-head">
|
||||
<span class="term-head-name">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="term-head-meta term-tag is-red">{{ t(key="auth", lang=lang | default(value='sk')) }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h1 class="term-title">{{ t(key="login-auth", lang=lang | default(value='sk')) }}</h1>
|
||||
{% if error %}
|
||||
<div class="alert alert-error mt-2">
|
||||
<span>✗ {{ t(key="login-error", lang=lang | default(value='sk')) }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/login" hx-boost="false" class="space-y-2">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text t-green">{{ t(key="login-email", lang=lang | default(value='sk')) }}:</span></label>
|
||||
<input type="email" name="email" required autofocus class="input input-bordered w-full">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text t-green">{{ t(key="login-password", lang=lang | default(value='sk')) }}:</span></label>
|
||||
<input type="password" name="password" required class="input input-bordered w-full">
|
||||
</div>
|
||||
<button class="btn btn-primary mt-2 w-full">[ {{ t(key="login-auth", lang=lang | default(value='sk')) }} ]</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
42
assets/views/admin/orders/index.html
Normal file
42
assets/views/admin/orders/index.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% 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 {{ ui::table_wrap_cls() }}">
|
||||
{% if orders | length > 0 %}
|
||||
<table class="{{ ui::table_cls() }}">
|
||||
<thead class="{{ ui::thead_cls() }}">
|
||||
<tr>
|
||||
{{ ui::th(label=t(key="order-number", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="order-customer", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="order-status", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="order-total", lang=lang | default(value='sk')), align="text-right") }}
|
||||
{{ ui::th(label="") }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="{{ ui::tbody_cls() }}">
|
||||
{% for order in orders %}
|
||||
<tr class="{{ ui::row_cls() }}">
|
||||
<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">
|
||||
{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="neutral") }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums">{{ order.total }} {{ order.currency }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/admin/orders/" ~ order.id, size="px-3 py-1.5 text-xs") }}
|
||||
</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 %}
|
||||
127
assets/views/admin/orders/show.html
Normal file
127
assets/views/admin/orders/show.html
Normal file
@@ -0,0 +1,127 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% 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>
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="admin-orders", lang=lang | default(value='sk')), href="/admin/orders", size="px-3 py-2 text-sm") }}
|
||||
</div>
|
||||
|
||||
{% if ship_error %}
|
||||
{{ ui::alert_danger(message=ship_error, extra="mt-4") }}
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
<div class="{{ ui::table_wrap_cls() }}">
|
||||
<table class="{{ ui::table_cls() }}">
|
||||
<thead class="{{ ui::thead_cls() }}">
|
||||
<tr>
|
||||
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="quantity", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="order-total", lang=lang | default(value='sk')), align="text-right") }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="{{ ui::tbody_cls() }}">
|
||||
{% 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="{{ ui::tfoot_cls() }}">
|
||||
<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>
|
||||
{% if order.phone %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.phone }}</p>{% endif %}
|
||||
</div>
|
||||
{% if order.account_type == "company" %}
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-company-details", lang=lang | default(value='sk')) }}</p>
|
||||
<p class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.company_name }}</p>
|
||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ t(key="company-ico", lang=lang | default(value='sk')) }}: {{ order.company_id }}</p>
|
||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ t(key="company-dic", lang=lang | default(value='sk')) }}: {{ order.tax_id }}</p>
|
||||
{% if order.vat_id %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ t(key="company-icdph", lang=lang | default(value='sk')) }}: {{ order.vat_id }}</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="order-fulfillment", lang=lang | default(value='sk')) }}</p>
|
||||
{% if order.tracking_number %}
|
||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">
|
||||
{{ t(key="order-shipped-via", lang=lang | default(value='sk')) }} <span class="font-medium">{{ carrier | upper }}</span>
|
||||
</p>
|
||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">
|
||||
{{ t(key="order-tracking", lang=lang | default(value='sk')) }}: <span class="font-mono font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.tracking_number }}</span>
|
||||
</p>
|
||||
{% if order.label_url %}
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="order-label", lang=lang | default(value='sk')), href=order.label_url, size="px-3 py-1.5 text-xs", attrs='target="_blank" rel="noopener"') }}
|
||||
{% endif %}
|
||||
{% elif carrier == "none" %}
|
||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-manual-fulfillment", lang=lang | default(value='sk')) }}</p>
|
||||
{% elif can_ship %}
|
||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-send-hint", lang=lang | default(value='sk')) }}</p>
|
||||
<form method="post" action="/admin/orders/{{ order.id }}/ship"
|
||||
onsubmit="return confirm('{{ t(key="order-send-confirm", lang=lang | default(value='sk')) }}')">
|
||||
{% set carrier_up = carrier | upper %}
|
||||
{% set ship_label = t(key="order-send-to-carrier", lang=lang | default(value='sk')) ~ " " ~ carrier_up %}
|
||||
{{ ui::button(label=ship_label, type="submit", extra="w-full") }}
|
||||
</form>
|
||||
{% 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>
|
||||
<div class="relative">
|
||||
<select id="status" name="status"
|
||||
class="w-full appearance-none rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-on-surface/60 dark:text-on-surface-dark/60"><path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
{{ ui::button(label=t(key="order-update-status", lang=lang | default(value='sk')), type="submit", extra="w-full") }}
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
30
assets/views/admin/shipping/index.html
Normal file
30
assets/views/admin/shipping/index.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% 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.carrier | upper }}{% 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>
|
||||
{{ ui::input(name="price", id="price-" ~ method.id, value=method.price, width="w-28", attrs='inputmode="decimal"') }}
|
||||
</div>
|
||||
<div class="pb-2">{{ ui::checkbox(name="enabled", label=t(key="shipping-enabled", lang=lang | default(value='sk')), checked=method.enabled) }}</div>
|
||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -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 }}">▶ 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
64
assets/views/auth/login.html
Normal file
64
assets/views/auth/login.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="login-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto mt-8 max-w-sm">
|
||||
<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="brand", lang=lang | default(value='sk')) }}
|
||||
</span>
|
||||
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="login-title", lang=lang | default(value='sk')) }}
|
||||
</h1>
|
||||
|
||||
{% if error == "unverified" %}
|
||||
{{ ui::alert_danger(message=t(key="login-error-unverified", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||
{% elif error %}
|
||||
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/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>
|
||||
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email", attrs="autofocus") }}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="current-password") }}
|
||||
</div>
|
||||
|
||||
{{ ui::button(label=t(key="login-auth", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||
</form>
|
||||
|
||||
<div class="mt-5 flex items-center gap-3 text-xs text-on-surface/50 dark:text-on-surface-dark/50">
|
||||
<span class="h-px flex-1 bg-outline dark:bg-outline-dark"></span>
|
||||
{{ t(key="auth-or", lang=lang | default(value='sk')) }}
|
||||
<span class="h-px flex-1 bg-outline dark:bg-outline-dark"></span>
|
||||
</div>
|
||||
{{ ui::button(label=t(key="auth-google", lang=lang | default(value='sk')), href="/api/oauth2/google", variant="outline-secondary", attrs='hx-boost="false"', extra="mt-4 w-full", icon='<svg class="size-4" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"/><path fill="#FF3D00" d="m6.306 14.691 6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z"/><path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"/><path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z"/></svg>') }}
|
||||
|
||||
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
|
||||
{{ t(key="login-no-account", lang=lang | default(value='sk')) }}
|
||||
<a href="/register"
|
||||
class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-register", lang=lang | default(value='sk')) }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
72
assets/views/auth/register.html
Normal file
72
assets/views/auth/register.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="register-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto mt-8 max-w-sm">
|
||||
<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="brand", lang=lang | default(value='sk')) }}
|
||||
</span>
|
||||
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="register-title", lang=lang | default(value='sk')) }}
|
||||
</h1>
|
||||
|
||||
{% if error == "exists" %}
|
||||
{{ ui::alert_danger(message=t(key="register-error-exists", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||
{% elif error %}
|
||||
{{ ui::alert_danger(message=t(key="register-error-invalid", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/register" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="name"
|
||||
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="register-name", lang=lang | default(value='sk')) }}
|
||||
</label>
|
||||
{{ ui::input(name="name", id="name", required=true, autocomplete="name", attrs="autofocus") }}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email") }}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password") }}
|
||||
</div>
|
||||
|
||||
{{ ui::button(label=t(key="register-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||
</form>
|
||||
|
||||
<div class="mt-5 flex items-center gap-3 text-xs text-on-surface/50 dark:text-on-surface-dark/50">
|
||||
<span class="h-px flex-1 bg-outline dark:bg-outline-dark"></span>
|
||||
{{ t(key="auth-or", lang=lang | default(value='sk')) }}
|
||||
<span class="h-px flex-1 bg-outline dark:bg-outline-dark"></span>
|
||||
</div>
|
||||
{{ ui::button(label=t(key="auth-google", lang=lang | default(value='sk')), href="/api/oauth2/google", variant="outline-secondary", attrs='hx-boost="false"', extra="mt-4 w-full", icon='<svg class="size-4" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"/><path fill="#FF3D00" d="m6.306 14.691 6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z"/><path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"/><path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z"/></svg>') }}
|
||||
|
||||
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
|
||||
{{ t(key="login-have-account", lang=lang | default(value='sk')) }}
|
||||
<a href="/login"
|
||||
class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
27
assets/views/auth/verified.html
Normal file
27
assets/views/auth/verified.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{% if ok %}{{ t(key="verify-ok-title", lang=lang | default(value='sk')) }}{% else %}{{ t(key="verify-fail-title", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto mt-8 max-w-sm">
|
||||
<div
|
||||
class="rounded-radius border border-outline bg-surface-alt p-5 shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
{% if ok %}
|
||||
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="verify-ok-title", lang=lang | default(value='sk')) }}
|
||||
</h1>
|
||||
<p class="mt-3 text-sm text-on-surface dark:text-on-surface-dark">
|
||||
{{ t(key="verify-ok-body", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
{{ ui::button(label=t(key="login-auth", lang=lang | default(value='sk')), href="/login", extra="mt-4 w-full") }}
|
||||
{% else %}
|
||||
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="verify-fail-title", lang=lang | default(value='sk')) }}
|
||||
</h1>
|
||||
{{ ui::alert_danger(message=t(key="verify-fail-body", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||
{{ ui::button(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", variant="outline-primary", extra="mt-4 w-full") }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
20
assets/views/auth/verify_sent.html
Normal file
20
assets/views/auth/verify_sent.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="verify-sent-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto mt-8 max-w-sm">
|
||||
<div
|
||||
class="rounded-radius border border-outline bg-surface-alt p-5 shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="verify-sent-title", lang=lang | default(value='sk')) }}
|
||||
</h1>
|
||||
<p class="mt-3 text-sm text-on-surface dark:text-on-surface-dark">
|
||||
{{ t(key="verify-sent-body", lang=lang | default(value='sk')) }}
|
||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ email }}</span>
|
||||
</p>
|
||||
{{ ui::button(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", variant="outline-primary", extra="mt-4 w-full") }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -1,3 +1,4 @@
|
||||
{% import "macros/ui.html" as ui %}
|
||||
<!doctype html>
|
||||
<html lang="{{ lang | default(value='sk') }}" data-theme="dark">
|
||||
<head>
|
||||
@@ -11,356 +12,310 @@
|
||||
<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();
|
||||
document.addEventListener('DOMContentLoaded', markActiveNav);
|
||||
document.addEventListener('htmx:afterSwap', markActiveNav);
|
||||
// Sum the quantities stored in the `cart` cookie for the header badge.
|
||||
function cartCount() {
|
||||
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);
|
||||
}
|
||||
// --- 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) {}
|
||||
// Show a floating toast notification. Usage: toast('Saved').
|
||||
// Bridges to the vendored Penguin UI toast component, which listens for a
|
||||
// `notify` event with { variant, title, message }.
|
||||
function toast(message) {
|
||||
window.dispatchEvent(new CustomEvent('notify', { detail: { variant: 'success', message: message } }));
|
||||
}
|
||||
function uwRestore() {
|
||||
try {
|
||||
var d = JSON.parse(sessionStorage.getItem('uwQueue') || 'null');
|
||||
if (d && d.q) { uwQueue = d.q; uwIndex = (typeof d.i === 'number' ? d.i : -1); }
|
||||
} catch (e) {}
|
||||
}
|
||||
function uwRenderQueue() {
|
||||
var player = document.getElementById('uw-player');
|
||||
if (player) player.classList.toggle('uw-has-queue', uwQueue.length > 0);
|
||||
var badge = document.getElementById('uw-queue-badge');
|
||||
if (badge) badge.textContent = uwQueue.length;
|
||||
var count = document.getElementById('uw-queue-count');
|
||||
if (count) count.textContent = uwQueue.length + (uwQueue.length === 1 ? ' track' : ' tracks');
|
||||
var list = document.getElementById('uw-queue-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
uwQueue.forEach(function (t, idx) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'uw-queue-item' + (idx === uwIndex ? ' is-current' : '');
|
||||
var jump = document.createElement('button');
|
||||
jump.type = 'button';
|
||||
jump.className = 'uw-queue-jump';
|
||||
jump.setAttribute('data-uw-jump', idx);
|
||||
jump.textContent = (idx === uwIndex ? '▸' : (idx + 1));
|
||||
var name = document.createElement('span');
|
||||
name.className = 'uw-queue-name';
|
||||
name.setAttribute('data-uw-jump', idx);
|
||||
name.textContent = t.title || 'unknown track';
|
||||
var rm = document.createElement('button');
|
||||
rm.type = 'button';
|
||||
rm.className = 'uw-queue-remove';
|
||||
rm.setAttribute('data-uw-remove', idx);
|
||||
rm.setAttribute('aria-label', 'Remove from playlist');
|
||||
rm.textContent = '✕';
|
||||
li.appendChild(jump);
|
||||
li.appendChild(name);
|
||||
li.appendChild(rm);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
// Point <audio> at the current queue entry; play it when asked.
|
||||
function uwLoad(autoplay) {
|
||||
var audio = document.getElementById('uw-audio');
|
||||
var now = document.getElementById('uw-now');
|
||||
if (!audio) return;
|
||||
var t = uwQueue[uwIndex];
|
||||
if (!t) {
|
||||
if (now) now.textContent = '—';
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
document.documentElement.classList.remove('uw-playing');
|
||||
uwRenderQueue();
|
||||
uwSave();
|
||||
return;
|
||||
}
|
||||
document.documentElement.classList.add('uw-playing');
|
||||
if (now) now.textContent = t.title || 'unknown track';
|
||||
if (audio.getAttribute('src') !== t.src) {
|
||||
audio.setAttribute('src', t.src);
|
||||
audio.load();
|
||||
}
|
||||
if (autoplay) {
|
||||
var p = audio.play();
|
||||
if (p && p.catch) p.catch(function () {});
|
||||
}
|
||||
uwRenderQueue();
|
||||
uwSave();
|
||||
}
|
||||
// Replace the whole queue with a fresh set of tracks and play.
|
||||
function uwPlayList(tracks) {
|
||||
if (!tracks || !tracks.length) return;
|
||||
uwQueue = tracks.slice();
|
||||
uwIndex = 0;
|
||||
uwLoad(true);
|
||||
}
|
||||
// Add one track: play it now if idle, otherwise queue it.
|
||||
function uwAdd(src, title) {
|
||||
var audio = document.getElementById('uw-audio');
|
||||
var idle = uwIndex < 0 || !audio || audio.ended || !audio.getAttribute('src');
|
||||
uwQueue.push({ src: src, title: title });
|
||||
if (idle) { uwIndex = uwQueue.length - 1; uwLoad(true); }
|
||||
else { uwRenderQueue(); uwSave(); }
|
||||
}
|
||||
function uwNext() {
|
||||
if (uwIndex >= 0 && uwIndex < uwQueue.length - 1) { uwIndex++; uwLoad(true); }
|
||||
}
|
||||
function uwPrev() {
|
||||
var audio = document.getElementById('uw-audio');
|
||||
if (audio && audio.currentTime > 3) { audio.currentTime = 0; return; }
|
||||
if (uwIndex > 0) { uwIndex--; uwLoad(true); }
|
||||
else if (audio) audio.currentTime = 0;
|
||||
}
|
||||
function uwJump(idx) {
|
||||
if (idx >= 0 && idx < uwQueue.length) { uwIndex = idx; uwLoad(true); }
|
||||
}
|
||||
function uwRemove(idx) {
|
||||
if (idx < 0 || idx >= uwQueue.length) return;
|
||||
var playing = document.documentElement.classList.contains('uw-playing');
|
||||
uwQueue.splice(idx, 1);
|
||||
if (idx < uwIndex) { uwIndex--; uwRenderQueue(); uwSave(); }
|
||||
else if (idx > uwIndex) { uwRenderQueue(); uwSave(); }
|
||||
else {
|
||||
if (uwIndex >= uwQueue.length) uwIndex = uwQueue.length - 1;
|
||||
uwLoad(playing);
|
||||
}
|
||||
}
|
||||
function uwClear() {
|
||||
uwQueue = [];
|
||||
uwIndex = -1;
|
||||
uwLoad(false);
|
||||
var panel = document.getElementById('uw-queue');
|
||||
if (panel) panel.hidden = true;
|
||||
}
|
||||
function uwInit() {
|
||||
var audio = document.getElementById('uw-audio');
|
||||
if (!audio || audio.dataset.uwBound) return;
|
||||
audio.dataset.uwBound = '1';
|
||||
uwRestore();
|
||||
audio.addEventListener('ended', uwNext);
|
||||
uwRenderQueue();
|
||||
if (uwIndex >= 0 && uwQueue[uwIndex]) uwLoad(false);
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function () { initPage(); uwInit(); });
|
||||
document.addEventListener('htmx:afterSwap', initPage);
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!e.target.closest) return;
|
||||
var albumBtn = e.target.closest('.uw-play-album');
|
||||
if (albumBtn) {
|
||||
var sel = albumBtn.getAttribute('data-tracks-from');
|
||||
var scope = (sel && document.querySelector(sel)) || document;
|
||||
var tracks = [];
|
||||
scope.querySelectorAll('.uw-play').forEach(function (b) {
|
||||
tracks.push({ src: b.getAttribute('data-src'), title: b.getAttribute('data-title') });
|
||||
});
|
||||
uwPlayList(tracks);
|
||||
return;
|
||||
}
|
||||
// Play an album straight from the listing: fetch its tracks first.
|
||||
var remoteBtn = e.target.closest('.uw-play-album-remote');
|
||||
if (remoteBtn) {
|
||||
var rurl = remoteBtn.getAttribute('data-album-tracks-url');
|
||||
if (rurl) {
|
||||
remoteBtn.disabled = true;
|
||||
fetch(rurl, { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { if (d && d.tracks) uwPlayList(d.tracks); })
|
||||
.catch(function () {})
|
||||
.then(function () { remoteBtn.disabled = false; });
|
||||
}
|
||||
return;
|
||||
}
|
||||
var playBtn = e.target.closest('.uw-play');
|
||||
if (playBtn) {
|
||||
uwAdd(playBtn.getAttribute('data-src'), playBtn.getAttribute('data-title'));
|
||||
return;
|
||||
}
|
||||
var jumpEl = e.target.closest('[data-uw-jump]');
|
||||
if (jumpEl) { uwJump(parseInt(jumpEl.getAttribute('data-uw-jump'), 10)); return; }
|
||||
var rmEl = e.target.closest('[data-uw-remove]');
|
||||
if (rmEl) { uwRemove(parseInt(rmEl.getAttribute('data-uw-remove'), 10)); return; }
|
||||
if (e.target.closest('#uw-next')) { uwNext(); return; }
|
||||
if (e.target.closest('#uw-prev')) { uwPrev(); return; }
|
||||
if (e.target.closest('#uw-queue-clear')) { uwClear(); return; }
|
||||
if (e.target.closest('#uw-queue-toggle')) {
|
||||
var panel = document.getElementById('uw-queue');
|
||||
if (panel) panel.hidden = !panel.hidden;
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#uw-close')) { uwClear(); return; }
|
||||
});
|
||||
</script>
|
||||
<link href="/static/css/app.css?v=2026-05-20b" rel="stylesheet" type="text/css">
|
||||
<link href="/static/css/theme.css?v=2026-05-20b" rel="stylesheet" type="text/css">
|
||||
<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) -->
|
||||
{% set hamburger_icon = ui::icon(name="hamburger", size="size-6") %}
|
||||
{{ ui::icon_button(aria_label=t(key='categories', lang=lang | default(value='sk')), attrs='@click="cats = !cats" :aria-expanded="cats"', extra="lg:hidden", icon=hamburger_icon) }}
|
||||
<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 — Penguin navbar link treatment via ui::nav_link -->
|
||||
<ul class="ml-2 hidden items-center gap-6 md:flex">
|
||||
<li>{{ ui::nav_link(label=t(key="nav-home", lang=lang | default(value='sk')), href="/", data_nav="/") }}</li>
|
||||
<li>{{ ui::nav_link(label=t(key="nav-shop", lang=lang | default(value='sk')), href="/shop", data_nav="/shop") }}</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>{{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}</li>
|
||||
<li>
|
||||
<form method="post" action="/admin/logout" hx-boost="false">
|
||||
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
||||
<form method="post" action="/logout" hx-boost="false">
|
||||
<button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% elif logged_in_customer %}
|
||||
<li>{{ ui::nav_link(label=t(key="nav-profile", lang=lang | default(value='sk')), href="/account/profile", data_nav="/account") }}</li>
|
||||
<li>
|
||||
<form method="post" action="/logout" hx-boost="false">
|
||||
<button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ 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>{{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}</li>
|
||||
<li>{{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="term-nav-right">
|
||||
<div class="dropdown dropdown-end md:hidden">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<ul tabindex="0"
|
||||
class="menu dropdown-content z-50 mt-3 w-52 border border-base-300 bg-base-200 p-2 shadow-lg">
|
||||
<li><a href="/">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
|
||||
<li><a href="/blog">{{ t(key="nav-blog", lang=lang | default(value='sk')) }}</a></li>
|
||||
<li><a href="/audio/albums">{{ t(key="nav-audio", lang=lang | default(value='sk')) }}</a></li>
|
||||
<li><a href="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li>
|
||||
<li><a href="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li>
|
||||
{% if logged_in_admin %}
|
||||
<li><a href="/admin/dashboard" hx-boost="false" class="t-yellow">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
|
||||
<li>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li><a href="/admin/login">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}" title="{{ t(key='settings', lang=lang | default(value='sk')) }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<form method="post" action="/lang" hx-boost="false">
|
||||
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 border border-base-300 bg-base-200 p-2 shadow-lg">
|
||||
<li class="menu-title">{{ t(key="settings-language", lang=lang | default(value='sk')) }}</li>
|
||||
<li>
|
||||
<button type="submit" name="lang" value="en" class="{% if lang | default(value='sk') == 'en' %}active{% endif %}">
|
||||
English
|
||||
{% if lang | default(value='sk') == 'en' %}
|
||||
<span class="ml-auto">✓</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="submit" name="lang" value="sk" class="{% if lang | default(value='sk') == 'sk' %}active{% endif %}">
|
||||
Slovenčina
|
||||
{% if lang | default(value='sk') == 'sk' %}
|
||||
<span class="ml-auto">✓</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="menu-title">{{ t(key="settings-theme", lang=lang | default(value='sk')) }}</li>
|
||||
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">{{ t(key="theme-system", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">{{ t(key="theme-light", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">{{ t(key="theme-dark", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<!-- 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 = cartCount(); ['htmx:afterSwap', 'htmx:afterRequest'].forEach(function (e) { window.addEventListener(e, function () { count = cartCount() }) })"
|
||||
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 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
|
||||
{{ ui::icon(name="cart") }}
|
||||
<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">
|
||||
{% include "partials/settings_dropdown.html" %}
|
||||
</div>
|
||||
|
||||
<!-- mobile hamburger — Penguin animated icon swap (bars ↔ X), kept in
|
||||
our ghost-square icon-button shell for consistency with cart/gear -->
|
||||
<button type="button" @click="mobile = !mobile" :aria-expanded="mobile" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 md:hidden dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
|
||||
{{ ui::icon(name="hamburger", size="size-6", attrs='x-show="!mobile"') }}
|
||||
{{ ui::icon(name="close", size="size-6", attrs='x-cloak x-show="mobile"') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- mobile menu panel — Penguin sidebar-style menu rows (hover:bg-primary/5,
|
||||
underline focus), active state via data-nav + markActiveNav() -->
|
||||
<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="/" data-nav="/" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark 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="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark 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" data-nav="/admin" class="block rounded-radius px-3 py-2 text-sm font-medium text-warning underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
|
||||
<li>
|
||||
<form method="post" action="/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 underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% elif logged_in_customer %}
|
||||
<li><a href="/account/profile" data-nav="/account" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-profile", lang=lang | default(value='sk')) }}</a></li>
|
||||
<li>
|
||||
<form method="post" action="/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 underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li><a href="/login" data-nav="/login" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a></li>
|
||||
<li><a href="/register" data-nav="/register" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-register", 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">☰ 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- toast notifications: fire from anywhere with toast('message').
|
||||
Adapted from the vendored Penguin UI component
|
||||
(penguinui-components/toast-notification/stacking-toast-notification.html):
|
||||
the docs-only demo trigger buttons are omitted and the malformed quotes on
|
||||
the upstream dismiss-button <svg> tags are fixed. -->
|
||||
<div x-data="{
|
||||
notifications: [],
|
||||
displayDuration: 8000,
|
||||
soundEffect: false,
|
||||
addNotification({ variant = 'info', sender = null, title = null, message = null}) {
|
||||
const id = Date.now()
|
||||
const notification = { id, variant, sender, title, message }
|
||||
if (this.notifications.length >= 20) {
|
||||
this.notifications.splice(0, this.notifications.length - 19)
|
||||
}
|
||||
this.notifications.push(notification)
|
||||
},
|
||||
removeNotification(id) {
|
||||
setTimeout(() => {
|
||||
this.notifications = this.notifications.filter(
|
||||
(notification) => notification.id !== id,
|
||||
)
|
||||
}, 400);
|
||||
},
|
||||
}" x-on:notify.window="addNotification({
|
||||
variant: $event.detail.variant,
|
||||
sender: $event.detail.sender,
|
||||
title: $event.detail.title,
|
||||
message: $event.detail.message,
|
||||
})">
|
||||
|
||||
<div x-on:mouseenter="$dispatch('pause-auto-dismiss')" x-on:mouseleave="$dispatch('resume-auto-dismiss')" class="group pointer-events-none fixed inset-x-8 top-0 z-99 flex max-w-full flex-col gap-2 bg-transparent px-6 py-6 md:bottom-0 md:left-[unset] md:right-0 md:top-[unset] md:max-w-sm">
|
||||
<template x-for="(notification, index) in notifications" x-bind:key="notification.id">
|
||||
<div>
|
||||
<!-- Info Notification -->
|
||||
<template x-if="notification.variant === 'info'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-info bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-info/10 rounded-radius p-4 transition-all duration-300">
|
||||
<div class="rounded-full bg-info/15 p-0.5 text-info" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-info" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Success Notification -->
|
||||
<template x-if="notification.variant === 'success'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-success bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-success/10 rounded-radius p-4 transition-all duration-300">
|
||||
<div class="rounded-full bg-success/15 p-0.5 text-success" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-success" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Warning Notification -->
|
||||
<template x-if="notification.variant === 'warning'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-warning bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-warning/10 rounded-radius p-4 transition-all duration-300">
|
||||
<div class="rounded-full bg-warning/15 p-0.5 text-warning" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-warning" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Danger Notification -->
|
||||
<template x-if="notification.variant === 'danger'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-danger bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-danger/10 rounded-radius p-4 transition-all duration-300">
|
||||
<div class="rounded-full bg-danger/15 p-0.5 text-danger" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-danger" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Message Notification -->
|
||||
<template x-if="notification.variant === 'message'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-outline bg-surface text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window="timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id) }, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full rounded-radius items-center gap-2.5 bg-surface-alt p-4 transition-all duration-300 dark:bg-surface-dark-alt">
|
||||
<div class="flex w-full items-center gap-2.5">
|
||||
<img x-cloak x-show="notification.sender.avatar" class="mr-2 size-12 rounded-full" alt="avatar" aria-hidden="true" x-bind:src="notification.sender.avatar"/>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<h3 x-cloak x-show="notification.sender.name" class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" x-text="notification.sender.name"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="button" class="whitespace-nowrap bg-transparent text-center text-sm font-bold tracking-wide text-primary transition hover:opacity-75 active:opacity-100 dark:text-primary-dark">Reply</button>
|
||||
<button type="button" class="whitespace-nowrap bg-transparent text-center text-sm font-bold tracking-wide text-on-surface transition hover:opacity-75 active:opacity-100 dark:text-on-surface-dark" x-on:click=" (isVisible = false), setTimeout(() => { removeNotification(notification.id) }, 400)">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<ol id="uw-queue-list" class="uw-queue-list"></ol>
|
||||
</div>
|
||||
<div class="uw-player-inner">
|
||||
<span class="uw-player-tag">▶ now playing</span>
|
||||
<span id="uw-now" class="uw-player-title">—</span>
|
||||
<button type="button" id="uw-prev" class="uw-player-btn" aria-label="Previous track" title="Previous">⏮</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">⏭</button>
|
||||
<button type="button" id="uw-queue-toggle" class="uw-player-btn" aria-label="Toggle playlist" title="Playlist">☰<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">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -1,186 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
210
assets/views/macros/ui.html
Normal file
210
assets/views/macros/ui.html
Normal file
@@ -0,0 +1,210 @@
|
||||
{# Reusable UI macros adapted from vendored Penguin UI components.
|
||||
These are OUR adaptation layer; the byte-for-byte upstream sources live under
|
||||
penguinui-components/. Tailwind sees the full literal class strings here
|
||||
(assets/css/app.css has @source "../views"), so every branch must spell its
|
||||
classes out in full — never build class names by concatenation.
|
||||
|
||||
Usage:
|
||||
{% import "macros/ui.html" as ui %}
|
||||
{{ ui::button(label=t(key="save", lang=lang)) }} {# default primary #}
|
||||
{{ ui::button(label="Add", attrs='hx-post="/x"' | safe) }}
|
||||
{{ ui::button(label="Cancel", variant="outline-secondary", href="/back") }}
|
||||
{{ ui::button(label="Send", size="px-6 py-2.5 text-sm") }} {# keep a non-default size #}
|
||||
{{ ui::badge(label="Published", variant="success") }}
|
||||
|
||||
Notes:
|
||||
- Macros can't see template context vars (e.g. `lang`); pass already-translated
|
||||
strings as `label`.
|
||||
- `attrs` is injected raw (caller must pass it through `| safe`); use it for
|
||||
htmx / name / value / @click / :disabled etc. For buttons whose attrs carry
|
||||
nested quotes (e.g. hx-on with toast(...)), keep them inline instead.
|
||||
- `pad` is the size (default Penguin "px-4 py-2"); override to preserve an
|
||||
existing size rather than normalizing it.
|
||||
- The button class strings are the **verbatim** Penguin variants from
|
||||
penguinui/buttons/{default,outline,ghost}-button.html (only `inline-flex
|
||||
items-center justify-center` is added so <a> and w-full render correctly,
|
||||
and the upstream `text-onDanger`/`text-onSuccess`… token typos are fixed to
|
||||
our real `text-on-*` tokens). `variant` selects a Penguin variant:
|
||||
solid : primary (default) | secondary | danger | success | warning | info
|
||||
outline : outline-primary | outline-secondary | outline-alternate | outline-danger
|
||||
ghost : ghost-primary | ghost-secondary | ghost-danger #}
|
||||
|
||||
{% macro button(label, variant="primary", type="button", href="", attrs="", extra="", icon="", size="px-4 py-2 text-sm") -%}
|
||||
{%- if variant == "secondary" -%}{% set cls = "border border-secondary bg-secondary text-on-secondary focus-visible:outline-secondary dark:border-secondary-dark dark:bg-secondary-dark dark:text-on-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
||||
{%- elif variant == "danger" -%}{% set cls = "border border-danger bg-danger text-on-danger focus-visible:outline-danger dark:bg-danger dark:border-danger dark:text-on-danger dark:focus-visible:outline-danger" -%}
|
||||
{%- elif variant == "success" -%}{% set cls = "border border-success bg-success text-on-success focus-visible:outline-success dark:bg-success dark:border-success dark:text-on-success dark:focus-visible:outline-success" -%}
|
||||
{%- elif variant == "warning" -%}{% set cls = "border border-warning bg-warning text-on-warning focus-visible:outline-warning dark:bg-warning dark:border-warning dark:text-on-warning dark:focus-visible:outline-warning" -%}
|
||||
{%- elif variant == "info" -%}{% set cls = "border border-info bg-info text-on-info focus-visible:outline-info dark:bg-info dark:border-info dark:text-on-info dark:focus-visible:outline-info" -%}
|
||||
{%- elif variant == "outline-primary" -%}{% set cls = "border border-primary bg-transparent text-primary focus-visible:outline-primary dark:border-primary-dark dark:text-primary-dark dark:focus-visible:outline-primary-dark" -%}
|
||||
{%- elif variant == "outline-secondary" -%}{% set cls = "border border-secondary bg-transparent text-secondary focus-visible:outline-secondary dark:border-secondary-dark dark:text-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
||||
{%- elif variant == "outline-alternate" -%}{% set cls = "border border-outline bg-transparent text-outline focus-visible:outline-outline dark:border-outline-dark dark:text-outline-dark dark:focus-visible:outline-outline-dark" -%}
|
||||
{%- elif variant == "outline-danger" -%}{% set cls = "border border-danger bg-transparent text-danger focus-visible:outline-danger dark:border-danger dark:text-danger dark:focus-visible:outline-danger" -%}
|
||||
{%- elif variant == "ghost-primary" -%}{% set cls = "bg-transparent text-primary focus-visible:outline-primary dark:text-primary-dark dark:focus-visible:outline-primary-dark" -%}
|
||||
{%- elif variant == "ghost-secondary" -%}{% set cls = "bg-transparent text-secondary focus-visible:outline-secondary dark:text-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
||||
{%- elif variant == "ghost-danger" -%}{% set cls = "bg-transparent text-danger focus-visible:outline-danger dark:text-danger dark:focus-visible:outline-danger" -%}
|
||||
{%- else -%}{% set cls = "border border-primary bg-primary text-on-primary focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark" -%}
|
||||
{%- endif -%}
|
||||
{% if href %}<a href="{{ href }}"{% else %}<button type="{{ type }}"{% endif %} class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius {{ size }} text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 {{ cls }} {{ extra }}" {{ attrs | safe }}>{{ icon | safe }}{{ label }}</{% if href %}a{% else %}button{% endif %}>
|
||||
{%- endmacro button %}
|
||||
|
||||
{# Icon-only button (square). Penguin ghost treatment (bg-transparent,
|
||||
hover:opacity-75); pass the raw <svg> as `icon`, an accessible name via
|
||||
`aria_label`/`sr`, and any Alpine/htmx via `attrs` (raw). variant ∈
|
||||
ghost-secondary (default) | ghost-primary | ghost-danger | ghost-alternate. #}
|
||||
{% macro icon_button(icon, variant="ghost-secondary", type="button", href="", attrs="", extra="", aria_label="", sr="", size="size-9") -%}
|
||||
{%- if variant == "ghost-primary" -%}{% set cls = "text-primary focus-visible:outline-primary dark:text-primary-dark dark:focus-visible:outline-primary-dark" -%}
|
||||
{%- elif variant == "ghost-danger" -%}{% set cls = "text-danger focus-visible:outline-danger dark:text-danger dark:focus-visible:outline-danger" -%}
|
||||
{%- elif variant == "ghost-alternate" -%}{% set cls = "text-outline focus-visible:outline-outline dark:text-outline-dark dark:focus-visible:outline-outline-dark" -%}
|
||||
{%- else -%}{% set cls = "text-secondary focus-visible:outline-secondary dark:text-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
||||
{%- endif -%}
|
||||
{% if href %}<a href="{{ href }}"{% else %}<button type="{{ type }}"{% endif %}{% if aria_label %} aria-label="{{ aria_label }}" title="{{ aria_label }}"{% endif %} class="inline-flex shrink-0 items-center justify-center rounded-radius bg-transparent {{ size }} transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 {{ cls }} {{ extra }}" {{ attrs | safe }}>{{ icon | safe }}{% if sr %}<span class="sr-only">{{ sr }}</span>{% endif %}</{% if href %}a{% else %}button{% endif %}>
|
||||
{%- endmacro icon_button %}
|
||||
|
||||
{# Inline icon set — the Heroicons-style SVGs that were duplicated across
|
||||
base.html / admin/base.html (hamburger, close, cart). Penguin ships no icon
|
||||
library, so this is pure dedup, not a port. `size` sets the box (default
|
||||
size-5), `extra` adds classes, `attrs` is raw (x-show / x-cloak etc.). Icons
|
||||
are decorative: aria-hidden is baked in — put the accessible name on the
|
||||
enclosing button/link. The chevron dropdown arrows (checkout, _sidebar) stay
|
||||
inline at their call sites because they carry nested-quote Alpine :class
|
||||
bindings (see the attrs note at the top of this file). name ∈
|
||||
hamburger (default) | close | cart. #}
|
||||
{% macro icon(name, size="size-5", extra="", attrs="") -%}
|
||||
{%- if name == "cart" -%}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><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>
|
||||
{%- elif name == "close" -%}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
|
||||
{%- else -%}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>
|
||||
{%- endif -%}
|
||||
{%- endmacro icon %}
|
||||
|
||||
{# Compact danger alert (form/inline errors). Adapted from
|
||||
penguinui/alert/default-alert.html (danger variant), trimmed to a single line
|
||||
with the danger icon. #}
|
||||
{% macro alert_danger(message, extra="") -%}
|
||||
<div class="flex w-full items-center gap-2 overflow-hidden rounded-radius border border-danger bg-danger/10 px-3 py-2 text-sm text-danger {{ extra }}" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{%- endmacro alert_danger %}
|
||||
|
||||
{# Soft-color badge. variant ∈ success | danger | warning | info | primary | neutral #}
|
||||
{% macro badge(label, variant="neutral") -%}
|
||||
{% if variant == "success" -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-success bg-surface text-xs font-medium text-success dark:bg-surface-dark"><span class="bg-success/10 px-2 py-1">{{ label }}</span></span>
|
||||
{%- elif variant == "danger" -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-danger bg-surface text-xs font-medium text-danger dark:bg-surface-dark"><span class="bg-danger/10 px-2 py-1">{{ label }}</span></span>
|
||||
{%- elif variant == "warning" -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-warning bg-surface text-xs font-medium text-warning dark:bg-surface-dark"><span class="bg-warning/10 px-2 py-1">{{ label }}</span></span>
|
||||
{%- elif variant == "info" -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-info bg-surface text-xs font-medium text-info dark:bg-surface-dark"><span class="bg-info/10 px-2 py-1">{{ label }}</span></span>
|
||||
{%- elif variant == "primary" -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-primary bg-surface text-xs font-medium text-primary dark:border-primary-dark dark:bg-surface-dark dark:text-primary-dark"><span class="bg-primary/10 px-2 py-1 dark:bg-primary-dark/10">{{ label }}</span></span>
|
||||
{%- else -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-outline bg-surface text-xs font-medium text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark"><span class="bg-surface-alt/40 px-2 py-1 dark:bg-surface-dark-alt/40">{{ label }}</span></span>
|
||||
{%- endif %}
|
||||
{%- endmacro badge %}
|
||||
|
||||
{# ---- Form controls. Verbatim Penguin classes from
|
||||
penguinui/{text-input,text-area,select,checkbox,file-input}/default-*.html.
|
||||
These macros emit only the control (callers keep their own <label>/layout), so
|
||||
text-color utilities are added here (upstream sets them on the wrapper div). #}
|
||||
|
||||
{# Text/email/number/password input. #}
|
||||
{% macro input(name, type="text", id="", value="", placeholder="", required=false, autocomplete="", attrs="", extra="", width="w-full") -%}
|
||||
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="{{ type }}"{% if value != "" %} value="{{ value }}"{% endif %}{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %}{% if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %} class="{{ width }} rounded-radius border border-outline bg-surface-alt px-2 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
|
||||
{%- endmacro input %}
|
||||
|
||||
{% macro textarea(name, id="", value="", rows="3", placeholder="", required=false, attrs="", extra="") -%}
|
||||
<textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}>{{ value }}</textarea>
|
||||
{%- endmacro textarea %}
|
||||
|
||||
{# File input. #}
|
||||
{% macro file_input(name, id="", accept="", attrs="", extra="") -%}
|
||||
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
|
||||
{%- endmacro file_input %}
|
||||
|
||||
{# Checkbox (full Penguin control: custom box + check icon + label text). #}
|
||||
{% macro checkbox(name, label, id="", value="on", checked=false, attrs="", extra="") -%}
|
||||
<label {% if id %}for="{{ id }}" {% endif %}class="flex items-center gap-2 text-sm font-medium text-on-surface dark:text-on-surface-dark has-checked:text-on-surface-strong dark:has-checked:text-on-surface-dark-strong has-disabled:cursor-not-allowed has-disabled:opacity-75 {{ extra }}">
|
||||
<span class="relative flex items-center">
|
||||
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" value="{{ value }}" type="checkbox"{% if checked %} checked{% endif %} class="before:content[''] peer relative size-4 appearance-none overflow-hidden rounded-sm border border-outline bg-surface-alt before:absolute before:inset-0 checked:border-primary checked:before:bg-primary focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary active:outline-offset-0 disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark-alt dark:checked:border-primary-dark dark:checked:before:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark" {{ attrs | safe }}/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor" fill="none" stroke-width="4" class="pointer-events-none invisible absolute left-1/2 top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 text-on-primary peer-checked:visible dark:text-on-primary-dark">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{{ label }}</span>
|
||||
</label>
|
||||
{%- endmacro checkbox %}
|
||||
|
||||
{# Radio dot (verbatim Penguin custom radio from penguinui/radio/radio-with-container.html).
|
||||
Emits ONLY the <input> (the styled dot) — callers keep their own card-style
|
||||
<label> wrapper (e.g. checkout's has-[:checked]:border-primary cards). Use
|
||||
`attrs` for x-model / required etc.; callers whose @change mixes nested
|
||||
single+double quotes (carrier loop) spell this class out inline instead. #}
|
||||
{% macro radio(name, value="", id="", checked=false, attrs="", extra="") -%}
|
||||
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="radio" value="{{ value }}"{% if checked %} checked{% endif %} class="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
|
||||
{%- endmacro radio %}
|
||||
|
||||
{# ---- Table chrome. The same wrapper/thead/tbody/row/tfoot class strings were
|
||||
copy-pasted across 5 admin/shop tables; centralized here so the styling has a
|
||||
single source of truth. Tera has no slot/{% raw %}{% call %}{% endraw %} mechanism, so cells stay
|
||||
inline (their content varies too much to macro-ize: images, htmx forms, Alpine
|
||||
inputs, badges) and these macros expose just the shared CLASS STRINGS used as
|
||||
`class="{{ ui::thead_cls() }}"`. Adopts Penguin default-table.html's
|
||||
`w-full overflow-x-auto` wrapper so wide tables scroll on mobile.
|
||||
|
||||
Skeleton:
|
||||
<div class="{{ ui::table_wrap_cls() }}">
|
||||
<table class="{{ ui::table_cls() }}">
|
||||
<thead class="{{ ui::thead_cls() }}"><tr>{{ ui::th(label="Name") }}{{ ui::th(label="Total", align="text-right") }}</tr></thead>
|
||||
<tbody class="{{ ui::tbody_cls() }}">
|
||||
<tr class="{{ ui::row_cls() }}"><td class="px-4 py-3">…</td></tr> {# row_cls = hover; omit for non-interactive rows #}
|
||||
</tbody>
|
||||
</table>
|
||||
</div> #}
|
||||
{% macro table_wrap_cls() -%}
|
||||
overflow-hidden w-full overflow-x-auto rounded-radius border border-outline dark:border-outline-dark
|
||||
{%- endmacro table_wrap_cls %}
|
||||
|
||||
{% macro table_cls() -%}
|
||||
w-full text-left text-sm
|
||||
{%- endmacro table_cls %}
|
||||
|
||||
{% macro thead_cls() -%}
|
||||
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
|
||||
{%- endmacro thead_cls %}
|
||||
|
||||
{% macro tbody_cls() -%}
|
||||
divide-y divide-outline dark:divide-outline-dark
|
||||
{%- endmacro tbody_cls %}
|
||||
|
||||
{% macro row_cls() -%}
|
||||
hover:bg-surface-alt dark:hover:bg-surface-dark-alt
|
||||
{%- endmacro row_cls %}
|
||||
|
||||
{% macro tfoot_cls() -%}
|
||||
border-t border-outline dark:border-outline-dark
|
||||
{%- endmacro tfoot_cls %}
|
||||
|
||||
{# Header cell. align ∈ "" (left, default) | "text-right". Pass label="" for the
|
||||
empty actions column. #}
|
||||
{% macro th(label, align="") -%}
|
||||
<th class="px-4 py-3 font-semibold{% if align %} {{ align }}{% endif %}">{{ label }}</th>
|
||||
{%- endmacro th %}
|
||||
|
||||
{# Top-nav link. Penguin navbar/default-navbar.html link treatment: text-only,
|
||||
underline on focus, hover:text-primary, active (aria-current=page, set by
|
||||
markActiveNav() via data-nav) = font-semibold + primary. Matches the ported
|
||||
sidebars. variant ∈ default | warning (admin) | danger (logout-style links).
|
||||
Logout itself stays an inline <form><button> (not an <a>, so not this macro). #}
|
||||
{% macro nav_link(label, href, data_nav="", variant="default", attrs="") -%}
|
||||
{%- if variant == "warning" -%}{% set c = "text-warning hover:opacity-75 dark:text-warning" -%}
|
||||
{%- elif variant == "danger" -%}{% set c = "text-danger hover:opacity-75 dark:text-danger" -%}
|
||||
{%- else -%}{% set c = "text-on-surface hover:text-primary aria-[current=page]:font-semibold aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark" -%}
|
||||
{%- endif -%}
|
||||
<a href="{{ href }}"{% if data_nav %} data-nav="{{ data_nav }}"{% endif %} class="text-sm font-medium underline-offset-2 transition focus:outline-hidden focus-visible:underline {{ c }}" {{ attrs | safe }}>{{ label }}</a>
|
||||
{%- endmacro nav_link %}
|
||||
@@ -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 %}
|
||||
56
assets/views/partials/settings_dropdown.html
Normal file
56
assets/views/partials/settings_dropdown.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{# Settings dropdown (language + theme). Shared by base.html and admin/base.html
|
||||
to kill the former ~100-line copy-paste duplication.
|
||||
|
||||
Adapted from the vendored Penguin UI component
|
||||
penguinui-components/dropdowns/dropdown-with-click.html: Penguin's dropdown
|
||||
menu container + item treatment. Deviations: kept our gear icon-only trigger
|
||||
and our core-Alpine open / @click.outside toggle (upstream's x-trap / $focus
|
||||
need the Alpine Focus plugin, which we don't bundle); item hover uses
|
||||
bg-primary/5 to stay consistent with the rest of our Penguin-ified UI.
|
||||
|
||||
The host template provides the wrapper
|
||||
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative ...">
|
||||
so it controls its own positioning (e.g. ml-auto in admin). #}
|
||||
{{ ui::icon_button(aria_label=t(key='settings', lang=lang | default(value='sk')), attrs='@click="open = !open" :aria-expanded="open"', icon='<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>') }}
|
||||
<div x-show="open" x-cloak @click.outside="open = false" x-transition.origin.top.right
|
||||
class="absolute right-0 mt-2 flex w-56 flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt py-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt"
|
||||
role="menu">
|
||||
<form method="post" action="/lang" hx-boost="false">
|
||||
<p class="px-4 py-1.5 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" role="menuitem"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<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" role="menuitem"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<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-4 py-1.5 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>
|
||||
{# Theme picker: Penguin UI's switch toggle (penguinui-components/toggle/default-toggle.html),
|
||||
adapted from a standalone checkbox into a light/dark switch wired to our existing
|
||||
setTheme()/currentTheme() helpers in base.html (which drive <html data-theme>). The `dark`
|
||||
state initialises from the resolved theme — so a stored 'system' preference still reflects
|
||||
the current appearance — and stays in sync via the theme:changed event the helpers dispatch.
|
||||
Toggling writes an explicit light/dark choice (the old tri-state 'system' option is dropped,
|
||||
matching Penguin's binary toggle). #}
|
||||
<div class="px-4 py-2"
|
||||
x-data="{
|
||||
labels: { light: '{{ t(key='theme-light', lang=lang | default(value='sk')) }}', dark: '{{ t(key='theme-dark', lang=lang | default(value='sk')) }}' },
|
||||
dark: document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
}"
|
||||
@theme:changed.document="dark = document.documentElement.getAttribute('data-theme') === 'dark'">
|
||||
<label for="themeToggle" class="inline-flex w-full cursor-pointer items-center justify-between gap-3">
|
||||
<span class="text-sm font-medium text-on-surface dark:text-on-surface-dark" x-text="dark ? labels.dark : labels.light"></span>
|
||||
<input id="themeToggle" type="checkbox" class="peer sr-only" role="switch"
|
||||
:checked="dark" @change="setTheme($event.target.checked ? 'dark' : 'light')" />
|
||||
<div class="relative h-6 w-11 after:h-5 after:w-5 peer-checked:after:translate-x-5 rounded-full border border-outline bg-surface-alt after:absolute after:bottom-0 after:left-[0.0625rem] after:top-0 after:my-auto after:rounded-full after:bg-on-surface after:transition-all after:content-[''] peer-checked:bg-primary peer-checked:after:bg-on-primary peer-focus:outline-2 peer-focus:outline-offset-2 peer-focus:outline-outline-strong peer-focus:peer-checked:outline-primary peer-active:outline-offset-0 dark:border-outline-dark dark:bg-surface-dark-alt dark:after:bg-on-surface-dark dark:peer-checked:bg-primary-dark dark:peer-checked:after:bg-on-primary-dark dark:peer-focus:outline-outline-dark-strong dark:peer-focus:peer-checked:outline-primary-dark" aria-hidden="true"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
37
assets/views/shop/_card.html
Normal file
37
assets/views/shop/_card.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{# Adapted from the vendored Penguin UI component
|
||||
(penguinui-components/card/ecommerce-product-card.html):
|
||||
wired to our product data + i18n + htmx add-to-cart + toast. The demo rating
|
||||
stars, hardcoded title/price/description/image and the `max-w-sm` (which fights
|
||||
the shop grid) are dropped; the whole card links to the product page. #}
|
||||
<article
|
||||
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt text-on-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-primary-dark">
|
||||
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
|
||||
<!-- Image -->
|
||||
<div class="h-44 overflow-hidden bg-surface-alt md:h-64 dark:bg-surface-dark">
|
||||
{% if product.image %}
|
||||
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="flex flex-1 flex-col gap-1 p-6 pb-2">
|
||||
<!-- Header: Title & Price -->
|
||||
<div class="flex justify-between gap-4">
|
||||
<h3 class="text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{{ product.price }} {{ product.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex flex-col gap-2 p-6 pt-0">
|
||||
{% if product.stock > 0 %}
|
||||
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
|
||||
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
|
||||
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
|
||||
<input type="hidden" name="product_id" value="{{ product.id }}">
|
||||
<input type="hidden" name="quantity" value="1">
|
||||
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", extra="w-full", icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="size-3.5"><path fill-rule="evenodd" d="M5 4a3 3 0 0 1 6 0v1h.643a1.5 1.5 0 0 1 1.492 1.35l.7 7A1.5 1.5 0 0 1 12.342 15H3.657a1.5 1.5 0 0 1-1.492-1.65l.7-7A1.5 1.5 0 0 1 4.357 5H5V4Zm4.5 0v1h-3V4a1.5 1.5 0 0 1 3 0Zm-3 3.75a.75.75 0 0 0-1.5 0v1a3 3 0 1 0 6 0v-1a.75.75 0 0 0-1.5 0v1a1.5 1.5 0 1 1-3 0v-1Z" clip-rule="evenodd" /></svg>') }}
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="inline-flex justify-center rounded-radius bg-danger/10 px-3 py-2 text-xs font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
72
assets/views/shop/_cart_body.html
Normal file
72
assets/views/shop/_cart_body.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{# Cart contents, swapped in via htmx on quantity change / removal so the page
|
||||
never does a full reload. Rendered inside <div id="cart-body"> in cart.html
|
||||
and returned on its own by /cart/update and /cart/remove. #}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
{% if items | length > 0 %}
|
||||
<div class="{{ ui::table_wrap_cls() }}">
|
||||
<table class="{{ ui::table_cls() }}">
|
||||
<thead class="{{ ui::thead_cls() }}">
|
||||
<tr>
|
||||
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="price", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="quantity", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="cart-total", lang=lang | default(value='sk')), align="text-right") }}
|
||||
{{ ui::th(label="") }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="{{ ui::tbody_cls() }}">
|
||||
{% 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">
|
||||
{# Changing the quantity posts via htmx (custom `cartchange` event) and
|
||||
swaps only #cart-body. Dropping to 0 asks for confirmation first,
|
||||
reverting to the previous quantity if the customer cancels. #}
|
||||
<form method="post" action="/cart/update"
|
||||
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
|
||||
<input type="hidden" name="product_id" value="{{ item.id }}">
|
||||
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
|
||||
@change="
|
||||
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
|
||||
$el.value = '{{ item.quantity }}';
|
||||
} else {
|
||||
$el.dispatchEvent(new Event('cartchange', { bubbles: true }));
|
||||
}
|
||||
"
|
||||
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||
</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-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
|
||||
<input type="hidden" name="product_id" value="{{ item.id }}">
|
||||
{{ ui::button(variant="ghost-danger", label=t(key="cart-remove", lang=lang | default(value='sk')), type="submit", size="px-2 py-1 text-xs") }}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot class="{{ ui::tfoot_cls() }}">
|
||||
<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="mt-6 flex flex-wrap justify-between gap-3">
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cart-continue", lang=lang | default(value='sk')), href="/shop") }}
|
||||
{{ ui::button(label=t(key="cart-checkout", lang=lang | default(value='sk')), href="/checkout", size="px-5 py-2 text-sm") }}
|
||||
</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>
|
||||
{{ ui::button(label=t(key="cart-continue", lang=lang | default(value='sk')), href="/shop", extra="mt-4") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
62
assets/views/shop/_sidebar.html
Normal file
62
assets/views/shop/_sidebar.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{# Site-wide category menu, served as an htmx partial and swapped into the
|
||||
<aside> in base.html. `category_groups` is a two-level list of top-level
|
||||
categories, each `{ name, slug, children: [{ name, slug }] }`. A category
|
||||
with children is expandable (accordion); one without is a plain link.
|
||||
Active state is set client-side by markActiveNav() via data-nav +
|
||||
aria-current; groups auto-expand when the current page is the category or
|
||||
one of its subcategories.
|
||||
|
||||
Adapted from the vendored Penguin UI component
|
||||
penguinui-components/sidebar/sidebar-with-collapsible-menus.html: Penguin's
|
||||
link treatment + active state + chevron-down rotation. Deviations: the group
|
||||
row keeps our link + toggle split (categories are navigable, not just
|
||||
expandable), and we use x-show/x-transition instead of upstream's x-collapse
|
||||
(that Alpine plugin isn't bundled in our build). #}
|
||||
<p class="px-2 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>
|
||||
<div class="flex flex-col gap-1">
|
||||
<a href="/shop" data-nav="/shop"
|
||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
{% for group in category_groups %}
|
||||
{% if group.children | length > 0 %}
|
||||
<div x-data="{ open: false }" class="flex flex-col"
|
||||
x-init="open = ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}].some(s => location.pathname === '/category/' + s)">
|
||||
<div class="flex items-stretch">
|
||||
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||
class="flex flex-1 items-center gap-2 truncate rounded-l-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ group.name }}
|
||||
</a>
|
||||
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
|
||||
aria-label="{{ group.name }}"
|
||||
class="inline-flex w-8 shrink-0 items-center justify-center rounded-r-radius text-on-surface/60 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark/60 dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||
class="size-5 shrink-0 transition-transform rotate-0" x-bind:class="open ? 'rotate-180' : 'rotate-0'" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul x-show="open" x-cloak x-transition class="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-outline pl-1 dark:border-outline-dark">
|
||||
{% for child in group.children %}
|
||||
<li>
|
||||
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}"
|
||||
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ child.name }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ group.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if category_groups | length == 0 %}
|
||||
<p class="px-2 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 %}
|
||||
14
assets/views/shop/cart.html
Normal file
14
assets/views/shop/cart.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% 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>
|
||||
|
||||
<div id="cart-body">
|
||||
{% include "shop/_cart_body.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
43
assets/views/shop/category.html
Normal file
43
assets/views/shop/category.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% 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 %}
|
||||
254
assets/views/shop/checkout.html
Normal file
254
assets/views/shop/checkout.html
Normal file
@@ -0,0 +1,254 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% 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: '',
|
||||
accountType: '{{ prefill_account_type | default(value='personal') }}',
|
||||
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">
|
||||
<!-- personal vs company -->
|
||||
<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="account-type", lang=lang | default(value='sk')) }}</legend>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<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">
|
||||
{{ ui::radio(name="account_type", value="personal", attrs='x-model="accountType"') }}
|
||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-personal", 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">
|
||||
{{ ui::radio(name="account_type", value="company", attrs='x-model="accountType"') }}
|
||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-company", lang=lang | default(value='sk')) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- company billing details (company accounts only) -->
|
||||
<fieldset x-show="accountType === 'company'" x-cloak 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="account-company-details", lang=lang | default(value='sk')) }}</legend>
|
||||
<div class="space-y-1.5">
|
||||
<label for="company_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-name", lang=lang | default(value='sk')) }}</label>
|
||||
{{ ui::input(name="company_name", id="company_name", value=prefill_company_name | default(value=''), autocomplete="organization") }}
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-1.5">
|
||||
<label for="company_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-ico", lang=lang | default(value='sk')) }}</label>
|
||||
{{ ui::input(name="company_id", id="company_id", value=prefill_company_id | default(value='')) }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="tax_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-dic", lang=lang | default(value='sk')) }}</label>
|
||||
{{ ui::input(name="tax_id", id="tax_id", value=prefill_tax_id | default(value='')) }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="vat_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-icdph", lang=lang | default(value='sk')) }} <span class="text-on-surface/50 dark:text-on-surface-dark/50">({{ t(key="field-optional", lang=lang | default(value='sk')) }})</span></label>
|
||||
{{ ui::input(name="vat_id", id="vat_id", value=prefill_vat_id | default(value='')) }}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 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>
|
||||
{{ ui::input(name="email", id="email", type="email", value=prefill_email | default(value=''), required=true, autocomplete="email") }}
|
||||
</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>
|
||||
{{ ui::input(name="customer_name", id="customer_name", value=prefill_name | default(value=''), required=true, autocomplete="name") }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="phone" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-phone", lang=lang | default(value='sk')) }}</label>
|
||||
<div class="flex gap-2">
|
||||
<!-- editable combobox: type freely or pick from the dropdown -->
|
||||
<div class="relative w-28 shrink-0" @click.outside="prefixOpen = false"
|
||||
x-data="{ prefixOpen: false, prefix: '{{ prefill_phone_prefix | default(value='+421') }}', opts: [
|
||||
{ v: '+421', l: '🇸🇰 +421' }, { v: '+420', l: '🇨🇿 +420' },
|
||||
{ v: '+43', l: '🇦🇹 +43' }, { v: '+49', l: '🇩🇪 +49' },
|
||||
{ v: '+48', l: '🇵🇱 +48' }, { v: '+36', l: '🇭🇺 +36' },
|
||||
{ v: '+44', l: '🇬🇧 +44' }, { v: '+39', l: '🇮🇹 +39' }, { v: '+33', l: '🇫🇷 +33' }
|
||||
], get filtered() { return this.opts.filter(o => !this.prefix || o.v.includes(this.prefix)) } }">
|
||||
<input name="phone_prefix" type="text" x-model="prefix" required @focus="prefixOpen = true" @input="prefixOpen = true"
|
||||
aria-label="{{ t(key='checkout-phone', lang=lang | default(value='sk')) }}" autocomplete="tel-country-code" inputmode="tel"
|
||||
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-7 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="button" tabindex="-1" @click="prefixOpen = !prefixOpen"
|
||||
class="absolute inset-y-0 right-0 flex w-7 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
||||
class="size-4 transition-transform" :class="prefixOpen && 'rotate-180'">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul x-show="prefixOpen" x-cloak x-transition
|
||||
class="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-radius border border-outline bg-surface p-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<template x-for="o in filtered" :key="o.v">
|
||||
<li><button type="button" @click="prefix = o.v; prefixOpen = false" x-text="o.l"
|
||||
class="block w-full rounded-radius px-3 py-1.5 text-left text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark"></button></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
{{ ui::input(name="phone", id="phone", type="tel", value=prefill_phone | default(value=''), required=true, autocomplete="tel", placeholder="900 000 000", attrs='inputmode="tel"') }}
|
||||
</div>
|
||||
</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>
|
||||
{{ ui::input(name="address", id="address", value=prefill_address | default(value=''), required=true, autocomplete="street-address") }}
|
||||
</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>
|
||||
{{ ui::input(name="city", id="city", value=prefill_city | default(value=''), required=true, autocomplete="address-level2") }}
|
||||
</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>
|
||||
{{ ui::input(name="zip", id="zip", value=prefill_zip | default(value=''), required=true, autocomplete="postal-code") }}
|
||||
</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>
|
||||
<div class="relative" @click.outside="countryOpen = false"
|
||||
x-data="{ countryOpen: false, country: '{{ prefill_country | default(value=t(key='country-sk', lang=lang | default(value='sk'))) }}', opts: [
|
||||
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-de', lang=lang | default(value='sk')) }}', l: '🇩🇪 {{ t(key='country-de', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
|
||||
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
|
||||
<input id="country" name="country" type="text" x-model="country" required @focus="countryOpen = true" @input="countryOpen = true"
|
||||
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 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="button" tabindex="-1" @click="countryOpen = !countryOpen"
|
||||
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
||||
class="size-4 transition-transform" :class="countryOpen && 'rotate-180'">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul x-show="countryOpen" x-cloak x-transition
|
||||
class="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-radius border border-outline bg-surface p-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<template x-for="o in filtered" :key="o.v">
|
||||
<li><button type="button" @click="country = o.v; countryOpen = false" x-text="o.l"
|
||||
class="block w-full rounded-radius px-3 py-1.5 text-left text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark"></button></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</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">
|
||||
<!-- Penguin radio dot inline (the @change mixes nested single+double quotes, can't pass through a Tera macro arg) -->
|
||||
<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="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-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">
|
||||
{{ ui::radio(name="payment_method", value="cod", attrs='required x-model="paymentMethod"') }}
|
||||
<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">
|
||||
{{ ui::radio(name="payment_method", value="bank_transfer", attrs='required x-model="paymentMethod"') }}
|
||||
<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>
|
||||
{{ ui::textarea(name="note", id="note", rows="3") }}
|
||||
</div>
|
||||
|
||||
{% if logged_in_customer %}
|
||||
<!-- logged-in customers can persist this address to their profile for next time -->
|
||||
{{ ui::checkbox(name="save_profile", id="save_profile", label=t(key="checkout-save-profile", lang=lang | default(value='sk')), checked=true) }}
|
||||
{% endif %}
|
||||
</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>
|
||||
{{ ui::button(label=t(key="checkout-place-order", lang=lang | default(value='sk')), type="submit", attrs=':disabled="!canSubmit"', extra="w-full", size="px-6 py-2.5 text-sm") }}
|
||||
</aside>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
25
assets/views/shop/index.html
Normal file
25
assets/views/shop/index.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% 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 %}
|
||||
62
assets/views/shop/order_confirmed.html
Normal file
62
assets/views/shop/order_confirmed.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% 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">
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cart-continue", lang=lang | default(value='sk')), href="/shop", size="px-5 py-2 text-sm") }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
79
assets/views/shop/show.html
Normal file
79
assets/views/shop/show.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ product.name }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid gap-10 lg:grid-cols-2">
|
||||
<!-- gallery — prev/next arrows + opacity transitions adapted from
|
||||
penguinui/carousel/default-carousel.html; kept our product thumbnail strip
|
||||
(more useful than carousel dots for a product) and 0-based `active` -->
|
||||
<div x-data="{ active: 0, count: {{ images | length }},
|
||||
prev() { this.active = this.active > 0 ? this.active - 1 : this.count - 1 },
|
||||
next() { this.active = this.active < this.count - 1 ? this.active + 1 : 0 } }"
|
||||
class="space-y-4">
|
||||
<div class="relative 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 }}" x-transition.opacity.duration.300ms src="/images/{{ image }}" alt="{{ product.name }}" class="absolute inset-0 size-full object-cover">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if images | length > 1 %}
|
||||
<!-- previous slide -->
|
||||
<button type="button" @click="prev()" aria-label="{{ t(key='gallery-prev', lang=lang | default(value='sk')) }}"
|
||||
class="absolute left-3 top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-surface/40 p-2 text-on-surface transition hover:bg-surface/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:bg-surface-dark/40 dark:text-on-surface-dark dark:hover:bg-surface-dark/60 dark:focus-visible:outline-primary-dark">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="3" class="size-5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- next slide -->
|
||||
<button type="button" @click="next()" aria-label="{{ t(key='gallery-next', lang=lang | default(value='sk')) }}"
|
||||
class="absolute right-3 top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-surface/40 p-2 text-on-surface transition hover:bg-surface/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:bg-surface-dark/40 dark:text-on-surface-dark dark:hover:bg-surface-dark/60 dark:focus-visible:outline-primary-dark">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="3" class="size-5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{% 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-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
||||
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
|
||||
<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>
|
||||
{{ ui::input(name="quantity", id="quantity", type="number", value="1", width="w-24", attrs='min="1" max="' ~ product.stock ~ '"') }}
|
||||
</div>
|
||||
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", size="px-5 py-2 text-sm") }}
|
||||
</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 %}
|
||||
24
config/casbin/model.conf
Normal file
24
config/casbin/model.conf
Normal file
@@ -0,0 +1,24 @@
|
||||
# Casbin access model for the storefront.
|
||||
#
|
||||
# Request is (subject, object, action) = (role, request-path, HTTP-method);
|
||||
# axum-casbin supplies path + method automatically and the subject comes from
|
||||
# our JWT-derived CasbinVals (see src/shared/rbac.rs).
|
||||
#
|
||||
# Deny-override: every request is allowed unless a matching policy line marks it
|
||||
# `deny`. That keeps the public storefront fully open and lets the policy file
|
||||
# carve out the protected `/admin/*` subtree for non-admins only.
|
||||
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
|
||||
[policy_definition]
|
||||
p = sub, obj, act, eft
|
||||
|
||||
[role_definition]
|
||||
g = _, _
|
||||
|
||||
[policy_effect]
|
||||
e = !some(where (p.eft == deny))
|
||||
|
||||
[matchers]
|
||||
m = (r.sub == p.sub || g(r.sub, p.sub)) && keyMatch(r.obj, p.obj) && regexMatch(r.act, p.act)
|
||||
25
config/casbin/policy.csv
Normal file
25
config/casbin/policy.csv
Normal file
@@ -0,0 +1,25 @@
|
||||
# Authorization policy. Format: p, subject(role), object(path-pattern), action(method-regex), effect
|
||||
#
|
||||
# DECISION: this app intentionally runs with a SINGLE hardcoded admin (the user
|
||||
# whose email matches ADMIN_EMAIL in .env, via guard::is_admin / admin_seeder).
|
||||
# Everyone else is `customer` (logged in) or `anonymous` (not). There is no
|
||||
# stored `role` column yet. This is a deliberate choice for current scale, not a
|
||||
# limitation of the wiring — see src/shared/rbac.rs for the upgrade path.
|
||||
#
|
||||
# Deny everyone except admins under the admin subtree. `keyMatch` treats the
|
||||
# trailing `*` as "anything after /admin/", so /admin/dashboard, /admin/orders/5
|
||||
# etc. are all covered; the bare /admin entry point stays open so it can redirect
|
||||
# to /login. Admins match no deny rule and so are allowed through.
|
||||
#
|
||||
# To grow this: give users a real role, emit it as the subject in
|
||||
# src/shared/rbac.rs, then add `p` lines (allow/deny) and `g, <user>, <role>`
|
||||
# mappings here.
|
||||
p, customer, /admin/*, .*, deny
|
||||
p, anonymous, /admin/*, .*, deny
|
||||
# Admin-only endpoints that live outside the /admin/* subtree: the admin JSON
|
||||
# API and the image upload. Public image serving (/images/{filename}) is GET and
|
||||
# not matched here, so it stays open.
|
||||
p, customer, /api/admin/*, .*, deny
|
||||
p, anonymous, /api/admin/*, .*, deny
|
||||
p, customer, /images/upload, .*, deny
|
||||
p, anonymous, /images/upload, .*, deny
|
||||
|
Can't render this file because it has a wrong number of fields in line 2.
|
@@ -71,7 +71,7 @@ mailer:
|
||||
# Database Configuration
|
||||
database:
|
||||
# Database connection URI
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/gitara_web_development") }}
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_development") }}
|
||||
# When enabled, the sql query will be logged.
|
||||
enable_logging: false
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
@@ -105,3 +105,50 @@ 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="") }}
|
||||
# Packeta REST API secret + sender label, used by admin "Send to carrier"
|
||||
# (manual shipment creation). See docs/integrations/packeta.md.
|
||||
packeta_api_password: {{ get_env(name="PACKETA_API_PASSWORD", default="") }}
|
||||
packeta_sender_label: {{ get_env(name="PACKETA_SENDER_LABEL", default="") }}
|
||||
# DPD shipment API (see docs/integrations/dpd.md). Empty = not configured.
|
||||
dpd_api_base: {{ get_env(name="DPD_API_BASE", default="") }}
|
||||
dpd_login: {{ get_env(name="DPD_LOGIN", default="") }}
|
||||
dpd_password: {{ get_env(name="DPD_PASSWORD", default="") }}
|
||||
dpd_customer_number: {{ get_env(name="DPD_CUSTOMER_NUMBER", default="") }}
|
||||
# DHL shipment API (see docs/integrations/dhl.md). Empty = not configured.
|
||||
dhl_api_base: {{ get_env(name="DHL_API_BASE", default="") }}
|
||||
dhl_api_key: {{ get_env(name="DHL_API_KEY", default="") }}
|
||||
dhl_api_secret: {{ get_env(name="DHL_API_SECRET", default="") }}
|
||||
dhl_account_number: {{ get_env(name="DHL_ACCOUNT_NUMBER", 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.") }}
|
||||
|
||||
# loco-oauth2: social login. Credentials come from .env (create them in the
|
||||
# Google Cloud console and register the redirect_url below as an authorized
|
||||
# redirect URI). Until OAUTH_CLIENT_ID/SECRET are set, the "Continue with
|
||||
# Google" button will fail at the consent screen — the rest of auth is unaffected.
|
||||
initializers:
|
||||
oauth2:
|
||||
# Key for the loco-oauth2 private cookie jar (>= 64 bytes). Override in prod.
|
||||
secret_key: {{ get_env(name="OAUTH_PRIVATE_KEY", default="144, 76, 183, 1, 15, 184, 233, 174, 214, 251, 190, 186, 122, 61, 74, 84, 225, 110, 189, 115, 10, 251, 133, 128, 52, 46, 15, 66, 85, 1, 245, 73, 27, 113, 189, 15, 209, 205, 61, 100, 73, 31, 18, 58, 235, 105, 141, 36, 70, 92, 231, 151, 27, 32, 243, 117, 30, 244, 110, 89, 233, 196, 137, 130") }}
|
||||
authorization_code:
|
||||
- client_identifier: google
|
||||
client_credentials:
|
||||
client_id: {{ get_env(name="OAUTH_CLIENT_ID", default="oauth_client_id") }}
|
||||
client_secret: {{ get_env(name="OAUTH_CLIENT_SECRET", default="oauth_client_secret") }}
|
||||
url_config:
|
||||
auth_url: {{ get_env(name="OAUTH_AUTH_URL", default="https://accounts.google.com/o/oauth2/auth") }}
|
||||
token_url: {{ get_env(name="OAUTH_TOKEN_URL", default="https://www.googleapis.com/oauth2/v3/token") }}
|
||||
redirect_url: {{ get_env(name="OAUTH_REDIRECT_URL", default="http://localhost:5150/api/oauth2/google/callback/cookie") }}
|
||||
profile_url: {{ get_env(name="OAUTH_PROFILE_URL", default="https://openidconnect.googleapis.com/v1/userinfo") }}
|
||||
scopes:
|
||||
- "https://www.googleapis.com/auth/userinfo.email"
|
||||
- "https://www.googleapis.com/auth/userinfo.profile"
|
||||
cookie_config:
|
||||
# After loco-oauth2 sets its session cookie it redirects here, where we
|
||||
# mint our own auth_token cookie (see controllers/oauth2.rs::complete).
|
||||
protected_url: {{ get_env(name="OAUTH_PROTECTED_URL", default="http://localhost:5150/api/oauth2/protected") }}
|
||||
timeout_seconds: 600
|
||||
|
||||
@@ -55,3 +55,25 @@ auth:
|
||||
settings:
|
||||
admin_email: "{{ get_env(name="ADMIN_EMAIL", default="") }}"
|
||||
uploads_root: "{{ get_env(name="UPLOADS_ROOT", default="data/uploads") }}"
|
||||
|
||||
# loco-oauth2 social login. All values must come from the environment in prod;
|
||||
# OAUTH_REDIRECT_URL / OAUTH_PROTECTED_URL must use the real public origin.
|
||||
initializers:
|
||||
oauth2:
|
||||
secret_key: "{{ get_env(name="OAUTH_PRIVATE_KEY") }}"
|
||||
authorization_code:
|
||||
- client_identifier: google
|
||||
client_credentials:
|
||||
client_id: "{{ get_env(name="OAUTH_CLIENT_ID") }}"
|
||||
client_secret: "{{ get_env(name="OAUTH_CLIENT_SECRET") }}"
|
||||
url_config:
|
||||
auth_url: "{{ get_env(name="OAUTH_AUTH_URL", default="https://accounts.google.com/o/oauth2/auth") }}"
|
||||
token_url: "{{ get_env(name="OAUTH_TOKEN_URL", default="https://www.googleapis.com/oauth2/v3/token") }}"
|
||||
redirect_url: "{{ get_env(name="OAUTH_REDIRECT_URL") }}"
|
||||
profile_url: "{{ get_env(name="OAUTH_PROFILE_URL", default="https://openidconnect.googleapis.com/v1/userinfo") }}"
|
||||
scopes:
|
||||
- "https://www.googleapis.com/auth/userinfo.email"
|
||||
- "https://www.googleapis.com/auth/userinfo.profile"
|
||||
cookie_config:
|
||||
protected_url: "{{ get_env(name="OAUTH_PROTECTED_URL") }}"
|
||||
timeout_seconds: 600
|
||||
|
||||
@@ -68,7 +68,7 @@ mailer:
|
||||
# Database Configuration
|
||||
database:
|
||||
# Database connection URI
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/gitara_web_test") }}
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_test") }}
|
||||
# When enabled, the sql query will be logged.
|
||||
enable_logging: false
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
gitara-web:
|
||||
container_name: gitara-web
|
||||
kompress:
|
||||
container_name: kompress
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -9,9 +9,9 @@ services:
|
||||
env_file:
|
||||
- .env.production
|
||||
volumes:
|
||||
- gitara_web_data:/usr/app/data
|
||||
- kompress_eshop_data:/usr/app/data
|
||||
networks:
|
||||
- gitara-net
|
||||
- kompress_eshop-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:5150/_ping"]
|
||||
@@ -21,10 +21,10 @@ services:
|
||||
start_period: 20s
|
||||
|
||||
networks:
|
||||
gitara-net:
|
||||
kompress_eshop-net:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
gitara_web_data:
|
||||
kompress_eshop_data:
|
||||
external: true
|
||||
name: gitara_web_data
|
||||
name: kompress_eshop_data
|
||||
|
||||
126
docs/integrations/README.md
Normal file
126
docs/integrations/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Carrier integrations
|
||||
|
||||
This eshop manages **delivery options** as plain rows in the `shipping_methods`
|
||||
table (admin UI at `/admin/shipping` — add / edit price + toggle / remove). A
|
||||
delivery option is just a name, a price, and two flags. **None of that talks to
|
||||
a carrier yet** — it only decides what the customer can pick and how much they
|
||||
pay.
|
||||
|
||||
Integrating a real carrier (Packeta, DPD, DHL) means wiring two *separate*
|
||||
concerns on top of an existing delivery option:
|
||||
|
||||
1. **Pickup-point selection** (checkout, browser-side) — only for carriers that
|
||||
deliver to pickup points / lockers. The customer picks a point via the
|
||||
carrier's JS map widget; the chosen id + name land in the order.
|
||||
2. **Shipment creation** (server-side, after the order is placed) — you call the
|
||||
carrier's HTTP API to register the parcel, then store the returned tracking
|
||||
number and print the label.
|
||||
|
||||
These are independent: you can ship to a Packeta pickup point manually (no API)
|
||||
just by enabling the pickup widget, and you can create DHL labels via API for a
|
||||
home-delivery option that has no pickup point at all.
|
||||
|
||||
> ❗ This is **not** a many-to-many / database relationship between your tables.
|
||||
> A carrier is an **external HTTP API** you call from the server. The only
|
||||
> schema you add is a few columns (which carrier a method maps to; a tracking
|
||||
> number on the order) — see "Shared groundwork" below.
|
||||
|
||||
## What already exists in the codebase
|
||||
|
||||
| Piece | Where | Status |
|
||||
|---|---|---|
|
||||
| Delivery option CRUD | `src/controllers/admin_shipping.rs`, `assets/views/admin/shipping/index.html` | ✅ done |
|
||||
| `shipping_methods` table (`code`, `name`, `price_cents`, `requires_pickup_point`, `enabled`, `position`) | `migration/.../m20260616_150755_shipping_methods.rs` | ✅ done |
|
||||
| Carrier choice + pickup fields on checkout | `assets/views/shop/checkout.html` (`carrier_code`, `pickup_point_id`, `pickup_point_name`) | ✅ done |
|
||||
| Order stores carrier + pickup point | `orders` table (`carrier_code`, `carrier_name`, `pickup_point_id`, `pickup_point_name`, `shipping_cents`) | ✅ done |
|
||||
| Settings lookup | `src/shared/settings.rs` → reads `settings.*` from `config/*.yaml` | ✅ done |
|
||||
| Packeta pickup-point widget | `assets/views/shop/checkout.html` (loads when `packeta_api_key` set) | ✅ scaffolded |
|
||||
| `shipping_methods.carrier` (which API a method maps to) | `migration/.../m20260617_000001_*` + admin add-form dropdown | ✅ done |
|
||||
| Tracking / shipment id / label on order | `migration/.../m20260617_000002_*` (`orders.tracking_number`, `shipment_id`, `label_url`) | ✅ done |
|
||||
| Manual "Send to carrier" admin action | `src/controllers/admin_orders.rs` (`ship`), order detail page | ✅ done |
|
||||
| Carrier client dispatch | `src/integrations/` (`create_shipment`) | ✅ done |
|
||||
| Packeta shipment client | `src/integrations/packeta.rs` (real `createPacket`) | ✅ done |
|
||||
| DPD / DHL shipment clients | `src/integrations/dpd.rs`, `dhl.rs` | 🟡 credential-guarded stub — fill in HTTP call per contract |
|
||||
|
||||
**Shipments are created only when an admin clicks "Send to carrier" on the order
|
||||
page** — never automatically at checkout. Packeta is wired end-to-end (needs
|
||||
just the API password + sender label). DPD/DHL run through the same flow but
|
||||
their HTTP body must be finalised against your contract (clearly marked TODOs in
|
||||
each file).
|
||||
|
||||
## Shared groundwork (do this once, before any carrier's API step)
|
||||
|
||||
The pickup-widget half needs nothing new. The **shipment-creation** half needs:
|
||||
|
||||
1. **An HTTP client dependency.** Add to `Cargo.toml`:
|
||||
```toml
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
```
|
||||
(Loco already pulls `tokio`/`serde`/`serde_json`.)
|
||||
|
||||
2. **A place for carrier clients.** Create `src/integrations/mod.rs` and a file
|
||||
per carrier (`packeta.rs`, `dpd.rs`, `dhl.rs`). Register `pub mod integrations;`
|
||||
in `src/lib.rs` (next to `pub mod controllers;` etc.).
|
||||
|
||||
3. **Map a delivery option to a carrier.** Add a `carrier` column to
|
||||
`shipping_methods` so each admin-created option knows which API (if any) to
|
||||
call. Generate the migration:
|
||||
```bash
|
||||
cargo loco generate migration add_carrier_to_shipping_methods carrier:string
|
||||
```
|
||||
Values: `none` (manual, the default), `packeta`, `dpd`, `dhl`. Then add a
|
||||
`<select name="carrier">` to the add-form in
|
||||
`assets/views/admin/shipping/index.html` and persist it in
|
||||
`admin_shipping::create`.
|
||||
|
||||
4. **Store the tracking number / label on the order.** Generate:
|
||||
```bash
|
||||
cargo loco generate migration add_tracking_to_orders \
|
||||
tracking_number:string shipment_id:string label_url:string
|
||||
```
|
||||
|
||||
5. **A "Create shipment" admin action.** In the admin order detail
|
||||
(`src/controllers/admin_orders.rs`), add a button/handler that: looks up the
|
||||
order's `carrier_code` → finds the `shipping_methods.carrier` → calls the
|
||||
matching `integrations::<carrier>::create_shipment(...)` → saves
|
||||
`tracking_number` + `label_url` back onto the order. Optionally do this
|
||||
automatically in `orders::place`, but a manual admin trigger is safer to
|
||||
start (you can review the order first).
|
||||
|
||||
After the groundwork, each carrier file implements one async function roughly
|
||||
like:
|
||||
|
||||
```rust
|
||||
pub struct ShipmentRequest<'a> {
|
||||
pub order_number: &'a str,
|
||||
pub recipient_name: &'a str,
|
||||
pub email: &'a str,
|
||||
pub phone: Option<&'a str>,
|
||||
pub address: Option<&'a str>,
|
||||
pub city: Option<&'a str>,
|
||||
pub zip: Option<&'a str>,
|
||||
pub country: Option<&'a str>,
|
||||
pub pickup_point_id: Option<&'a str>,
|
||||
pub cod_cents: i64, // 0 unless cash-on-delivery
|
||||
pub currency: &'a str,
|
||||
pub weight_grams: i32,
|
||||
}
|
||||
|
||||
pub struct ShipmentResult {
|
||||
pub shipment_id: String,
|
||||
pub tracking_number: String,
|
||||
pub label_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_shipment(ctx: &AppContext, req: ShipmentRequest<'_>) -> loco_rs::Result<ShipmentResult> { ... }
|
||||
```
|
||||
|
||||
## Read next
|
||||
|
||||
- [`packeta.md`](packeta.md) — Packeta / Zásilkovna (pickup points + home, SK/CZ-centric)
|
||||
- [`dpd.md`](dpd.md) — DPD (home delivery + Pickup parcelshops)
|
||||
- [`dhl.md`](dhl.md) — DHL (international, Parcel/Express)
|
||||
|
||||
> ⚠️ Carrier APIs change. Treat the endpoint names, field names, and auth
|
||||
> details here as a **map of the moving parts**, and confirm exact request
|
||||
> formats against each carrier's current developer portal before coding.
|
||||
150
docs/integrations/dhl.md
Normal file
150
docs/integrations/dhl.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# DHL integration
|
||||
|
||||
DHL is best for **home delivery and international/express** shipments. Like DPD,
|
||||
**nothing DHL-specific is scaffolded** here. DHL is mostly an **address (home)
|
||||
delivery** carrier — pickup points exist (DHL ServicePoint / Packstation, mostly
|
||||
DE) but most shops use DHL for door-to-door, so you can usually skip the pickup
|
||||
widget entirely.
|
||||
|
||||
> DHL has **several separate APIs** behind one developer portal
|
||||
> (<https://developer.dhl.com>). Pick the one that matches your service:
|
||||
> - **DHL Parcel DE (Post & Parcel Germany) — Shipping API** for German domestic
|
||||
> parcels / Packstation.
|
||||
> - **DHL eCommerce (Parcel) APIs** for various countries.
|
||||
> - **DHL Express — MyDHL API** for international express.
|
||||
> Confirm which your contract covers before coding.
|
||||
|
||||
---
|
||||
|
||||
## 1. Get DHL API access
|
||||
|
||||
1. Create an account on the **DHL Developer Portal**: <https://developer.dhl.com>.
|
||||
2. Create an **app** and subscribe it to the specific API you need (e.g.
|
||||
"Shipping API" or "MyDHL API"). You receive an **API key (client id) +
|
||||
secret**.
|
||||
3. Separately you need a **DHL business/customer account** (EKP / account
|
||||
number, billing number) — the developer key alone can't bill shipments. Link
|
||||
your business account credentials to the app.
|
||||
4. Most DHL APIs use **OAuth2 client-credentials**: you exchange key+secret for a
|
||||
short-lived **Bearer token**, then call the shipping endpoints with it. (Some
|
||||
older endpoints use Basic auth — check your API's docs.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Create the delivery option
|
||||
|
||||
At **`/admin/shipping`** → "Add delivery option":
|
||||
- **Name**: e.g. `DHL` or `DHL Express (international)`
|
||||
- **Price**: your fee
|
||||
- **Requires pickup point**: ❌ off for normal home delivery
|
||||
(turn ✅ on *only* if you specifically offer DHL Packstation/ServicePoint and
|
||||
build a picker — see section 4)
|
||||
- ✅ **Active**
|
||||
|
||||
With the option active, customers can already choose DHL and you can create the
|
||||
label manually in DHL Business Customer Portal. The API (section 3) automates
|
||||
that.
|
||||
|
||||
---
|
||||
|
||||
## 3. Create shipments via the DHL API
|
||||
|
||||
Do the [shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||
first. Set `shipping_methods.carrier = "dhl"` for your DHL options.
|
||||
|
||||
### Credentials
|
||||
|
||||
```bash
|
||||
DHL_API_KEY=your_client_id
|
||||
DHL_API_SECRET=your_client_secret
|
||||
DHL_ACCOUNT_NUMBER=your_ekp_or_billing_number
|
||||
DHL_API_BASE=https://api-eu.dhl.com # depends on the specific API
|
||||
```
|
||||
Add matching lines under `settings:` in `config/*.yaml`:
|
||||
```yaml
|
||||
dhl_api_key: {{ get_env(name="DHL_API_KEY", default="") }}
|
||||
dhl_api_secret: {{ get_env(name="DHL_API_SECRET", default="") }}
|
||||
dhl_account_number: {{ get_env(name="DHL_ACCOUNT_NUMBER", default="") }}
|
||||
dhl_api_base: {{ get_env(name="DHL_API_BASE", default="") }}
|
||||
```
|
||||
|
||||
### Flow (OAuth2 + create shipment)
|
||||
|
||||
1. **Token** → `POST {base}/.../token` with `grant_type=client_credentials` +
|
||||
key/secret → `access_token` (Bearer; cache until it expires).
|
||||
2. **Create shipment** → `POST` the shipment-orders endpoint with the Bearer
|
||||
token: shipper (your account/EKP), consignee (recipient from the order),
|
||||
product code (domestic vs international/express), weight, customs data for
|
||||
non-EU, and references (`order_number`). COD is a value-added service if you
|
||||
offer it.
|
||||
3. **Label** → the response includes a **tracking/shipment number** and a
|
||||
**label** (PDF/base64). Store/print it.
|
||||
|
||||
### Client sketch (`src/integrations/dhl.rs`)
|
||||
|
||||
```rust
|
||||
use loco_rs::prelude::*;
|
||||
use crate::shared::settings;
|
||||
|
||||
async fn bearer(ctx: &AppContext) -> Result<String> {
|
||||
let base = settings::get(ctx, "dhl_api_base").unwrap_or_default();
|
||||
let key = settings::get(ctx, "dhl_api_key").unwrap_or_default();
|
||||
let secret = settings::get(ctx, "dhl_api_secret").unwrap_or_default();
|
||||
// POST client_credentials → access_token; cache with expiry.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||
-> Result<super::ShipmentResult>
|
||||
{
|
||||
let token = bearer(ctx).await?;
|
||||
let account = settings::get(ctx, "dhl_account_number").unwrap_or_default();
|
||||
// Build shipment JSON:
|
||||
// - shipper: your account address (account = EKP/billing number)
|
||||
// - consignee: req.recipient_name / address / city / zip / country
|
||||
// - details: weight, product code (domestic / express), currency
|
||||
// - refs: req.order_number
|
||||
// - for international: customs (HS codes, declared value, contents)
|
||||
// POST {base}/.../shipments with Authorization: Bearer {token}
|
||||
todo!("parse tracking number + label into ShipmentResult")
|
||||
}
|
||||
```
|
||||
|
||||
Wire into the admin "Create shipment" action for `carrier == "dhl"` orders.
|
||||
|
||||
> 🌍 **International note:** for shipments outside the EU customs union you must
|
||||
> send **customs/commodity data** (HS codes, declared value, item descriptions).
|
||||
> Your `order_items` only store name + price today — if you ship internationally
|
||||
> you'll likely add a customs description/HS-code field to products.
|
||||
|
||||
---
|
||||
|
||||
## 4. (Optional) DHL pickup points
|
||||
|
||||
If you offer **Packstation / ServicePoint**, set "Requires pickup point" ✅ on
|
||||
that delivery option and render DHL's **Location Finder** (a separate DHL API)
|
||||
in the checkout pickup block (the `x-show="requiresPoint"` section of
|
||||
`assets/views/shop/checkout.html`), writing the chosen locker id into the
|
||||
existing hidden `pickup_point_id` / `pickup_point_name` fields. For Packstation
|
||||
you also need the recipient's **DHL post number** — an extra field most shops
|
||||
avoid unless targeting Germany.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing
|
||||
|
||||
- DHL provides a **sandbox** environment per API (separate base URL + test
|
||||
credentials) on the developer portal. Get a token and create one test
|
||||
shipment there before production.
|
||||
- Validate the tracking number on <https://www.dhl.com/track>.
|
||||
|
||||
## 6. Go-live checklist
|
||||
|
||||
- [ ] DHL developer app created + subscribed to the right API
|
||||
- [ ] DHL business account (EKP/billing number) linked
|
||||
- [ ] `DHL_*` env vars set; matching `settings:` lines added to `config/production.yaml`
|
||||
- [ ] Delivery option created in `/admin/shipping`; `carrier = "dhl"` set
|
||||
- [ ] `src/integrations/dhl.rs` implemented; OAuth token caching working
|
||||
- [ ] (International) customs data available on products/items
|
||||
- [ ] Test shipment in DHL sandbox → tracking number stored on order
|
||||
- [ ] Switched from sandbox to production base URL/credentials
|
||||
147
docs/integrations/dpd.md
Normal file
147
docs/integrations/dpd.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# DPD integration
|
||||
|
||||
DPD offers **home/business delivery** and **DPD Pickup parcelshops & lockers**.
|
||||
Unlike Packeta, **nothing DPD-specific is scaffolded yet** in this repo, so this
|
||||
is a full integration: an optional pickup widget plus the shipment-creation API.
|
||||
|
||||
---
|
||||
|
||||
## 1. Get a DPD account & API access
|
||||
|
||||
1. You need a **business contract** with DPD in your country (e.g. DPD SK:
|
||||
<https://www.dpd.com/sk>). Ask your account manager for **API access**.
|
||||
2. DPD exposes a few different APIs depending on country/era — confirm which one
|
||||
your contract uses:
|
||||
- **REST Shipping API** (modern; JSON) — most new integrations.
|
||||
- **SOAP "Login/Shipment" web services** (older; still common in CEE).
|
||||
- Some markets use the **DPD Geodata / Shop Finder API** for parcelshops.
|
||||
3. You'll receive: an **API base URL**, a **delisId / login**, and a
|
||||
**password** (the SOAP `login` call returns a short-lived **auth token** you
|
||||
reuse on subsequent calls). REST variants use an API key/token directly.
|
||||
4. Note your **sender address** and **DPD customer number** — required on every
|
||||
shipment.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decide which DPD services you offer
|
||||
|
||||
Create one delivery option per service at **`/admin/shipping`** → "Add delivery
|
||||
option":
|
||||
|
||||
| Option | "Requires pickup point" | Notes |
|
||||
|---|---|---|
|
||||
| `DPD Home` (classic) | ❌ off | delivered to the address on the order |
|
||||
| `DPD Pickup` (parcelshop/locker) | ✅ on | customer must choose a shop/locker |
|
||||
|
||||
For `DPD Pickup` you need a **point picker** (section 3). For `DPD Home` you can
|
||||
skip straight to the API (section 4).
|
||||
|
||||
---
|
||||
|
||||
## 3. (Pickup only) Add the DPD parcelshop picker at checkout
|
||||
|
||||
The checkout already has the generic pickup machinery: when the selected method
|
||||
has `requires_pickup_point = true`, the block with hidden `pickup_point_id` /
|
||||
`pickup_point_name` shows (`assets/views/shop/checkout.html`, the `x-show=
|
||||
"requiresPoint"` block). Today that block only renders the **Packeta** widget
|
||||
(guarded by `{% if packeta_api_key %}`) or a text fallback.
|
||||
|
||||
To support DPD you make that block carrier-aware:
|
||||
|
||||
1. Pass a `dpd_enabled` / map-widget key flag into the checkout context from
|
||||
`src/controllers/checkout.rs` (like `packeta_api_key` is passed today).
|
||||
2. In the pickup block, branch on the chosen `carrier` (the Alpine `carrier`
|
||||
variable already holds the method `code`) and render DPD's parcelshop map
|
||||
widget when a DPD pickup method is selected. DPD provides an embeddable
|
||||
**map/widget** (or you query their **Shop Finder API** and render your own
|
||||
list); on selection, write the shop id into `pointId` and a human label into
|
||||
`pointName` — exactly what the existing hidden inputs expect.
|
||||
|
||||
No new order fields are needed — `pickup_point_id` / `pickup_point_name` already
|
||||
carry the DPD shop id + name.
|
||||
|
||||
---
|
||||
|
||||
## 4. Create shipments via the DPD API
|
||||
|
||||
Do the [shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||
first. Set `shipping_methods.carrier = "dpd"` for your DPD options.
|
||||
|
||||
### Auth (SOAP-style, common in CEE)
|
||||
|
||||
```bash
|
||||
DPD_API_BASE=https://api.dpd.sk # from your account manager
|
||||
DPD_LOGIN=your_delis_login
|
||||
DPD_PASSWORD=your_password
|
||||
DPD_CUSTOMER_NUMBER=your_customer_no
|
||||
```
|
||||
Add matching lines under `settings:` in `config/*.yaml`:
|
||||
```yaml
|
||||
dpd_api_base: {{ get_env(name="DPD_API_BASE", default="") }}
|
||||
dpd_login: {{ get_env(name="DPD_LOGIN", default="") }}
|
||||
dpd_password: {{ get_env(name="DPD_PASSWORD", default="") }}
|
||||
dpd_customer_number: {{ get_env(name="DPD_CUSTOMER_NUMBER", default="") }}
|
||||
```
|
||||
|
||||
### Flow
|
||||
|
||||
1. **Login** → `LoginService.getAuth(delisId, password)` returns an **auth
|
||||
token** (valid for a while; cache it).
|
||||
2. **Create shipment** → `ShipmentService.storeOrders(...)` with the auth token,
|
||||
recipient address (or parcelshop id for Pickup), parcel weight, references
|
||||
(your `order_number`), and COD amount if cash-on-delivery. Returns a
|
||||
**parcel number (MPS id)** = your tracking number, plus label data.
|
||||
3. **Label** → the same call (or `getParcelLabels`) returns a **PDF/ZPL label**;
|
||||
store or print it.
|
||||
|
||||
### Client sketch (`src/integrations/dpd.rs`)
|
||||
|
||||
```rust
|
||||
use loco_rs::prelude::*;
|
||||
use crate::shared::settings;
|
||||
|
||||
async fn auth_token(ctx: &AppContext) -> Result<String> {
|
||||
let base = settings::get(ctx, "dpd_api_base").unwrap_or_default();
|
||||
let login = settings::get(ctx, "dpd_login").unwrap_or_default();
|
||||
let pass = settings::get(ctx, "dpd_password").unwrap_or_default();
|
||||
// POST login → parse token from response. Cache it (e.g. in-memory w/ expiry).
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||
-> Result<super::ShipmentResult>
|
||||
{
|
||||
let token = auth_token(ctx).await?;
|
||||
let customer = settings::get(ctx, "dpd_customer_number").unwrap_or_default();
|
||||
// Build storeOrders payload:
|
||||
// - product: "CL" (classic/home) or "Pickup" + parcelShopId = req.pickup_point_id
|
||||
// - recipient: req.recipient_name / address / city / zip / country / phone
|
||||
// - cod: req.cod_cents (set cash-on-delivery service if > 0)
|
||||
// - reference: req.order_number
|
||||
// POST to {base}/shipment ... with `token`.
|
||||
todo!("parse parcel number + label into ShipmentResult")
|
||||
}
|
||||
```
|
||||
|
||||
Wire it into the admin "Create shipment" action for `carrier == "dpd"` orders.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing
|
||||
|
||||
- DPD provides a **test/integration environment** (separate base URL +
|
||||
credentials) — get it from your account manager. Validate login + one
|
||||
shipment there first.
|
||||
- Confirm the returned parcel number tracks on
|
||||
`https://tracking.dpd.de/...` / your local DPD tracking site.
|
||||
|
||||
## 6. Go-live checklist
|
||||
|
||||
- [ ] DPD business contract + API credentials obtained
|
||||
- [ ] `DPD_*` env vars set; matching `settings:` lines added to `config/production.yaml`
|
||||
- [ ] Delivery option(s) created in `/admin/shipping` (`DPD Home` and/or `DPD Pickup`)
|
||||
- [ ] `carrier = "dpd"` set on those methods (via the shared `carrier` column)
|
||||
- [ ] (Pickup) parcelshop picker rendered in checkout for DPD methods
|
||||
- [ ] `src/integrations/dpd.rs` implemented; login token caching working
|
||||
- [ ] Test shipment in DPD test env → tracking number stored on order
|
||||
- [ ] Switched base URL/credentials from test to production
|
||||
104
docs/integrations/google-oauth.md
Normal file
104
docs/integrations/google-oauth.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Google OAuth2 sign-in
|
||||
|
||||
"Continue with Google" on `/login` and `/register` is wired through
|
||||
[`loco-oauth2`](https://github.com/yinho999/loco-oauth2). The code is complete
|
||||
and compiles; this doc is the checklist to make the live flow work. Until the
|
||||
credentials below are set, the button reaches Google and fails at the consent
|
||||
screen — the rest of auth (password login, registration, verification) is
|
||||
unaffected.
|
||||
|
||||
## How the flow works (for context)
|
||||
|
||||
1. User clicks **Continue with Google** → `GET /api/oauth2/google` redirects to
|
||||
Google's consent screen.
|
||||
2. Google redirects back to `GET /api/oauth2/google/callback/cookie`.
|
||||
loco-oauth2 exchanges the code, fetches the profile, upserts the user
|
||||
(`OAuth2UserTrait::upsert_with_oauth`), stores an `o_auth2_sessions` row, sets
|
||||
its own private session cookie, and redirects to `protected_url`.
|
||||
3. `protected_url` = `GET /api/oauth2/protected` (our bridge,
|
||||
`controllers/oauth2.rs::complete`). It mints **our** `auth_token` JWT cookie
|
||||
and redirects: admins (email == `ADMIN_EMAIL`) → `/admin/dashboard`,
|
||||
everyone else → `/`.
|
||||
|
||||
From there the user is a normal logged-in user (same JWT cookie as a password
|
||||
login; the Casbin layer and guards treat them identically).
|
||||
|
||||
## 1. Create Google OAuth credentials
|
||||
|
||||
1. Go to <https://console.cloud.google.com/> → create/select a project.
|
||||
2. **APIs & Services → OAuth consent screen**: configure it (External), add the
|
||||
`.../auth/userinfo.email` and `.../auth/userinfo.profile` scopes, and add
|
||||
your Google account as a **test user** while the app is in "Testing".
|
||||
3. **APIs & Services → Credentials → Create Credentials → OAuth client ID**:
|
||||
- Application type: **Web application**.
|
||||
- **Authorized redirect URIs** — add exactly (must match the config's
|
||||
`redirect_url`, no trailing slash):
|
||||
- dev: `http://localhost:5150/api/oauth2/google/callback/cookie`
|
||||
- prod: `https://YOUR_DOMAIN/api/oauth2/google/callback/cookie`
|
||||
4. Copy the generated **Client ID** and **Client secret**.
|
||||
|
||||
## 2. Set environment variables (`.env`)
|
||||
|
||||
Read by `config/development.yaml` → `initializers.oauth2` (and the prod
|
||||
equivalent). dotenvy loads `.env` on boot.
|
||||
|
||||
```bash
|
||||
# Required
|
||||
OAUTH_CLIENT_ID=xxxxxxxx.apps.googleusercontent.com
|
||||
OAUTH_CLIENT_SECRET=xxxxxxxx
|
||||
|
||||
# Required in PRODUCTION (dev has working defaults)
|
||||
OAUTH_PRIVATE_KEY="comma,separated,bytes >= 64 long" # key for loco-oauth2's private cookie jar
|
||||
OAUTH_REDIRECT_URL=https://YOUR_DOMAIN/api/oauth2/google/callback/cookie
|
||||
OAUTH_PROTECTED_URL=https://YOUR_DOMAIN/api/oauth2/protected
|
||||
```
|
||||
|
||||
Notes:
|
||||
- **dev** ships defaults for everything except `OAUTH_CLIENT_ID` /
|
||||
`OAUTH_CLIENT_SECRET`, so locally you only need those two.
|
||||
- `OAUTH_PRIVATE_KEY` must be ≥ 64 bytes (the dev default is a sample key — do
|
||||
**not** reuse it in production). Generate a fresh one, e.g.
|
||||
`python3 -c "import os;print(','.join(str(b) for b in os.urandom(64)))"`.
|
||||
- `OAUTH_REDIRECT_URL` here and the Authorized redirect URI in the Google
|
||||
console must be byte-for-byte identical.
|
||||
|
||||
## 3. Run / test
|
||||
|
||||
```bash
|
||||
nix develop -c cargo loco start # MUST be inside nix develop (OpenSSL link, see memory)
|
||||
```
|
||||
|
||||
- `auto_migrate: true` (dev) creates the `o_auth2_sessions` table on boot.
|
||||
- Open `http://localhost:5150/login` → **Continue with Google** → consent →
|
||||
you should land back on `/` logged in (cart/nav reflect the session).
|
||||
|
||||
## 4. Production checklist
|
||||
|
||||
- [ ] Separate OAuth client (or at least the prod redirect URI) in Google.
|
||||
- [ ] OAuth consent screen **published** (not just "Testing"), or real users
|
||||
get blocked.
|
||||
- [ ] `OAUTH_PRIVATE_KEY` set to a fresh ≥64-byte key (not the dev sample).
|
||||
- [ ] `OAUTH_REDIRECT_URL` / `OAUTH_PROTECTED_URL` use the real `https://` origin.
|
||||
- [ ] `server.host` / public origin correct so cookies + redirects resolve.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---|---|
|
||||
| `redirect_uri_mismatch` at Google | Authorized redirect URI ≠ `OAUTH_REDIRECT_URL`. Make them identical (scheme, host, port, path, no trailing slash). |
|
||||
| 403 / "access blocked: app not verified" | Add your account as a test user, or publish the consent screen. |
|
||||
| `openssl-sys ... Could not find directory` at build | You ran `cargo` outside the dev shell. Use `nix develop -c cargo ...`. |
|
||||
| Callback 500 / "could not create oauth2 store" | `initializers.oauth2` missing/invalid, or `OAUTH_PRIVATE_KEY` < 64 bytes. |
|
||||
| Logged into Google but not into the app | The bridge (`/api/oauth2/protected`) didn't run — check `protected_url` (`OAUTH_PROTECTED_URL`) points at it. |
|
||||
|
||||
## Where things live
|
||||
|
||||
- Config: `config/development.yaml` / `config/production.yaml` →
|
||||
`initializers.oauth2`
|
||||
- Client store + session initializers: `src/initializers/oauth2.rs`,
|
||||
`src/initializers/oauth2_session.rs`
|
||||
- Routes + bridge handler: `src/controllers/oauth2.rs`
|
||||
- User upsert (random password per advisory LOC-2025-04): `src/models/users.rs`
|
||||
(`OAuth2UserTrait`)
|
||||
- Session table: `src/models/o_auth2_sessions.rs` +
|
||||
`migration/.../m20260618_000001_o_auth2_sessions.rs`
|
||||
174
docs/integrations/packeta.md
Normal file
174
docs/integrations/packeta.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Packeta (Zásilkovna) integration
|
||||
|
||||
Packeta delivers mainly to **pickup points** and **Z-BOX lockers** (plus
|
||||
home delivery in some regions). It's the most common choice for SK/CZ eshops.
|
||||
This repo is already **scaffolded** for Packeta's pickup-point picker — you
|
||||
mostly need an API key to switch it on. Shipment creation via API is extra,
|
||||
optional work.
|
||||
|
||||
---
|
||||
|
||||
## 1. Get a Packeta account & keys
|
||||
|
||||
1. Register a client account at <https://client.packeta.com> (Zásilkovna /
|
||||
Packeta). For SK: <https://www.packeta.sk>.
|
||||
2. In the client portal open **Client support → API / Nastavenia API** (or
|
||||
"Integrations"). You get **two different secrets** — don't mix them up:
|
||||
- **Web/Widget API key** — public-ish key used by the browser pickup-point
|
||||
widget (`Packeta.Widget.pick`). This is the one this repo already uses.
|
||||
- **API password (REST/SOAP)** — secret server key used to *create packets*
|
||||
(shipments). Never expose this to the browser.
|
||||
3. (For real shipping) configure your **sender/pickup address and label
|
||||
format** in the portal.
|
||||
|
||||
---
|
||||
|
||||
## 2. Activate the pickup-point picker (already built)
|
||||
|
||||
The checkout template already loads the widget and wires the chosen point into
|
||||
the order **whenever `packeta_api_key` is non-empty**
|
||||
(`assets/views/shop/checkout.html`):
|
||||
|
||||
- loads `https://widget.packeta.com/v6/www/js/library.js`
|
||||
- `Packeta.Widget.pick(packetaKey, point => …)` fills hidden
|
||||
`pickup_point_id` + `pickup_point_name`
|
||||
- if the key is empty it falls back to a plain text field
|
||||
|
||||
So to turn it on:
|
||||
|
||||
### a) Set the Web/Widget API key
|
||||
|
||||
Set the env var (read by `config/development.yaml` / `production.yaml` →
|
||||
`settings.packeta_api_key`, exposed via `src/shared/settings.rs`):
|
||||
|
||||
```bash
|
||||
# .env (development) or your production environment
|
||||
PACKETA_API_KEY=your_web_widget_api_key
|
||||
```
|
||||
|
||||
`config/development.yaml` already contains:
|
||||
```yaml
|
||||
settings:
|
||||
packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }}
|
||||
```
|
||||
For production, add the same line under `settings:` in `config/production.yaml`
|
||||
(it isn't there yet).
|
||||
|
||||
### b) Create a Packeta delivery option in the admin
|
||||
|
||||
Go to **`/admin/shipping`** → "Add delivery option":
|
||||
- **Name**: e.g. `Packeta – pickup point`
|
||||
- **Price**: your fee (e.g. `2.90`)
|
||||
- ✅ **Requires pickup point** ← this makes the picker appear at checkout
|
||||
- ✅ **Active**
|
||||
|
||||
The auto-generated `code` will be `packeta-pickup-point` (or similar). Customers
|
||||
now see the option, click "Choose pickup point", pick on the map, and the order
|
||||
stores `pickup_point_id` + `pickup_point_name`.
|
||||
|
||||
**At this point you have a working Packeta flow** — you read the pickup point on
|
||||
the order in `/admin/orders` and create the parcel manually in the Packeta
|
||||
portal. Many small shops stop here.
|
||||
|
||||
---
|
||||
|
||||
## 3. (Optional) Create shipments via API
|
||||
|
||||
Automate "register the parcel + get tracking + print label". Do the
|
||||
[shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||
first (HTTP client, `integrations` module, `carrier` column, tracking columns).
|
||||
|
||||
### Endpoint & auth
|
||||
|
||||
- Packeta REST API base: `https://www.zasilkovna.cz/api/rest` (SOAP also
|
||||
available at `http://www.zasilkovna.cz/api/soap.wsdl`).
|
||||
- Auth = your **API password** (the server secret from step 1), sent in the
|
||||
request body, **not** the widget key.
|
||||
- Key operation: **`createPacket`**. You send sender id, recipient
|
||||
name/email/phone, the chosen **pickup point id** (`addressId`), value, weight,
|
||||
and COD amount; you receive a **packet id + barcode (tracking)**. A separate
|
||||
**`packetLabelPdf`** call returns the label PDF.
|
||||
|
||||
### Store the secret
|
||||
|
||||
```bash
|
||||
PACKETA_API_PASSWORD=your_secret_api_password
|
||||
```
|
||||
Add to `config/*.yaml` under `settings:`:
|
||||
```yaml
|
||||
packeta_api_password: {{ get_env(name="PACKETA_API_PASSWORD", default="") }}
|
||||
```
|
||||
|
||||
### Client sketch (`src/integrations/packeta.rs`)
|
||||
|
||||
```rust
|
||||
use loco_rs::prelude::*;
|
||||
use crate::shared::settings;
|
||||
|
||||
// createPacket accepts XML; serde_json works for the JSON REST variant.
|
||||
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||
-> Result<super::ShipmentResult>
|
||||
{
|
||||
let api_password = settings::get(ctx, "packeta_api_password")
|
||||
.ok_or_else(|| Error::string("packeta_api_password not configured"))?;
|
||||
|
||||
// Packeta's createPacket is XML/SOAP-ish; build the body per their docs.
|
||||
// number = your order_number
|
||||
// name/surname = recipient
|
||||
// addressId = req.pickup_point_id (the chosen point)
|
||||
// cod = req.cod_cents / 100 (0 if not COD)
|
||||
// value = goods value
|
||||
// eshop = your sender label/id from the portal
|
||||
let body = format!(r#"<createPacket>
|
||||
<apiPassword>{api_password}</apiPassword>
|
||||
<packetAttributes>
|
||||
<number>{}</number>
|
||||
<name>{}</name>
|
||||
<email>{}</email>
|
||||
<addressId>{}</addressId>
|
||||
<cod>{}</cod>
|
||||
<value>{}</value>
|
||||
<weight>{}</weight>
|
||||
<eshop>YOUR_SENDER_LABEL</eshop>
|
||||
</packetAttributes>
|
||||
</createPacket>"#,
|
||||
req.order_number, req.recipient_name, req.email,
|
||||
req.pickup_point_id.unwrap_or(""),
|
||||
req.cod_cents as f64 / 100.0,
|
||||
req.cod_cents as f64 / 100.0,
|
||||
req.weight_grams);
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post("https://www.zasilkovna.cz/api/rest")
|
||||
.body(body)
|
||||
.send().await.map_err(|e| Error::string(&e.to_string()))?
|
||||
.text().await.map_err(|e| Error::string(&e.to_string()))?;
|
||||
|
||||
// Parse <id> (packet id) and <barcode> (tracking) out of the XML response.
|
||||
// Then optionally call packetLabelPdf with that id to fetch the label.
|
||||
todo!("parse resp into ShipmentResult")
|
||||
}
|
||||
```
|
||||
|
||||
Then call it from your admin "Create shipment" action for orders whose
|
||||
`shipping_methods.carrier == "packeta"`, and save `tracking_number` /
|
||||
`shipment_id` back on the order.
|
||||
|
||||
---
|
||||
|
||||
## 4. Testing
|
||||
|
||||
- Use the Packeta **sandbox/staging** portal if your account offers one, or a
|
||||
test API password. Verify `createPacket` returns a packet id before going
|
||||
live.
|
||||
- Track the parcel at `https://tracking.packeta.com/...` using the returned
|
||||
barcode.
|
||||
|
||||
## 5. Go-live checklist
|
||||
|
||||
- [ ] `PACKETA_API_KEY` (widget) set in production env
|
||||
- [ ] `packeta_api_key` line added under `settings:` in `config/production.yaml`
|
||||
- [ ] Packeta delivery option created in `/admin/shipping` with **Requires pickup point** ✅
|
||||
- [ ] (If using API) `PACKETA_API_PASSWORD` set + `src/integrations/packeta.rs` implemented
|
||||
- [ ] Sender address & label format configured in the Packeta portal
|
||||
- [ ] Test order → pickup point saved on order → (API) tracking number stored
|
||||
@@ -1,6 +1,6 @@
|
||||
#[allow(unused_imports)]
|
||||
use loco_rs::{cli::playground, prelude::*};
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.9 KiB |
15
flake.nix
15
flake.nix
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "Development Nix flake for the gitara_web (kompress_eshop) loco-rs app";
|
||||
description = "Development Nix flake for the kompress (kompress_eshop) loco-rs app";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
@@ -41,8 +41,8 @@
|
||||
cargo = pkgs.rust-bin.stable.latest.minimal;
|
||||
rustc = pkgs.rust-bin.stable.latest.minimal;
|
||||
};
|
||||
gitara-web = rustPlatform.buildRustPackage {
|
||||
pname = "gitara_web";
|
||||
kompress = rustPlatform.buildRustPackage {
|
||||
pname = "kompress_eshop";
|
||||
inherit version;
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
@@ -57,7 +57,7 @@
|
||||
];
|
||||
|
||||
# Build only the application binary.
|
||||
cargoBuildFlags = [ "--bin" "gitara_web-cli" ];
|
||||
cargoBuildFlags = [ "--bin" "kompress-eshop-cli" ];
|
||||
# Tests need a database/runtime environment; skip during the build.
|
||||
doCheck = false;
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
};
|
||||
in
|
||||
{
|
||||
gitara-web = gitara-web;
|
||||
default = gitara-web;
|
||||
kompress = kompress;
|
||||
default = kompress;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -86,7 +86,10 @@
|
||||
buildInputs = [
|
||||
rust
|
||||
pkgs.pkg-config
|
||||
# OpenSSL for crypto dependencies (loco-oauth2 -> oauth2/reqwest
|
||||
# use native-tls); .dev provides headers + pkg-config metadata.
|
||||
pkgs.openssl
|
||||
pkgs.openssl.dev
|
||||
pkgs.cmake
|
||||
pkgs.llvmPackages.clang
|
||||
pkgs.llvmPackages.libclang.lib
|
||||
|
||||
637
hardcoded-inventory.md
Normal file
637
hardcoded-inventory.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# Handcoded UI Components — Penguin UI Replacement Index
|
||||
|
||||
> **Scope**: Every handcoded UI component.
|
||||
> Each item maps to a [Penguin UI](https://github.com/SalarHoushvand/penguinui-components/tree/main) component that duplicates the same purpose with fewer lines and better accessibility.
|
||||
|
||||
## Vendoring convention
|
||||
|
||||
The full library is now vendored locally at repo-root
|
||||
`penguinui-components/` (177 component `.html` files, moved there
|
||||
2026-06-18). Read components from there — **NO** network/curl/WebFetch
|
||||
needed anymore.
|
||||
|
||||
### HARD RULE — read-only, never edit
|
||||
|
||||
`penguinui-components/` is a read-only third-party library, **NOT our
|
||||
code**. Never edit, never `{% include %}`, never adapt in place. It is
|
||||
reference only; **copy markup OUT** of it and adapt at the use-site.
|
||||
|
||||
When a Penguin UI component can replace a handcoded one:
|
||||
|
||||
1. Find the component in the local `penguinui-components/` directory.
|
||||
2. Copy its markup **out** into the appropriate `assets/views/` location,
|
||||
adapting it where used (strip docs-only demo triggers, fix obvious
|
||||
upstream bugs, wire data bindings, map Penguin classes to our design
|
||||
tokens). Note the deviations in a comment next to the adapted copy.
|
||||
3. Keep the original `penguinui-components/` file **untouched** — it stays
|
||||
as a byte-for-byte reference snapshot.
|
||||
4. Rebuild Tailwind (`make css`) so any new utility classes get compiled.
|
||||
5. Mark the section below as ✅ **DONE**.
|
||||
|
||||
### Why it's at repo root + the build guard
|
||||
|
||||
Moved OUT of `assets/views/` to repo root because Tailwind v4 auto-detects
|
||||
sources from the project root — so `assets/css/app.css` carries
|
||||
`@source not "../../penguinui-components";` to explicitly exclude it
|
||||
from the build. If the build ever balloons, check that exclusion is intact.
|
||||
|
||||
---
|
||||
|
||||
## 0. Toast — ✅ DONE
|
||||
|
||||
**Penguin UI: `toast-notification/stacking-toast-notification.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/toast-notification/stacking-toast-notification.html` (reference only)
|
||||
- Adapted/rendered copy lives inline in `assets/views/base.html` (demo triggers
|
||||
removed; the upstream dismiss-button `<svg>` quote bugs fixed)
|
||||
- The global `toast('message')` JS helper now dispatches the component's
|
||||
`notify` event (`{ variant: 'success', message }`), so existing callsites
|
||||
(`shop/show.html`, `shop/_card.html`) keep working unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 1. Navbar — ✅ DONE
|
||||
**Penguin UI: `navbar/default-navbar.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/navbar/default-navbar.html` (reference only)
|
||||
- **Link treatment** adopted from Penguin (matches the already-ported sidebars): the
|
||||
desktop nav links lost the pill-hover (`hover:bg-surface-alt` + `px-3 py-1.5`) for
|
||||
Penguin's text-only `underline-offset-2 hover:text-primary focus-visible:underline
|
||||
focus:outline-hidden`, active (`aria-current=page`, set by `markActiveNav()` via
|
||||
`data-nav`) = `font-semibold` + primary. Centralized into a `ui::nav_link(label,
|
||||
href, data_nav, variant, attrs)` macro in `macros/ui.html` (variant ∈ default |
|
||||
warning admin | danger). Logout stays an inline `<form><button>` (not an `<a>`).
|
||||
- **Hamburger animation** adopted: both the site mobile-menu button and the admin
|
||||
sidebar toggle now swap bars ↔ X (`x-show="!open"`/`x-show="open"`, Penguin X-path
|
||||
`M6 18 18 6M6 6l12 12`), kept inside our ghost-square icon-button shell for
|
||||
consistency with the cart/gear buttons.
|
||||
- **Mobile menu panel**: kept our compact dropdown (better for this app's dense top
|
||||
bar than Penguin's full-screen `fixed inset-x-0 top-0 pt-20` overlay, which would
|
||||
cover the cart/settings/category-toggle). Items now use the sidebar menu-row
|
||||
treatment (`hover:bg-primary/5`, underline focus) + `data-nav` so they show the
|
||||
active state too.
|
||||
- **Preserved intact** (the integration risks flagged here): cart icon + live
|
||||
cookie-read badge, the `partials/settings_dropdown.html` include (language switcher
|
||||
+ theme tristate), the mobile category-drawer toggle, and all Alpine toggles
|
||||
(`mobile`, `cats`, `showSidebar`).
|
||||
|
||||
| # | Location | What it is |
|
||||
|---|----------|------------|
|
||||
| 1 | `assets/views/base.html` | Full site navbar (brand, links, cart badge, settings, mobile menu) |
|
||||
| 2 | `assets/views/admin/base.html` | Admin top bar: animated hamburger + breadcrumb + settings |
|
||||
|
||||
---
|
||||
|
||||
## 2. Sidebar (Admin) — ✅ DONE
|
||||
**Penguin UI: `sidebar/simple-sidebar.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/sidebar/simple-sidebar.html` (reference only)
|
||||
- Adapted at use-site in `assets/views/admin/base.html`: the nav links + bottom
|
||||
exit/logout now use Penguin's link treatment (`hover:bg-primary/5`,
|
||||
`underline-offset-2 focus-visible:underline focus:outline-hidden`) and the
|
||||
subtle active state (`bg-primary/10` + `text-on-surface-strong`) mapped onto
|
||||
our `data-nav`/`aria-current` so `markActiveNav()` still drives it.
|
||||
- The fixed-rail translate-X show/hide mechanics + mobile overlay (#4) are layout
|
||||
scaffolding, kept as-is. Icons were intentionally not added (no verified icon
|
||||
set yet) — possible follow-up.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sidebar (Category Accordion) — ✅ DONE
|
||||
**Penguin UI: `sidebar/sidebar-with-collapsible-menus.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/sidebar/sidebar-with-collapsible-menus.html` (reference only)
|
||||
- Adapted at use-site in `assets/views/shop/_sidebar.html`: Penguin link treatment +
|
||||
active state + chevron-down rotation (`rotate-180`); child items now sit in a
|
||||
bordered/indented list instead of the old `padding-left:28px` + `↳`. Kept our
|
||||
htmx partial, data-driven `category_groups`, auto-expand `x-init`, and
|
||||
`data-nav`/`markActiveNav()` active routing.
|
||||
- Deviations: group row keeps our link + chevron-toggle split (categories are
|
||||
navigable, not just expandable); uses `x-show`/`x-transition` instead of
|
||||
upstream's `x-collapse` (that Alpine plugin isn't bundled in our build).
|
||||
- The `<aside>` drawer + mobile overlay (#6) in `base.html` are layout
|
||||
scaffolding, kept as-is.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dropdown (Settings) — ✅ DONE
|
||||
**Penguin UI: `dropdowns/dropdown-with-click.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/dropdowns/dropdown-with-click.html` (reference only)
|
||||
- **De-duplicated**: the ~103-line copy-paste is now one shared partial
|
||||
`assets/views/partials/settings_dropdown.html`, included by both `base.html`
|
||||
and `admin/base.html` (each host keeps its own positioning wrapper
|
||||
`<div x-data="{ open:false }" class="relative [ml-auto]">`).
|
||||
- Adopts Penguin's dropdown menu container + item treatment. Deviations: kept our
|
||||
gear icon-only trigger and core-Alpine open/@click.outside toggle (upstream's
|
||||
`x-trap`/`$focus` need the Alpine Focus plugin we don't bundle); item hover
|
||||
uses `bg-primary/5` for consistency with the rest of the UI.
|
||||
|
||||
---
|
||||
|
||||
## 5. Country / Phone Combobox — ⛔ WON'T PORT (conscious deviation)
|
||||
**Penguin UI: `combobox/phone-number-input-with-country-code-dropdown.html`**
|
||||
|
||||
> **Decision 2026-06-18:** keep our lightweight hand-rolled comboboxes; do NOT
|
||||
> port the Penguin one. The Penguin combobox depends on the **Alpine Focus
|
||||
> plugin** (`x-trap`, `$focus.wrap().next()`) which this build does not bundle
|
||||
> (same reason the settings dropdown & category accordion deviate), ships a
|
||||
> 240-country `allOptions` list + `flagcdn.com` remote flag images, and a search
|
||||
> field — far heavier than our deliberate 9-prefix / 6-country editable inputs.
|
||||
> Our versions already use the Penguin design tokens (`bg-surface`, `border-outline`,
|
||||
> `focus:outline-primary`) and emoji flags, so they look on-brand. Net: porting
|
||||
> would add a JS dependency and external image loads for negative UX value.
|
||||
> Revisit only if we adopt the Alpine Focus plugin project-wide.
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 9 | `assets/views/shop/checkout.html:49-74` | Phone prefix combobox (`+421`, `+420`, …, `+33`) | ~25 lines |
|
||||
| 10 | `assets/views/shop/checkout.html:102-127` | Country combobox (SK, CZ, AT, DE, PL, HU) | ~26 lines |
|
||||
|
||||
**Details for #9:**
|
||||
- Alpine `x-data` with `prefix`, `prefixOpen`, `opts` array of `{ v, l }` (9 country codes)
|
||||
- Manual `filtered` computed property
|
||||
- Inline chevron SVG that rotates via `:class="prefixOpen && 'rotate-180'"`
|
||||
- Dropdown list with `<template x-for>` and `@click` selection
|
||||
|
||||
**Details for #10:**
|
||||
- Same pattern as #9 but with translate-able country names (6 countries)
|
||||
- Includes `+421` prefix shortcut
|
||||
|
||||
---
|
||||
|
||||
## 6. Product Card — ✅ DONE
|
||||
**Penguin UI: `card/ecommerce-product-card.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/card/ecommerce-product-card.html` (reference only)
|
||||
- Adapted/rendered copy is `assets/views/shop/_card.html`: `<article>` shell + Penguin
|
||||
image/title/price layout and the cart-icon add-to-cart button, wired to our product
|
||||
data + i18n + htmx `hx-post` add-to-cart + `toast()`. Demo-only rating stars,
|
||||
hardcoded content and `max-w-sm` (fights the shop grid) were dropped; whole card
|
||||
links to the product page; out-of-stock badge kept.
|
||||
|
||||
---
|
||||
|
||||
## 7. Product Image Gallery — ✅ DONE
|
||||
**Penguin UI: `carousel/default-carousel.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/carousel/default-carousel.html` (reference only)
|
||||
- Adapted at use-site in `assets/views/shop/show.html`: added Penguin's overlay
|
||||
prev/next arrow buttons (`bg-surface/40` rounded, verbatim chevron SVGs) and
|
||||
`x-transition.opacity.duration.300ms` fade between images. Added `prev()`/`next()`
|
||||
with wraparound to the gallery `x-data`; arrows + transitions only render when
|
||||
`images | length > 1`.
|
||||
- Deviations: kept our **product thumbnail strip** (more useful than carousel
|
||||
dot indicators for a product page) and our **0-based `active`** index (Penguin
|
||||
uses 1-based `currentSlideIndex`); main images switched to `absolute inset-0`
|
||||
so the fade cross-dissolves inside the `aspect-square` frame. New i18n keys
|
||||
`gallery-prev`/`gallery-next` (sk + en) for the arrow `aria-label`s.
|
||||
|
||||
---
|
||||
|
||||
## 8. Radio-Button Groups — ✅ DONE
|
||||
**Penguin UI: `radio/radio-with-container.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/radio/radio-with-container.html` (reference only)
|
||||
- New `ui::radio(name, value, id, checked, attrs, extra)` macro in `macros/ui.html`
|
||||
emits **only** the Penguin custom radio-dot `<input>` (verbatim `appearance-none`
|
||||
+ `before:` dot + `checked:bg-primary`). Callers keep their own card-style
|
||||
`<label>` wrapper — we kept our `has-[:checked]:border-primary` card highlight,
|
||||
which is richer than Penguin's plain `bg-surface-alt` container.
|
||||
- Adopted at `shop/checkout.html`: both **payment** radios (`ui::radio` with
|
||||
`attrs='required x-model="paymentMethod"'`). The **carrier** radio (in the
|
||||
`{% for m in shipping_methods %}` loop) keeps the same Penguin dot class **inline**
|
||||
because its `@change="carrier='{{ m.code }}'; …"` mixes nested single+double
|
||||
quotes that can't pass through a Tera macro arg (same convention as the cart
|
||||
qty input). Native `text-primary` radios are gone.
|
||||
|
||||
---
|
||||
|
||||
## 9. Checkbox — ✅ DONE
|
||||
**Penguin UI: `checkbox/default-checkbox.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/checkbox/default-checkbox.html` (reference only)
|
||||
- `ui::checkbox(name, label, id, value="on", checked, attrs)` macro in `macros/ui.html`
|
||||
(full Penguin control: custom box + check-icon + label, `has-checked:`/`peer` variants).
|
||||
- Adopted: product/category "Published" + shipping "Enabled".
|
||||
|
||||
## 10. Text Input — ✅ DONE
|
||||
**Penguin UI: `text-input/default-text-input.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/text-input/default-text-input.html` (reference only)
|
||||
- `ui::input(name, type, id, value, placeholder, required, autocomplete, attrs, extra, width="w-full")`
|
||||
macro — **verbatim** Penguin classes (`bg-surface-alt`, `focus-visible:outline-*`).
|
||||
Adopted at every text/email/number/password input: login (2), checkout (email,
|
||||
name, phone, address, city, zip), product form (6), category form (3), product
|
||||
detail quantity, shipping price (`width="w-28"`).
|
||||
- The cart-body quantity input keeps its complex `@change` handler **inline** with
|
||||
the same Penguin classes (mixed single/double quotes can't pass through a macro arg).
|
||||
- Note: padding is Penguin's `px-2 py-2` (was `px-3`) and bg is `bg-surface-alt` (was
|
||||
`bg-surface`) — the real Penguin look.
|
||||
|
||||
## 11. Textarea — ✅ DONE
|
||||
**Penguin UI: `text-area/default-textarea.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/text-area/default-textarea.html` (reference only)
|
||||
- `ui::textarea(name, id, value, rows, placeholder, required, attrs, extra)` macro.
|
||||
- Adopted: checkout note, product & category description.
|
||||
|
||||
## 12. Select/Dropdown (Native) — ✅ DONE
|
||||
**Penguin UI: `select/default-select.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/select/default-select.html` (reference only)
|
||||
- Adopted inline (3 sites: product category, category parent, order status) — Penguin
|
||||
`appearance-none` select on `bg-surface-alt` wrapped in `relative` with the chevron
|
||||
SVG. Inline rather than a macro because the `<option>` set is caller-specific.
|
||||
|
||||
## 13. File Input — ✅ DONE
|
||||
**Penguin UI: `file-input/default-file-input.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/file-input/default-file-input.html` (reference only)
|
||||
- `ui::file_input(name, id, accept, attrs, extra)` macro (verbatim Penguin `file:` styling).
|
||||
- Adopted: product & category image upload.
|
||||
|
||||
---
|
||||
|
||||
## 14. Table — ✅ DONE
|
||||
**Penguin UI: `table/default-table.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/table/default-table.html` (reference only)
|
||||
- The same wrapper/thead/tbody/row/tfoot class structure was copy-pasted across all
|
||||
5 tables (orders index, order detail, products, categories, cart body). Centralized
|
||||
into **class-string macros** in `macros/ui.html`: `ui::table_wrap_cls()`,
|
||||
`ui::table_cls()`, `ui::thead_cls()`, `ui::tbody_cls()`, `ui::row_cls()` (hover),
|
||||
`ui::tfoot_cls()`, plus an element macro `ui::th(label, align="")` for header cells.
|
||||
- **Why class-string macros, not full row macros:** Tera has no slot/`{% raw %}{% call %}{% endraw %}`
|
||||
mechanism, and the cells are heterogeneous (product image+name, htmx quantity input
|
||||
with inline Alpine `@change`, badges, action-button forms), so rows stay inline. The
|
||||
macros centralize only the drift-prone chrome styling — `class="{{ ui::thead_cls() }}"`.
|
||||
- **Penguin improvement adopted:** the wrapper now carries `w-full overflow-x-auto`
|
||||
(from `default-table.html`) so wide tables scroll horizontally on mobile instead of
|
||||
overflowing. Our `text-xs uppercase` thead + `px-4 py-3` cells were kept (deliberate,
|
||||
richer than Penguin's `text-sm`/`p-4`).
|
||||
- Interactive lists (orders/products/categories) use `ui::row_cls()` for the hover
|
||||
highlight; non-interactive rows (order items, cart) omit it. `tfoot` (order detail +
|
||||
cart totals) uses `ui::tfoot_cls()`.
|
||||
|
||||
| # | Location | What it is |
|
||||
|---|----------|------------|
|
||||
| 34 | `assets/views/admin/orders/index.html` | Orders table |
|
||||
| 35 | `assets/views/admin/orders/show.html` | Order items table + tfoot summary |
|
||||
| 36 | `assets/views/admin/catalog/products.html` | Products table (image+name, status pill, actions) |
|
||||
| 37 | `assets/views/admin/catalog/categories.html` | Categories table (tree-indented, actions) |
|
||||
| 38 | `assets/views/shop/_cart_body.html` | Cart table (htmx qty input, remove) + tfoot total |
|
||||
|
||||
---
|
||||
|
||||
## 15. Alert / Error Banner — ✅ DONE
|
||||
**Penguin UI: `alert/default-alert.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/alert/default-alert.html` (reference only)
|
||||
- Adapted into the `ui::alert_danger(message, extra="")` macro in
|
||||
`assets/views/macros/ui.html` (compact one-line danger alert + danger icon).
|
||||
- Adopted at both sites: `admin/login.html` (login error) and
|
||||
`admin/orders/show.html` (ship error).
|
||||
|
||||
---
|
||||
|
||||
## 16. Badge / Status Pill — ✅ DONE
|
||||
**Penguin UI: `badge/soft-color-badge.html`**
|
||||
|
||||
- Exact upstream mirror at `penguinui-components/badge/soft-color-badge.html` (reference only)
|
||||
- Adapted into the `ui::badge(label, variant)` macro in `assets/views/macros/ui.html`
|
||||
(variants: success | danger | warning | info | primary | neutral).
|
||||
- Adopted at the status-pill sites: "Auth" badge (`admin/login.html`), order status
|
||||
(`orders/index.html`, neutral), Published/Draft pills (`products.html` +
|
||||
`categories.html`, success/neutral).
|
||||
- Intentionally left inline (not soft-color pills): the cart item-count **notification**
|
||||
badge in `base.html` (count bubble, a different Penguin badge type) and the
|
||||
block-style "out of stock" notice in `_card.html`.
|
||||
|
||||
---
|
||||
|
||||
## 17. Buttons — ✅ DONE
|
||||
**Penguin UI: `buttons/default-button.html`, `outline-button.html`, `ghost-button.html`, `button-with-icon.html`**
|
||||
|
||||
- Exact upstream mirrors at `penguinui-components/buttons/*.html` (reference only).
|
||||
- Macros in `assets/views/macros/ui.html`:
|
||||
`ui::button(label, variant="primary", type, href, attrs, extra, icon, size="px-4 py-2 text-sm")`
|
||||
and `ui::icon_button(icon, variant="ghost-secondary", aria_label, attrs, …)`.
|
||||
The per-variant class strings are the **verbatim** Penguin variants (solid
|
||||
`primary|secondary|danger|success|warning|info`, `outline-*`, `ghost-*`) — only
|
||||
`inline-flex items-center justify-center gap-2` is added so `<a>`/`w-full`/`icon`
|
||||
render, and upstream's `text-onDanger`/`text-onSuccess`… token typos are fixed to
|
||||
our real `text-on-*` tokens. `href` → `<a>` else `<button>`; `attrs` is raw
|
||||
(htmx / `:disabled` / name / value); `icon` is a raw `<svg>` rendered before the
|
||||
label (Penguin button-with-icon).
|
||||
- **Sizes are NOT normalized**: `size` defaults to Penguin's `px-4 py-2 text-sm`
|
||||
but each call site that differed keeps it (`px-3 py-2` form-header cancels &
|
||||
order back, `px-5 py-2` add-to-cart / cart-checkout / order-confirmed continue,
|
||||
`px-6 py-2.5` checkout place-order, `px-3 py-1.5 text-xs` table actions).
|
||||
- Adopted across every standard filled/outline/submit button: login, product &
|
||||
category forms (save / cancel = `outline-secondary`), products/categories "new" +
|
||||
empty-state CTAs, orders detail (back/ship/status), shipping save, cart
|
||||
(continue/checkout/empty), checkout place-order (`:disabled` via `attrs`),
|
||||
product detail add-to-cart, order-confirmed continue.
|
||||
- Icon-only buttons now use `ui::icon_button(icon, variant="ghost-secondary",
|
||||
aria_label, attrs, …)` — Penguin ghost treatment, square. Converted: settings
|
||||
gear, both hamburgers (site + admin), admin sidebar toggle, mobile category
|
||||
toggle. The cart link (live `x-init` badge) and the category-accordion chevron
|
||||
keep the same Penguin ghost classes **inline** only because their markup mixes
|
||||
single+double quotes that can't be passed through a Tera macro arg — visually
|
||||
identical to `icon_button`.
|
||||
- Table row-actions (`edit`/`view`/`delete`/`View`/`label`) → `ui::button`
|
||||
`outline-secondary` / `outline-danger` at `size="px-3 py-1.5 text-xs"`; cart
|
||||
"Remove" → `ghost-danger`; card add-to-cart → `ui::button` with the cart `icon`.
|
||||
- Still genuinely not this component (tracked elsewhere): toast dismiss/Reply
|
||||
buttons (part of the vendored toast mirror, already Penguin), settings dropdown
|
||||
menu items (Penguin dropdown items), gallery thumbnail buttons (carousel),
|
||||
sidebar logout/exit (Penguin sidebar link treatment), and navbar nav-menu
|
||||
links/logout (belong to §1 Navbar). The file-input button is §13.
|
||||
|
||||
> Gotcha for future macro use: Tera renders `{% include %}` in the **includer's**
|
||||
> macro scope, so a template that includes a partial which calls `ui::` must also
|
||||
> `{% import "macros/ui.html" as ui %}` itself (see `shop/cart.html` →
|
||||
> `shop/_cart_body.html`). In an `{% extends %}` child the import must sit
|
||||
> directly after `{% extends %}` with no comment/content before it.
|
||||
|
||||
---
|
||||
|
||||
## 18. Toggle / Switch — LOW PRIORITY (de-duplication, not replacement)
|
||||
**Penguin UI: `toggle/` (3 variants)**
|
||||
|
||||
> **Priority: LOW** | ~36 lines,100% duplicated between `base.html` and `admin/base.html`.
|
||||
> This is JavaScript theme-switching logic (`applyTheme`, `setTheme`, `matchMedia`),
|
||||
> not a CSS toggle component. Penguin's `toggle/default-toggle.html` is a visual
|
||||
> on/off switch — not applicable here.
|
||||
> **Action:** de-duplicate the JS into a shared partial rather than porting.
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 53 | `assets/views/base.html:13-30` | Theme toggle (dark/light/system) — inline `<script>` JavaScript | ~18 lines |
|
||||
| 54 | `assets/views/admin/base.html:13-30` | **Exact duplicate** of the theme toggle JS | ~18 lines |
|
||||
|
||||
**Details:**
|
||||
- `applyTheme()`, `setTheme()`, `currentTheme()` — reads/writes `localStorage`
|
||||
- `matchMedia('prefers-color-scheme: dark')` listener
|
||||
- All hand-written vanilla JS, duplicated twice (36 lines total)
|
||||
|
||||
---
|
||||
|
||||
## 19. Inline SVG Icons — MOSTLY DONE
|
||||
**Penguin UI: none (Penguin uses Heroicons-equivalent inline SVGs)**
|
||||
|
||||
> **Priority: LOW.** Penguin ships no icon library, so this is dedup, not a port.
|
||||
> The repeated hamburger / close / cart SVGs are now centralized in the
|
||||
> `ui::icon(name, size, extra, attrs)` macro (`macros/ui.html`); call sites use
|
||||
> `{{ ui::icon(name="cart") }}` etc. The chevron dropdown arrows stay inline by
|
||||
> design — they carry nested-quote Alpine `:class` / `x-bind:class` bindings,
|
||||
> which Tera macro args can't pass cleanly (see the attrs note atop `ui.html`).
|
||||
|
||||
| # | Location | Icon | Status |
|
||||
|---|----------|------|--------|
|
||||
| 55 | `base.html` (categories + mobile toggle), `admin/base.html` (sidebar toggle) | Hamburger (3-line menu) | ✅ `ui::icon(name="hamburger")` |
|
||||
| 56 | `base.html` (mobile toggle), `admin/base.html` (sidebar toggle) | Close (X) | ✅ `ui::icon(name="close")` |
|
||||
| 57 | `base.html` (cart link) | Shopping cart | ✅ `ui::icon(name="cart")` |
|
||||
| 58 | ~~`base.html:220-221`~~ | Checkmark (toast success) | ✅ removed — now in vendored toast component |
|
||||
| 59 | `checkout.html:61,110` | Chevron-down (dropdown arrow) | inline — nested-quote `:class` binding |
|
||||
| 60 | `_sidebar.html:35` | Chevron-down (accordion expand, rotates) | inline — nested-quote `x-bind:class` binding |
|
||||
| 61 | `settings_dropdown.html` | Gear/cog (settings) | inline in `ui::icon_button` call (shared partial; single use) |
|
||||
|
||||
Remaining inline `<svg>` are the rotating chevrons (kept inline on purpose, above).
|
||||
|
||||
---
|
||||
|
||||
## 20. Empty State — LOW PRIORITY
|
||||
**Penguin UI: no dedicated empty-state component**
|
||||
|
||||
> **Priority: LOW** | ~22 lines across5 sites.
|
||||
> These are simple `<div>` messages, often with a CTA button already using
|
||||
> `ui::button`. Nothing to port — already consistent with project styling.
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 63 | `assets/views/admin/orders/index.html:38-39` | "No orders" message | ~2 lines |
|
||||
| 64 | `assets/views/admin/catalog/products.html:72-78` | "No products" with CTA button | ~7 lines |
|
||||
| 65 | `assets/views/admin/catalog/categories.html:61-67` | "No categories" with CTA button | ~7 lines |
|
||||
| 66 | `assets/views/shop/_cart_body.html:67-70` | "Cart empty" with CTA button | ~4 lines |
|
||||
| 67 | `assets/views/shop/_sidebar.html:58-59` | "No categories" message | ~2 lines |
|
||||
|
||||
---
|
||||
|
||||
## 21. Dashboard Navigation Cards — LOW PRIORITY
|
||||
**Penguin UI: `card/default-card.html` or `card/card-with-button.html`**
|
||||
|
||||
> **Priority: LOW** | ~16 lines.
|
||||
> Already uses card styling (`rounded-radius border border-outline hover:border-primary`).
|
||||
> Penguin's `default-card.html` adds a structured header/body layout — adopt if
|
||||
> cards ever grow beyond a title+description link.
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 68 | `assets/views/admin/index.html:12-27` | 3 dashboard link cards (Products, Categories, Orders) | ~16 lines |
|
||||
|
||||
**Details:**
|
||||
- Each card is an `<a>` styled with border, hover effect, and nested title+description
|
||||
- Same hover pattern: `hover:border-primary`
|
||||
|
||||
---
|
||||
|
||||
## 22. Checkout Order Summary — LOW PRIORITY
|
||||
**Penguin UI: `card/`**
|
||||
|
||||
> **Priority: LOW** | ~29 lines.
|
||||
> Already uses card-like styling with `tabular-nums`. All internal buttons use
|
||||
> `ui::button`. The only handcoded part is the outer `<div>` wrapper and line-item
|
||||
> layout. Penguin doesn't have an ecommerce-specific summary component.
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 69 | `assets/views/shop/checkout.html:190-218` | Cart summary aside: item list, subtotal, shipping, total, place-order button | ~29 lines |
|
||||
|
||||
**Details:**
|
||||
- Item list with name × quantity + line total
|
||||
- Subtotal + shipping + total with `tabular-nums`
|
||||
- Dynamic shipping price from Alpine `carrierPrice`
|
||||
- Disabled submit button when `!canSubmit`
|
||||
|
||||
---
|
||||
|
||||
## 23. Login Card — LOW PRIORITY
|
||||
**Penguin UI: `card/default-card.html`**
|
||||
|
||||
> **Priority: LOW** | ~56 lines.
|
||||
> Already fully uses Penguin macros inside: `ui::input`, `ui::button`,
|
||||
> `ui::badge`, `ui::alert_danger`. Only the outer card wrapper (border,
|
||||
> bg-surface-alt, shadow-sm) is handcoded. Adopting `default-card.html`
|
||||
> would add visual polish but little functional gain.
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 70 | `assets/views/admin/login.html:6-61` | Full login form: header with auth badge, email + password inputs, error alert, submit button | ~56 lines |
|
||||
|
||||
---
|
||||
|
||||
## 24. Checkout Fieldset Cards — LOW PRIORITY
|
||||
**Penguin UI: `card/`**
|
||||
|
||||
> **Priority: LOW** | ~142 lines across4 fieldsets.
|
||||
> Already uses card styling (`rounded-radius border border-outline bg-surface p-6`)
|
||||
> and all internal form controls use Penguin macros (`ui::input`, `ui::textarea`,
|
||||
> `ui::button`). Only the `<fieldset>` + `<legend>` wrapping is handcoded.
|
||||
> Low value in replacing — fieldset semantics are correct here.
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 71 | `assets/views/shop/checkout.html:34-79` | Contact info fieldset (email, name, phone+prefix) | ~46 lines |
|
||||
| 72 | `assets/views/shop/checkout.html:82-130` | Shipping address fieldset (address, city, zip, country) | ~49 lines |
|
||||
| 73 | `assets/views/shop/checkout.html:133-165` | Carrier selection fieldset | ~33 lines |
|
||||
| 74 | `assets/views/shop/checkout.html:167-180` | Payment method fieldset | ~14 lines |
|
||||
|
||||
Each fieldset uses `<fieldset>` + `<legend>` with the same `rounded-radius border border-outline bg-surface p-6` styling.
|
||||
|
||||
---
|
||||
|
||||
## 25. Order Detail Info Panel — LOW PRIORITY
|
||||
**Penguin UI: `card/`**
|
||||
|
||||
> **Priority: LOW** | ~64 lines across3 panels.
|
||||
> Already card-styled (`rounded-radius border border-outline bg-surface p-5`)
|
||||
> with Penguin macros inside. Deviating from this simple structure would
|
||||
> make the dense info layout harder to scan.
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 75 | `assets/views/admin/orders/show.html:49-77` | Customer + shipping + payment info panel | ~29 lines |
|
||||
| 76 | `assets/views/admin/orders/show.html:79-103` | Fulfillment panel (tracking, label link, ship button) | ~25 lines |
|
||||
| 77 | `assets/views/admin/orders/show.html:106-115` | Status update form panel | ~10 lines |
|
||||
|
||||
---
|
||||
|
||||
## 26. Shipping Method Settings Row — LOW PRIORITY
|
||||
**Penguin UI: `card/`**
|
||||
|
||||
> **Priority: LOW** | ~21 lines.
|
||||
> Already fully uses Penguin macros: `ui::input`, `ui::checkbox`, `ui::button`.
|
||||
> The card wrapper is the same pattern as other admin panels. Nothing to port.
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 78 | `assets/views/admin/shipping/index.html:14-34` | Per-carrier settings: name label, price input, enabled checkbox, save button | ~21 lines |
|
||||
|
||||
---
|
||||
|
||||
## 27. Product/Category Form Wrapper — LOW PRIORITY
|
||||
**Penguin UI: `card/`**
|
||||
|
||||
> **Priority: LOW** | ~150 lines across2 forms.
|
||||
> Already fully uses Penguin macros: `ui::input`, `ui::textarea`, `ui::select`,
|
||||
> `ui::file_input`, `ui::checkbox`, `ui::button`. The `<form>` card wrapper is
|
||||
> the same border/bg pattern. Penguin doesn't have a form-specific layout component.
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 79 | `assets/views/admin/catalog/product_form.html:15-99` | Full product edit/create form with all fields | ~84 lines |
|
||||
| 80 | `assets/views/admin/catalog/category_form.html:15-81` | Full category edit/create form with all fields | ~66 lines |
|
||||
|
||||
Both are wrapped in a single card-style `<form>`.
|
||||
|
||||
---
|
||||
|
||||
## Porting Roadmap (priority order)
|
||||
|
||||
### Phase 1 — HIGH (direct Penguin matches, clear win)
|
||||
|
||||
| # | Component | Penguin match | Est. effort | Lines saved |
|
||||
|---|-----------|---------------|-------------|-------------|
|
||||
| ~~5~~ | ~~Country/Phone Combobox~~ | ⛔ WON'T PORT — needs Alpine Focus plugin; our lightweight version kept | — | — |
|
||||
| ~~7~~ | ~~Image Gallery~~ | ✅ DONE — `carousel/default-carousel.html` | Small | ~19 |
|
||||
| ~~8~~ | ~~Radio Groups~~ | ✅ DONE — `radio/radio-with-container.html` | Small | ~47 |
|
||||
| ~~14~~ | ~~Table~~ | ✅ DONE — `table/default-table.html` (class-string macros) | Medium | ~196 |
|
||||
|
||||
### Phase 2 — MEDIUM (good match, more integration risk)
|
||||
|
||||
| # | Component | Penguin match | Est. effort | Lines saved |
|
||||
|---|-----------|---------------|-------------|-------------|
|
||||
| ~~1~~ | ~~Navbar~~ | ✅ DONE — `navbar/default-navbar.html` (link treatment + animated hamburger; `ui::nav_link`) | Large | ~143 |
|
||||
|
||||
### Phase 3 — LOW (mostly already Penguin, or no good match)
|
||||
|
||||
| # | Component | Action |
|
||||
|---|-----------|--------|
|
||||
| 18 | Toggle/Switch | De-duplicate JS into shared partial (not a Penguin port) |
|
||||
| 19 | Inline SVG Icons | Optional: extract `ui::icon(name)` macro |
|
||||
| 20 | Empty State | Already fine — nothing to port |
|
||||
| 21 | Dashboard Cards | Adopt `card/default-card.html` if cards grow |
|
||||
| 22 | Checkout Summary | Already fine — nothing to port |
|
||||
| 23 | Login Card | Already fine — only outer wrapper is handcoded |
|
||||
| 24 | Checkout Fieldsets | Already fine — only `<fieldset>` wrapper is handcoded |
|
||||
| 25 | Order Info Panels | Already fine — only card wrappers are handcoded |
|
||||
| 26 | Shipping Settings Row | Already fully uses Penguin macros |
|
||||
| 27 | Form Wrappers | Already fully uses Penguin macros |
|
||||
|
||||
### Already DONE (17 of 27)
|
||||
|
||||
| # | Component |
|
||||
|---|-----------|
|
||||
| 0 | Toast |
|
||||
| 1 | Navbar |
|
||||
| 2 | Sidebar (Admin) |
|
||||
| 3 | Sidebar (Category Accordion) |
|
||||
| 4 | Dropdown (Settings) |
|
||||
| 6 | Product Card |
|
||||
| 7 | Image Gallery |
|
||||
| 8 | Radio Groups |
|
||||
| 9 | Checkbox |
|
||||
| 10 | Text Input |
|
||||
| 11 | Textarea |
|
||||
| 12 | Select/Dropdown |
|
||||
| 13 | File Input |
|
||||
| 14 | Table |
|
||||
| 15 | Alert / Error Banner |
|
||||
| 16 | Badge / Status Pill |
|
||||
| 17 | Buttons |
|
||||
|
||||
**No real ports remain.** #5 Combobox is a conscious WON'T-PORT (Alpine Focus
|
||||
plugin dependency). All Phase-3 items (#18–27) are already internally
|
||||
Penguin-adapted or have no applicable component — leave as-is.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Component | Penguin UI Directory | Status | Lines |
|
||||
|---|-----------|---------------------|--------|-------|
|
||||
| 0 | Toast | `toast-notification/` | ✅ DONE | — |
|
||||
| 1 | Navbar | `navbar/` | ✅ DONE | ~143 |
|
||||
| 2 | Sidebar (admin) | `sidebar/` | ✅ DONE | ~46 |
|
||||
| 3 | Sidebar (category accordion) | `sidebar/` | ✅ DONE | ~62 |
|
||||
| 4 | Dropdown (settings) | `dropdowns/` | ✅ DONE | ~103 |
|
||||
| 5 | Country/Phone combobox | `combobox/` | ⛔ WON'T PORT | ~51 |
|
||||
| 6 | Product card | `card/` | ✅ DONE | ~30 |
|
||||
| 7 | Image gallery | `carousel/` | ✅ DONE | ~19 |
|
||||
| 8 | Radio groups | `radio/` | ✅ DONE | ~47 |
|
||||
| 9 | Checkbox | `checkbox/` | ✅ DONE | ~15 |
|
||||
| 10 | Text input | `text-input/` | ✅ DONE | ~146 |
|
||||
| 11 | Textarea | `text-area/` | ✅ DONE | ~10 |
|
||||
| 12 | Select | `select/` | ✅ DONE | ~23 |
|
||||
| 13 | File input | `file-input/` | ✅ DONE | ~12 |
|
||||
| 14 | Table | `table/` | ✅ DONE | ~196 |
|
||||
| 15 | Alert/Error | `alert/` | ✅ DONE | ~9 |
|
||||
| 16 | Badge/Pill | `badge/` | ✅ DONE | ~17 |
|
||||
| 17 | Button | `buttons/` | ✅ DONE | ~200+ |
|
||||
| 18 | Toggle (theme) | `toggle/` | LOW (dedup) | ~36 |
|
||||
| 19 | Inline SVG icons | N/A | LOW | ~50 |
|
||||
| 20 | Empty state | N/A | LOW | ~22 |
|
||||
| 21 | Dashboard cards | `card/` | LOW | ~16 |
|
||||
| 22 | Checkout summary | `card/` | LOW | ~29 |
|
||||
| 23 | Login card | `card/` | LOW | ~56 |
|
||||
| 24 | Checkout fieldsets | `card/` | LOW | ~142 |
|
||||
| 25 | Order info panels | `card/` | LOW | ~64 |
|
||||
| 26 | Shipping settings row | `card/` | LOW | ~21 |
|
||||
| 27 | Form wrappers | `card/` | LOW | ~150 |
|
||||
|
||||
**Status: 17 of 27 components fully ported to Penguin UI. No real ports remain.
|
||||
#5 Combobox is a conscious WON'T-PORT (Alpine Focus plugin dependency). The
|
||||
remaining Phase-3 items (#18–27) are already internally Penguin-adapted or have
|
||||
no applicable match — the migration is effectively complete.**
|
||||
@@ -13,7 +13,7 @@ loco-rs = { workspace = true }
|
||||
|
||||
|
||||
[dependencies.sea-orm-migration]
|
||||
version = "1.1.0"
|
||||
version = "1.1.20"
|
||||
features = [
|
||||
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
|
||||
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
|
||||
|
||||
@@ -15,6 +15,24 @@ 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;
|
||||
mod m20260617_000001_add_carrier_to_shipping_methods;
|
||||
mod m20260617_000002_add_shipment_to_orders;
|
||||
mod m20260617_000003_add_phone_to_orders;
|
||||
mod m20260618_000001_o_auth2_sessions;
|
||||
mod m20260618_000002_customer_profiles;
|
||||
mod m20260618_000003_account_type;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -34,7 +52,25 @@ 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),
|
||||
Box::new(m20260617_000001_add_carrier_to_shipping_methods::Migration),
|
||||
Box::new(m20260617_000002_add_shipment_to_orders::Migration),
|
||||
Box::new(m20260617_000003_add_phone_to_orders::Migration),
|
||||
Box::new(m20260618_000001_o_auth2_sessions::Migration),
|
||||
Box::new(m20260618_000002_customer_profiles::Migration),
|
||||
Box::new(m20260618_000003_account_type::Migration),
|
||||
// inject-above (do not remove this comment)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
30
migration/src/m20260616_123506_categories.rs
Normal file
30
migration/src/m20260616_123506_categories.rs
Normal 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
|
||||
}
|
||||
}
|
||||
35
migration/src/m20260616_123524_products.rs
Normal file
35
migration/src/m20260616_123524_products.rs
Normal 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
|
||||
}
|
||||
}
|
||||
28
migration/src/m20260616_123550_product_images.rs
Normal file
28
migration/src/m20260616_123550_product_images.rs
Normal 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
|
||||
}
|
||||
}
|
||||
26
migration/src/m20260616_123611_product_tags.rs
Normal file
26
migration/src/m20260616_123611_product_tags.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
35
migration/src/m20260616_130610_orders.rs
Normal file
35
migration/src/m20260616_130610_orders.rs
Normal 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
|
||||
}
|
||||
}
|
||||
29
migration/src/m20260616_130628_order_items.rs
Normal file
29
migration/src/m20260616_130628_order_items.rs
Normal 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
|
||||
}
|
||||
}
|
||||
42
migration/src/m20260616_131000_drop_audio_tables.rs
Normal file
42
migration/src/m20260616_131000_drop_audio_tables.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
29
migration/src/m20260616_132000_drop_blog_and_pages.rs
Normal file
29
migration/src/m20260616_132000_drop_blog_and_pages.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
30
migration/src/m20260616_150755_shipping_methods.rs
Normal file
30
migration/src/m20260616_150755_shipping_methods.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
55
migration/src/m20260616_160000_add_parent_to_categories.rs
Normal file
55
migration/src/m20260616_160000_add_parent_to_categories.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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> {
|
||||
// Which carrier API (if any) a delivery option maps to. "none" means the
|
||||
// option is fulfilled manually and never calls an external API.
|
||||
add_column(
|
||||
m,
|
||||
"shipping_methods",
|
||||
"carrier",
|
||||
ColType::StringWithDefault("none".to_string()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
remove_column(m, "shipping_methods", "carrier").await
|
||||
}
|
||||
}
|
||||
23
migration/src/m20260617_000002_add_shipment_to_orders.rs
Normal file
23
migration/src/m20260617_000002_add_shipment_to_orders.rs
Normal 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> {
|
||||
// Populated only after an admin manually sends the order to a carrier.
|
||||
add_column(m, "orders", "tracking_number", ColType::StringNull).await?;
|
||||
add_column(m, "orders", "shipment_id", ColType::StringNull).await?;
|
||||
add_column(m, "orders", "label_url", ColType::StringNull).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
remove_column(m, "orders", "tracking_number").await?;
|
||||
remove_column(m, "orders", "shipment_id").await?;
|
||||
remove_column(m, "orders", "label_url").await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
17
migration/src/m20260617_000003_add_phone_to_orders.rs
Normal file
17
migration/src/m20260617_000003_add_phone_to_orders.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
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> {
|
||||
// Customer contact phone, also passed to carriers for pickup SMS.
|
||||
add_column(m, "orders", "phone", ColType::StringNull).await
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
remove_column(m, "orders", "phone").await
|
||||
}
|
||||
}
|
||||
29
migration/src/m20260618_000001_o_auth2_sessions.rs
Normal file
29
migration/src/m20260618_000001_o_auth2_sessions.rs
Normal 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> {
|
||||
// OAuth2 session store used by loco-oauth2 to correlate the provider's
|
||||
// access token with a local user during the callback flow. `user` adds
|
||||
// a user_id FK to the users table.
|
||||
create_table(
|
||||
m,
|
||||
"o_auth2_sessions",
|
||||
&[
|
||||
("id", ColType::PkAuto),
|
||||
("session_id", ColType::StringUniq),
|
||||
("expires_at", ColType::TimestampWithTimeZone),
|
||||
],
|
||||
&[("user", "")],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
drop_table(m, "o_auth2_sessions").await
|
||||
}
|
||||
}
|
||||
44
migration/src/m20260618_000002_customer_profiles.rs
Normal file
44
migration/src/m20260618_000002_customer_profiles.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
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> {
|
||||
// One shipping/contact profile per customer, used to prefill the
|
||||
// checkout form. `name`/`email` already live on `users`; this table
|
||||
// holds only the address + phone fields. `user` adds a user_id FK; the
|
||||
// unique index below makes the relationship 1:1.
|
||||
create_table(
|
||||
m,
|
||||
"customer_profiles",
|
||||
&[
|
||||
("id", ColType::PkAuto),
|
||||
("phone_prefix", ColType::StringNull),
|
||||
("phone", ColType::StringNull),
|
||||
("address", ColType::StringNull),
|
||||
("city", ColType::StringNull),
|
||||
("zip", ColType::StringNull),
|
||||
("country", ColType::StringNull),
|
||||
],
|
||||
&[("user", "")],
|
||||
)
|
||||
.await?;
|
||||
|
||||
m.create_index(
|
||||
Index::create()
|
||||
.name("idx_customer_profiles_user_id_unique")
|
||||
.table(Alias::new("customer_profiles"))
|
||||
.col(Alias::new("user_id"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
drop_table(m, "customer_profiles").await
|
||||
}
|
||||
}
|
||||
39
migration/src/m20260618_000003_account_type.rs
Normal file
39
migration/src/m20260618_000003_account_type.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use loco_rs::schema::*;
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
// Personal vs company purchasing. `account_type` is "personal" or "company";
|
||||
// the company_* columns hold the Slovak invoicing identifiers (IČO, DIČ and the
|
||||
// optional VAT id IČ DPH) and are only filled for company accounts/orders.
|
||||
const COMPANY_COLUMNS: [&str; 4] = ["company_name", "company_id", "tax_id", "vat_id"];
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
for table in ["customer_profiles", "orders"] {
|
||||
add_column(
|
||||
m,
|
||||
table,
|
||||
"account_type",
|
||||
ColType::StringWithDefault("personal".to_string()),
|
||||
)
|
||||
.await?;
|
||||
for col in COMPANY_COLUMNS {
|
||||
add_column(m, table, col, ColType::StringNull).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
for table in ["customer_profiles", "orders"] {
|
||||
remove_column(m, table, "account_type").await?;
|
||||
for col in COMPANY_COLUMNS {
|
||||
remove_column(m, table, col).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
30
penguinui-components/README.md
Normal file
30
penguinui-components/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 🐧 Penguin UI
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
*A collection of beautifully designed UI components built with **Tailwind CSS** and **Alpine.js***
|
||||
|
||||
[📚 **Documentation**](https://www.penguinui.com) • [🚀 **Getting Started**](https://www.penguinui.com/docs/getting-started)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](https://www.penguinui.com/docs/license) for details.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Created by Salar Houshvand**
|
||||
|
||||
[](https://x.com/salar_houshvand)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
<div class="w-full divide-y divide-outline text-on-surface dark:divide-outline-dark dark:text-on-surface-dark">
|
||||
<div x-data="{ isExpanded: false }">
|
||||
<button id="controlsAccordionItemOne" type="button"
|
||||
class="flex w-full items-center justify-between gap-4 py-4 text-left underline-offset-2 focus-visible:underline focus-visible:outline-hidden"
|
||||
aria-controls="accordionItemOne" x-on:click="isExpanded = ! isExpanded"
|
||||
x-bind:class="isExpanded ? 'text-on-surface-strong dark:text-on-surface-dark-strong font-bold' : 'text-on-surface dark:text-on-surface-dark font-medium'"
|
||||
x-bind:aria-expanded="isExpanded ? 'true' : 'false'">
|
||||
What browsers are supported?
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2"
|
||||
stroke="currentColor" class="size-5 shrink-0 transition" aria-hidden="true"
|
||||
x-bind:class="isExpanded ? 'rotate-180' : ''">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-cloak x-show="isExpanded" id="accordionItemOne" role="region" aria-labelledby="controlsAccordionItemOne"
|
||||
x-collapse>
|
||||
<div class="pb-4 text-sm sm:text-base text-pretty">
|
||||
Our website is optimized for the latest versions of Chrome, Firefox, Safari, and Edge. Check our <a
|
||||
href="#" class="underline underline-offset-2 text-primary dark:text-primary-dark">documentation</a>
|
||||
for additional information.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-data="{ isExpanded: false }">
|
||||
<button id="controlsAccordionItemTwo" type="button"
|
||||
class="flex w-full items-center justify-between gap-4 py-4 text-left underline-offset-2 focus-visible:underline focus-visible:outline-hidden"
|
||||
aria-controls="accordionItemTwo" x-on:click="isExpanded = ! isExpanded"
|
||||
x-bind:class="isExpanded ? 'text-on-surface-strong dark:text-on-surface-dark-strong font-bold' : 'text-on-surface dark:text-on-surface-dark font-medium'"
|
||||
x-bind:aria-expanded="isExpanded ? 'true' : 'false'">
|
||||
How can I contact customer support?
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2"
|
||||
stroke="currentColor" class="size-5 shrink-0 transition" aria-hidden="true"
|
||||
x-bind:class="isExpanded ? 'rotate-180' : ''">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-cloak x-show="isExpanded" id="accordionItemTwo" role="region" aria-labelledby="controlsAccordionItemTwo"
|
||||
x-collapse>
|
||||
<div class="pb-4 text-sm sm:text-base text-pretty">
|
||||
Reach out to our dedicated support team via email at <a href="#"
|
||||
class="underline underline-offset-2 text-primary dark:text-primary-dark">support@example.com</a> or
|
||||
call our toll-free number at 1-800-123-4567 during business hours.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-data="{ isExpanded: false }">
|
||||
<button id="controlsAccordionItemThree" type="button"
|
||||
class="flex w-full items-center justify-between gap-4 py-4 text-left underline-offset-2 focus-visible:underline focus-visible:outline-hidden"
|
||||
aria-controls="accordionItemThree" x-on:click="isExpanded = ! isExpanded"
|
||||
x-bind:class="isExpanded ? 'text-on-surface-strong dark:text-on-surface-dark-strong font-bold' : 'text-on-surface dark:text-on-surface-dark font-medium'"
|
||||
x-bind:aria-expanded="isExpanded ? 'true' : 'false'">
|
||||
What is the refund policy?
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2"
|
||||
stroke="currentColor" class="size-5 shrink-0 transition" aria-hidden="true"
|
||||
x-bind:class="isExpanded ? 'rotate-180' : ''">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-cloak x-show="isExpanded" id="accordionItemThree" role="region"
|
||||
aria-labelledby="controlsAccordionItemThree" x-collapse>
|
||||
<div class="pb-4 text-sm sm:text-base text-pretty">
|
||||
Please refer to our <a href="#"
|
||||
class="underline underline-offset-2 text-primary dark:text-primary-dark">refund policy page</a> on
|
||||
the website for detailed information regarding eligibility, timeframes, and the process for requesting a
|
||||
refund.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
70
penguinui-components/accordion/default-accordion.html
Normal file
70
penguinui-components/accordion/default-accordion.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<div
|
||||
class="w-full divide-y divide-outline overflow-hidden rounded-radius border border-outline bg-surface-alt/40 text-on-surface dark:divide-outline-dark dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark">
|
||||
<div x-data="{ isExpanded: false }">
|
||||
<button id="controlsAccordionItemOne" type="button"
|
||||
class="flex w-full items-center justify-between gap-4 bg-surface-alt p-4 text-left underline-offset-2 hover:bg-surface-alt/75 focus-visible:bg-surface-alt/75 focus-visible:underline focus-visible:outline-hidden dark:bg-surface-dark-alt dark:hover:bg-surface-dark-alt/75 dark:focus-visible:bg-surface-dark-alt/75"
|
||||
aria-controls="accordionItemOne" x-on:click="isExpanded = ! isExpanded"
|
||||
x-bind:class="isExpanded ? 'text-on-surface-strong dark:text-on-surface-dark-strong font-bold' : 'text-on-surface dark:text-on-surface-dark font-medium'"
|
||||
x-bind:aria-expanded="isExpanded ? 'true' : 'false'">
|
||||
What browsers are supported?
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2"
|
||||
stroke="currentColor" class="size-5 shrink-0 transition" aria-hidden="true"
|
||||
x-bind:class="isExpanded ? 'rotate-180' : ''">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-cloak x-show="isExpanded" id="accordionItemOne" role="region" aria-labelledby="controlsAccordionItemOne"
|
||||
x-collapse>
|
||||
<div class="p-4 text-sm sm:text-base text-pretty">
|
||||
Our website is optimized for the latest versions of Chrome, Firefox, Safari, and Edge. Check our <a
|
||||
href="#" class="underline underline-offset-2 text-primary dark:text-primary-dark">documentation</a>
|
||||
for additional information.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-data="{ isExpanded: false }">
|
||||
<button id="controlsAccordionItemTwo" type="button"
|
||||
class="flex w-full items-center justify-between gap-4 bg-surface-alt p-4 text-left underline-offset-2 hover:bg-surface-alt/75 focus-visible:bg-surface-alt/75 focus-visible:underline focus-visible:outline-hidden dark:bg-surface-dark-alt dark:hover:bg-surface-dark-alt/75 dark:focus-visible:bg-surface-dark-alt/75"
|
||||
aria-controls="accordionItemTwo" x-on:click="isExpanded = ! isExpanded"
|
||||
x-bind:class="isExpanded ? 'text-on-surface-strong dark:text-on-surface-dark-strong font-bold' : 'text-on-surface dark:text-on-surface-dark font-medium'"
|
||||
x-bind:aria-expanded="isExpanded ? 'true' : 'false'">
|
||||
How can I contact customer support?
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2"
|
||||
stroke="currentColor" class="size-5 shrink-0 transition" aria-hidden="true"
|
||||
x-bind:class="isExpanded ? 'rotate-180' : ''">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-cloak x-show="isExpanded" id="accordionItemTwo" role="region" aria-labelledby="controlsAccordionItemTwo"
|
||||
x-collapse>
|
||||
<div class="p-4 text-sm sm:text-base text-pretty">
|
||||
Reach out to our dedicated support team via email at <a href="#"
|
||||
class="underline underline-offset-2 text-primary dark:text-primary-dark">support@example.com</a> or
|
||||
call our toll-free number at 1-800-123-4567 during business hours.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-data="{ isExpanded: false }">
|
||||
<button id="controlsAccordionItemThree" type="button"
|
||||
class="flex w-full items-center justify-between gap-4 bg-surface-alt p-4 text-left underline-offset-2 hover:bg-surface-alt/75 focus-visible:bg-surface-alt/75 focus-visible:underline focus-visible:outline-hidden dark:bg-surface-dark-alt dark:hover:bg-surface-dark-alt/75 dark:focus-visible:bg-surface-dark-alt/75"
|
||||
aria-controls="accordionItemThree" x-on:click="isExpanded = ! isExpanded"
|
||||
x-bind:class="isExpanded ? 'text-on-surface-strong dark:text-on-surface-dark-strong font-bold' : 'text-on-surface dark:text-on-surface-dark font-medium'"
|
||||
x-bind:aria-expanded="isExpanded ? 'true' : 'false'">
|
||||
What is the refund policy?
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2"
|
||||
stroke="currentColor" class="size-5 shrink-0 transition" aria-hidden="true"
|
||||
x-bind:class="isExpanded ? 'rotate-180' : ''">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-cloak x-show="isExpanded" id="accordionItemThree" role="region"
|
||||
aria-labelledby="controlsAccordionItemThree" x-collapse>
|
||||
<div class="p-4 text-sm sm:text-base text-pretty">
|
||||
Please refer to our <a href="#"
|
||||
class="underline underline-offset-2 text-primary dark:text-primary-dark">refund policy page</a> on
|
||||
the website for detailed information regarding eligibility, timeframes, and the process for requesting a
|
||||
refund.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user