Compare commits
65 Commits
4e1722ce35
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ee87fbdd7 | ||
|
|
c9eb47860d | ||
|
|
8dc153efcc | ||
|
|
db6b609937 | ||
|
|
86888b3877 | ||
|
|
5b203ed248 | ||
|
|
b787d48665 | ||
|
|
e138fb6579 | ||
|
|
3da840c0c9 | ||
|
|
0310f2d2f4 | ||
|
|
42f30261d0 | ||
|
|
ffda718a46 | ||
|
|
673b28c361 | ||
|
|
454d5cb349 | ||
|
|
14ae859152 | ||
|
|
43c6c04dcf | ||
|
|
e51eda9a8c | ||
|
|
12e00a782d | ||
|
|
5278988842 | ||
|
|
e70743996b | ||
|
|
11762728c9 | ||
|
|
ebb208baba | ||
|
|
7cba3d9eba | ||
|
|
35e2b6edc9 | ||
|
|
f3daa27ce7 | ||
|
|
46cc2459bd | ||
|
|
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=
|
REVERSE_PROXY_NETWORK=
|
||||||
UPLOADS_VOLUME_NAME=universal_web_uploads
|
UPLOADS_VOLUME_NAME=universal_web_uploads
|
||||||
|
|
||||||
APP_HOST=https://gitara.farmeris.sk
|
APP_HOST=https://eshop.example.com
|
||||||
PORT=5150
|
PORT=5150
|
||||||
SERVER_BINDING=0.0.0.0
|
SERVER_BINDING=0.0.0.0
|
||||||
|
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -19,6 +19,12 @@ target/
|
|||||||
*.sqlite-*
|
*.sqlite-*
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
|
.envrc
|
||||||
|
.direnv/
|
||||||
uploads/
|
uploads/
|
||||||
*.report.html
|
*.report.html
|
||||||
favicon_io.zip
|
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
|
encode gzip
|
||||||
|
|
||||||
@static path /static/*
|
@static path /static/*
|
||||||
@@ -6,9 +6,9 @@ gitara.farmeris.sk {
|
|||||||
|
|
||||||
rewrite /favicon.ico /static/favicon/favicon.ico
|
rewrite /favicon.ico /static/favicon/favicon.ico
|
||||||
|
|
||||||
reverse_proxy gitara-web:5150
|
reverse_proxy kompress:5150
|
||||||
}
|
}
|
||||||
|
|
||||||
www.gitara.farmeris.sk {
|
eshop.example.com {
|
||||||
redir https://gitara.farmeris.sk{uri} permanent
|
redir https://eshop.example.com{uri} permanent
|
||||||
}
|
}
|
||||||
|
|||||||
1850
Cargo.lock
generated
1850
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
33
Cargo.toml
@@ -1,11 +1,11 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "gitara_web"
|
name = "kompress_eshop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
publish = false
|
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
|
# 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 }
|
loco-rs = { workspace = true }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1" }
|
serde_json = { version = "1" }
|
||||||
tokio = { version = "1.45", default-features = false, features = [
|
tokio = { version = "1.52", default-features = false, features = [
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
] }
|
] }
|
||||||
async-trait = { version = "0.1" }
|
async-trait = { version = "0.1" }
|
||||||
axum = { version = "0.8", features = ["multipart"] }
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
tracing = { version = "0.1" }
|
tracing = { version = "0.1" }
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
regex = { version = "1.11" }
|
regex = { version = "1.12" }
|
||||||
migration = { path = "migration" }
|
migration = { path = "migration" }
|
||||||
sea-orm = { version = "1.1", features = [
|
sea-orm = { version = "1.1", features = [
|
||||||
"sqlx-sqlite",
|
"sqlx-sqlite",
|
||||||
@@ -35,22 +35,37 @@ chrono = { version = "0.4" }
|
|||||||
time = { version = "0.3" }
|
time = { version = "0.3" }
|
||||||
dotenvy = { version = "0.15" }
|
dotenvy = { version = "0.15" }
|
||||||
validator = { version = "0.20" }
|
validator = { version = "0.20" }
|
||||||
uuid = { version = "1.6", features = ["v4"] }
|
uuid = { version = "1.23", features = ["v4"] }
|
||||||
include_dir = { version = "0.7" }
|
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
|
# view engine i18n
|
||||||
fluent-templates = { version = "0.13", features = ["tera"] }
|
fluent-templates = { version = "0.13", features = ["tera"] }
|
||||||
unic-langid = { version = "0.9" }
|
unic-langid = { version = "0.9" }
|
||||||
# /view engine
|
# /view engine
|
||||||
axum-extra = { version = "0.10", features = ["form"] }
|
axum-extra = { version = "0.10", features = ["form"] }
|
||||||
bytes = { version = "1" }
|
bytes = { version = "1" }
|
||||||
|
axum-casbin = "1.3.0"
|
||||||
|
loco-oauth2 = "0.5.0"
|
||||||
|
passwords = "3.1.16"
|
||||||
|
tower-sessions = "0.14"
|
||||||
|
# TOTP (Google Authenticator) for optional two-factor auth
|
||||||
|
totp-rs = { version = "5", features = ["qr", "gen_secret"] }
|
||||||
|
# CSRF: HMAC-signed double-submit token + body inspection for the `_csrf` field
|
||||||
|
hmac = { version = "0.12" }
|
||||||
|
sha2 = { version = "0.10" }
|
||||||
|
subtle = { version = "2.6" }
|
||||||
|
form_urlencoded = { version = "1" }
|
||||||
|
multer = { version = "3" }
|
||||||
|
futures-util = { version = "0.3" }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gitara_web-cli"
|
name = "kompress-eshop-cli"
|
||||||
path = "src/bin/main.rs"
|
path = "src/bin/main.rs"
|
||||||
required-features = []
|
required-features = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
loco-rs = { workspace = true, features = ["testing"] }
|
loco-rs = { workspace = true, features = ["testing"] }
|
||||||
serial_test = { version = "3.1.1" }
|
serial_test = { version = "3.5.0" }
|
||||||
rstest = { version = "0.25" }
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
RUN cargo build --release --bin gitara_web-cli
|
RUN cargo build --release --bin kompress-cli
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
@@ -17,9 +17,9 @@ WORKDIR /usr/app
|
|||||||
|
|
||||||
COPY --from=builder /usr/src/assets assets
|
COPY --from=builder /usr/src/assets assets
|
||||||
COPY --from=builder /usr/src/config config
|
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
|
ENV LOCO_ENV=production
|
||||||
EXPOSE 5150
|
EXPOSE 5150
|
||||||
ENTRYPOINT ["/usr/app/gitara_web-cli"]
|
ENTRYPOINT ["/usr/app/kompress-cli"]
|
||||||
CMD ["start"]
|
CMD ["start"]
|
||||||
|
|||||||
29
Makefile
29
Makefile
@@ -1,6 +1,33 @@
|
|||||||
COMPOSE = docker compose -f docker-compose.prod.yml --env-file .env.production
|
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:
|
up:
|
||||||
$(COMPOSE) up -d --build
|
$(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!
|
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-home = Home
|
||||||
nav-about = About
|
nav-about = About
|
||||||
nav-blog = Blog
|
nav-blog = Blog
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
brand = My guitar
|
brand = Kompress eshop
|
||||||
hello-world = Hello world!
|
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-home = Home
|
||||||
nav-about = About
|
nav-about = About
|
||||||
nav-blog = Blog
|
nav-blog = Blog
|
||||||
@@ -57,12 +57,31 @@ album-by = by
|
|||||||
album-play-full = Play full album
|
album-play-full = Play full album
|
||||||
album-queue-all = queue all tracks in order
|
album-queue-all = queue all tracks in order
|
||||||
album-no-tracks = no tracks yet
|
album-no-tracks = no tracks yet
|
||||||
login-title = Admin login
|
login-title = Sign in
|
||||||
login-error = Access denied - invalid email or password.
|
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-root = root
|
||||||
login-auth = Authenticate
|
login-auth = Sign in
|
||||||
login-email = Email
|
login-email = Email
|
||||||
login-password = Password
|
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
|
auth = Auth
|
||||||
admin-session = Session
|
admin-session = Session
|
||||||
readonly = readonly
|
readonly = readonly
|
||||||
@@ -172,3 +191,199 @@ track-number = Track number
|
|||||||
track-number-help = Optional - this song's position in the album track list.
|
track-number-help = Optional - this song's position in the album track list.
|
||||||
featured-help = Highlight this song on the site
|
featured-help = Highlight this song on the site
|
||||||
publish-song-now = Publish now - visitors can see it.
|
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
|
||||||
|
position-auto = added to the end
|
||||||
|
position-hint = Sort order in the menu (lowest first). Leave blank to add it last.
|
||||||
|
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
|
||||||
|
profile-company-required = For a company account, please fill in company name, IČO and DIČ.
|
||||||
|
profile-first-name = First name
|
||||||
|
profile-last-name = Surname
|
||||||
|
profile-edit = Edit profile
|
||||||
|
profile-cancel = Cancel
|
||||||
|
profile-not-set = Not set
|
||||||
|
nav-account = My account
|
||||||
|
account-orders = My orders
|
||||||
|
account-change-password = Change password
|
||||||
|
orders-active = Active orders
|
||||||
|
orders-past = Past orders
|
||||||
|
orders-empty = You don't have any orders yet.
|
||||||
|
password-change-title = Change password
|
||||||
|
password-current = Current password
|
||||||
|
password-current-wrong = Your current password is incorrect.
|
||||||
|
password-changed = Your password has been changed.
|
||||||
|
|
||||||
|
# Two-factor authentication (TOTP / Google Authenticator)
|
||||||
|
security-title = Security
|
||||||
|
security-2fa-intro = Two-factor authentication (2FA) adds a one-time code from an app like Google Authenticator to your sign-in.
|
||||||
|
security-2fa-on = 2FA is on
|
||||||
|
security-2fa-off = 2FA is off
|
||||||
|
security-2fa-enable = Enable two-factor authentication
|
||||||
|
security-2fa-scan = Scan this QR code in Google Authenticator (or any compatible app).
|
||||||
|
security-2fa-manual = Or enter the key manually:
|
||||||
|
security-2fa-enter-code = Enter the 6-digit code from the app
|
||||||
|
security-2fa-confirm = Confirm and enable
|
||||||
|
security-2fa-code-wrong = That code is wrong or expired. Please try again.
|
||||||
|
security-2fa-enroll-error = Could not start 2FA setup. Please try again.
|
||||||
|
security-2fa-enabled-ok = Two-factor authentication is enabled.
|
||||||
|
security-2fa-backup-intro = Save these backup codes somewhere safe. Each can be used once if you lose access to your app.
|
||||||
|
security-2fa-backup-remaining = Backup codes remaining
|
||||||
|
security-2fa-regenerate = Generate new backup codes
|
||||||
|
security-2fa-disable = Disable two-factor authentication
|
||||||
|
security-2fa-disable-hint = Enter your current password to confirm.
|
||||||
|
|
||||||
|
# Second login step (after password)
|
||||||
|
login-totp-title = Two-factor authentication
|
||||||
|
login-totp-intro = Enter the code from your authenticator app.
|
||||||
|
login-totp-error = That code is wrong or expired.
|
||||||
|
login-totp-code = Verification code
|
||||||
|
login-totp-submit = Verify
|
||||||
|
login-totp-backup-hint = No access to your app? Enter one of your backup codes.
|
||||||
|
|
||||||
|
account-type-locked = Account type can't be changed after registration.
|
||||||
|
checkout-create-account = Create an account from this order
|
||||||
|
checkout-create-account-hint = We'll email you a link to set your password. This order will be linked to your account.
|
||||||
|
order-account-created = We created an account for you. Check your email to set your password.
|
||||||
|
set-password-title = Set your password
|
||||||
|
set-password-intro = Choose a password to finish setting up your account.
|
||||||
|
set-password-new = New password
|
||||||
|
set-password-confirm = Confirm password
|
||||||
|
set-password-submit = Set password
|
||||||
|
set-password-invalid = This link is invalid or has expired.
|
||||||
|
set-password-weak = Password must be at least 8 characters.
|
||||||
|
set-password-mismatch = Passwords don't match.
|
||||||
|
resend-verification-title = Resend verification email
|
||||||
|
resend-verification-intro = Enter your email and we'll send a fresh verification link.
|
||||||
|
resend-verification-submit = Resend
|
||||||
|
resend-verification-done = If that email belongs to an unverified account, we've sent a new verification link. Check your inbox (and spam). You can request another in a minute.
|
||||||
|
login-resend = Didn't get the verification email? Resend it
|
||||||
|
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-delivered = Delivered
|
||||||
|
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!
|
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-home = Domov
|
||||||
nav-about = O mne
|
nav-about = O mne
|
||||||
nav-blog = Blog
|
nav-blog = Blog
|
||||||
@@ -57,12 +57,31 @@ album-by = od
|
|||||||
album-play-full = Prehrať celý album
|
album-play-full = Prehrať celý album
|
||||||
album-queue-all = zoradiť všetky skladby v poradí
|
album-queue-all = zoradiť všetky skladby v poradí
|
||||||
album-no-tracks = zatiaľ žiadne skladby
|
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 = 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-root = root
|
||||||
login-auth = Prihlásiť sa
|
login-auth = Prihlásiť sa
|
||||||
login-email = E-mail
|
login-email = E-mail
|
||||||
login-password = Heslo
|
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
|
auth = Overenie
|
||||||
admin-session = Relácia
|
admin-session = Relácia
|
||||||
readonly = iba na čítanie
|
readonly = iba na čítanie
|
||||||
@@ -172,3 +191,199 @@ track-number = Číslo skladby
|
|||||||
track-number-help = Voliteľné - pozícia skladby v zozname albumu.
|
track-number-help = Voliteľné - pozícia skladby v zozname albumu.
|
||||||
featured-help = Zvýrazniť túto skladbu na webe
|
featured-help = Zvýrazniť túto skladbu na webe
|
||||||
publish-song-now = Zverejniť teraz - návštevníci ju uvidia.
|
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
|
||||||
|
position-auto = pridá sa na koniec
|
||||||
|
position-hint = Poradie v menu (najnižšie ako prvé). Nechajte prázdne a pridá sa na koniec.
|
||||||
|
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
|
||||||
|
profile-company-required = Pri firemnom účte vyplňte názov firmy, IČO a DIČ.
|
||||||
|
profile-first-name = Meno
|
||||||
|
profile-last-name = Priezvisko
|
||||||
|
profile-edit = Upraviť profil
|
||||||
|
profile-cancel = Zrušiť
|
||||||
|
profile-not-set = Neuvedené
|
||||||
|
nav-account = Môj účet
|
||||||
|
account-orders = Moje objednávky
|
||||||
|
account-change-password = Zmeniť heslo
|
||||||
|
orders-active = Aktívne objednávky
|
||||||
|
orders-past = Staršie objednávky
|
||||||
|
orders-empty = Zatiaľ nemáte žiadne objednávky.
|
||||||
|
password-change-title = Zmeniť heslo
|
||||||
|
password-current = Súčasné heslo
|
||||||
|
password-current-wrong = Vaše súčasné heslo je nesprávne.
|
||||||
|
password-changed = Vaše heslo bolo zmenené.
|
||||||
|
|
||||||
|
# Two-factor authentication (TOTP / Google Authenticator)
|
||||||
|
security-title = Zabezpečenie
|
||||||
|
security-2fa-intro = Dvojfaktorové overenie (2FA) pridáva k prihláseniu jednorazový kód z aplikácie ako Google Authenticator.
|
||||||
|
security-2fa-on = 2FA je zapnuté
|
||||||
|
security-2fa-off = 2FA je vypnuté
|
||||||
|
security-2fa-enable = Zapnúť dvojfaktorové overenie
|
||||||
|
security-2fa-scan = Naskenujte tento QR kód v aplikácii Google Authenticator (alebo inej kompatibilnej).
|
||||||
|
security-2fa-manual = Alebo zadajte kľúč ručne:
|
||||||
|
security-2fa-enter-code = Zadajte 6-miestny kód z aplikácie
|
||||||
|
security-2fa-confirm = Potvrdiť a zapnúť
|
||||||
|
security-2fa-code-wrong = Kód je nesprávny alebo vypršal. Skúste to znova.
|
||||||
|
security-2fa-enroll-error = Nepodarilo sa pripraviť 2FA. Skúste to znova.
|
||||||
|
security-2fa-enabled-ok = Dvojfaktorové overenie je zapnuté.
|
||||||
|
security-2fa-backup-intro = Uložte si tieto záložné kódy na bezpečné miesto. Každý sa dá použiť iba raz, ak nemáte prístup k aplikácii.
|
||||||
|
security-2fa-backup-remaining = Zostávajúce záložné kódy
|
||||||
|
security-2fa-regenerate = Vygenerovať nové záložné kódy
|
||||||
|
security-2fa-disable = Vypnúť dvojfaktorové overenie
|
||||||
|
security-2fa-disable-hint = Na potvrdenie zadajte svoje súčasné heslo.
|
||||||
|
|
||||||
|
# Second login step (after password)
|
||||||
|
login-totp-title = Dvojfaktorové overenie
|
||||||
|
login-totp-intro = Zadajte kód z vašej autentifikačnej aplikácie.
|
||||||
|
login-totp-error = Kód je nesprávny alebo vypršal.
|
||||||
|
login-totp-code = Overovací kód
|
||||||
|
login-totp-submit = Overiť
|
||||||
|
login-totp-backup-hint = Nemáte prístup k aplikácii? Zadajte jeden zo svojich záložných kódov.
|
||||||
|
|
||||||
|
account-type-locked = Typ účtu sa po registrácii nedá zmeniť.
|
||||||
|
checkout-create-account = Vytvoriť účet z tejto objednávky
|
||||||
|
checkout-create-account-hint = Pošleme vám e-mail na nastavenie hesla. Objednávka sa priradí k vášmu účtu.
|
||||||
|
order-account-created = Vytvorili sme vám účet. Skontrolujte si e-mail a nastavte si heslo.
|
||||||
|
set-password-title = Nastavte si heslo
|
||||||
|
set-password-intro = Zvoľte si heslo a dokončite vytvorenie účtu.
|
||||||
|
set-password-new = Nové heslo
|
||||||
|
set-password-confirm = Potvrďte heslo
|
||||||
|
set-password-submit = Nastaviť heslo
|
||||||
|
set-password-invalid = Odkaz je neplatný alebo vypršal.
|
||||||
|
set-password-weak = Heslo musí mať aspoň 8 znakov.
|
||||||
|
set-password-mismatch = Heslá sa nezhodujú.
|
||||||
|
resend-verification-title = Znova odoslať overovací e-mail
|
||||||
|
resend-verification-intro = Zadajte svoj e-mail a pošleme vám nový overovací odkaz.
|
||||||
|
resend-verification-submit = Odoslať znova
|
||||||
|
resend-verification-done = Ak k tomuto e-mailu patrí neoverený účet, poslali sme naň nový overovací odkaz. Skontrolujte si schránku aj priečinok so spamom. Ďalšiu žiadosť môžete odoslať o minútu.
|
||||||
|
login-resend = Nedostali ste overovací e-mail? Poslať znova
|
||||||
|
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-delivered = Doručené
|
||||||
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
15
assets/static/vendor/alpine/alpine-focus-3.14.9.min.js
vendored
Normal file
15
assets/static/vendor/alpine/alpine-focus-3.14.9.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
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
76
assets/views/account/order_detail.html
Normal file
76
assets/views/account/order_detail.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ order.order_number }}{% endblock title %}
|
||||||
|
|
||||||
|
{% macro status_badge(status) %}
|
||||||
|
{% if status == "delivered" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="success") }}
|
||||||
|
{% elif status == "shipped" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% elif status == "paid" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="info") }}
|
||||||
|
{% elif status == "cancelled" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="danger") }}
|
||||||
|
{% else %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="warning") }}
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro status_badge %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
|
<a href="/account/orders" class="inline-flex items-center gap-1 text-sm text-primary underline-offset-2 hover:underline dark:text-primary-dark">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" /></svg>
|
||||||
|
{{ t(key="account-orders", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{{ self::status_badge(status=order.status) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ order.created_at | truncate(length=10, end="") }}</p>
|
||||||
|
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<ul class="space-y-2 pb-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.tracking_number %}
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-4 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-tracking", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="font-mono font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.tracking_number }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-6 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.customer_name }}</p>
|
||||||
|
{% if order.address %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}</p>{% endif %}
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if order.payment_method == "bank_transfer" and order.status == "pending" %}
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
52
assets/views/account/orders.html
Normal file
52
assets/views/account/orders.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="account-orders", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{# status → badge variant #}
|
||||||
|
{% macro status_badge(status) %}
|
||||||
|
{% if status == "delivered" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="success") }}
|
||||||
|
{% elif status == "shipped" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% elif status == "paid" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="info") }}
|
||||||
|
{% elif status == "cancelled" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="danger") }}
|
||||||
|
{% else %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="warning") }}
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro status_badge %}
|
||||||
|
|
||||||
|
{% macro order_row(order) %}
|
||||||
|
<a href="/account/orders/{{ order.order_number }}"
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface p-4 transition hover:border-primary hover:bg-primary/5 dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-mono text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</p>
|
||||||
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.created_at | truncate(length=10, end="") }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{{ self::status_badge(status=order.status) }}
|
||||||
|
<span class="tabular-nums text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.total }} {{ order.currency }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endmacro order_row %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-3xl space-y-8">
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
{% if active_orders | length == 0 and past_orders | length == 0 %}
|
||||||
|
<p class="rounded-radius border border-outline bg-surface p-6 text-sm text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">{{ t(key="orders-empty", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if active_orders | length > 0 %}
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="orders-active", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
{% for order in active_orders %}{{ self::order_row(order=order) }}{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if past_orders | length > 0 %}
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="orders-past", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
{% for order in past_orders %}{{ self::order_row(order=order) }}{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
44
assets/views/account/password.html
Normal file
44
assets/views/account/password.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="password-change-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-md">
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="password-change-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
{% if changed %}
|
||||||
|
<div class="mt-4 rounded-radius border border-success bg-success/10 px-4 py-3 text-sm text-success" role="status">
|
||||||
|
{{ t(key="password-changed", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if error == "current" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="password-current-wrong", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% elif error == "mismatch" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-mismatch", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% elif error == "weak" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-weak", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/account/password" hx-boost="false" class="mt-6 flex flex-col gap-4"
|
||||||
|
x-data="{ password: '', confirm: '' }">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="current_password" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="password-current", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="current_password", id="current_password", type="password", required=true, autocomplete="current-password") }}
|
||||||
|
</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="set-password-new", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password", attrs='x-model="password"') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="password_confirm" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="set-password-confirm", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="password_confirm", id="password_confirm", type="password", required=true, autocomplete="new-password", attrs='x-model="confirm"') }}
|
||||||
|
<span x-cloak x-show="confirm.length > 0 && password !== confirm" class="text-xs text-danger dark:text-danger">
|
||||||
|
{{ t(key="set-password-mismatch", lang=lang | default(value='sk')) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="password-change-title", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full", attrs=':disabled="password !== confirm"') }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
229
assets/views/account/profile.html
Normal file
229
assets/views/account/profile.html
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="profile-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% macro field(label, value) %}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ label }}</label>
|
||||||
|
{% if value %}
|
||||||
|
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">{{ value }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm italic text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="profile-not-set", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro field %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-2xl" x-data="{ editing: {% if error %}true{% else %}false{% endif %} }">
|
||||||
|
<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 %}
|
||||||
|
{% if error %}
|
||||||
|
{{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- read-only view (default) -->
|
||||||
|
<div x-show="!editing" class="mt-6 space-y-6">
|
||||||
|
<fieldset class="space-y-2 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="flex items-center gap-2">
|
||||||
|
{% if account_type == "company" %}
|
||||||
|
{{ ui::badge(label=t(key="account-company", lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% else %}
|
||||||
|
{{ ui::badge(label=t(key="account-personal", lang=lang | default(value='sk')), variant="neutral") }}
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-type-locked", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% if account_type == "company" %}
|
||||||
|
<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="account-company-details", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
{{ self::field(label=t(key="company-name", lang=lang | default(value='sk')), value=company_name) }}
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
{{ self::field(label=t(key="company-ico", lang=lang | default(value='sk')), value=company_id) }}
|
||||||
|
{{ self::field(label=t(key="company-dic", lang=lang | default(value='sk')), value=tax_id) }}
|
||||||
|
{{ self::field(label=t(key="company-icdph", lang=lang | default(value='sk')), value=vat_id) }}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{{ self::field(label=t(key="checkout-name", lang=lang | default(value='sk')), value=name) }}
|
||||||
|
{{ self::field(label=t(key="checkout-email", lang=lang | default(value='sk')), value=email) }}
|
||||||
|
{% if phone %}
|
||||||
|
{% set phone_full = phone_prefix | default(value='') %}
|
||||||
|
{% set phone_full = phone_full ~ ' ' ~ phone %}
|
||||||
|
{{ self::field(label=t(key="checkout-phone", lang=lang | default(value='sk')), value=phone_full) }}
|
||||||
|
{% else %}
|
||||||
|
{{ self::field(label=t(key="checkout-phone", lang=lang | default(value='sk')), value='') }}
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{{ self::field(label=t(key="checkout-address", lang=lang | default(value='sk')), value=address) }}
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
{{ self::field(label=t(key="checkout-city", lang=lang | default(value='sk')), value=city) }}
|
||||||
|
{{ self::field(label=t(key="checkout-zip", lang=lang | default(value='sk')), value=zip) }}
|
||||||
|
{{ self::field(label=t(key="checkout-country", lang=lang | default(value='sk')), value=country) }}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{{ ui::button(label=t(key="profile-edit", lang=lang | default(value='sk')), type="button", size="px-6 py-2.5 text-sm", attrs='@click="editing = true"') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- edit form -->
|
||||||
|
<form x-show="editing" x-cloak method="post" action="/account/profile" hx-boost="false" class="mt-6 space-y-6">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<!-- account type is fixed at registration and shown read-only -->
|
||||||
|
<fieldset class="space-y-2 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="flex items-center gap-2">
|
||||||
|
{% if account_type == "company" %}
|
||||||
|
{{ ui::badge(label=t(key="account-company", lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% else %}
|
||||||
|
{{ ui::badge(label=t(key="account-personal", lang=lang | default(value='sk')), variant="neutral") }}
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-type-locked", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% if account_type == "company" %}
|
||||||
|
<!-- company billing details (company accounts only) -->
|
||||||
|
<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="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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 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="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="first_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-first-name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="first_name", id="first_name", value=first_name | default(value=''), autocomplete="given-name") }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="last_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-last-name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="last_name", id="last_name", value=last_name | default(value=''), autocomplete="family-name") }}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{{ ui::button(label=t(key="profile-save", lang=lang | default(value='sk')), type="submit", size="px-6 py-2.5 text-sm") }}
|
||||||
|
{{ ui::button(label=t(key="profile-cancel", lang=lang | default(value='sk')), type="button", variant="outline-secondary", size="px-6 py-2.5 text-sm", attrs='@click="editing = false"') }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
84
assets/views/account/security.html
Normal file
84
assets/views/account/security.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="security-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-md">
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="security-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="mt-2 text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-intro", lang=lang | default(value='sk')) }}</p>
|
||||||
|
|
||||||
|
{% if error == "password" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="password-current-wrong", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% elif error == "code" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="security-2fa-code-wrong", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% elif error == "enroll" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="security-2fa-enroll-error", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# --- One-time backup codes, shown right after enabling / regenerating --- #}
|
||||||
|
{% if backup_codes and backup_codes | length > 0 %}
|
||||||
|
<div class="mt-6 rounded-radius border border-success bg-success/10 px-4 py-3" role="status">
|
||||||
|
<p class="text-sm font-medium text-success">{{ t(key="security-2fa-enabled-ok", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="mt-2 text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-backup-intro", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<ul class="mt-3 grid grid-cols-2 gap-2 font-mono text-sm text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{% for code in backup_codes %}
|
||||||
|
<li class="rounded-radius bg-surface px-3 py-1.5 text-center tracking-wider dark:bg-surface-dark">{{ code }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if enrolling %}
|
||||||
|
{# --- Step 2: scan the QR and confirm a code --- #}
|
||||||
|
<div class="mt-6 flex flex-col gap-4 rounded-radius border border-outline bg-surface-alt p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<p class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-scan", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<img src="{{ qr }}" alt="TOTP QR" class="mx-auto size-48 rounded-radius bg-white p-2" />
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-xs text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-manual", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<code class="mt-1 inline-block break-all font-mono text-sm text-on-surface-strong dark:text-on-surface-dark-strong">{{ secret }}</code>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/account/security/confirm" hx-boost="false" class="flex flex-col gap-3">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<label for="code" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="security-2fa-enter-code", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="code", id="code", type="text", required=true, autocomplete="one-time-code", attrs='inputmode="numeric" pattern="[0-9]*" maxlength="6" autofocus') }}
|
||||||
|
{{ ui::button(label=t(key="security-2fa-confirm", lang=lang | default(value='sk')), type="submit", extra="w-full") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif totp_enabled %}
|
||||||
|
{# --- Enabled: status + remaining backup codes + disable / regenerate --- #}
|
||||||
|
<div class="mt-6 flex items-center gap-2">
|
||||||
|
{{ ui::badge(label=t(key="security-2fa-on", lang=lang | default(value='sk')), variant="success") }}
|
||||||
|
<span class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-backup-remaining", lang=lang | default(value='sk')) }}: {{ backup_remaining }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/account/security/backup-codes" hx-boost="false" class="mt-6 flex flex-col gap-3 rounded-radius border border-outline bg-surface-alt p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<p class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="security-2fa-regenerate", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<label for="regen_pw" class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="password-current", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="current_password", id="regen_pw", type="password", required=true, autocomplete="current-password") }}
|
||||||
|
{{ ui::button(label=t(key="security-2fa-regenerate", lang=lang | default(value='sk')), type="submit", variant="outline-secondary", extra="w-full") }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/account/security/disable" hx-boost="false" class="mt-4 flex flex-col gap-3 rounded-radius border border-danger/40 bg-danger/5 p-5">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<p class="text-sm font-medium text-danger">{{ t(key="security-2fa-disable", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-xs text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-disable-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<label for="disable_pw" class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="password-current", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="current_password", id="disable_pw", type="password", required=true, autocomplete="current-password") }}
|
||||||
|
{{ ui::button(label=t(key="security-2fa-disable", lang=lang | default(value='sk')), type="submit", variant="danger", extra="w-full") }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# --- Disabled: offer to enable --- #}
|
||||||
|
<form method="post" action="/account/security/enable" hx-boost="false" class="mt-6">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{{ ui::badge(label=t(key="security-2fa-off", lang=lang | default(value='sk')), variant="neutral") }}
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="security-2fa-enable", lang=lang | default(value='sk')), type="submit", extra="mt-4 w-full") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</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>
|
<!doctype html>
|
||||||
<html lang="{{ lang | default(value='sk') }}" data-theme="dark">
|
<html lang="{{ lang | default(value='sk') }}" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
@@ -16,139 +17,117 @@
|
|||||||
|| (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|| (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
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) {
|
function setTheme(t) {
|
||||||
localStorage.setItem('theme', t);
|
localStorage.setItem('theme', t);
|
||||||
applyTheme(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 () {
|
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 () {
|
function markActiveNav() {
|
||||||
highlightTheme(localStorage.getItem('theme') || 'dark');
|
|
||||||
var path = location.pathname;
|
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');
|
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>
|
</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 %}
|
{% 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>
|
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
|
||||||
<style>
|
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
||||||
@media (min-width: 768px) {
|
|
||||||
.nav-menu { flex-direction: row; }
|
|
||||||
}
|
|
||||||
#nav-backdrop { display: none; }
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
#nav-backdrop {
|
|
||||||
display: block;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 40;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: opacity 0.15s ease, visibility 0s linear 0.2s;
|
|
||||||
}
|
|
||||||
.term-titlebar:has(.dropdown:focus-within) ~ #nav-backdrop {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transition: opacity 0.15s ease, visibility 0s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="flex min-h-screen flex-col bg-base-100 text-base-content antialiased">
|
<body
|
||||||
<header class="term-titlebar">
|
hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'
|
||||||
<nav class="term-nav">
|
x-data="{ showSidebar: false }"
|
||||||
<a href="/admin/dashboard" class="term-brand">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a>
|
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
<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>
|
<!-- dark overlay for the open sidebar on small screens -->
|
||||||
<li><a href="/admin/blog/articles" data-nav="/admin/blog">{{ t(key="admin-blog", lang=lang | default(value='sk')) }}</a></li>
|
<div x-cloak x-show="showSidebar" x-transition.opacity aria-hidden="true"
|
||||||
<li><a href="/admin/audio/albums" data-nav="/admin/audio">{{ t(key="admin-audio", lang=lang | default(value='sk')) }}</a></li>
|
@click="showSidebar = false"
|
||||||
<li><a href="/admin/about" data-nav="/admin/about">{{ t(key="admin-about", lang=lang | default(value='sk')) }}</a></li>
|
class="fixed inset-0 z-30 bg-black/50 md:hidden"></div>
|
||||||
<li><a href="/" class="t-blue">{{ t(key="admin-exit", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li>
|
<!-- sidebar -->
|
||||||
<form method="post" action="/admin/logout">
|
<nav aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||||
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
x-bind:class="showSidebar ? 'translate-x-0' : '-translate-x-60'"
|
||||||
</form>
|
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">
|
||||||
</li>
|
|
||||||
</ul>
|
{# Sidebar nav links — adapted from the vendored Penguin UI component
|
||||||
<div class="term-nav-right">
|
penguinui-components/sidebar/simple-sidebar.html: Penguin's link
|
||||||
<div class="dropdown dropdown-end md:hidden">
|
treatment (hover:bg-primary/5, focus-visible:underline) with the active
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}">
|
state (bg-primary/10 + text-on-surface-strong) mapped onto our
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
data-nav / aria-current so markActiveNav() keeps driving it. #}
|
||||||
stroke="currentColor" class="h-5 w-5">
|
<a href="/admin/dashboard"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
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">
|
||||||
</svg>
|
{{ t(key="admin-title", lang=lang | default(value='sk')) }}
|
||||||
</div>
|
</a>
|
||||||
<ul tabindex="0"
|
|
||||||
class="menu dropdown-content z-50 mt-3 w-52 border border-base-300 bg-base-200 p-2 shadow-lg">
|
<div class="flex flex-1 flex-col gap-1 overflow-y-auto p-4">
|
||||||
<li><a href="/admin/dashboard">{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}</a></li>
|
<a href="/admin/dashboard" data-nav="/admin/dashboard"
|
||||||
<li><a href="/admin/blog/articles">{{ t(key="admin-blog", lang=lang | default(value='sk')) }}</a></li>
|
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">
|
||||||
<li><a href="/admin/audio/albums">{{ t(key="admin-audio", lang=lang | default(value='sk')) }}</a></li>
|
{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}
|
||||||
<li><a href="/admin/about">{{ t(key="admin-about", lang=lang | default(value='sk')) }}</a></li>
|
</a>
|
||||||
<li><a href="/" class="t-blue">{{ t(key="admin-exit", lang=lang | default(value='sk')) }}</a></li>
|
<a href="/admin/catalog/products" data-nav="/admin/catalog/products"
|
||||||
<li>
|
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">
|
||||||
<form method="post" action="/admin/logout">
|
{{ t(key="admin-products", lang=lang | default(value='sk')) }}
|
||||||
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
</a>
|
||||||
</form>
|
<a href="/admin/catalog/categories" data-nav="/admin/catalog/categories"
|
||||||
</li>
|
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">
|
||||||
</ul>
|
{{ t(key="admin-categories", lang=lang | default(value='sk')) }}
|
||||||
</div>
|
</a>
|
||||||
<div class="dropdown dropdown-end">
|
<a href="/admin/orders" data-nav="/admin/orders"
|
||||||
<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')) }}">
|
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">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
|
||||||
stroke="currentColor" class="h-5 w-5">
|
</a>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<a href="/admin/shipping" data-nav="/admin/shipping"
|
||||||
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" />
|
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">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
|
||||||
</svg>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/lang" hx-boost="false">
|
|
||||||
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 border border-base-300 bg-base-200 p-2 shadow-lg">
|
<div class="border-t border-outline p-4 dark:border-outline-dark">
|
||||||
<li class="menu-title">{{ t(key="settings-language", lang=lang | default(value='sk')) }}</li>
|
<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">
|
||||||
<li>
|
{{ t(key="admin-exit", lang=lang | default(value='sk')) }}
|
||||||
<button type="submit" name="lang" value="en" class="{% if lang | default(value='sk') == 'en' %}active{% endif %}">
|
</a>
|
||||||
{{ t(key="language-en", lang=lang | default(value='sk')) }}
|
<form method="post" action="/logout">
|
||||||
{% if lang | default(value='sk') == 'en' %}
|
{{ ui::csrf_field() }}
|
||||||
<span class="ml-auto">✓</span>
|
<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">
|
||||||
{% endif %}
|
{{ t(key="logout", lang=lang | default(value='sk')) }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</form>
|
||||||
<li>
|
</div>
|
||||||
<button type="submit" name="lang" value="sk" class="{% if lang | default(value='sk') == 'sk' %}active{% endif %}">
|
</nav>
|
||||||
{{ t(key="language-sk", lang=lang | default(value='sk')) }}
|
|
||||||
{% if lang | default(value='sk') == 'sk' %}
|
<!-- content column -->
|
||||||
<span class="ml-auto">✓</span>
|
<div class="flex min-h-screen flex-col md:ml-60">
|
||||||
{% endif %}
|
<header class="sticky top-0 z-20 flex h-16 items-center gap-4 border-b border-outline bg-surface/95 px-4 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
||||||
</button>
|
<!-- Penguin animated hamburger (bars ↔ X) in our ghost-square shell -->
|
||||||
</li>
|
<button type="button" @click="showSidebar = !showSidebar" :aria-expanded="showSidebar" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||||
<li class="menu-title">{{ t(key="settings-theme", lang=lang | default(value='sk')) }}</li>
|
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">
|
||||||
<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>
|
{{ ui::icon(name="hamburger", size="size-6", attrs='x-show="!showSidebar"') }}
|
||||||
<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>
|
{{ ui::icon(name="close", size="size-6", attrs='x-cloak x-show="showSidebar"') }}
|
||||||
<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>
|
</button>
|
||||||
</ul>
|
|
||||||
</form>
|
<span class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
</div>
|
{% block crumb %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- settings (language + theme) dropdown (self-contained Alpine state) -->
|
||||||
|
<div class="ml-auto">
|
||||||
|
{% include "partials/settings_dropdown.html" %}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</header>
|
||||||
</header>
|
|
||||||
<div id="nav-backdrop" aria-hidden="true"></div>
|
<main class="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
|
||||||
<main class="term-main">
|
{% block content %}{% endblock content %}
|
||||||
{% block content %}{% endblock content %}
|
</main>
|
||||||
</main>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 %}
|
|
||||||
65
assets/views/admin/catalog/categories.html
Normal file
65
assets/views/admin/catalog/categories.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{% 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::csrf_field() }}
|
||||||
|
{{ 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 %}
|
||||||
76
assets/views/admin/catalog/category_form.html
Normal file
76
assets/views/admin/catalog/category_form.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{% 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">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
|
{% if category %}
|
||||||
|
{% set v_name = category.name %}{% set v_pos = category.position %}{% set v_desc = category.description | default(value="") %}{% set v_pub = category.published %}
|
||||||
|
{% else %}
|
||||||
|
{% set v_name = "" %}{% set v_pos = "" %}{% 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="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>
|
||||||
|
|
||||||
|
<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')) }}
|
||||||
|
<span class="font-normal text-on-surface/60 dark:text-on-surface-dark/60">({{ t(key="field-optional", lang=lang | default(value='sk')) }})</span>
|
||||||
|
</label>
|
||||||
|
{{ ui::input(name="position", id="position", type="number", value=v_pos, placeholder=t(key='position-auto', lang=lang | default(value='sk'))) }}
|
||||||
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="position-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</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 %}
|
||||||
87
assets/views/admin/catalog/product_form.html
Normal file
87
assets/views/admin/catalog/product_form.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{% 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">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
|
{% 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_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_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="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 %}
|
||||||
75
assets/views/admin/catalog/products.html
Normal file
75
assets/views/admin/catalog/products.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{% 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::csrf_field() }}
|
||||||
|
{{ 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" %}
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
{% 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 %}
|
{% block content %}
|
||||||
<header class="term-cmd">
|
<header class="space-y-1">
|
||||||
<div>
|
<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>
|
||||||
<h1 class="term-title">{{ 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>
|
||||||
<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>
|
</header>
|
||||||
|
|
||||||
<div class="term-grid">
|
<div class="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<article class="card">
|
<a href="/admin/catalog/products"
|
||||||
<div class="term-head">
|
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">
|
||||||
<span class="term-head-name">/admin/blog</span>
|
<h2 class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h2>
|
||||||
<span class="term-head-meta term-tag">{{ t(key="manage", lang=lang | default(value='sk')) }}</span>
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-products-desc", lang=lang | default(value='sk')) }}</p>
|
||||||
</div>
|
</a>
|
||||||
<div class="card-body">
|
<a href="/admin/catalog/categories"
|
||||||
<h2 class="card-title text-base">{{ t(key="blog-title", lang=lang | default(value='sk')) }}</h2>
|
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">
|
||||||
<p class="text-sm opacity-70">{{ t(key="admin-blog-desc", lang=lang | default(value='sk')) }}</p>
|
<h2 class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-categories", lang=lang | default(value='sk')) }}</h2>
|
||||||
<div class="pt-2">
|
<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 href="/admin/blog/articles" class="btn btn-primary btn-sm">[ {{ t(key="blog-manage", lang=lang | default(value='sk')) }} → ]</a>
|
</a>
|
||||||
</div>
|
<a href="/admin/orders"
|
||||||
</div>
|
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">
|
||||||
</article>
|
<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>
|
||||||
<article class="card">
|
</a>
|
||||||
<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>
|
</div>
|
||||||
{% endblock content %}
|
{% 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 %}
|
||||||
137
assets/views/admin/orders/show.html
Normal file
137
assets/views/admin/orders/show.html
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
{% 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">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 class="font-mono text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</h1>
|
||||||
|
{% if order.status == "delivered" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="success") }}
|
||||||
|
{% elif order.status == "shipped" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% elif order.status == "paid" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="info") }}
|
||||||
|
{% elif order.status == "cancelled" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="danger") }}
|
||||||
|
{% else %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="warning") }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{{ 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')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
{% 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">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<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 %}
|
||||||
31
assets/views/admin/shipping/index.html
Normal file
31
assets/views/admin/shipping/index.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{% 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">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<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 %}
|
|
||||||
68
assets/views/auth/login.html
Normal file
68
assets/views/auth/login.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{% 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") }}
|
||||||
|
<p class="mt-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<a href="/resend-verification" class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="login-resend", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</p>
|
||||||
|
{% 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">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<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 %}
|
||||||
48
assets/views/auth/login_totp.html
Normal file
48
assets/views/auth/login_totp.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="login-totp-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-totp-title", lang=lang | default(value='sk')) }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
{{ t(key="login-totp-intro", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
{{ ui::alert_danger(message=t(key="login-totp-error", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/login/totp" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="code"
|
||||||
|
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="login-totp-code", lang=lang | default(value='sk')) }}
|
||||||
|
</label>
|
||||||
|
{{ ui::input(name="code", id="code", type="text", required=true, autocomplete="one-time-code", attrs='inputmode="numeric" autofocus') }}
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="login-totp-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-4 text-xs text-on-surface dark:text-on-surface-dark">
|
||||||
|
{{ t(key="login-totp-backup-hint", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
97
assets/views/auth/register.html
Normal file
97
assets/views/auth/register.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{% 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 == "mismatch" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-mismatch", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
{% elif error == "weak" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-weak", 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"
|
||||||
|
x-data="{ password: '', confirm: '' }">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-type", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 rounded-radius border border-outline px-3 py-2 text-sm transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
||||||
|
{{ ui::radio(name="account_type", value="personal", checked=true) }}
|
||||||
|
<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-2 rounded-radius border border-outline px-3 py-2 text-sm transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
||||||
|
{{ ui::radio(name="account_type", value="company") }}
|
||||||
|
<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>
|
||||||
|
<span class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-type-locked", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</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", 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="new-password", attrs='x-model="password"') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="password_confirm"
|
||||||
|
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="set-password-confirm", lang=lang | default(value='sk')) }}
|
||||||
|
</label>
|
||||||
|
{{ ui::input(name="password_confirm", id="password_confirm", type="password", required=true, autocomplete="new-password", attrs='x-model="confirm"') }}
|
||||||
|
<span x-cloak x-show="confirm.length > 0 && password !== confirm"
|
||||||
|
class="text-xs text-danger dark:text-danger">
|
||||||
|
{{ t(key="set-password-mismatch", lang=lang | default(value='sk')) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ ui::button(label=t(key="register-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full", attrs=':disabled="password !== confirm"') }}
|
||||||
|
</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 %}
|
||||||
38
assets/views/auth/resend_verification.html
Normal file
38
assets/views/auth/resend_verification.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="resend-verification-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="resend-verification-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
{% if done %}
|
||||||
|
<div class="mt-3 rounded-radius border border-success bg-success/10 px-4 py-3 text-sm text-success" role="status">
|
||||||
|
{{ t(key="resend-verification-done", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<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>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-1 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="resend-verification-intro", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<form method="post" action="/resend-verification" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<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>
|
||||||
|
{{ ui::button(label=t(key="resend-verification-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
48
assets/views/auth/set_password.html
Normal file
48
assets/views/auth/set_password.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="set-password-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="set-password-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
{% if not valid %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-invalid", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<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>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-1 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="set-password-intro", lang=lang | default(value='sk')) }}</p>
|
||||||
|
|
||||||
|
{% if error == "mismatch" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-mismatch", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
{% elif error == "weak" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-weak", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/set-password" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<input type="hidden" name="token" value="{{ token }}">
|
||||||
|
<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="set-password-new", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password", attrs="autofocus") }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="password_confirm" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="set-password-confirm", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="password_confirm", id="password_confirm", type="password", required=true, autocomplete="new-password") }}
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="set-password-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</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>
|
<!doctype html>
|
||||||
<html lang="{{ lang | default(value='sk') }}" data-theme="dark">
|
<html lang="{{ lang | default(value='sk') }}" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
@@ -11,356 +12,361 @@
|
|||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png">
|
||||||
<link rel="manifest" href="/static/favicon/site.webmanifest">
|
<link rel="manifest" href="/static/favicon/site.webmanifest">
|
||||||
<script>
|
<script>
|
||||||
|
// Apply the saved theme before first paint to avoid a flash.
|
||||||
function applyTheme(t) {
|
function applyTheme(t) {
|
||||||
var dark = t === 'dark'
|
var dark = t === 'dark'
|
||||||
|| (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|| (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
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) {
|
function setTheme(t) {
|
||||||
localStorage.setItem('theme', t);
|
localStorage.setItem('theme', t);
|
||||||
applyTheme(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 () {
|
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() {
|
function markActiveNav() {
|
||||||
var path = location.pathname;
|
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');
|
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() {
|
document.addEventListener('DOMContentLoaded', markActiveNav);
|
||||||
highlightTheme(localStorage.getItem('theme') || 'dark');
|
document.addEventListener('htmx:afterSwap', markActiveNav);
|
||||||
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 ----------
|
// True while any other navbar menu (profile / settings / mobile / category
|
||||||
// Survives htmx-boosted navigation: window state persists and
|
// toggle) is open — those triggers expose aria-expanded="true". Used to
|
||||||
// #uw-player carries hx-preserve so <audio> keeps playing.
|
// suppress the cart hover preview so menus don't stack/overlap.
|
||||||
var uwQueue = []; // [{ src, title }]
|
function anyMenuOpen() {
|
||||||
var uwIndex = -1; // index of the current track, -1 when empty
|
return !!document.querySelector('header [aria-expanded="true"]');
|
||||||
|
|
||||||
function uwSave() {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem('uwQueue', JSON.stringify({ q: uwQueue, i: uwIndex }));
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
function uwRestore() {
|
// Show a floating toast notification. Usage: toast('Saved').
|
||||||
try {
|
// Bridges to the vendored Penguin UI toast component, which listens for a
|
||||||
var d = JSON.parse(sessionStorage.getItem('uwQueue') || 'null');
|
// `notify` event with { variant, title, message }.
|
||||||
if (d && d.q) { uwQueue = d.q; uwIndex = (typeof d.i === 'number' ? d.i : -1); }
|
function toast(message) {
|
||||||
} catch (e) {}
|
window.dispatchEvent(new CustomEvent('notify', { detail: { variant: 'success', message: message } }));
|
||||||
}
|
}
|
||||||
function uwRenderQueue() {
|
|
||||||
var player = document.getElementById('uw-player');
|
|
||||||
if (player) player.classList.toggle('uw-has-queue', uwQueue.length > 0);
|
|
||||||
var badge = document.getElementById('uw-queue-badge');
|
|
||||||
if (badge) badge.textContent = uwQueue.length;
|
|
||||||
var count = document.getElementById('uw-queue-count');
|
|
||||||
if (count) count.textContent = uwQueue.length + (uwQueue.length === 1 ? ' track' : ' tracks');
|
|
||||||
var list = document.getElementById('uw-queue-list');
|
|
||||||
if (!list) return;
|
|
||||||
list.innerHTML = '';
|
|
||||||
uwQueue.forEach(function (t, idx) {
|
|
||||||
var li = document.createElement('li');
|
|
||||||
li.className = 'uw-queue-item' + (idx === uwIndex ? ' is-current' : '');
|
|
||||||
var jump = document.createElement('button');
|
|
||||||
jump.type = 'button';
|
|
||||||
jump.className = 'uw-queue-jump';
|
|
||||||
jump.setAttribute('data-uw-jump', idx);
|
|
||||||
jump.textContent = (idx === uwIndex ? '▸' : (idx + 1));
|
|
||||||
var name = document.createElement('span');
|
|
||||||
name.className = 'uw-queue-name';
|
|
||||||
name.setAttribute('data-uw-jump', idx);
|
|
||||||
name.textContent = t.title || 'unknown track';
|
|
||||||
var rm = document.createElement('button');
|
|
||||||
rm.type = 'button';
|
|
||||||
rm.className = 'uw-queue-remove';
|
|
||||||
rm.setAttribute('data-uw-remove', idx);
|
|
||||||
rm.setAttribute('aria-label', 'Remove from playlist');
|
|
||||||
rm.textContent = '✕';
|
|
||||||
li.appendChild(jump);
|
|
||||||
li.appendChild(name);
|
|
||||||
li.appendChild(rm);
|
|
||||||
list.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Point <audio> at the current queue entry; play it when asked.
|
|
||||||
function uwLoad(autoplay) {
|
|
||||||
var audio = document.getElementById('uw-audio');
|
|
||||||
var now = document.getElementById('uw-now');
|
|
||||||
if (!audio) return;
|
|
||||||
var t = uwQueue[uwIndex];
|
|
||||||
if (!t) {
|
|
||||||
if (now) now.textContent = '—';
|
|
||||||
audio.pause();
|
|
||||||
audio.removeAttribute('src');
|
|
||||||
document.documentElement.classList.remove('uw-playing');
|
|
||||||
uwRenderQueue();
|
|
||||||
uwSave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.documentElement.classList.add('uw-playing');
|
|
||||||
if (now) now.textContent = t.title || 'unknown track';
|
|
||||||
if (audio.getAttribute('src') !== t.src) {
|
|
||||||
audio.setAttribute('src', t.src);
|
|
||||||
audio.load();
|
|
||||||
}
|
|
||||||
if (autoplay) {
|
|
||||||
var p = audio.play();
|
|
||||||
if (p && p.catch) p.catch(function () {});
|
|
||||||
}
|
|
||||||
uwRenderQueue();
|
|
||||||
uwSave();
|
|
||||||
}
|
|
||||||
// Replace the whole queue with a fresh set of tracks and play.
|
|
||||||
function uwPlayList(tracks) {
|
|
||||||
if (!tracks || !tracks.length) return;
|
|
||||||
uwQueue = tracks.slice();
|
|
||||||
uwIndex = 0;
|
|
||||||
uwLoad(true);
|
|
||||||
}
|
|
||||||
// Add one track: play it now if idle, otherwise queue it.
|
|
||||||
function uwAdd(src, title) {
|
|
||||||
var audio = document.getElementById('uw-audio');
|
|
||||||
var idle = uwIndex < 0 || !audio || audio.ended || !audio.getAttribute('src');
|
|
||||||
uwQueue.push({ src: src, title: title });
|
|
||||||
if (idle) { uwIndex = uwQueue.length - 1; uwLoad(true); }
|
|
||||||
else { uwRenderQueue(); uwSave(); }
|
|
||||||
}
|
|
||||||
function uwNext() {
|
|
||||||
if (uwIndex >= 0 && uwIndex < uwQueue.length - 1) { uwIndex++; uwLoad(true); }
|
|
||||||
}
|
|
||||||
function uwPrev() {
|
|
||||||
var audio = document.getElementById('uw-audio');
|
|
||||||
if (audio && audio.currentTime > 3) { audio.currentTime = 0; return; }
|
|
||||||
if (uwIndex > 0) { uwIndex--; uwLoad(true); }
|
|
||||||
else if (audio) audio.currentTime = 0;
|
|
||||||
}
|
|
||||||
function uwJump(idx) {
|
|
||||||
if (idx >= 0 && idx < uwQueue.length) { uwIndex = idx; uwLoad(true); }
|
|
||||||
}
|
|
||||||
function uwRemove(idx) {
|
|
||||||
if (idx < 0 || idx >= uwQueue.length) return;
|
|
||||||
var playing = document.documentElement.classList.contains('uw-playing');
|
|
||||||
uwQueue.splice(idx, 1);
|
|
||||||
if (idx < uwIndex) { uwIndex--; uwRenderQueue(); uwSave(); }
|
|
||||||
else if (idx > uwIndex) { uwRenderQueue(); uwSave(); }
|
|
||||||
else {
|
|
||||||
if (uwIndex >= uwQueue.length) uwIndex = uwQueue.length - 1;
|
|
||||||
uwLoad(playing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function uwClear() {
|
|
||||||
uwQueue = [];
|
|
||||||
uwIndex = -1;
|
|
||||||
uwLoad(false);
|
|
||||||
var panel = document.getElementById('uw-queue');
|
|
||||||
if (panel) panel.hidden = true;
|
|
||||||
}
|
|
||||||
function uwInit() {
|
|
||||||
var audio = document.getElementById('uw-audio');
|
|
||||||
if (!audio || audio.dataset.uwBound) return;
|
|
||||||
audio.dataset.uwBound = '1';
|
|
||||||
uwRestore();
|
|
||||||
audio.addEventListener('ended', uwNext);
|
|
||||||
uwRenderQueue();
|
|
||||||
if (uwIndex >= 0 && uwQueue[uwIndex]) uwLoad(false);
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', function () { initPage(); uwInit(); });
|
|
||||||
document.addEventListener('htmx:afterSwap', initPage);
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
if (!e.target.closest) return;
|
|
||||||
var albumBtn = e.target.closest('.uw-play-album');
|
|
||||||
if (albumBtn) {
|
|
||||||
var sel = albumBtn.getAttribute('data-tracks-from');
|
|
||||||
var scope = (sel && document.querySelector(sel)) || document;
|
|
||||||
var tracks = [];
|
|
||||||
scope.querySelectorAll('.uw-play').forEach(function (b) {
|
|
||||||
tracks.push({ src: b.getAttribute('data-src'), title: b.getAttribute('data-title') });
|
|
||||||
});
|
|
||||||
uwPlayList(tracks);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Play an album straight from the listing: fetch its tracks first.
|
|
||||||
var remoteBtn = e.target.closest('.uw-play-album-remote');
|
|
||||||
if (remoteBtn) {
|
|
||||||
var rurl = remoteBtn.getAttribute('data-album-tracks-url');
|
|
||||||
if (rurl) {
|
|
||||||
remoteBtn.disabled = true;
|
|
||||||
fetch(rurl, { headers: { 'Accept': 'application/json' } })
|
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (d) { if (d && d.tracks) uwPlayList(d.tracks); })
|
|
||||||
.catch(function () {})
|
|
||||||
.then(function () { remoteBtn.disabled = false; });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var playBtn = e.target.closest('.uw-play');
|
|
||||||
if (playBtn) {
|
|
||||||
uwAdd(playBtn.getAttribute('data-src'), playBtn.getAttribute('data-title'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var jumpEl = e.target.closest('[data-uw-jump]');
|
|
||||||
if (jumpEl) { uwJump(parseInt(jumpEl.getAttribute('data-uw-jump'), 10)); return; }
|
|
||||||
var rmEl = e.target.closest('[data-uw-remove]');
|
|
||||||
if (rmEl) { uwRemove(parseInt(rmEl.getAttribute('data-uw-remove'), 10)); return; }
|
|
||||||
if (e.target.closest('#uw-next')) { uwNext(); return; }
|
|
||||||
if (e.target.closest('#uw-prev')) { uwPrev(); return; }
|
|
||||||
if (e.target.closest('#uw-queue-clear')) { uwClear(); return; }
|
|
||||||
if (e.target.closest('#uw-queue-toggle')) {
|
|
||||||
var panel = document.getElementById('uw-queue');
|
|
||||||
if (panel) panel.hidden = !panel.hidden;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.target.closest('#uw-close')) { uwClear(); return; }
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
<link href="/static/css/app.css?v=2026-05-20b" rel="stylesheet" type="text/css">
|
<link href="/static/css/app.css?v=2026-06-16" rel="stylesheet" type="text/css">
|
||||||
<link href="/static/css/theme.css?v=2026-05-20b" rel="stylesheet" type="text/css">
|
|
||||||
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
|
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
|
||||||
<style>
|
<!-- Alpine Focus plugin (x-trap / $focus) — must load before Alpine core;
|
||||||
@media (min-width: 768px) {
|
required by the Penguin UI keyboard-accessible dropdowns. -->
|
||||||
.nav-menu { flex-direction: row; }
|
<script defer src="/static/vendor/alpine/alpine-focus-3.14.9.min.js"></script>
|
||||||
}
|
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
||||||
#nav-backdrop { display: none; }
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
#nav-backdrop {
|
|
||||||
display: block;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 40;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: opacity 0.15s ease, visibility 0s linear 0.2s;
|
|
||||||
}
|
|
||||||
.term-titlebar:has(.dropdown:focus-within) ~ #nav-backdrop {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transition: opacity 0.15s ease, visibility 0s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body hx-boost="true" class="flex min-h-screen flex-col bg-base-100 text-base-content antialiased">
|
<body hx-boost="true"
|
||||||
<header class="term-titlebar">
|
hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'
|
||||||
<nav class="term-nav">
|
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }"
|
||||||
<a href="/" class="term-brand">{{ t(key="brand", lang=lang | default(value='sk')) }}</a>
|
x-init="window.matchMedia('(min-width: 1024px)').addEventListener('change', e => lg = e.matches)"
|
||||||
<ul class="nav-menu term-navlinks menu menu-sm hidden items-center md:flex">
|
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
<li><a href="/" data-nav="/">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
|
<header
|
||||||
<li><a href="/blog" data-nav="/blog">{{ t(key="nav-blog", lang=lang | default(value='sk')) }}</a></li>
|
class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
||||||
<li><a href="/audio/albums" data-nav="/audio/albums">{{ t(key="nav-audio", lang=lang | default(value='sk')) }}</a></li>
|
<nav x-data="{ mobile: false }" class="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3">
|
||||||
<li><a href="/audio/tracks" data-nav="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li>
|
<!-- category sidebar toggle (mobile only) -->
|
||||||
<li><a href="/about" data-nav="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li>
|
{% 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 %}
|
{% 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>
|
<li>
|
||||||
<form method="post" action="/admin/logout" hx-boost="false">
|
<form method="post" action="/logout" hx-boost="false">
|
||||||
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
{{ ui::csrf_field() }}
|
||||||
|
<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 %}
|
||||||
|
{# customer account links live in the profile dropdown next to the cart #}
|
||||||
|
{% else %}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- right side: cart + settings + mobile toggle -->
|
||||||
|
<div class="ml-auto flex items-center gap-3">
|
||||||
|
<!-- customer profile dropdown (avatar + name + account type) -->
|
||||||
|
{% if logged_in_customer %}
|
||||||
|
{% include "partials/profile_menu.html" %}
|
||||||
|
{% endif %}
|
||||||
|
<!-- cart: hover opens an Alza-style mini-cart preview (Penguin
|
||||||
|
dropdown-with-hover), lazy-loaded from /partials/cart on each hover
|
||||||
|
so it's always fresh. Click still does a full navigation to /cart
|
||||||
|
(hx-boost=false; the explicit hx-trigger is mouseenter, so click is
|
||||||
|
not an htmx trigger). The badge reads the `cart` cookie client-side. -->
|
||||||
|
<div x-data="{ isOpen: false, leaveTimeout: null }"
|
||||||
|
x-on:mouseleave="leaveTimeout = setTimeout(() => isOpen = false, 250)"
|
||||||
|
x-on:mouseenter="leaveTimeout && clearTimeout(leaveTimeout)"
|
||||||
|
x-on:keydown.esc.window="isOpen = false"
|
||||||
|
class="relative">
|
||||||
|
<a href="/cart" data-nav="/cart" hx-boost="false"
|
||||||
|
x-on:mouseenter="if (!anyMenuOpen()) isOpen = true"
|
||||||
|
x-data="{ count: 0 }"
|
||||||
|
x-init="count = cartCount(); ['htmx:afterSwap', 'htmx:afterRequest'].forEach(function (e) { window.addEventListener(e, function () { count = cartCount() }) })"
|
||||||
|
hx-get="/partials/cart" hx-trigger="mouseenter delay:150ms" hx-target="#cart-preview-body" hx-swap="innerHTML"
|
||||||
|
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>
|
||||||
|
<!-- hover preview panel (no id on the panel → not htmx-settled on boosted nav) -->
|
||||||
|
<div x-cloak x-show="isOpen" x-transition
|
||||||
|
x-on:mouseenter="isOpen = true"
|
||||||
|
class="absolute right-0 mt-2 w-80 overflow-hidden rounded-radius border border-outline bg-surface-alt shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt"
|
||||||
|
role="dialog" aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}">
|
||||||
|
<div id="cart-preview-body">
|
||||||
|
<div class="px-4 py-10 text-center text-sm text-on-surface dark:text-on-surface-dark">…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- settings (language + theme) dropdown (self-contained Alpine state) -->
|
||||||
|
{% include "partials/settings_dropdown.html" %}
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<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">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<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>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a href="/admin/login" data-nav="/admin/login">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li>
|
<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 %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="term-nav-right">
|
|
||||||
<div class="dropdown dropdown-end md:hidden">
|
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
|
||||||
stroke="currentColor" class="h-5 w-5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<ul tabindex="0"
|
|
||||||
class="menu dropdown-content z-50 mt-3 w-52 border border-base-300 bg-base-200 p-2 shadow-lg">
|
|
||||||
<li><a href="/">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li><a href="/blog">{{ t(key="nav-blog", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li><a href="/audio/albums">{{ t(key="nav-audio", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li><a href="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li><a href="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
{% if logged_in_admin %}
|
|
||||||
<li><a href="/admin/dashboard" hx-boost="false" class="t-yellow">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/admin/logout">
|
|
||||||
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{% else %}
|
|
||||||
<li><a href="/admin/login">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown dropdown-end">
|
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}" title="{{ t(key='settings', lang=lang | default(value='sk')) }}">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
|
||||||
stroke="currentColor" class="h-5 w-5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/lang" hx-boost="false">
|
|
||||||
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 border border-base-300 bg-base-200 p-2 shadow-lg">
|
|
||||||
<li class="menu-title">{{ t(key="settings-language", lang=lang | default(value='sk')) }}</li>
|
|
||||||
<li>
|
|
||||||
<button type="submit" name="lang" value="en" class="{% if lang | default(value='sk') == 'en' %}active{% endif %}">
|
|
||||||
English
|
|
||||||
{% if lang | default(value='sk') == 'en' %}
|
|
||||||
<span class="ml-auto">✓</span>
|
|
||||||
{% endif %}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button type="submit" name="lang" value="sk" class="{% if lang | default(value='sk') == 'sk' %}active{% endif %}">
|
|
||||||
Slovenčina
|
|
||||||
{% if lang | default(value='sk') == 'sk' %}
|
|
||||||
<span class="ml-auto">✓</span>
|
|
||||||
{% endif %}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="menu-title">{{ t(key="settings-theme", lang=lang | default(value='sk')) }}</li>
|
|
||||||
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">{{ t(key="theme-system", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden">✓</span></button></li>
|
|
||||||
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">{{ t(key="theme-light", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden">✓</span></button></li>
|
|
||||||
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">{{ t(key="theme-dark", lang=lang | default(value='sk')) }} <span class="opt-check ml-auto hidden">✓</span></button></li>
|
|
||||||
</ul>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div id="nav-backdrop" aria-hidden="true"></div>
|
|
||||||
<main class="term-main">
|
<!-- dark overlay behind the category drawer on small screens -->
|
||||||
{% block content %}{% endblock content %}
|
<div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true"
|
||||||
</main>
|
class="fixed inset-0 z-30 bg-black/50 lg:hidden"></div>
|
||||||
<div id="uw-player" hx-preserve="true">
|
|
||||||
<div id="uw-queue" class="uw-queue" hidden>
|
<div class="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
|
||||||
<div class="uw-queue-head">
|
{% if account_nav %}
|
||||||
<span class="uw-queue-title">☰ playlist</span>
|
<!-- account-area sidebar: replaces the storefront categories while the
|
||||||
<span id="uw-queue-count" class="uw-queue-meta">0 tracks</span>
|
customer is inside /account/*. -->
|
||||||
<button type="button" id="uw-queue-clear" class="uw-queue-clear">clear</button>
|
<aside x-cloak x-show="cats || lg" aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
|
||||||
|
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">
|
||||||
|
<h2 class="px-3 pb-2 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-account", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li><a href="/account/orders" data-nav="/account/orders" 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="account-orders", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
<li><a href="/account/profile" data-nav="/account/profile" 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="profile-title", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
<li><a href="/account/password" data-nav="/account/password" 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="account-change-password", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
<li><a href="/account/security" data-nav="/account/security" 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="security-title", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
</ul>
|
||||||
|
<form method="post" action="/logout" hx-boost="false" class="mt-4 border-t border-outline pt-3 dark:border-outline-dark">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<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>
|
||||||
|
</aside>
|
||||||
|
{% else %}
|
||||||
|
<!-- 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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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>
|
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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" %}
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
{% block title %}{{ t(key="home-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
{% block title %}{{ t(key="brand", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
{% block crumb %}{% endblock crumb %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if logged_in_admin %}
|
<div class="space-y-12">
|
||||||
<header class="term-cmd">
|
<!-- hero -->
|
||||||
<div>
|
<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="term-title">{{ t(key="home-title", lang=lang | default(value='sk')) }}</h1>
|
<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="term-sub">// {{ t(key="home-sub", lang=lang | default(value='sk')) }}</p>
|
<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>
|
||||||
</div>
|
<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>
|
||||||
<div class="term-cmd-actions">
|
</section>
|
||||||
<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 %}
|
<!-- featured products -->
|
||||||
<section class="mb-8">
|
{% if products | length > 0 %}
|
||||||
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-picks", lang=lang | default(value='sk')) }}</p>
|
<section class="space-y-5">
|
||||||
<div class="term-grid">
|
<div class="flex items-end justify-between">
|
||||||
{% if featured_track %}
|
<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>
|
||||||
<article class="card">
|
<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 class="term-head">
|
</div>
|
||||||
<span class="term-head-name">~/audio/tracks/{{ featured_track.slug }}</span>
|
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
<span class="term-head-meta term-tag is-green">{{ t(key="song", lang=lang | default(value='sk')) }}</span>
|
{% for product in products %}
|
||||||
</div>
|
{% include "shop/_card.html" %}
|
||||||
<div class="card-body">
|
|
||||||
<div class="term-track">
|
|
||||||
<button type="button" class="uw-play btn btn-primary btn-sm"
|
|
||||||
data-src="/audio/tracks/{{ featured_track.id }}/stream" data-title="{{ featured_track.title }}">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
|
|
||||||
<span class="term-track-name"><span class="t-green">▸</span> {{ featured_track.title }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
{% if featured_album %}
|
|
||||||
<article class="card">
|
|
||||||
<div class="term-head">
|
|
||||||
<span class="term-head-name">~/audio/{{ featured_album.slug }}/</span>
|
|
||||||
<span class="term-head-meta term-tag is-purple">{{ t(key="album", lang=lang | default(value='sk')) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if featured_album.cover_image_id %}
|
|
||||||
<img src="/images/{{ featured_album.cover_image_id }}" alt="" class="mb-3">
|
|
||||||
{% endif %}
|
|
||||||
<h2 class="card-title text-base">{{ featured_album.title }}</h2>
|
|
||||||
{% if featured_album.artist %}
|
|
||||||
<p class="text-sm t-aqua">{{ featured_album.artist }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if featured_album.description %}
|
|
||||||
<p class="term-prose text-sm opacity-80">{{ featured_album.description }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<div class="flex flex-wrap gap-2 pt-2">
|
|
||||||
<button type="button" class="uw-play-album-remote btn btn-primary btn-sm"
|
|
||||||
data-album-tracks-url="/audio/albums/{{ featured_album.slug }}/tracks">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
|
|
||||||
<a href="/audio/albums/{{ featured_album.slug }}" class="btn btn-outline btn-sm">{{ t(key="audio-open", lang=lang | default(value='sk')) }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-recent", lang=lang | default(value='sk')) }} <span class="t-dim">({{ articles | length }})</span></p>
|
|
||||||
{% if articles | length > 0 %}
|
|
||||||
<div class="term-stack">
|
|
||||||
{% for article in articles %}
|
|
||||||
<article class="card">
|
|
||||||
<div class="term-head">
|
|
||||||
<span class="term-head-name">~/blog/{{ article.slug }}.txt</span>
|
|
||||||
<span class="term-head-meta term-tag">{{ t(key="post", lang=lang | default(value='sk')) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-base">
|
|
||||||
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
|
|
||||||
</h2>
|
|
||||||
{% if article.excerpt %}
|
|
||||||
<p class="term-prose text-sm opacity-80">{{ article.excerpt }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<div class="pt-2">
|
|
||||||
<a href="/blog/{{ article.slug }}" class="btn btn-primary btn-sm">{{ t(key="blog-read", lang=lang | default(value='sk')) }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</section>
|
||||||
<div class="term-empty">
|
|
||||||
<p class="font-medium">{{ t(key="home-no-posts", lang=lang | default(value='sk')) }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</div>
|
||||||
{% else %}
|
|
||||||
<header class="term-cmd">
|
|
||||||
<div>
|
|
||||||
<h1 class="term-title">{{ t(key="home-title", lang=lang | default(value='sk')) }}</h1>
|
|
||||||
<p class="term-sub">{{ t(key="home-sub", lang=lang | default(value='sk')) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="term-cmd-actions">
|
|
||||||
<a href="/blog" class="btn btn-outline btn-sm">{{ t(key="home-all-posts", lang=lang | default(value='sk')) }}</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if featured_track or featured_album %}
|
|
||||||
<section class="mb-8">
|
|
||||||
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-picks", lang=lang | default(value='sk')) }}</p>
|
|
||||||
<div class="term-grid">
|
|
||||||
{% if featured_track %}
|
|
||||||
<article class="card">
|
|
||||||
<div class="term-head">
|
|
||||||
<span class="term-head-name">~/audio/tracks/{{ featured_track.slug }}</span>
|
|
||||||
<span class="term-head-meta term-tag is-green">{{ t(key="song", lang=lang | default(value='sk')) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="term-track">
|
|
||||||
<button type="button" class="uw-play btn btn-primary btn-sm"
|
|
||||||
data-src="/audio/tracks/{{ featured_track.id }}/stream" data-title="{{ featured_track.title }}">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
|
|
||||||
<span class="term-track-name"><span class="t-green">▸</span> {{ featured_track.title }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
{% if featured_album %}
|
|
||||||
<article class="card">
|
|
||||||
<div class="term-head">
|
|
||||||
<span class="term-head-name">~/audio/{{ featured_album.slug }}/</span>
|
|
||||||
<span class="term-head-meta term-tag is-purple">{{ t(key="album", lang=lang | default(value='sk')) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if featured_album.cover_image_id %}
|
|
||||||
<img src="/images/{{ featured_album.cover_image_id }}" alt="" class="mb-3">
|
|
||||||
{% endif %}
|
|
||||||
<h2 class="card-title text-base">{{ featured_album.title }}</h2>
|
|
||||||
{% if featured_album.artist %}
|
|
||||||
<p class="text-sm t-aqua">{{ featured_album.artist }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if featured_album.description %}
|
|
||||||
<p class="term-prose text-sm opacity-80">{{ featured_album.description }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<div class="flex flex-wrap gap-2 pt-2">
|
|
||||||
<button type="button" class="uw-play-album-remote btn btn-primary btn-sm"
|
|
||||||
data-album-tracks-url="/audio/albums/{{ featured_album.slug }}/tracks">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
|
|
||||||
<a href="/audio/albums/{{ featured_album.slug }}" class="btn btn-outline btn-sm">{{ t(key="audio-open", lang=lang | default(value='sk')) }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-recent", lang=lang | default(value='sk')) }} <span class="t-dim">({{ articles | length }})</span></p>
|
|
||||||
{% if articles | length > 0 %}
|
|
||||||
<div class="term-stack">
|
|
||||||
{% for article in articles %}
|
|
||||||
<article class="card">
|
|
||||||
<div class="term-head">
|
|
||||||
<span class="term-head-name">~/blog/{{ article.slug }}.txt</span>
|
|
||||||
<span class="term-head-meta term-tag">{{ t(key="post", lang=lang | default(value='sk')) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-base">
|
|
||||||
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
|
|
||||||
</h2>
|
|
||||||
{% if article.excerpt %}
|
|
||||||
<p class="text-sm opacity-80">{{ article.excerpt }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<div class="pt-2">
|
|
||||||
<a href="/blog/{{ article.slug }}" class="btn btn-primary btn-sm">{{ t(key="blog-read", lang=lang | default(value='sk')) }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="term-empty">
|
|
||||||
<p class="font-medium">{{ t(key="home-no-posts", lang=lang | default(value='sk')) }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
222
assets/views/macros/ui.html
Normal file
222
assets/views/macros/ui.html
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
{# 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 #}
|
||||||
|
|
||||||
|
{# CSRF hidden field for native (non-htmx) <form method="post"> submits. htmx
|
||||||
|
requests instead inherit the X-CSRF-Token header from <body hx-headers>.
|
||||||
|
`csrf_token()` is a global Tera function bound per-request by shared::csrf. #}
|
||||||
|
{% macro csrf_field() -%}
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% 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. #}
|
||||||
|
{# Required-field marker: a red asterisk appended to a field label. #}
|
||||||
|
{% macro req() -%}
|
||||||
|
<span class="ml-0.5 text-danger" aria-hidden="true">*</span>
|
||||||
|
{%- endmacro req %}
|
||||||
|
|
||||||
|
{% 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 %}
|
|
||||||
78
assets/views/partials/profile_menu.html
Normal file
78
assets/views/partials/profile_menu.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{# Customer profile dropdown in the storefront navbar.
|
||||||
|
|
||||||
|
Proper Penguin UI dropdown: behaviour is the vendored
|
||||||
|
dropdowns/dropdown-with-icons.html verbatim (isOpen / openedWithKeyboard,
|
||||||
|
x-trap + $focus keyboard nav, x-cloak x-show, @click.outside). Trigger is the
|
||||||
|
round initials avatar (avatar-with-initials.html, primary variant). Menu items
|
||||||
|
are our account links.
|
||||||
|
|
||||||
|
Needs the Alpine Focus plugin (loaded before Alpine core in base.html) for
|
||||||
|
x-trap / $focus. Self-contained Alpine state; the host only needs to place it
|
||||||
|
in the navbar flex row. The panel has NO id on purpose — an id would make htmx
|
||||||
|
hx-boost "settle" it across boosted navigations and reappear; id-less Penguin
|
||||||
|
dropdowns are unaffected. #}
|
||||||
|
|
||||||
|
{# initials from the full name, e.g. "Filip Priec" -> "FP" #}
|
||||||
|
{% set _name = customer_name | default(value='') | trim %}
|
||||||
|
{% set _parts = _name | split(pat=' ') %}
|
||||||
|
{% set _initials = _parts.0 | truncate(length=1, end='') | upper %}
|
||||||
|
{% if _parts | length > 1 %}{% set _second = _parts | last | truncate(length=1, end='') | upper %}{% set _initials = _initials ~ _second %}{% endif %}
|
||||||
|
{% if customer_account_type == "company" %}{% set _type_label = t(key="account-company", lang=lang | default(value='sk')) %}{% else %}{% set _type_label = t(key="account-personal", lang=lang | default(value='sk')) %}{% endif %}
|
||||||
|
|
||||||
|
{% set _person_icon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-5"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z" clip-rule="evenodd"/></svg>' %}
|
||||||
|
|
||||||
|
<div x-data="{ isOpen: false, openedWithKeyboard: false }"
|
||||||
|
x-on:keydown.esc.window="isOpen = false, openedWithKeyboard = false"
|
||||||
|
class="relative">
|
||||||
|
<!-- Toggle Button: round initials avatar -->
|
||||||
|
<button type="button" x-on:click="isOpen = ! isOpen"
|
||||||
|
x-on:keydown.space.prevent="openedWithKeyboard = true" x-on:keydown.enter.prevent="openedWithKeyboard = true" x-on:keydown.down.prevent="openedWithKeyboard = true"
|
||||||
|
x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"
|
||||||
|
aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
|
||||||
|
class="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-sm font-bold tracking-wider text-on-primary/90 transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90 dark:focus-visible:outline-primary-dark">
|
||||||
|
{%- if _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
||||||
|
</button>
|
||||||
|
<!-- Dropdown Menu (positioned like the settings cog: right-0 mt-2) -->
|
||||||
|
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
|
||||||
|
x-on:click.outside="isOpen = false, openedWithKeyboard = false"
|
||||||
|
x-on:keydown.down.prevent="$focus.wrap().next()" x-on:keydown.up.prevent="$focus.wrap().previous()"
|
||||||
|
class="absolute right-0 mt-2 flex w-60 min-w-48 flex-col divide-y divide-outline overflow-hidden rounded-radius border border-outline bg-surface-alt shadow-lg dark:divide-outline-dark dark:border-outline-dark dark:bg-surface-dark-alt" role="menu">
|
||||||
|
<!-- header: avatar + name + account type -->
|
||||||
|
<div class="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<span class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-base font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
|
||||||
|
{%- if _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
<div class="flex min-w-0 flex-col">
|
||||||
|
<span class="truncate text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ _name }}</span>
|
||||||
|
<p class="truncate text-xs text-on-surface dark:text-on-surface-dark">{{ _type_label }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- account links (with icons) -->
|
||||||
|
<div class="flex flex-col py-1.5">
|
||||||
|
<a href="/account/orders" data-nav="/account/orders" role="menuitem" class="flex items-center gap-2 bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
|
||||||
|
{{ ui::icon(name="cart", size="size-4", extra="shrink-0") }}
|
||||||
|
{{ t(key="account-orders", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
<a href="/account/profile" data-nav="/account/profile" role="menuitem" class="flex items-center gap-2 bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-4 shrink-0"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z" clip-rule="evenodd"/></svg>
|
||||||
|
{{ t(key="profile-title", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
<a href="/account/password" data-nav="/account/password" role="menuitem" class="flex items-center gap-2 bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-4 shrink-0"><path fill-rule="evenodd" d="M15.75 1.5a6.75 6.75 0 00-6.651 7.906c.067.39-.032.717-.221.906l-6.5 6.499a3 3 0 00-.878 2.121v2.818c0 .414.336.75.75.75H6a.75.75 0 00.75-.75v-1.5h1.5A.75.75 0 009 21v-1.5h1.5a.75.75 0 00.53-.22l2.658-2.658c.19-.189.517-.288.906-.22A6.75 6.75 0 1015.75 1.5zm0 3a.75.75 0 000 1.5A2.25 2.25 0 0118 8.25a.75.75 0 001.5 0 3.75 3.75 0 00-3.75-3.75z" clip-rule="evenodd"/></svg>
|
||||||
|
{{ t(key="account-change-password", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
<a href="/account/security" data-nav="/account/security" role="menuitem" class="flex items-center gap-2 bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-4 shrink-0"><path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 00-5.25 5.25v3a3 3 0 00-3 3v6.75a3 3 0 003 3h10.5a3 3 0 003-3v-6.75a3 3 0 00-3-3v-3c0-2.9-2.35-5.25-5.25-5.25zm3.75 8.25v-3a3.75 3.75 0 10-7.5 0v3h7.5z" clip-rule="evenodd"/></svg>
|
||||||
|
{{ t(key="security-title", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- logout -->
|
||||||
|
<div class="flex flex-col py-1.5">
|
||||||
|
<form method="post" action="/logout" hx-boost="false">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}"><button type="submit" role="menuitem" class="flex w-full items-center gap-2 bg-surface-alt px-4 py-2 text-left text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-4 shrink-0"><path fill-rule="evenodd" d="M7.5 3.75A1.5 1.5 0 006 5.25v13.5a1.5 1.5 0 001.5 1.5h6a1.5 1.5 0 001.5-1.5V15a.75.75 0 011.5 0v3.75a3 3 0 01-3 3h-6a3 3 0 01-3-3V5.25a3 3 0 013-3h6a3 3 0 013 3V9A.75.75 0 0115 9V5.25a1.5 1.5 0 00-1.5-1.5h-6zm10.72 4.72a.75.75 0 011.06 0l3 3a.75.75 0 010 1.06l-3 3a.75.75 0 11-1.06-1.06l1.72-1.72H9a.75.75 0 010-1.5h10.94l-1.72-1.72a.75.75 0 010-1.06z" clip-rule="evenodd"/></svg>
|
||||||
|
{{ t(key="logout", lang=lang | default(value='sk')) }}
|
||||||
|
</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
62
assets/views/partials/settings_dropdown.html
Normal file
62
assets/views/partials/settings_dropdown.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{# Settings dropdown (language + theme). Shared by base.html and admin/base.html
|
||||||
|
to kill the former ~100-line copy-paste duplication.
|
||||||
|
|
||||||
|
Proper Penguin UI dropdown: behaviour is the vendored
|
||||||
|
dropdowns/dropdown-with-icons.html verbatim (isOpen / openedWithKeyboard,
|
||||||
|
x-trap + $focus keyboard nav, x-cloak x-show, @click.outside). Trigger is our
|
||||||
|
gear icon-only button; content is the language form + theme toggle. Needs the
|
||||||
|
Alpine Focus plugin (loaded in base.html) for x-trap / $focus.
|
||||||
|
|
||||||
|
Self-contained Alpine state + relative positioning; the host only places it
|
||||||
|
(e.g. ml-auto in admin). The panel has NO id on purpose (see profile_menu.html
|
||||||
|
for why — htmx hx-boost settles by id). #}
|
||||||
|
<div x-data="{ isOpen: false, openedWithKeyboard: false }"
|
||||||
|
x-on:keydown.esc.window="isOpen = false, openedWithKeyboard = false"
|
||||||
|
class="relative">
|
||||||
|
{{ ui::icon_button(aria_label=t(key='settings', lang=lang | default(value='sk')), attrs='x-on:click="isOpen = ! isOpen" x-on:keydown.space.prevent="openedWithKeyboard = true" x-on:keydown.enter.prevent="openedWithKeyboard = true" x-on:keydown.down.prevent="openedWithKeyboard = true" x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"', 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-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
|
||||||
|
x-on:click.outside="isOpen = false, openedWithKeyboard = false"
|
||||||
|
x-on:keydown.down.prevent="$focus.wrap().next()" x-on:keydown.up.prevent="$focus.wrap().previous()"
|
||||||
|
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">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
38
assets/views/shop/_card.html
Normal file
38
assets/views/shop/_card.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{# 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="_csrf" value="{{ csrf_token() }}">
|
||||||
|
<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>
|
||||||
74
assets/views/shop/_cart_body.html
Normal file
74
assets/views/shop/_cart_body.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{# 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">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<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">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<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", attrs='hx-boost="false"') }}
|
||||||
|
</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 %}
|
||||||
31
assets/views/shop/_cart_preview.html
Normal file
31
assets/views/shop/_cart_preview.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{# Mini-cart preview shown on hover over the navbar cart (Alza-style).
|
||||||
|
Lazy-loaded via htmx from /partials/cart into the hover dropdown panel in
|
||||||
|
base.html. Receives: items[], total, currency, lang. #}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
{% if items | length > 0 %}
|
||||||
|
<div class="max-h-80 divide-y divide-outline overflow-y-auto dark:divide-outline-dark">
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="flex items-start gap-3 px-4 py-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<a href="/shop/{{ item.slug }}" class="block truncate text-sm font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
||||||
|
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ item.currency }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ item.line_total }} {{ item.currency }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-outline px-4 py-3 dark:border-outline-dark">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="text-base font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{{ ui::button(href="/cart", variant="outline-primary", label=t(key="cart-title", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}
|
||||||
|
{{ ui::button(href="/checkout", variant="primary", label=t(key="cart-checkout", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-4 py-10 text-center text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
{{ t(key="cart-empty", lang=lang | default(value='sk')) }}
|
||||||
|
</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 %}
|
||||||
276
assets/views/shop/checkout.html
Normal file
276
assets/views/shop/checkout.html
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
{% 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">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
|
<div class="space-y-6 lg:col-span-2">
|
||||||
|
<!-- personal vs company. Fixed (read-only) for a logged-in account; a guest
|
||||||
|
picks it and the choice will type any account they create. -->
|
||||||
|
<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>
|
||||||
|
{% if account_fixed %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if prefill_account_type == "company" %}
|
||||||
|
{{ ui::badge(label=t(key="account-company", lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% else %}
|
||||||
|
{{ ui::badge(label=t(key="account-personal", lang=lang | default(value='sk')), variant="neutral") }}
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-type-locked", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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')) }}{{ ui::req() }}</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 and not profile_filled %}
|
||||||
|
<!-- offered only when the profile has no saved address yet; if it was filled
|
||||||
|
in advance we leave it untouched -->
|
||||||
|
{{ ui::checkbox(name="save_profile", id="save_profile", label=t(key="checkout-save-profile", lang=lang | default(value='sk'))) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_create_account %}
|
||||||
|
<!-- guests may turn this order into an account (typed by their choice above) -->
|
||||||
|
<div class="space-y-1.5 rounded-radius border border-outline bg-surface p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::checkbox(name="create_account", id="create_account", label=t(key="checkout-create-account", lang=lang | default(value='sk'))) }}
|
||||||
|
<p class="pl-6 text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-create-account-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
68
assets/views/shop/order_confirmed.html
Normal file
68
assets/views/shop/order_confirmed.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{% 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>
|
||||||
|
|
||||||
|
{% if account_created %}
|
||||||
|
<div class="rounded-radius border border-primary/40 bg-primary/5 p-4 text-sm text-on-surface dark:border-primary-dark/40 dark:text-on-surface-dark" role="status">
|
||||||
|
{{ t(key="order-account-created", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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 %}
|
||||||
80
assets/views/shop/show.html
Normal file
80
assets/views/shop/show.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{% 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')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<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.
|
@@ -45,19 +45,24 @@ workers:
|
|||||||
|
|
||||||
# Mailer Configuration.
|
# Mailer Configuration.
|
||||||
mailer:
|
mailer:
|
||||||
# SMTP mailer configuration.
|
# SMTP mailer configuration. Defaults target a local catcher (MailHog/Mailpit
|
||||||
|
# on localhost:1025); set the SMTP_* env vars to point at a real server. The
|
||||||
|
# auth block is only emitted when SMTP_PASSWORD is provided, so the secret is
|
||||||
|
# never stored here — pass it in at launch (e.g. from `pass`).
|
||||||
smtp:
|
smtp:
|
||||||
# Enable/Disable smtp mailer.
|
# Enable/Disable smtp mailer.
|
||||||
enable: true
|
enable: {{ get_env(name="SMTP_ENABLE", default="true") }}
|
||||||
# SMTP server host. e.x localhost, smtp.gmail.com
|
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||||
host: localhost
|
host: "{{ get_env(name="SMTP_HOST", default="localhost") }}"
|
||||||
# SMTP server port
|
# SMTP server port
|
||||||
port: 1025
|
port: {{ get_env(name="SMTP_PORT", default="1025") }}
|
||||||
# Use secure connection (SSL/TLS).
|
# Use secure connection (SSL/TLS).
|
||||||
secure: false
|
secure: {{ get_env(name="SMTP_SECURE", default="false") }}
|
||||||
# auth:
|
{% if get_env(name="SMTP_PASSWORD", default="") != "" %}
|
||||||
# user:
|
auth:
|
||||||
# password:
|
user: "{{ get_env(name="SMTP_USER", default="") }}"
|
||||||
|
password: "{{ get_env(name="SMTP_PASSWORD", default="") }}"
|
||||||
|
{% endif %}
|
||||||
# Override the SMTP hello name (default is the machine's hostname)
|
# Override the SMTP hello name (default is the machine's hostname)
|
||||||
# hello_name:
|
# hello_name:
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@ mailer:
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
database:
|
database:
|
||||||
# Database connection URI
|
# 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.
|
# When enabled, the sql query will be logged.
|
||||||
enable_logging: false
|
enable_logging: false
|
||||||
# Set the timeout duration when acquiring a connection.
|
# Set the timeout duration when acquiring a connection.
|
||||||
@@ -105,3 +110,50 @@ auth:
|
|||||||
settings:
|
settings:
|
||||||
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}
|
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}
|
||||||
uploads_root: {{ get_env(name="UPLOADS_ROOT", default="uploads") }}
|
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:
|
settings:
|
||||||
admin_email: "{{ get_env(name="ADMIN_EMAIL", default="") }}"
|
admin_email: "{{ get_env(name="ADMIN_EMAIL", default="") }}"
|
||||||
uploads_root: "{{ get_env(name="UPLOADS_ROOT", default="data/uploads") }}"
|
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
|
||||||
|
|||||||
@@ -42,33 +42,58 @@ workers:
|
|||||||
|
|
||||||
|
|
||||||
# Mailer Configuration.
|
# Mailer Configuration.
|
||||||
|
# Defaults keep the whole suite on the in-memory stub mailer. The real-SMTP
|
||||||
|
# smoke test (tests/mailer/smtp_send.rs) opts in by setting these env vars
|
||||||
|
# before boot; nothing else in the suite sends real mail.
|
||||||
mailer:
|
mailer:
|
||||||
stub: true
|
stub: {{ get_env(name="MAILER_STUB", default="true") }}
|
||||||
# SMTP mailer configuration.
|
# SMTP mailer configuration.
|
||||||
smtp:
|
smtp:
|
||||||
# Enable/Disable smtp mailer.
|
# Enable/Disable smtp mailer.
|
||||||
enable: true
|
enable: {{ get_env(name="SMTP_ENABLE", default="true") }}
|
||||||
# SMTP server host. e.x localhost, smtp.gmail.com
|
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||||
host: localhost
|
host: "{{ get_env(name="SMTP_HOST", default="localhost") }}"
|
||||||
# SMTP server port
|
# SMTP server port
|
||||||
port: 1025
|
port: {{ get_env(name="SMTP_PORT", default="1025") }}
|
||||||
# Use secure connection (SSL/TLS).
|
# Use secure connection (SSL/TLS).
|
||||||
secure: false
|
secure: {{ get_env(name="SMTP_SECURE", default="false") }}
|
||||||
# auth:
|
auth:
|
||||||
# user:
|
user: "{{ get_env(name="SMTP_USER", default="") }}"
|
||||||
# password:
|
password: "{{ get_env(name="SMTP_PASSWORD", default="") }}"
|
||||||
|
|
||||||
# Initializers Configuration
|
# Initializers Configuration
|
||||||
# initializers:
|
# OAuth2StoreInitializer requires this block to boot (it builds the client store
|
||||||
# oauth2:
|
# in after_routes). Static, non-secret placeholders: tests never perform a real
|
||||||
# authorization_code: # Authorization code grant type
|
# OAuth2 handshake, they just need the store to construct successfully.
|
||||||
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
|
initializers:
|
||||||
# ... other fields
|
oauth2:
|
||||||
|
# Private-cookie key: a ", "-separated list of >=64 byte values (not a
|
||||||
|
# plain string). This is loco-oauth2's documented sample key; fine for tests.
|
||||||
|
secret_key: "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: test-client-id
|
||||||
|
client_secret: test-client-secret
|
||||||
|
url_config:
|
||||||
|
auth_url: https://accounts.google.com/o/oauth2/auth
|
||||||
|
token_url: https://www.googleapis.com/oauth2/v3/token
|
||||||
|
redirect_url: http://localhost:5150/api/oauth2/google/callback
|
||||||
|
profile_url: 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: http://localhost:5150/
|
||||||
|
timeout_seconds: 600
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
database:
|
database:
|
||||||
# Database connection URI
|
# Database connection URI. Pinned to the throwaway test DB and intentionally
|
||||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/gitara_web_test") }}
|
# NOT read from `DATABASE_URL`: the app loads `.env` on boot (app.rs
|
||||||
|
# `load_config`), and this config has `dangerously_recreate: true`, so honoring
|
||||||
|
# an env override here would let `cargo test` recreate the dev/prod database.
|
||||||
|
uri: "postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_test"
|
||||||
# When enabled, the sql query will be logged.
|
# When enabled, the sql query will be logged.
|
||||||
enable_logging: false
|
enable_logging: false
|
||||||
# Set the timeout duration when acquiring a connection.
|
# Set the timeout duration when acquiring a connection.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
gitara-web:
|
kompress:
|
||||||
container_name: gitara-web
|
container_name: kompress
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -9,9 +9,9 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env.production
|
- .env.production
|
||||||
volumes:
|
volumes:
|
||||||
- gitara_web_data:/usr/app/data
|
- kompress_eshop_data:/usr/app/data
|
||||||
networks:
|
networks:
|
||||||
- gitara-net
|
- kompress_eshop-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:5150/_ping"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:5150/_ping"]
|
||||||
@@ -21,10 +21,10 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
gitara-net:
|
kompress_eshop-net:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
gitara_web_data:
|
kompress_eshop_data:
|
||||||
external: true
|
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)]
|
#[allow(unused_imports)]
|
||||||
use loco_rs::{cli::playground, prelude::*};
|
use loco_rs::{cli::playground, prelude::*};
|
||||||
use gitara_web::app::App;
|
use kompress_eshop::app::App;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> loco_rs::Result<()> {
|
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 = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
cargo = pkgs.rust-bin.stable.latest.minimal;
|
cargo = pkgs.rust-bin.stable.latest.minimal;
|
||||||
rustc = pkgs.rust-bin.stable.latest.minimal;
|
rustc = pkgs.rust-bin.stable.latest.minimal;
|
||||||
};
|
};
|
||||||
gitara-web = rustPlatform.buildRustPackage {
|
kompress = rustPlatform.buildRustPackage {
|
||||||
pname = "gitara_web";
|
pname = "kompress_eshop";
|
||||||
inherit version;
|
inherit version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
cargoLock.lockFile = ./Cargo.lock;
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
# Build only the application binary.
|
# 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.
|
# Tests need a database/runtime environment; skip during the build.
|
||||||
doCheck = false;
|
doCheck = false;
|
||||||
|
|
||||||
@@ -66,8 +66,8 @@
|
|||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
gitara-web = gitara-web;
|
kompress = kompress;
|
||||||
default = gitara-web;
|
default = kompress;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,7 +86,10 @@
|
|||||||
buildInputs = [
|
buildInputs = [
|
||||||
rust
|
rust
|
||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
|
# OpenSSL for crypto dependencies (loco-oauth2 -> oauth2/reqwest
|
||||||
|
# use native-tls); .dev provides headers + pkg-config metadata.
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
|
pkgs.openssl.dev
|
||||||
pkgs.cmake
|
pkgs.cmake
|
||||||
pkgs.llvmPackages.clang
|
pkgs.llvmPackages.clang
|
||||||
pkgs.llvmPackages.libclang.lib
|
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]
|
[dependencies.sea-orm-migration]
|
||||||
version = "1.1.0"
|
version = "1.1.20"
|
||||||
features = [
|
features = [
|
||||||
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
|
# 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.
|
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
|
||||||
|
|||||||
@@ -15,6 +15,26 @@ mod m20260517_000010_drop_user_roles;
|
|||||||
mod m20260517_000011_site_pages;
|
mod m20260517_000011_site_pages;
|
||||||
mod m20260517_000012_standalone_audio_tracks;
|
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;
|
||||||
|
mod m20260618_000004_account_ownership;
|
||||||
|
mod m20260620_000001_add_totp_to_users;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -34,6 +54,26 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260517_000010_drop_user_roles::Migration),
|
Box::new(m20260517_000010_drop_user_roles::Migration),
|
||||||
Box::new(m20260517_000011_site_pages::Migration),
|
Box::new(m20260517_000011_site_pages::Migration),
|
||||||
Box::new(m20260517_000012_standalone_audio_tracks::Migration),
|
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),
|
||||||
|
Box::new(m20260618_000004_account_ownership::Migration),
|
||||||
|
Box::new(m20260620_000001_add_totp_to_users::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user