Compare commits
17 Commits
4e1722ce35
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,3 +22,7 @@ target/
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
147
Cargo.lock
generated
147
Cargo.lock
generated
@@ -1504,9 +1504,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1523,36 +1525,6 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gitara_web"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"axum",
|
|
||||||
"axum-extra",
|
|
||||||
"bytes",
|
|
||||||
"chrono",
|
|
||||||
"dotenvy",
|
|
||||||
"fluent-templates",
|
|
||||||
"include_dir",
|
|
||||||
"insta",
|
|
||||||
"loco-rs",
|
|
||||||
"migration",
|
|
||||||
"regex",
|
|
||||||
"rstest",
|
|
||||||
"sea-orm",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serial_test",
|
|
||||||
"time",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"unic-langid",
|
|
||||||
"uuid",
|
|
||||||
"validator",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glob"
|
name = "glob"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -1785,6 +1757,22 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots 1.0.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -2124,6 +2112,37 @@ dependencies = [
|
|||||||
"simple_asn1",
|
"simple_asn1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kompress_eshop"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"dotenvy",
|
||||||
|
"fluent-templates",
|
||||||
|
"include_dir",
|
||||||
|
"insta",
|
||||||
|
"loco-rs",
|
||||||
|
"migration",
|
||||||
|
"regex",
|
||||||
|
"reqwest",
|
||||||
|
"rstest",
|
||||||
|
"sea-orm",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serial_test",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"unic-langid",
|
||||||
|
"uuid",
|
||||||
|
"validator",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kqueue"
|
name = "kqueue"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -2332,6 +2351,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3090,6 +3115,61 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2 0.5.10",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2 0.5.10",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -3297,16 +3377,21 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower 0.5.3",
|
"tower 0.5.3",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -3316,6 +3401,7 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3520,6 +3606,7 @@ version = "1.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "2021"
|
||||||
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
|
||||||
|
|
||||||
@@ -37,6 +37,8 @@ dotenvy = { version = "0.15" }
|
|||||||
validator = { version = "0.20" }
|
validator = { version = "0.20" }
|
||||||
uuid = { version = "1.6", features = ["v4"] }
|
uuid = { version = "1.6", features = ["v4"] }
|
||||||
include_dir = { version = "0.7" }
|
include_dir = { version = "0.7" }
|
||||||
|
# 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" }
|
||||||
@@ -45,7 +47,7 @@ axum-extra = { version = "0.10", features = ["form"] }
|
|||||||
bytes = { version = "1" }
|
bytes = { version = "1" }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gitara_web-cli"
|
name = "kompress-eshop-cli"
|
||||||
path = "src/bin/main.rs"
|
path = "src/bin/main.rs"
|
||||||
required-features = []
|
required-features = []
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
73
assets/css/app.css
Normal file
73
assets/css/app.css
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/* ============================================================
|
||||||
|
* Tailwind v4 source — built into assets/static/css/app.css
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* Stack: Tailwind CSS v4 + Alpine.js v3 + PenguinUI components
|
||||||
|
* (https://www.penguinui.com). PenguinUI is copy-paste: paste a
|
||||||
|
* component's markup into a template and it picks up the design
|
||||||
|
* tokens defined in the @theme block below.
|
||||||
|
*
|
||||||
|
* Build: make css (one-off, minified)
|
||||||
|
* make css-watch (rebuild on change while developing)
|
||||||
|
*
|
||||||
|
* The compiled output assets/static/css/app.css IS committed, so
|
||||||
|
* the Docker image / loco static server need no Node at runtime.
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Scan every template so used utility classes are emitted. */
|
||||||
|
@source "../views";
|
||||||
|
|
||||||
|
/* PenguinUI toggles dark styles with a `dark:` variant. This app
|
||||||
|
* already sets <html data-theme="dark|light"> (see base.html), so
|
||||||
|
* key the variant off that attribute instead of the OS setting. */
|
||||||
|
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||||
|
|
||||||
|
/* === PenguinUI design tokens ================================
|
||||||
|
* "Modern" starting palette. Swap any line for another Tailwind
|
||||||
|
* color (e.g. --color-primary: var(--color-emerald-600)) or grab
|
||||||
|
* a ready-made theme from https://www.penguinui.com/theme.
|
||||||
|
* Components reference these as bg-primary, text-on-surface,
|
||||||
|
* dark:bg-surface-dark, border-outline, etc.
|
||||||
|
* ============================================================ */
|
||||||
|
@theme {
|
||||||
|
/* light mode */
|
||||||
|
--color-surface: var(--color-white);
|
||||||
|
--color-surface-alt: var(--color-slate-100);
|
||||||
|
--color-on-surface: var(--color-slate-700);
|
||||||
|
--color-on-surface-strong: var(--color-slate-900);
|
||||||
|
--color-primary: var(--color-indigo-600);
|
||||||
|
--color-on-primary: var(--color-white);
|
||||||
|
--color-secondary: var(--color-slate-600);
|
||||||
|
--color-on-secondary: var(--color-white);
|
||||||
|
--color-outline: var(--color-slate-300);
|
||||||
|
--color-outline-strong: var(--color-slate-800);
|
||||||
|
|
||||||
|
/* dark mode */
|
||||||
|
--color-surface-dark: var(--color-slate-900);
|
||||||
|
--color-surface-dark-alt: var(--color-slate-800);
|
||||||
|
--color-on-surface-dark: var(--color-slate-300);
|
||||||
|
--color-on-surface-dark-strong: var(--color-white);
|
||||||
|
--color-primary-dark: var(--color-indigo-400);
|
||||||
|
--color-on-primary-dark: var(--color-slate-950);
|
||||||
|
--color-secondary-dark: var(--color-slate-300);
|
||||||
|
--color-on-secondary-dark: var(--color-slate-950);
|
||||||
|
--color-outline-dark: var(--color-slate-700);
|
||||||
|
--color-outline-dark-strong: var(--color-slate-300);
|
||||||
|
|
||||||
|
/* shared status colors (same in both modes) */
|
||||||
|
--color-info: var(--color-sky-500);
|
||||||
|
--color-on-info: var(--color-white);
|
||||||
|
--color-success: var(--color-green-600);
|
||||||
|
--color-on-success: var(--color-white);
|
||||||
|
--color-warning: var(--color-amber-500);
|
||||||
|
--color-on-warning: var(--color-white);
|
||||||
|
--color-danger: var(--color-red-600);
|
||||||
|
--color-on-danger: var(--color-white);
|
||||||
|
|
||||||
|
/* shared design tokens */
|
||||||
|
--radius-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide Alpine x-cloak elements until Alpine initializes. */
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
@@ -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
|
||||||
@@ -172,3 +172,118 @@ 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
|
||||||
|
parent-category = Parent category
|
||||||
|
no-parent = — None (top level) —
|
||||||
|
quantity = Quantity
|
||||||
|
add-to-cart = Add to cart
|
||||||
|
in-stock = In stock
|
||||||
|
out-of-stock = Out of stock
|
||||||
|
confirm-delete = Delete this for good?
|
||||||
|
shop-title = Shop
|
||||||
|
shop-subtitle = browse our products.
|
||||||
|
shop-empty = There are no products here yet.
|
||||||
|
categories = Categories
|
||||||
|
all-products = All products
|
||||||
|
cart-title = Cart
|
||||||
|
cart-empty = Your cart is empty.
|
||||||
|
cart-total = Total
|
||||||
|
cart-checkout = Proceed to checkout
|
||||||
|
cart-remove = Remove
|
||||||
|
cart-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-place-order = Place order
|
||||||
|
checkout-summary = Order summary
|
||||||
|
order-confirmed-title = Thank you for your order!
|
||||||
|
order-confirmed-sub = We have received your order.
|
||||||
|
order-number = Order number
|
||||||
|
order-status = Status
|
||||||
|
order-total = Total
|
||||||
|
order-items = Items
|
||||||
|
order-date = Date
|
||||||
|
order-customer = Customer
|
||||||
|
admin-no-orders = No orders yet.
|
||||||
|
order-status-pending = Pending
|
||||||
|
order-status-paid = Paid
|
||||||
|
order-status-shipped = Shipped
|
||||||
|
order-status-cancelled = Cancelled
|
||||||
|
order-update-status = Update status
|
||||||
|
|
||||||
|
# --- eshop: shipping & payment ---
|
||||||
|
checkout-carrier = Delivery
|
||||||
|
checkout-payment = Payment method
|
||||||
|
checkout-subtotal = Subtotal
|
||||||
|
checkout-shipping-cost = Shipping
|
||||||
|
checkout-pick-point = Choose pickup point
|
||||||
|
checkout-chosen-point = Chosen point
|
||||||
|
checkout-pickup-point = Pickup point
|
||||||
|
payment-cod = Cash on delivery
|
||||||
|
payment-bank = Bank transfer
|
||||||
|
payment-bank-instructions = Please transfer the amount to our account:
|
||||||
|
payment-cod-note = You will pay for the goods on delivery.
|
||||||
|
payment-bank-note = We will ship once the payment arrives.
|
||||||
|
bank-account-name = Account holder
|
||||||
|
bank-variable-symbol = Variable symbol
|
||||||
|
bank-amount = Amount
|
||||||
|
admin-shipping = Shipping
|
||||||
|
admin-shipping-desc = set 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
|
||||||
@@ -172,3 +172,118 @@ 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
|
||||||
|
parent-category = Nadradená kategória
|
||||||
|
no-parent = — Žiadna (najvyššia úroveň) —
|
||||||
|
quantity = Množstvo
|
||||||
|
add-to-cart = Pridať do košíka
|
||||||
|
in-stock = Na sklade
|
||||||
|
out-of-stock = Vypredané
|
||||||
|
confirm-delete = Naozaj zmazať?
|
||||||
|
shop-title = Obchod
|
||||||
|
shop-subtitle = prezrite si našu ponuku produktov.
|
||||||
|
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
||||||
|
categories = Kategórie
|
||||||
|
all-products = Všetky produkty
|
||||||
|
cart-title = Košík
|
||||||
|
cart-empty = Váš košík je prázdny.
|
||||||
|
cart-total = Spolu
|
||||||
|
cart-checkout = Pokračovať k pokladni
|
||||||
|
cart-remove = Odstrániť
|
||||||
|
cart-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-place-order = Odoslať objednávku
|
||||||
|
checkout-summary = Súhrn objednávky
|
||||||
|
order-confirmed-title = Ďakujeme za objednávku!
|
||||||
|
order-confirmed-sub = Vašu objednávku sme prijali.
|
||||||
|
order-number = Číslo objednávky
|
||||||
|
order-status = Stav
|
||||||
|
order-total = Spolu
|
||||||
|
order-items = Položky
|
||||||
|
order-date = Dátum
|
||||||
|
order-customer = Zákazník
|
||||||
|
admin-no-orders = Zatiaľ žiadne objednávky.
|
||||||
|
order-status-pending = Čaká na spracovanie
|
||||||
|
order-status-paid = Zaplatené
|
||||||
|
order-status-shipped = Odoslané
|
||||||
|
order-status-cancelled = Zrušené
|
||||||
|
order-update-status = Zmeniť stav
|
||||||
|
|
||||||
|
# --- eshop: shipping & payment ---
|
||||||
|
checkout-carrier = Doprava
|
||||||
|
checkout-payment = Spôsob platby
|
||||||
|
checkout-subtotal = Medzisúčet
|
||||||
|
checkout-shipping-cost = Doprava
|
||||||
|
checkout-pick-point = Vybrať výdajné miesto
|
||||||
|
checkout-chosen-point = Vybrané miesto
|
||||||
|
checkout-pickup-point = Výdajné miesto
|
||||||
|
payment-cod = Dobierka (platba pri prevzatí)
|
||||||
|
payment-bank = Bankový prevod
|
||||||
|
payment-bank-instructions = Sumu uhraďte prevodom na náš účet:
|
||||||
|
payment-cod-note = Za tovar zaplatíte pri jeho prevzatí.
|
||||||
|
payment-bank-note = Po prijatí platby objednávku odošleme.
|
||||||
|
bank-account-name = Príjemca
|
||||||
|
bank-variable-symbol = Variabilný symbol
|
||||||
|
bank-amount = Suma
|
||||||
|
admin-shipping = Doprava
|
||||||
|
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
|
||||||
|
shipping-enabled = Aktívne
|
||||||
|
shipping-new = Pridať možnosť dopravy
|
||||||
|
shipping-add = Pridať
|
||||||
|
shipping-requires-pickup = Vyžaduje výdajné miesto
|
||||||
|
shipping-carrier = Dopravca
|
||||||
|
carrier-none = Manuálne (bez API)
|
||||||
|
carrier-packeta = Packeta
|
||||||
|
carrier-dpd = DPD
|
||||||
|
carrier-dhl = DHL
|
||||||
|
order-fulfillment = Expedícia
|
||||||
|
order-shipped-via = Odoslané cez
|
||||||
|
order-tracking = Sledovanie
|
||||||
|
order-label = Tlačiť štítok
|
||||||
|
order-manual-fulfillment = Manuálne spracovanie — táto možnosť nemá API dopravcu.
|
||||||
|
order-send-hint = Keď je tovar pripravený, odošlite objednávku dopravcovi.
|
||||||
|
order-send-to-carrier = Odoslať dopravcovi
|
||||||
|
order-send-confirm = Odoslať túto objednávku dopravcovi teraz?
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,781 +0,0 @@
|
|||||||
/* ============================================================
|
|
||||||
* Terminal theme
|
|
||||||
* ------------------------------------------------------------
|
|
||||||
* Project-owned styling. The vendored `app.css` (a pre-compiled
|
|
||||||
* Tailwind + DaisyUI bundle) is NOT edited. This file loads
|
|
||||||
* after it (see base.html / admin/base.html) and provides:
|
|
||||||
*
|
|
||||||
* 1. Catppuccin Latte for DaisyUI's `light` theme
|
|
||||||
* and Gruvbox for DaisyUI's `dark` theme
|
|
||||||
* 2. square corners (terminals have none)
|
|
||||||
* 3. a terminal look & feel: monospace, window chrome,
|
|
||||||
* vim-style statusline, CRT scanlines
|
|
||||||
* 4. `.term-*` building blocks used by the templates
|
|
||||||
*
|
|
||||||
* Why CSS classes and not utility classes: `app.css` is frozen
|
|
||||||
* and only contains the utilities the original project used, so
|
|
||||||
* new Tailwind classes would not exist. The DaisyUI *components*
|
|
||||||
* (card/btn/badge/menu/...) do exist and are reused; everything
|
|
||||||
* else is defined here as real, themeable CSS.
|
|
||||||
*
|
|
||||||
* Palettes:
|
|
||||||
* - https://github.com/catppuccin/catppuccin (Latte)
|
|
||||||
* - https://github.com/morhetz/gruvbox (dark, bright)
|
|
||||||
* DaisyUI color vars are OKLch "L% C H" triplets; this file can
|
|
||||||
* therefore tint anything with `oklch(var(--x) / <alpha>)`.
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
/* === 1. Theme palettes ====================================== */
|
|
||||||
/* Catppuccin Latte. */
|
|
||||||
[data-theme="light"] {
|
|
||||||
--b1: 95.78% 0.006 264.5; /* #eff1f5 base */
|
|
||||||
--b2: 93.35% 0.009 264.5; /* #e6e9ef mantle */
|
|
||||||
--b3: 90.60% 0.012 264.5; /* #dce0e8 crust */
|
|
||||||
--bc: 43.55% 0.043 279.3; /* #4c4f69 text */
|
|
||||||
|
|
||||||
--n: 80.83% 0.017 271.2; /* #bcc0cc surface1 */
|
|
||||||
--nc: 43.55% 0.043 279.3; /* #4c4f69 text */
|
|
||||||
|
|
||||||
--p: 55.86% 0.226 262.1; /* #1e66f5 blue primary */
|
|
||||||
--pc: 95.78% 0.006 264.5; /* #eff1f5 text on primary */
|
|
||||||
--s: 55.47% 0.250 297.0; /* #8839ef mauve secondary */
|
|
||||||
--sc: 95.78% 0.006 264.5; /* #eff1f5 text on secondary */
|
|
||||||
--a: 60.23% 0.098 201.1; /* #179299 teal accent */
|
|
||||||
--ac: 95.78% 0.006 264.5; /* #eff1f5 text on accent */
|
|
||||||
|
|
||||||
--in: 68.20% 0.145 235.4; /* #04a5e5 sky info */
|
|
||||||
--su: 62.50% 0.177 140.4; /* #40a02b green success */
|
|
||||||
--wa: 71.40% 0.149 67.8; /* #df8e1d yellow warning */
|
|
||||||
--er: 55.05% 0.216 19.8; /* #d20f39 red error */
|
|
||||||
--inc: 43.55% 0.043 279.3; /* #4c4f69 text on status */
|
|
||||||
--suc: 95.78% 0.006 264.5;
|
|
||||||
--wac: 95.78% 0.006 264.5;
|
|
||||||
--erc: 95.78% 0.006 264.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Source hex noted per line. To retune: change hex, reconvert
|
|
||||||
* to OKLch, update the value. */
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--b1: 27.69% 0 0; /* #282828 bg0 screen background */
|
|
||||||
--b2: 31.10% 0.003 49.7; /* #32302f bg0_s panels / chrome */
|
|
||||||
--b3: 34.40% 0.0066 48.7; /* #3c3836 bg1 borders */
|
|
||||||
--bc: 89.42% 0.0566 89.5; /* #ebdbb2 fg body text */
|
|
||||||
|
|
||||||
--n: 34.40% 0.0066 48.7; /* #3c3836 bg1 */
|
|
||||||
--nc: 89.42% 0.0566 89.5; /* #ebdbb2 fg */
|
|
||||||
|
|
||||||
--p: 73.10% 0.182 51.7; /* #fe8019 bright orange primary */
|
|
||||||
--pc: 27.69% 0 0; /* #282828 text on primary */
|
|
||||||
--s: 70.54% 0.097 2.3; /* #d3869b bright purple secondary */
|
|
||||||
--sc: 27.69% 0 0; /* #282828 text on secondary */
|
|
||||||
--a: 75.57% 0.108 137.6; /* #8ec07c bright aqua accent */
|
|
||||||
--ac: 27.69% 0 0; /* #282828 text on accent */
|
|
||||||
|
|
||||||
--in: 69.26% 0.042 169.8; /* #83a598 bright blue info */
|
|
||||||
--su: 76.52% 0.158 110.8; /* #b8bb26 bright green success */
|
|
||||||
--wa: 83.49% 0.160 83.6; /* #fabd2f bright yellow warning */
|
|
||||||
--er: 65.97% 0.217 30.4; /* #fb4934 bright red error */
|
|
||||||
--inc: 24.07% 0.005 220.9; /* #1d2021 bg0_h text on status */
|
|
||||||
--suc: 24.07% 0.005 220.9;
|
|
||||||
--wac: 24.07% 0.005 220.9;
|
|
||||||
--erc: 24.07% 0.005 220.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === 2. Square corners ====================================== */
|
|
||||||
/* `[data-theme]` matches the same <html> element as the vendored
|
|
||||||
* `[data-theme=dark|light]` rules with equal specificity, and
|
|
||||||
* wins by load order. Applies to both themes. */
|
|
||||||
[data-theme] {
|
|
||||||
--rounded-box: 0;
|
|
||||||
--rounded-btn: 0;
|
|
||||||
--rounded-badge: 0;
|
|
||||||
--tab-radius: 0;
|
|
||||||
--animation-btn: 0;
|
|
||||||
--animation-input: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === 3. Terminal look & feel ================================ */
|
|
||||||
/* Root font-size drives every rem in this file and in app.css.
|
|
||||||
* Bump this to scale the whole UI; drop to shrink. */
|
|
||||||
html { font-size: 19px; }
|
|
||||||
body {
|
|
||||||
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code",
|
|
||||||
ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text selection + scrollbars */
|
|
||||||
[data-theme="light"] ::selection { background: #acb0be; color: #4c4f69; }
|
|
||||||
[data-theme="light"] { scrollbar-color: #bcc0cc #eff1f5; }
|
|
||||||
[data-theme="light"] ::-webkit-scrollbar { width: 12px; height: 12px; }
|
|
||||||
[data-theme="light"] ::-webkit-scrollbar-track { background: #eff1f5; }
|
|
||||||
[data-theme="light"] ::-webkit-scrollbar-thumb {
|
|
||||||
background: #bcc0cc; border: 3px solid #eff1f5;
|
|
||||||
}
|
|
||||||
[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: #acb0be; }
|
|
||||||
|
|
||||||
[data-theme="dark"] ::selection { background: #fe8019; color: #282828; }
|
|
||||||
[data-theme="dark"] { scrollbar-color: #504945 #282828; }
|
|
||||||
[data-theme="dark"] ::-webkit-scrollbar { width: 12px; height: 12px; }
|
|
||||||
[data-theme="dark"] ::-webkit-scrollbar-track { background: #282828; }
|
|
||||||
[data-theme="dark"] ::-webkit-scrollbar-thumb {
|
|
||||||
background: #504945; border: 3px solid #282828;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #665c54; }
|
|
||||||
|
|
||||||
/* Faint CRT scanlines — dark only. Remove this block to drop it. */
|
|
||||||
[data-theme="dark"] body::before {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 90;
|
|
||||||
pointer-events: none;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
0deg, rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15) 1px,
|
|
||||||
transparent 1px, transparent 3px);
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- color helpers (theme-adaptive: gruvbox in dark) -------- */
|
|
||||||
.t-orange { color: oklch(var(--p)); }
|
|
||||||
.t-purple { color: oklch(var(--s)); }
|
|
||||||
.t-aqua { color: oklch(var(--a)); }
|
|
||||||
.t-blue { color: oklch(var(--in)); }
|
|
||||||
.t-green { color: oklch(var(--su)); }
|
|
||||||
.t-yellow { color: oklch(var(--wa)); }
|
|
||||||
.t-red { color: oklch(var(--er)); }
|
|
||||||
.t-dim { color: oklch(var(--bc) / 0.75); }
|
|
||||||
|
|
||||||
/* --- window titlebar (the header) -------------------------- */
|
|
||||||
.term-titlebar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 50;
|
|
||||||
background: oklch(var(--b2));
|
|
||||||
border-bottom: 1px solid oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.term-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.85rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 72rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
}
|
|
||||||
.term-dots { display: inline-flex; gap: 0.45rem; flex: none; }
|
|
||||||
.term-dot {
|
|
||||||
width: 0.72rem;
|
|
||||||
height: 0.72rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.term-dot.r { background: oklch(var(--er)); }
|
|
||||||
.term-dot.y { background: oklch(var(--wa)); }
|
|
||||||
.term-dot.g { background: oklch(var(--su)); }
|
|
||||||
.term-brand {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.term-brand:hover { text-decoration: none; }
|
|
||||||
.term-nav-right { margin-left: auto; display: flex; align-items: center; gap: 0.25rem; }
|
|
||||||
|
|
||||||
/* horizontal nav links */
|
|
||||||
.term-navlinks { padding: 0; gap: 0; }
|
|
||||||
.term-navlinks li > a,
|
|
||||||
.term-navlinks li > form > button {
|
|
||||||
padding: 0.2rem 0.55rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
.term-navlinks li > a::before { content: ""; }
|
|
||||||
.term-navlinks li > a:hover,
|
|
||||||
.term-navlinks li > form > button:hover {
|
|
||||||
background: transparent;
|
|
||||||
color: oklch(var(--p));
|
|
||||||
}
|
|
||||||
.term-navlinks a.is-active {
|
|
||||||
color: oklch(var(--p));
|
|
||||||
background: oklch(var(--p) / 0.12);
|
|
||||||
}
|
|
||||||
.term-navlinks a.is-active::before {
|
|
||||||
content: "▸ ";
|
|
||||||
color: oklch(var(--p));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- page body layout -------------------------------------- */
|
|
||||||
.term-main {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 72rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2.25rem 1rem 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- command-prompt page heading --------------------------- */
|
|
||||||
.term-cmd {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1.75rem;
|
|
||||||
padding-bottom: 0.85rem;
|
|
||||||
border-bottom: 1px solid oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.term-cmd-line { font-size: 0.8rem; color: oklch(var(--bc) / 0.85); }
|
|
||||||
.term-title {
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
font-size: 1.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.15;
|
|
||||||
color: oklch(var(--p));
|
|
||||||
}
|
|
||||||
.term-sub { margin-top: 0.2rem; font-size: 0.85rem; color: oklch(var(--bc) / 0.8); }
|
|
||||||
.term-cmd-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
||||||
|
|
||||||
/* --- responsive card grid ---------------------------------- */
|
|
||||||
.term-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
|
||||||
}
|
|
||||||
.term-stack > * + * { margin-top: 1rem; }
|
|
||||||
|
|
||||||
/* --- cards as terminal windows ----------------------------- */
|
|
||||||
.card {
|
|
||||||
background: oklch(var(--b2));
|
|
||||||
border: 1px solid oklch(var(--b3));
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.card:hover { border-color: oklch(var(--p) / 0.5); }
|
|
||||||
/* filename strip at the top of a card */
|
|
||||||
.term-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.4rem 0.85rem;
|
|
||||||
font-size: 0.74rem;
|
|
||||||
color: oklch(var(--bc) / 0.6);
|
|
||||||
background: oklch(var(--b1));
|
|
||||||
border-bottom: 1px solid oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.term-head .term-dots .term-dot { width: 0.55rem; height: 0.55rem; }
|
|
||||||
.term-head-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.term-head-meta { margin-left: auto; }
|
|
||||||
.card-title a { color: oklch(var(--p)); text-decoration: none; }
|
|
||||||
.card-title a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
/* --- inline tags ------------------------------------------- */
|
|
||||||
.term-tag {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.02rem 0.45rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
border: 1px solid oklch(var(--p) / 0.55);
|
|
||||||
color: oklch(var(--p));
|
|
||||||
}
|
|
||||||
.term-tag.is-aqua { border-color: oklch(var(--a) / 0.55); color: oklch(var(--a)); }
|
|
||||||
.term-tag.is-purple { border-color: oklch(var(--s) / 0.55); color: oklch(var(--s)); }
|
|
||||||
.term-tag.is-blue { border-color: oklch(var(--in) / 0.55); color: oklch(var(--in)); }
|
|
||||||
.term-tag.is-green { border-color: oklch(var(--su) / 0.55); color: oklch(var(--su)); }
|
|
||||||
|
|
||||||
/* --- empty / "no results" state ---------------------------- */
|
|
||||||
.term-empty {
|
|
||||||
padding: 2.25rem 1rem;
|
|
||||||
text-align: center;
|
|
||||||
color: oklch(var(--bc) / 0.6);
|
|
||||||
border: 1px dashed oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.term-empty-cmd { font-size: 0.8rem; color: oklch(var(--bc) / 0.45); }
|
|
||||||
|
|
||||||
/* --- how-it-works note + form helpers (admin) -------------- */
|
|
||||||
.term-note {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 0.9rem 1.1rem;
|
|
||||||
background: oklch(var(--b2));
|
|
||||||
border: 1px solid oklch(var(--b3));
|
|
||||||
border-left: 3px solid oklch(var(--a));
|
|
||||||
}
|
|
||||||
.term-note-title { margin-bottom: 0.55rem; font-size: 0.8rem; color: oklch(var(--a)); }
|
|
||||||
.term-step { display: flex; gap: 0.55rem; font-size: 0.88rem; }
|
|
||||||
.term-step + .term-step { margin-top: 0.3rem; }
|
|
||||||
.term-step-n { flex: none; color: oklch(var(--p)); }
|
|
||||||
.term-note-foot { margin-top: 0.6rem; font-size: 0.8rem; color: oklch(var(--bc) / 0.6); }
|
|
||||||
.term-help { margin-top: 0.2rem; font-size: 0.76rem; color: oklch(var(--bc) / 0.55); }
|
|
||||||
.term-picklist {
|
|
||||||
border: 1px solid oklch(var(--b3));
|
|
||||||
background: oklch(var(--b1));
|
|
||||||
max-height: 18rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.term-pick {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.65rem;
|
|
||||||
padding: 0.5rem 0.7rem;
|
|
||||||
border-top: 1px solid oklch(var(--b3));
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.term-pick:first-child { border-top: 0; }
|
|
||||||
.term-pick:hover { background: oklch(var(--b2)); }
|
|
||||||
.term-formdiv { margin: 1.25rem 0; border-top: 1px dashed oklch(var(--b3)); }
|
|
||||||
|
|
||||||
/* --- terminal session block (mockup-code substitute) ------- */
|
|
||||||
.term-screen {
|
|
||||||
background: oklch(var(--b1));
|
|
||||||
border: 1px solid oklch(var(--b3));
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.term-screen .line { white-space: pre-wrap; }
|
|
||||||
.term-screen .line::before {
|
|
||||||
content: attr(data-p) " ";
|
|
||||||
color: oklch(var(--su));
|
|
||||||
}
|
|
||||||
.term-screen .line.out::before { content: ""; }
|
|
||||||
.term-screen .line.out { color: oklch(var(--bc) / 0.8); }
|
|
||||||
|
|
||||||
/* --- prose (article / about bodies) ------------------------ */
|
|
||||||
.term-prose { line-height: 1.7; }
|
|
||||||
.term-prose a { color: oklch(var(--in)); }
|
|
||||||
|
|
||||||
/* --- audio rows -------------------------------------------- */
|
|
||||||
.term-track {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.7rem;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
border-top: 1px solid oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.term-track:first-child { border-top: 0; }
|
|
||||||
.term-track .btn { flex: none; }
|
|
||||||
.term-track-name { font-size: 0.9rem; }
|
|
||||||
/* "play album" row sitting above the per-track list */
|
|
||||||
.term-track-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.7rem;
|
|
||||||
padding-bottom: 0.65rem;
|
|
||||||
margin-bottom: 0.15rem;
|
|
||||||
border-bottom: 1px solid oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.term-track-bar .btn { flex: none; }
|
|
||||||
|
|
||||||
/* --- persistent audio player bar --------------------------- */
|
|
||||||
/* Hidden until the first song plays; shown by adding `uw-playing`
|
|
||||||
* to <html>. The bar itself carries `hx-preserve` so the <audio>
|
|
||||||
* keeps playing while htmx swaps the page around it. */
|
|
||||||
#uw-player { display: none; }
|
|
||||||
.uw-playing #uw-player {
|
|
||||||
display: block;
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 80;
|
|
||||||
background: oklch(var(--b2));
|
|
||||||
border-top: 3px solid oklch(var(--p));
|
|
||||||
box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
.uw-playing body { padding-bottom: 6.75rem; }
|
|
||||||
.uw-player-inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1.15rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 72rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
}
|
|
||||||
.uw-player-tag {
|
|
||||||
flex: none;
|
|
||||||
font-size: 0.98rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: oklch(var(--p));
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.uw-player-title {
|
|
||||||
flex: none;
|
|
||||||
max-width: 24rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: oklch(var(--bc));
|
|
||||||
}
|
|
||||||
#uw-audio {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 9rem;
|
|
||||||
width: auto;
|
|
||||||
height: 3.25rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.uw-player-close {
|
|
||||||
flex: none;
|
|
||||||
width: 2.85rem;
|
|
||||||
height: 2.85rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid oklch(var(--b3));
|
|
||||||
color: oklch(var(--bc) / 0.7);
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.uw-player-close:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
|
|
||||||
/* transport + playlist toggle buttons in the player bar */
|
|
||||||
.uw-player-btn {
|
|
||||||
flex: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
min-width: 2.85rem;
|
|
||||||
height: 2.85rem;
|
|
||||||
padding: 0 0.6rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid oklch(var(--b3));
|
|
||||||
color: oklch(var(--bc) / 0.78);
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.uw-player-btn:hover { color: oklch(var(--p)); border-color: oklch(var(--p)); }
|
|
||||||
.uw-queue-badge {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
min-width: 1.25rem;
|
|
||||||
padding: 0.05rem 0.3rem;
|
|
||||||
background: oklch(var(--p));
|
|
||||||
color: oklch(var(--pc));
|
|
||||||
}
|
|
||||||
#uw-player:not(.uw-has-queue) .uw-queue-badge { display: none; }
|
|
||||||
|
|
||||||
/* --- the SoundCloud-style playlist panel ------------------- */
|
|
||||||
.uw-queue {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 72rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
border-bottom: 1px solid oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.uw-queue[hidden] { display: none; }
|
|
||||||
.uw-queue-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.6rem 1.5rem;
|
|
||||||
border-bottom: 1px solid oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.uw-queue-title { font-weight: 700; font-size: 0.95rem; color: oklch(var(--p)); }
|
|
||||||
.uw-queue-meta { font-size: 0.8rem; color: oklch(var(--bc) / 0.6); }
|
|
||||||
.uw-queue-clear {
|
|
||||||
margin-left: auto;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid oklch(var(--b3));
|
|
||||||
color: oklch(var(--bc) / 0.7);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.uw-queue-clear:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
|
|
||||||
.uw-queue-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.35rem 0;
|
|
||||||
max-height: 15rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.uw-queue-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.7rem;
|
|
||||||
padding: 0.4rem 1.5rem;
|
|
||||||
}
|
|
||||||
.uw-queue-item:hover { background: oklch(var(--b3) / 0.5); }
|
|
||||||
.uw-queue-item.is-current { background: oklch(var(--p) / 0.12); }
|
|
||||||
.uw-queue-jump {
|
|
||||||
flex: none;
|
|
||||||
width: 1.85rem;
|
|
||||||
height: 1.85rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid oklch(var(--b3));
|
|
||||||
color: oklch(var(--bc) / 0.7);
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.uw-queue-item.is-current .uw-queue-jump { color: oklch(var(--p)); border-color: oklch(var(--p)); }
|
|
||||||
.uw-queue-name {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.uw-queue-item.is-current .uw-queue-name { color: oklch(var(--p)); font-weight: 600; }
|
|
||||||
.uw-queue-remove {
|
|
||||||
flex: none;
|
|
||||||
width: 1.85rem;
|
|
||||||
height: 1.85rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
color: oklch(var(--bc) / 0.5);
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.uw-queue-remove:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
/* Two-row layout: [title][prev][next][queue][close] on top,
|
|
||||||
* full-width <audio> scrubber underneath. */
|
|
||||||
.uw-player-tag { display: none; }
|
|
||||||
.uw-player-inner {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
gap: 0.4rem;
|
|
||||||
row-gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.uw-player-title {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: none;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.uw-player-btn {
|
|
||||||
min-width: 2.2rem;
|
|
||||||
height: 2.2rem;
|
|
||||||
padding: 0 0.35rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.uw-player-close { width: 2.2rem; height: 2.2rem; font-size: 0.95rem; }
|
|
||||||
#uw-audio {
|
|
||||||
order: 99;
|
|
||||||
flex: 1 1 100%;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
height: 2.4rem;
|
|
||||||
}
|
|
||||||
.uw-playing body { padding-bottom: 8.25rem; }
|
|
||||||
.uw-queue-head, .uw-queue-item { padding-left: 0.95rem; padding-right: 0.95rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- vim-style statusline (the footer) --------------------- */
|
|
||||||
.term-statusline {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: stretch;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
border-top: 1px solid oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.term-seg {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.25rem 0.8rem;
|
|
||||||
background: oklch(var(--b3));
|
|
||||||
color: oklch(var(--bc));
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.term-seg.is-mode {
|
|
||||||
background: oklch(var(--p));
|
|
||||||
color: oklch(var(--pc));
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
|
||||||
.term-seg.is-alt {
|
|
||||||
background: oklch(var(--s));
|
|
||||||
color: oklch(var(--sc));
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.term-seg.is-fill {
|
|
||||||
flex: 1 1 8rem;
|
|
||||||
background: oklch(var(--b2));
|
|
||||||
color: oklch(var(--bc) / 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- square the icon buttons ------------------------------- */
|
|
||||||
.btn-circle { border-radius: 0; }
|
|
||||||
|
|
||||||
/* --- blog editor ------------------------------------------- */
|
|
||||||
.blog-editor {
|
|
||||||
min-height: 24rem;
|
|
||||||
background: oklch(var(--b1));
|
|
||||||
}
|
|
||||||
.blog-editor .ql-editor {
|
|
||||||
min-height: 24rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
.ql-toolbar.ql-snow,
|
|
||||||
.ql-container.ql-snow {
|
|
||||||
border-color: oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.ql-toolbar.ql-snow {
|
|
||||||
background: oklch(var(--b2));
|
|
||||||
}
|
|
||||||
.ql-snow .ql-stroke,
|
|
||||||
.ql-snow .ql-stroke-miter {
|
|
||||||
stroke: oklch(var(--bc));
|
|
||||||
}
|
|
||||||
.ql-snow .ql-fill,
|
|
||||||
.ql-snow .ql-stroke.ql-fill {
|
|
||||||
fill: oklch(var(--bc));
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker,
|
|
||||||
.ql-snow .ql-picker-options {
|
|
||||||
color: oklch(var(--bc));
|
|
||||||
}
|
|
||||||
.ql-snow .ql-picker-options {
|
|
||||||
background: oklch(var(--b1));
|
|
||||||
border-color: oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label,
|
|
||||||
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options {
|
|
||||||
border-color: oklch(var(--b3));
|
|
||||||
}
|
|
||||||
/* active / hover toolbar state -> gruvbox accent */
|
|
||||||
.ql-snow.ql-toolbar button:hover,
|
|
||||||
.ql-snow.ql-toolbar button:focus,
|
|
||||||
.ql-snow.ql-toolbar button.ql-active,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-label:hover,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-label.ql-active,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-item:hover,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-item.ql-selected,
|
|
||||||
.ql-snow .ql-picker.ql-expanded .ql-picker-label {
|
|
||||||
color: oklch(var(--p));
|
|
||||||
}
|
|
||||||
.ql-snow.ql-toolbar button:hover .ql-stroke,
|
|
||||||
.ql-snow.ql-toolbar button:focus .ql-stroke,
|
|
||||||
.ql-snow.ql-toolbar button.ql-active .ql-stroke,
|
|
||||||
.ql-snow.ql-toolbar button:hover .ql-stroke-miter,
|
|
||||||
.ql-snow.ql-toolbar button.ql-active .ql-stroke-miter,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
|
|
||||||
.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke {
|
|
||||||
stroke: oklch(var(--p));
|
|
||||||
}
|
|
||||||
.ql-snow.ql-toolbar button:hover .ql-fill,
|
|
||||||
.ql-snow.ql-toolbar button:focus .ql-fill,
|
|
||||||
.ql-snow.ql-toolbar button.ql-active .ql-fill,
|
|
||||||
.ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill,
|
|
||||||
.ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill,
|
|
||||||
.ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill,
|
|
||||||
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill {
|
|
||||||
fill: oklch(var(--p));
|
|
||||||
}
|
|
||||||
/* link tooltip popup */
|
|
||||||
.ql-snow .ql-tooltip {
|
|
||||||
background-color: oklch(var(--b1));
|
|
||||||
border-color: oklch(var(--b3));
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
|
||||||
color: oklch(var(--bc));
|
|
||||||
}
|
|
||||||
.ql-snow .ql-tooltip input[type=text] {
|
|
||||||
background: oklch(var(--b2));
|
|
||||||
border-color: oklch(var(--b3));
|
|
||||||
color: oklch(var(--bc));
|
|
||||||
}
|
|
||||||
.ql-snow .ql-tooltip a {
|
|
||||||
color: oklch(var(--p));
|
|
||||||
}
|
|
||||||
.ql-snow .ql-tooltip a.ql-action::after {
|
|
||||||
border-color: oklch(var(--b3));
|
|
||||||
}
|
|
||||||
.ql-snow .ql-editor a {
|
|
||||||
color: oklch(var(--p));
|
|
||||||
}
|
|
||||||
.blog-image-size-controls {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
.blog-image-size-controls.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.blog-image-size-controls button {
|
|
||||||
border: 1px solid oklch(var(--b3));
|
|
||||||
background: oklch(var(--b2));
|
|
||||||
padding: 0.3rem 0.65rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.blog-image-size-controls button:hover {
|
|
||||||
border-color: oklch(var(--p));
|
|
||||||
color: oklch(var(--p));
|
|
||||||
}
|
|
||||||
.blog-image-size-controls label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
.blog-image-size-controls input {
|
|
||||||
width: 5rem;
|
|
||||||
}
|
|
||||||
.blog-editor img,
|
|
||||||
.blog-content img {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
margin: 1rem auto;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
.blog-editor img {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.blog-image-small {
|
|
||||||
width: min(100%, 18rem);
|
|
||||||
}
|
|
||||||
.blog-image-medium {
|
|
||||||
width: min(100%, 34rem);
|
|
||||||
}
|
|
||||||
.blog-image-full {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.blog-content {
|
|
||||||
line-height: 1.75;
|
|
||||||
}
|
|
||||||
.blog-content h2 {
|
|
||||||
margin: 1.5rem 0 0.75rem;
|
|
||||||
font-size: 1.35rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.blog-content h3 {
|
|
||||||
margin: 1.25rem 0 0.5rem;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.blog-content p,
|
|
||||||
.blog-content ul,
|
|
||||||
.blog-content ol {
|
|
||||||
margin: 0.75rem 0;
|
|
||||||
}
|
|
||||||
.blog-content ul {
|
|
||||||
list-style: disc;
|
|
||||||
padding-left: 1.4rem;
|
|
||||||
}
|
|
||||||
.blog-content ol {
|
|
||||||
list-style: decimal;
|
|
||||||
padding-left: 1.4rem;
|
|
||||||
}
|
|
||||||
.blog-content a {
|
|
||||||
color: oklch(var(--p));
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- small screens ----------------------------------------- */
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.term-nav { gap: 0.5rem; }
|
|
||||||
.term-title { font-size: 1.4rem; }
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
(function () {
|
|
||||||
function setImageSize(image, size) {
|
|
||||||
image.classList.remove('blog-image-small', 'blog-image-medium', 'blog-image-full');
|
|
||||||
image.style.removeProperty('width');
|
|
||||||
image.style.removeProperty('height');
|
|
||||||
image.classList.add('blog-image-' + size);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setImageWidth(image, width) {
|
|
||||||
var px = parseInt(width, 10);
|
|
||||||
if (!Number.isFinite(px) || px < 40) return;
|
|
||||||
image.classList.remove('blog-image-small', 'blog-image-medium', 'blog-image-full');
|
|
||||||
image.style.width = Math.min(px, 1200) + 'px';
|
|
||||||
image.style.height = 'auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEditorImages(root) {
|
|
||||||
root.querySelectorAll('img').forEach(function (image) {
|
|
||||||
if (
|
|
||||||
!image.classList.contains('blog-image-small')
|
|
||||||
&& !image.classList.contains('blog-image-medium')
|
|
||||||
&& !image.classList.contains('blog-image-full')
|
|
||||||
) {
|
|
||||||
image.classList.add('blog-image-full');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initEditor(form) {
|
|
||||||
var editorEl = form.querySelector('[data-rich-editor]');
|
|
||||||
var contentInput = form.querySelector('[data-rich-content]');
|
|
||||||
var status = form.querySelector('[data-rich-status]');
|
|
||||||
var imageControls = form.querySelector('[data-image-size-controls]');
|
|
||||||
var imageWidthInput = form.querySelector('[data-image-width]');
|
|
||||||
if (!editorEl || !contentInput || !window.Quill) return;
|
|
||||||
|
|
||||||
var selectedImage = null;
|
|
||||||
var toolbar = [
|
|
||||||
[{ header: [2, 3, false] }],
|
|
||||||
['bold', 'italic'],
|
|
||||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
|
||||||
['link', 'image'],
|
|
||||||
['clean']
|
|
||||||
];
|
|
||||||
var editor = new Quill(editorEl, {
|
|
||||||
modules: { toolbar: toolbar },
|
|
||||||
placeholder: '',
|
|
||||||
theme: 'snow'
|
|
||||||
});
|
|
||||||
var initialContent = contentInput.value.trim();
|
|
||||||
if (initialContent) {
|
|
||||||
if (initialContent.indexOf('<') >= 0) editor.clipboard.dangerouslyPasteHTML(initialContent);
|
|
||||||
else editor.setText(initialContent);
|
|
||||||
normalizeEditorImages(editor.root);
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncContent() {
|
|
||||||
normalizeEditorImages(editor.root);
|
|
||||||
contentInput.value = editor.root.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(message) {
|
|
||||||
if (status) status.textContent = message || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseImageFile() {
|
|
||||||
var input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = 'image/jpeg,image/png,image/webp,image/gif';
|
|
||||||
input.addEventListener('change', function () {
|
|
||||||
var file = input.files && input.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
uploadImage(file);
|
|
||||||
});
|
|
||||||
input.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadImage(file) {
|
|
||||||
var formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
setStatus(status ? status.dataset.uploading : '');
|
|
||||||
try {
|
|
||||||
var response = await fetch('/images/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('upload failed');
|
|
||||||
var result = await response.json();
|
|
||||||
var range = editor.getSelection(true);
|
|
||||||
editor.insertEmbed(range.index, 'image', result.url, 'user');
|
|
||||||
editor.setSelection(range.index + 1, 0, 'silent');
|
|
||||||
window.setTimeout(function () {
|
|
||||||
var images = editor.root.querySelectorAll('img');
|
|
||||||
var image = images[images.length - 1];
|
|
||||||
if (image) {
|
|
||||||
setImageSize(image, 'full');
|
|
||||||
selectedImage = image;
|
|
||||||
if (imageControls) imageControls.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
syncContent();
|
|
||||||
}, 0);
|
|
||||||
setStatus(status ? status.dataset.uploaded : '');
|
|
||||||
} catch (_error) {
|
|
||||||
setStatus(status ? status.dataset.error : '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.getModule('toolbar').addHandler('image', chooseImageFile);
|
|
||||||
|
|
||||||
editor.root.addEventListener('click', function (event) {
|
|
||||||
if (event.target && event.target.tagName === 'IMG') {
|
|
||||||
selectedImage = event.target;
|
|
||||||
if (imageWidthInput) imageWidthInput.value = parseInt(selectedImage.style.width, 10) || '';
|
|
||||||
if (imageControls) imageControls.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (imageControls) {
|
|
||||||
imageControls.addEventListener('click', function (event) {
|
|
||||||
var button = event.target.closest('[data-image-size]');
|
|
||||||
if (button && selectedImage) {
|
|
||||||
setImageSize(selectedImage, button.dataset.imageSize);
|
|
||||||
if (imageWidthInput) imageWidthInput.value = '';
|
|
||||||
syncContent();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (imageWidthInput) {
|
|
||||||
imageWidthInput.addEventListener('change', function () {
|
|
||||||
if (!selectedImage) return;
|
|
||||||
setImageWidth(selectedImage, imageWidthInput.value);
|
|
||||||
syncContent();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.on('text-change', syncContent);
|
|
||||||
form.addEventListener('submit', syncContent);
|
|
||||||
syncContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
document.querySelectorAll('[data-rich-editor]').forEach(function (editorEl) {
|
|
||||||
var form = editorEl.closest('form');
|
|
||||||
if (form) initEditor(form);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
5
assets/static/vendor/alpine/alpinejs-3.14.9.min.js
vendored
Normal file
5
assets/static/vendor/alpine/alpinejs-3.14.9.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
31
assets/static/vendor/quill/LICENSE
vendored
31
assets/static/vendor/quill/LICENSE
vendored
@@ -1,31 +0,0 @@
|
|||||||
Copyright (c) 2017-2024, Slab
|
|
||||||
Copyright (c) 2014, Jason Chen
|
|
||||||
Copyright (c) 2013, salesforce.com
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
|
||||||
modification, are permitted provided that the following conditions
|
|
||||||
are met:
|
|
||||||
|
|
||||||
1. Redistributions of source code must retain the above copyright
|
|
||||||
notice, this list of conditions and the following disclaimer.
|
|
||||||
|
|
||||||
2. Redistributions in binary form must reproduce the above copyright
|
|
||||||
notice, this list of conditions and the following disclaimer in the
|
|
||||||
documentation and/or other materials provided with the distribution.
|
|
||||||
|
|
||||||
3. Neither the name of the copyright holder nor the names of its
|
|
||||||
contributors may be used to endorse or promote products derived from
|
|
||||||
this software without specific prior written permission.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
|
||||||
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
|
||||||
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
||||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
||||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
||||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
||||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
||||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
||||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
||||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
3
assets/static/vendor/quill/quill.js
vendored
3
assets/static/vendor/quill/quill.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Quill Editor v2.0.3
|
|
||||||
* https://quilljs.com
|
|
||||||
* Copyright (c) 2017-2024, Slab
|
|
||||||
* Copyright (c) 2014, Jason Chen
|
|
||||||
* Copyright (c) 2013, salesforce.com
|
|
||||||
*/
|
|
||||||
10
assets/static/vendor/quill/quill.snow.css
vendored
10
assets/static/vendor/quill/quill.snow.css
vendored
File diff suppressed because one or more lines are too long
@@ -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 %}
|
|
||||||
@@ -16,139 +16,159 @@
|
|||||||
|| (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">
|
x-data="{ showSidebar: false }"
|
||||||
<nav class="term-nav">
|
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
<a href="/admin/dashboard" class="term-brand">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a>
|
|
||||||
<ul class="nav-menu term-navlinks menu menu-sm hidden items-center md:flex">
|
<!-- dark overlay for the open sidebar on small screens -->
|
||||||
<li><a href="/admin/dashboard" data-nav="/admin/dashboard">{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}</a></li>
|
<div x-cloak x-show="showSidebar" x-transition.opacity aria-hidden="true"
|
||||||
<li><a href="/admin/blog/articles" data-nav="/admin/blog">{{ t(key="admin-blog", lang=lang | default(value='sk')) }}</a></li>
|
@click="showSidebar = false"
|
||||||
<li><a href="/admin/audio/albums" data-nav="/admin/audio">{{ t(key="admin-audio", lang=lang | default(value='sk')) }}</a></li>
|
class="fixed inset-0 z-30 bg-black/50 md:hidden"></div>
|
||||||
<li><a href="/admin/about" data-nav="/admin/about">{{ t(key="admin-about", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li><a href="/" class="t-blue">{{ t(key="admin-exit", lang=lang | default(value='sk')) }}</a></li>
|
<!-- sidebar -->
|
||||||
<li>
|
<nav aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||||
<form method="post" action="/admin/logout">
|
x-bind:class="showSidebar ? 'translate-x-0' : '-translate-x-60'"
|
||||||
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
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">
|
||||||
</form>
|
|
||||||
</li>
|
<a href="/admin/dashboard"
|
||||||
</ul>
|
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">
|
||||||
<div class="term-nav-right">
|
{{ t(key="admin-title", lang=lang | default(value='sk')) }}
|
||||||
<div class="dropdown dropdown-end md:hidden">
|
</a>
|
||||||
<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"
|
<div class="flex flex-1 flex-col gap-1 overflow-y-auto p-4">
|
||||||
stroke="currentColor" class="h-5 w-5">
|
<a href="/admin/dashboard" data-nav="/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 items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
</svg>
|
{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}
|
||||||
</div>
|
</a>
|
||||||
<ul tabindex="0"
|
<a href="/admin/catalog/products" data-nav="/admin/catalog/products"
|
||||||
class="menu dropdown-content z-50 mt-3 w-52 border border-base-300 bg-base-200 p-2 shadow-lg">
|
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
<li><a href="/admin/dashboard">{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}</a></li>
|
{{ t(key="admin-products", lang=lang | default(value='sk')) }}
|
||||||
<li><a href="/admin/blog/articles">{{ t(key="admin-blog", lang=lang | default(value='sk')) }}</a></li>
|
</a>
|
||||||
<li><a href="/admin/audio/albums">{{ t(key="admin-audio", lang=lang | default(value='sk')) }}</a></li>
|
<a href="/admin/catalog/categories" data-nav="/admin/catalog/categories"
|
||||||
<li><a href="/admin/about">{{ t(key="admin-about", lang=lang | default(value='sk')) }}</a></li>
|
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
<li><a href="/" class="t-blue">{{ t(key="admin-exit", lang=lang | default(value='sk')) }}</a></li>
|
{{ t(key="admin-categories", lang=lang | default(value='sk')) }}
|
||||||
<li>
|
</a>
|
||||||
<form method="post" action="/admin/logout">
|
<a href="/admin/orders" data-nav="/admin/orders"
|
||||||
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
</form>
|
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
|
||||||
</li>
|
</a>
|
||||||
</ul>
|
<a href="/admin/shipping" data-nav="/admin/shipping"
|
||||||
</div>
|
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
<div class="dropdown dropdown-end">
|
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
|
||||||
<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')) }}">
|
</a>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
</div>
|
||||||
stroke="currentColor" class="h-5 w-5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<div class="border-t border-outline p-4 dark:border-outline-dark">
|
||||||
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" />
|
<a href="/" class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-info transition hover:bg-surface dark:hover:bg-surface-dark">
|
||||||
<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-exit", lang=lang | default(value='sk')) }}
|
||||||
</svg>
|
</a>
|
||||||
</div>
|
<form method="post" action="/admin/logout">
|
||||||
|
<button type="submit" class="flex w-full items-center gap-3 rounded-radius px-3 py-2 text-left text-sm font-medium text-danger transition hover:bg-surface dark:hover:bg-surface-dark">
|
||||||
|
{{ t(key="logout", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- content column -->
|
||||||
|
<div class="flex min-h-screen flex-col md:ml-60">
|
||||||
|
<header class="sticky top-0 z-20 flex h-16 items-center gap-4 border-b border-outline bg-surface/95 px-4 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
||||||
|
<button type="button" @click="showSidebar = !showSidebar" :aria-expanded="showSidebar"
|
||||||
|
aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||||
|
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt md:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
|
stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{% block crumb %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- settings (language + theme) dropdown -->
|
||||||
|
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative ml-auto">
|
||||||
|
<button type="button" @click="open = !open" :aria-expanded="open"
|
||||||
|
aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}"
|
||||||
|
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
|
stroke="currentColor" class="size-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="open" x-cloak @click.outside="open = false" x-transition.origin.top.right
|
||||||
|
class="absolute right-0 mt-2 w-56 rounded-radius border border-outline bg-surface p-2 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<form method="post" action="/lang" hx-boost="false">
|
<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">
|
<p class="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
<li class="menu-title">{{ t(key="settings-language", lang=lang | default(value='sk')) }}</li>
|
{{ t(key="settings-language", lang=lang | default(value='sk')) }}
|
||||||
<li>
|
</p>
|
||||||
<button type="submit" name="lang" value="en" class="{% if lang | default(value='sk') == 'en' %}active{% endif %}">
|
<button type="submit" name="lang" value="en"
|
||||||
{{ t(key="language-en", lang=lang | default(value='sk')) }}
|
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||||
{% if lang | default(value='sk') == 'en' %}
|
<span>English</span>
|
||||||
<span class="ml-auto">✓</span>
|
{% if lang | default(value='sk') == "en" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||||
{% endif %}
|
</button>
|
||||||
</button>
|
<button type="submit" name="lang" value="sk"
|
||||||
</li>
|
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||||
<li>
|
<span>Slovenčina</span>
|
||||||
<button type="submit" name="lang" value="sk" class="{% if lang | default(value='sk') == 'sk' %}active{% endif %}">
|
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||||
{{ t(key="language-sk", lang=lang | default(value='sk')) }}
|
</button>
|
||||||
{% 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>
|
</form>
|
||||||
|
<p class="mt-1 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
<div x-data="{ theme: currentTheme() }" @theme:changed.document="theme = $event.detail">
|
||||||
|
<button type="button" @click="setTheme('system')"
|
||||||
|
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||||
|
<span>{{ t(key="theme-system", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span x-show="theme === 'system'" class="text-primary dark:text-primary-dark">✓</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="setTheme('light')"
|
||||||
|
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||||
|
<span>{{ t(key="theme-light", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span x-show="theme === 'light'" class="text-primary dark:text-primary-dark">✓</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="setTheme('dark')"
|
||||||
|
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||||
|
<span>{{ t(key="theme-dark", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span x-show="theme === 'dark'" class="text-primary dark:text-primary-dark">✓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
|
||||||
70
assets/views/admin/catalog/categories.html
Normal file
70
assets/views/admin/catalog/categories.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-categories", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-categories-desc", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/catalog/categories/new"
|
||||||
|
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="new-category", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||||
|
{% if categories | length > 0 %}
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="name", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="status", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">{{ t(key="actions", lang=lang | default(value='sk')) }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
||||||
|
{% for row in categories %}
|
||||||
|
<tr class="hover:bg-surface-alt dark:hover:bg-surface-dark-alt">
|
||||||
|
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
<span style="padding-left: {{ row.depth * 20 }}px" class="inline-flex items-center gap-1.5">
|
||||||
|
{% if row.depth > 0 %}<span class="text-on-surface/40 dark:text-on-surface-dark/40">↳</span>{% endif %}
|
||||||
|
{{ row.category.name }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">{{ row.product_count }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% if row.category.published %}
|
||||||
|
<span class="inline-flex rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex rounded-full bg-surface-alt px-2 py-0.5 text-xs font-medium text-on-surface/70 dark:bg-surface-dark-alt dark:text-on-surface-dark/70">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
|
<a href="/admin/catalog/categories/{{ row.category.id }}/edit"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="edit", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<form method="post" action="/admin/catalog/categories/{{ row.category.id }}/delete"
|
||||||
|
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-danger transition hover:bg-danger/10 dark:border-outline-dark">{{ t(key="delete", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-categories", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<a href="/admin/catalog/categories/new"
|
||||||
|
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="new-category", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
82
assets/views/admin/catalog/category_form.html
Normal file
82
assets/views/admin/catalog/category_form.html
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
<a href="/admin/catalog/categories"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data"
|
||||||
|
action="{% if 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">
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="name" name="name" type="text" required value="{% if category %}{{ category.name }}{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="slug" name="slug" type="text" value="{% if category %}{{ category.slug }}{% endif %}"
|
||||||
|
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="position" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="position", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="position" name="position" type="number" value="{% if category %}{{ category.position }}{% else %}0{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="parent_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="parent-category", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<select id="parent_id" name="parent_id"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
<option value="">{{ t(key="no-parent", lang=lang | default(value='sk')) }}</option>
|
||||||
|
{% for parent in parents %}
|
||||||
|
<option value="{{ parent.id }}" {% if 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<textarea id="description" name="description" rows="4"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if category and category.description %}{{ category.description }}{% endif %}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{% if category and category.image_id %}
|
||||||
|
<img src="/images/{{ category.image_id }}" alt="" class="size-24 rounded-radius object-cover">
|
||||||
|
{% endif %}
|
||||||
|
<input id="image" name="image" type="file" accept="image/*"
|
||||||
|
class="block w-full text-sm text-on-surface file:mr-3 file:rounded-radius file:border-0 file:bg-primary file:px-3 file:py-2 file:text-sm file:font-medium file:text-on-primary dark:text-on-surface-dark dark:file:bg-primary-dark dark:file:text-on-primary-dark">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="published" value="on" {% if category and category.published %}checked{% endif %}
|
||||||
|
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="save", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/catalog/categories"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
100
assets/views/admin/catalog/product_form.html
Normal file
100
assets/views/admin/catalog/product_form.html
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
<a href="/admin/catalog/products"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data"
|
||||||
|
action="{% if 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">
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="name" name="name" type="text" required value="{% if product %}{{ product.name }}{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="price" name="price" type="text" inputmode="decimal" required value="{% if product %}{{ product.price }}{% endif %}"
|
||||||
|
placeholder="0.00"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="currency" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="currency", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="currency" name="currency" type="text" maxlength="3" value="{% if product %}{{ product.currency }}{% else %}EUR{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm uppercase text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="stock" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="stock" name="stock" type="number" min="0" value="{% if product %}{{ product.stock }}{% else %}0{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="sku" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="sku" name="sku" type="text" value="{% if product and product.sku %}{{ product.sku }}{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="category_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="category", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<select id="category_id" name="category_id"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
<option value="">{{ t(key="no-category", lang=lang | default(value='sk')) }}</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category.id }}" {% if product and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="slug" name="slug" type="text" value="{% if product %}{{ product.slug }}{% endif %}"
|
||||||
|
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<textarea id="description" name="description" rows="5"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if product and product.description %}{{ product.description }}{% endif %}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{% if product and product.image %}
|
||||||
|
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
|
||||||
|
{% endif %}
|
||||||
|
<input id="image" name="image" type="file" accept="image/*"
|
||||||
|
class="block w-full text-sm text-on-surface file:mr-3 file:rounded-radius file:border-0 file:bg-primary file:px-3 file:py-2 file:text-sm file:font-medium file:text-on-primary dark:text-on-surface-dark dark:file:bg-primary-dark dark:file:text-on-primary-dark">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="published" value="on" {% if product and product.published %}checked{% endif %}
|
||||||
|
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="save", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/catalog/products"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
81
assets/views/admin/catalog/products.html
Normal file
81
assets/views/admin/catalog/products.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-products-desc", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/catalog/products/new"
|
||||||
|
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="new-product", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||||
|
{% if products | length > 0 %}
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="product", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="price", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="stock", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="status", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">{{ t(key="actions", lang=lang | default(value='sk')) }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
||||||
|
{% for product in products %}
|
||||||
|
<tr class="hover:bg-surface-alt dark:hover:bg-surface-dark-alt">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{% if product.image %}
|
||||||
|
<img src="/images/{{ product.image }}" alt="" class="size-10 rounded-radius object-cover">
|
||||||
|
{% else %}
|
||||||
|
<div class="size-10 rounded-radius bg-surface-alt dark:bg-surface-dark-alt"></div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</div>
|
||||||
|
{% if product.category_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ product.category_name }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">{{ product.price }} {{ product.currency }}</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">{{ product.stock }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% if product.published %}
|
||||||
|
<span class="inline-flex rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex rounded-full bg-surface-alt px-2 py-0.5 text-xs font-medium text-on-surface/70 dark:bg-surface-dark-alt dark:text-on-surface-dark/70">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
|
<a href="/admin/catalog/products/{{ product.id }}/edit"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="edit", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/shop/{{ product.slug }}"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="view", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
|
||||||
|
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-danger transition hover:bg-danger/10 dark:border-outline-dark">{{ t(key="delete", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<a href="/admin/catalog/products/new"
|
||||||
|
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="new-product", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -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,32 +1,60 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ t(key="login-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
{% block title %}{{ t(key="login-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
{% block crumb %}admin/login{% endblock crumb %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mx-auto mt-8 max-w-sm">
|
<div class="mx-auto mt-8 max-w-sm">
|
||||||
<div class="card">
|
<div
|
||||||
<div class="term-head">
|
class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<span class="term-head-name">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</span>
|
<div
|
||||||
<span class="term-head-meta term-tag is-red">{{ t(key="auth", lang=lang | default(value='sk')) }}</span>
|
class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="nav-admin", lang=lang | default(value='sk')) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded-radius border border-danger/40 px-2 py-0.5 text-xs font-medium text-danger">
|
||||||
|
{{ t(key="auth", lang=lang | default(value='sk')) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<h1 class="term-title">{{ t(key="login-auth", lang=lang | default(value='sk')) }}</h1>
|
<div class="p-5">
|
||||||
|
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="login-auth", lang=lang | default(value='sk')) }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-error mt-2">
|
<div
|
||||||
<span>✗ {{ t(key="login-error", lang=lang | default(value='sk')) }}</span>
|
class="mt-3 rounded-radius border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||||
|
role="alert">
|
||||||
|
✗ {{ t(key="login-error", lang=lang | default(value='sk')) }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="/admin/login" hx-boost="false" class="space-y-2">
|
|
||||||
<div class="form-control">
|
<form method="post" action="/admin/login" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||||
<label class="label"><span class="label-text t-green">{{ t(key="login-email", lang=lang | default(value='sk')) }}:</span></label>
|
<div class="flex flex-col gap-1">
|
||||||
<input type="email" name="email" required autofocus class="input input-bordered w-full">
|
<label for="email"
|
||||||
|
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="login-email", lang=lang | default(value='sk')) }}
|
||||||
|
</label>
|
||||||
|
<input type="email" id="email" name="email" required autofocus
|
||||||
|
autocomplete="email"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark dark:focus:ring-primary-dark">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text t-green">{{ t(key="login-password", lang=lang | default(value='sk')) }}:</span></label>
|
<div class="flex flex-col gap-1">
|
||||||
<input type="password" name="password" required class="input input-bordered w-full">
|
<label for="password"
|
||||||
|
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="login-password", lang=lang | default(value='sk')) }}
|
||||||
|
</label>
|
||||||
|
<input type="password" id="password" name="password" required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark dark:focus:ring-primary-dark">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary mt-2 w-full">[ {{ t(key="login-auth", lang=lang | default(value='sk')) }} ]</button>
|
|
||||||
|
<button type="submit"
|
||||||
|
class="mt-1 w-full rounded-radius bg-primary px-4 py-2 text-sm font-semibold text-on-primary transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-surface-alt dark:bg-primary-dark dark:text-on-primary-dark dark:focus:ring-primary-dark dark:focus:ring-offset-surface-dark-alt">
|
||||||
|
{{ t(key="login-auth", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
41
assets/views/admin/orders/index.html
Normal file
41
assets/views/admin/orders/index.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
<div class="mt-6 overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||||
|
{% if orders | length > 0 %}
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="order-number", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="order-customer", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="order-status", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
||||||
|
{% for order in orders %}
|
||||||
|
<tr class="hover:bg-surface-alt dark:hover:bg-surface-dark-alt">
|
||||||
|
<td class="px-4 py-3 font-mono font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</td>
|
||||||
|
<td class="px-4 py-3">{{ order.email }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="inline-flex rounded-full bg-surface-alt px-2 py-0.5 text-xs font-medium text-on-surface/80 dark:bg-surface-dark-alt dark:text-on-surface-dark/80">{{ t(key="order-status-" ~ order.status, lang=lang | default(value='sk')) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right tabular-nums">{{ order.total }} {{ order.currency }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<a href="/admin/orders/{{ order.id }}" class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="view", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-4 py-16 text-center text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-orders", lang=lang | default(value='sk')) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
118
assets/views/admin/orders/show.html
Normal file
118
assets/views/admin/orders/show.html
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ order.order_number }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 class="font-mono text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</h1>
|
||||||
|
<a href="/admin/orders" class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if ship_error %}
|
||||||
|
<div class="mt-4 rounded-radius border border-danger/40 bg-danger/10 px-4 py-3 text-sm font-medium text-danger">
|
||||||
|
{{ ship_error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-6 lg:grid-cols-3">
|
||||||
|
<div class="space-y-6 lg:col-span-2">
|
||||||
|
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="product", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="quantity", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3">{{ item.product_name }}</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">{{ item.quantity }}</td>
|
||||||
|
<td class="px-4 py-3 text-right tabular-nums">{{ item.line_total }} {{ order.currency }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot class="border-t border-outline dark:border-outline-dark">
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</td>
|
||||||
|
<td class="px-4 py-3 text-right font-bold tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="space-y-6">
|
||||||
|
<div class="space-y-3 rounded-radius border border-outline bg-surface p-5 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="order-customer", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.customer_name }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.email }}</p>
|
||||||
|
{% if order.phone %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.phone }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}<br>{{ order.zip }} {{ order.city }}<br>{{ order.country }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.carrier_name }} — {{ order.shipping }} {{ order.currency }}</p>
|
||||||
|
{% if order.pickup_point_name %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.pickup_point_name }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">
|
||||||
|
{% if order.payment_method == "bank_transfer" %}{{ t(key="payment-bank", lang=lang | default(value='sk')) }} · VS {{ order.variable_symbol }}{% else %}{{ t(key="payment-cod", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% if order.note %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.note }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 %}
|
||||||
|
<a href="{{ order.label_url }}" target="_blank" rel="noopener"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="order-label", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% 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')) }}')">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="order-send-to-carrier", lang=lang | default(value='sk')) }} {{ carrier | upper }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/orders/{{ order.id }}/status" class="space-y-3 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<label for="status" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-status", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<select id="status" name="status"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
{% for status in statuses %}
|
||||||
|
<option value="{{ status }}" {% if order.status == status %}selected{% endif %}>{{ t(key="order-status-" ~ status, lang=lang | default(value='sk')) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="order-update-status", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
37
assets/views/admin/shipping/index.html
Normal file
37
assets/views/admin/shipping/index.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-shipping-desc", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
{% for method in methods %}
|
||||||
|
<form method="post" action="/admin/shipping/{{ method.id }}"
|
||||||
|
class="flex flex-wrap items-end gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="min-w-40">
|
||||||
|
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ method.name }}</p>
|
||||||
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.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>
|
||||||
|
<input id="price-{{ method.id }}" name="price" type="text" inputmode="decimal" value="{{ method.price }}"
|
||||||
|
class="w-28 rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 pb-2">
|
||||||
|
<input type="checkbox" name="enabled" value="on" {% if method.enabled %}checked{% endif %}
|
||||||
|
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shipping-enabled", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit"
|
||||||
|
class="ml-auto inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="save", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -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 %}
|
|
||||||
@@ -11,356 +11,199 @@
|
|||||||
<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 ----------
|
|
||||||
// Survives htmx-boosted navigation: window state persists and
|
|
||||||
// #uw-player carries hx-preserve so <audio> keeps playing.
|
|
||||||
var uwQueue = []; // [{ src, title }]
|
|
||||||
var uwIndex = -1; // index of the current track, -1 when empty
|
|
||||||
|
|
||||||
function uwSave() {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem('uwQueue', JSON.stringify({ q: uwQueue, i: uwIndex }));
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
function uwRestore() {
|
|
||||||
try {
|
|
||||||
var d = JSON.parse(sessionStorage.getItem('uwQueue') || 'null');
|
|
||||||
if (d && d.q) { uwQueue = d.q; uwIndex = (typeof d.i === 'number' ? d.i : -1); }
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
function uwRenderQueue() {
|
|
||||||
var player = document.getElementById('uw-player');
|
|
||||||
if (player) player.classList.toggle('uw-has-queue', uwQueue.length > 0);
|
|
||||||
var badge = document.getElementById('uw-queue-badge');
|
|
||||||
if (badge) badge.textContent = uwQueue.length;
|
|
||||||
var count = document.getElementById('uw-queue-count');
|
|
||||||
if (count) count.textContent = uwQueue.length + (uwQueue.length === 1 ? ' track' : ' tracks');
|
|
||||||
var list = document.getElementById('uw-queue-list');
|
|
||||||
if (!list) return;
|
|
||||||
list.innerHTML = '';
|
|
||||||
uwQueue.forEach(function (t, idx) {
|
|
||||||
var li = document.createElement('li');
|
|
||||||
li.className = 'uw-queue-item' + (idx === uwIndex ? ' is-current' : '');
|
|
||||||
var jump = document.createElement('button');
|
|
||||||
jump.type = 'button';
|
|
||||||
jump.className = 'uw-queue-jump';
|
|
||||||
jump.setAttribute('data-uw-jump', idx);
|
|
||||||
jump.textContent = (idx === uwIndex ? '▸' : (idx + 1));
|
|
||||||
var name = document.createElement('span');
|
|
||||||
name.className = 'uw-queue-name';
|
|
||||||
name.setAttribute('data-uw-jump', idx);
|
|
||||||
name.textContent = t.title || 'unknown track';
|
|
||||||
var rm = document.createElement('button');
|
|
||||||
rm.type = 'button';
|
|
||||||
rm.className = 'uw-queue-remove';
|
|
||||||
rm.setAttribute('data-uw-remove', idx);
|
|
||||||
rm.setAttribute('aria-label', 'Remove from playlist');
|
|
||||||
rm.textContent = '✕';
|
|
||||||
li.appendChild(jump);
|
|
||||||
li.appendChild(name);
|
|
||||||
li.appendChild(rm);
|
|
||||||
list.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Point <audio> at the current queue entry; play it when asked.
|
|
||||||
function uwLoad(autoplay) {
|
|
||||||
var audio = document.getElementById('uw-audio');
|
|
||||||
var now = document.getElementById('uw-now');
|
|
||||||
if (!audio) return;
|
|
||||||
var t = uwQueue[uwIndex];
|
|
||||||
if (!t) {
|
|
||||||
if (now) now.textContent = '—';
|
|
||||||
audio.pause();
|
|
||||||
audio.removeAttribute('src');
|
|
||||||
document.documentElement.classList.remove('uw-playing');
|
|
||||||
uwRenderQueue();
|
|
||||||
uwSave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.documentElement.classList.add('uw-playing');
|
|
||||||
if (now) now.textContent = t.title || 'unknown track';
|
|
||||||
if (audio.getAttribute('src') !== t.src) {
|
|
||||||
audio.setAttribute('src', t.src);
|
|
||||||
audio.load();
|
|
||||||
}
|
|
||||||
if (autoplay) {
|
|
||||||
var p = audio.play();
|
|
||||||
if (p && p.catch) p.catch(function () {});
|
|
||||||
}
|
|
||||||
uwRenderQueue();
|
|
||||||
uwSave();
|
|
||||||
}
|
|
||||||
// Replace the whole queue with a fresh set of tracks and play.
|
|
||||||
function uwPlayList(tracks) {
|
|
||||||
if (!tracks || !tracks.length) return;
|
|
||||||
uwQueue = tracks.slice();
|
|
||||||
uwIndex = 0;
|
|
||||||
uwLoad(true);
|
|
||||||
}
|
|
||||||
// Add one track: play it now if idle, otherwise queue it.
|
|
||||||
function uwAdd(src, title) {
|
|
||||||
var audio = document.getElementById('uw-audio');
|
|
||||||
var idle = uwIndex < 0 || !audio || audio.ended || !audio.getAttribute('src');
|
|
||||||
uwQueue.push({ src: src, title: title });
|
|
||||||
if (idle) { uwIndex = uwQueue.length - 1; uwLoad(true); }
|
|
||||||
else { uwRenderQueue(); uwSave(); }
|
|
||||||
}
|
|
||||||
function uwNext() {
|
|
||||||
if (uwIndex >= 0 && uwIndex < uwQueue.length - 1) { uwIndex++; uwLoad(true); }
|
|
||||||
}
|
|
||||||
function uwPrev() {
|
|
||||||
var audio = document.getElementById('uw-audio');
|
|
||||||
if (audio && audio.currentTime > 3) { audio.currentTime = 0; return; }
|
|
||||||
if (uwIndex > 0) { uwIndex--; uwLoad(true); }
|
|
||||||
else if (audio) audio.currentTime = 0;
|
|
||||||
}
|
|
||||||
function uwJump(idx) {
|
|
||||||
if (idx >= 0 && idx < uwQueue.length) { uwIndex = idx; uwLoad(true); }
|
|
||||||
}
|
|
||||||
function uwRemove(idx) {
|
|
||||||
if (idx < 0 || idx >= uwQueue.length) return;
|
|
||||||
var playing = document.documentElement.classList.contains('uw-playing');
|
|
||||||
uwQueue.splice(idx, 1);
|
|
||||||
if (idx < uwIndex) { uwIndex--; uwRenderQueue(); uwSave(); }
|
|
||||||
else if (idx > uwIndex) { uwRenderQueue(); uwSave(); }
|
|
||||||
else {
|
|
||||||
if (uwIndex >= uwQueue.length) uwIndex = uwQueue.length - 1;
|
|
||||||
uwLoad(playing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function uwClear() {
|
|
||||||
uwQueue = [];
|
|
||||||
uwIndex = -1;
|
|
||||||
uwLoad(false);
|
|
||||||
var panel = document.getElementById('uw-queue');
|
|
||||||
if (panel) panel.hidden = true;
|
|
||||||
}
|
|
||||||
function uwInit() {
|
|
||||||
var audio = document.getElementById('uw-audio');
|
|
||||||
if (!audio || audio.dataset.uwBound) return;
|
|
||||||
audio.dataset.uwBound = '1';
|
|
||||||
uwRestore();
|
|
||||||
audio.addEventListener('ended', uwNext);
|
|
||||||
uwRenderQueue();
|
|
||||||
if (uwIndex >= 0 && uwQueue[uwIndex]) uwLoad(false);
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', function () { initPage(); uwInit(); });
|
|
||||||
document.addEventListener('htmx:afterSwap', initPage);
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
if (!e.target.closest) return;
|
|
||||||
var albumBtn = e.target.closest('.uw-play-album');
|
|
||||||
if (albumBtn) {
|
|
||||||
var sel = albumBtn.getAttribute('data-tracks-from');
|
|
||||||
var scope = (sel && document.querySelector(sel)) || document;
|
|
||||||
var tracks = [];
|
|
||||||
scope.querySelectorAll('.uw-play').forEach(function (b) {
|
|
||||||
tracks.push({ src: b.getAttribute('data-src'), title: b.getAttribute('data-title') });
|
|
||||||
});
|
|
||||||
uwPlayList(tracks);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Play an album straight from the listing: fetch its tracks first.
|
|
||||||
var remoteBtn = e.target.closest('.uw-play-album-remote');
|
|
||||||
if (remoteBtn) {
|
|
||||||
var rurl = remoteBtn.getAttribute('data-album-tracks-url');
|
|
||||||
if (rurl) {
|
|
||||||
remoteBtn.disabled = true;
|
|
||||||
fetch(rurl, { headers: { 'Accept': 'application/json' } })
|
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (d) { if (d && d.tracks) uwPlayList(d.tracks); })
|
|
||||||
.catch(function () {})
|
|
||||||
.then(function () { remoteBtn.disabled = false; });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var playBtn = e.target.closest('.uw-play');
|
|
||||||
if (playBtn) {
|
|
||||||
uwAdd(playBtn.getAttribute('data-src'), playBtn.getAttribute('data-title'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var jumpEl = e.target.closest('[data-uw-jump]');
|
|
||||||
if (jumpEl) { uwJump(parseInt(jumpEl.getAttribute('data-uw-jump'), 10)); return; }
|
|
||||||
var rmEl = e.target.closest('[data-uw-remove]');
|
|
||||||
if (rmEl) { uwRemove(parseInt(rmEl.getAttribute('data-uw-remove'), 10)); return; }
|
|
||||||
if (e.target.closest('#uw-next')) { uwNext(); return; }
|
|
||||||
if (e.target.closest('#uw-prev')) { uwPrev(); return; }
|
|
||||||
if (e.target.closest('#uw-queue-clear')) { uwClear(); return; }
|
|
||||||
if (e.target.closest('#uw-queue-toggle')) {
|
|
||||||
var panel = document.getElementById('uw-queue');
|
|
||||||
if (panel) panel.hidden = !panel.hidden;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.target.closest('#uw-close')) { uwClear(); return; }
|
|
||||||
});
|
|
||||||
</script>
|
</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>
|
<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 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">
|
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }"
|
||||||
<nav class="term-nav">
|
x-init="window.matchMedia('(min-width: 1024px)').addEventListener('change', e => lg = e.matches)"
|
||||||
<a href="/" class="term-brand">{{ t(key="brand", 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">
|
<header
|
||||||
<li><a href="/" data-nav="/">{{ t(key="nav-home", 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="/blog" data-nav="/blog">{{ t(key="nav-blog", 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/albums" data-nav="/audio/albums">{{ t(key="nav-audio", lang=lang | default(value='sk')) }}</a></li>
|
<!-- category sidebar toggle (mobile only) -->
|
||||||
<li><a href="/audio/tracks" data-nav="/audio/tracks">{{ t(key="nav-songs", lang=lang | default(value='sk')) }}</a></li>
|
<button type="button" @click="cats = !cats" :aria-expanded="cats"
|
||||||
<li><a href="/about" data-nav="/about">{{ t(key="nav-about", lang=lang | default(value='sk')) }}</a></li>
|
aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
|
||||||
|
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt lg:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<a href="/"
|
||||||
|
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="brand", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- desktop links -->
|
||||||
|
<ul class="ml-2 hidden items-center gap-1 md:flex">
|
||||||
|
<li><a href="/" data-nav="/" class="rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary aria-[current=page]:text-primary aria-[current=page]:font-semibold dark:text-on-surface-dark dark:hover:bg-surface-dark-alt dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
<li><a href="/shop" data-nav="/shop" class="rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary aria-[current=page]:text-primary aria-[current=page]:font-semibold dark:text-on-surface-dark dark:hover:bg-surface-dark-alt dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a></li>
|
||||||
{% if logged_in_admin %}
|
{% if logged_in_admin %}
|
||||||
<li><a href="/admin/dashboard" hx-boost="false" class="t-yellow" data-nav="/admin">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
|
<li><a href="/admin/dashboard" hx-boost="false" data-nav="/admin" class="rounded-radius px-3 py-1.5 text-sm font-medium text-warning transition hover:bg-surface-alt dark:hover:bg-surface-dark-alt">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
|
||||||
<li>
|
<li>
|
||||||
<form method="post" action="/admin/logout" hx-boost="false">
|
<form method="post" action="/admin/logout" hx-boost="false">
|
||||||
<button type="submit" class="t-red w-full">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
<button type="submit" class="rounded-radius px-3 py-1.5 text-sm font-medium text-danger transition hover:bg-surface-alt dark:hover:bg-surface-dark-alt">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
||||||
</form>
|
</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="/admin/login" data-nav="/admin/login" class="rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary aria-[current=page]:text-primary aria-[current=page]:font-semibold dark:text-on-surface-dark dark:hover:bg-surface-dark-alt dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="term-nav-right">
|
|
||||||
<div class="dropdown dropdown-end md:hidden">
|
<!-- right side: cart + settings + mobile toggle -->
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}">
|
<div class="ml-auto flex items-center gap-1">
|
||||||
|
<!-- cart with live item-count badge read from the `cart` cookie -->
|
||||||
|
<a href="/cart" data-nav="/cart"
|
||||||
|
x-data="{ count: 0 }"
|
||||||
|
x-init="count = cartCount(); window.addEventListener('htmx:afterSwap', function () { count = cartCount() })"
|
||||||
|
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||||
|
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||||
|
class="relative inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
||||||
|
</svg>
|
||||||
|
<span x-show="count > 0" x-cloak x-text="count"
|
||||||
|
class="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-on-primary dark:bg-primary-dark dark:text-on-primary-dark"></span>
|
||||||
|
</a>
|
||||||
|
<!-- settings (language + theme) dropdown -->
|
||||||
|
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative">
|
||||||
|
<button type="button" @click="open = !open" :aria-expanded="open"
|
||||||
|
aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}"
|
||||||
|
title="{{ t(key='settings', lang=lang | default(value='sk')) }}"
|
||||||
|
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
stroke="currentColor" class="h-5 w-5">
|
stroke="currentColor" class="size-5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="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"
|
<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" />
|
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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="open" x-cloak @click.outside="open = false"
|
||||||
|
x-transition.origin.top.right
|
||||||
|
class="absolute right-0 mt-2 w-56 rounded-radius border border-outline bg-surface p-2 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<form method="post" action="/lang" hx-boost="false">
|
||||||
|
<p class="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ t(key="settings-language", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
<button type="submit" name="lang" value="en"
|
||||||
|
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||||
|
<span>English</span>
|
||||||
|
{% if lang | default(value='sk') == "en" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="lang" value="sk"
|
||||||
|
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||||
|
<span>Slovenčina</span>
|
||||||
|
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-1 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
<div x-data="{ theme: currentTheme() }" @theme:changed.document="theme = $event.detail">
|
||||||
|
<button type="button" @click="setTheme('system')"
|
||||||
|
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||||
|
<span>{{ t(key="theme-system", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span x-show="theme === 'system'" class="text-primary dark:text-primary-dark">✓</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="setTheme('light')"
|
||||||
|
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||||
|
<span>{{ t(key="theme-light", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span x-show="theme === 'light'" class="text-primary dark:text-primary-dark">✓</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="setTheme('dark')"
|
||||||
|
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||||
|
<span>{{ t(key="theme-dark", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span x-show="theme === 'dark'" class="text-primary dark:text-primary-dark">✓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<!-- mobile hamburger -->
|
||||||
|
<button type="button" @click="mobile = !mobile" :aria-expanded="mobile"
|
||||||
|
aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||||
|
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt md:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
|
stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- mobile menu panel -->
|
||||||
|
<ul x-show="mobile" x-cloak @click.outside="mobile = false" x-transition
|
||||||
|
class="absolute inset-x-0 top-full mx-4 mt-2 flex flex-col gap-1 rounded-radius border border-outline bg-surface p-2 shadow-lg md:hidden dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<li><a href="/" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
<li><a href="/shop" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
{% if logged_in_admin %}
|
||||||
|
<li><a href="/admin/dashboard" hx-boost="false" class="block rounded-radius px-3 py-2 text-sm font-medium text-warning hover:bg-surface-alt dark:hover:bg-surface-dark">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/admin/logout" hx-boost="false">
|
||||||
|
<button type="submit" class="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger hover:bg-surface-alt dark:hover:bg-surface-dark">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="/admin/login" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt hover:text-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
</nav>
|
</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">
|
<!-- persistent category sidebar (off-canvas drawer on mobile).
|
||||||
<span class="uw-queue-title">☰ playlist</span>
|
hx-preserve keeps this node across boosted page swaps, so it is
|
||||||
<span id="uw-queue-count" class="uw-queue-meta">0 tracks</span>
|
fetched once (hx-trigger=load) and never reloaded on navigation. -->
|
||||||
<button type="button" id="uw-queue-clear" class="uw-queue-clear">clear</button>
|
<aside id="category-sidebar" hx-preserve="true"
|
||||||
</div>
|
x-cloak x-show="cats || lg" aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
|
||||||
<ol id="uw-queue-list" class="uw-queue-list"></ol>
|
hx-get="/partials/categories" hx-trigger="load"
|
||||||
</div>
|
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">
|
||||||
<div class="uw-player-inner">
|
</aside>
|
||||||
<span class="uw-player-tag">▶ now playing</span>
|
|
||||||
<span id="uw-now" class="uw-player-title">—</span>
|
<main class="min-w-0 flex-1">
|
||||||
<button type="button" id="uw-prev" class="uw-player-btn" aria-label="Previous track" title="Previous">⏮</button>
|
{% block content %}{% endblock content %}
|
||||||
<audio id="uw-audio" controls preload="none"></audio>
|
</main>
|
||||||
<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,29 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% 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 %}
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
29
assets/views/shop/_card.html
Normal file
29
assets/views/shop/_card.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<div
|
||||||
|
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
|
||||||
|
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
|
||||||
|
<div class="aspect-square overflow-hidden bg-surface-alt dark:bg-surface-dark">
|
||||||
|
{% if product.image %}
|
||||||
|
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition group-hover:scale-105">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col gap-1 p-4 pb-2">
|
||||||
|
<h3 class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||||
|
<p class="mt-auto pt-2 font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="flex flex-col gap-2 px-4 pb-4">
|
||||||
|
{% 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-boost="false">
|
||||||
|
<input type="hidden" name="product_id" value="{{ product.id }}">
|
||||||
|
<input type="hidden" name="quantity" value="1">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
71
assets/views/shop/_cart_body.html
Normal file
71
assets/views/shop/_cart_body.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{# 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. #}
|
||||||
|
{% if items | length > 0 %}
|
||||||
|
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="product", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="price", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="quantity", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<a href="/shop/{{ item.slug }}" class="font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">{{ item.price }} {{ item.currency }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{# Changing the quantity posts via htmx (custom `cartchange` event) and
|
||||||
|
swaps only #cart-body. Dropping to 0 asks for confirmation first,
|
||||||
|
reverting to the previous quantity if the customer cancels. #}
|
||||||
|
<form method="post" action="/cart/update"
|
||||||
|
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="product_id" value="{{ item.id }}">
|
||||||
|
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
|
||||||
|
@change="
|
||||||
|
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
|
||||||
|
$el.value = '{{ item.quantity }}';
|
||||||
|
} else {
|
||||||
|
$el.dispatchEvent(new Event('cartchange', { bubbles: true }));
|
||||||
|
}
|
||||||
|
"
|
||||||
|
class="w-20 rounded-radius border border-outline bg-surface px-2 py-1 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ item.currency }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<form method="post" action="/cart/remove"
|
||||||
|
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="product_id" value="{{ item.id }}">
|
||||||
|
<button type="submit" class="text-xs font-medium text-danger hover:underline">{{ t(key="cart-remove", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot class="border-t border-outline dark:border-outline-dark">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap justify-between gap-3">
|
||||||
|
<a href="/shop" class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/checkout" class="inline-flex items-center justify-center rounded-radius bg-primary px-5 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-checkout", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-radius border border-outline px-6 py-16 text-center dark:border-outline-dark">
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="cart-empty", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<a href="/shop" class="mt-4 inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
60
assets/views/shop/_sidebar.html
Normal file
60
assets/views/shop/_sidebar.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{# 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. #}
|
||||||
|
<p class="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ t(key="categories", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
<ul class="flex flex-col gap-0.5">
|
||||||
|
<li>
|
||||||
|
<a href="/shop" data-nav="/shop"
|
||||||
|
class="block rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% for group in category_groups %}
|
||||||
|
{% if group.children | length > 0 %}
|
||||||
|
<li x-data="{ open: false }"
|
||||||
|
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-1 truncate rounded-l-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
{{ group.name }}
|
||||||
|
</a>
|
||||||
|
<button type="button" @click="open = !open" :aria-expanded="open"
|
||||||
|
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-surface hover:text-primary dark:text-on-surface-dark/60 dark:hover:bg-surface-dark dark:hover: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 transition-transform" :class="open && 'rotate-90'">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul x-show="open" x-cloak x-transition class="mt-0.5 flex flex-col gap-0.5">
|
||||||
|
{% for child in group.children %}
|
||||||
|
<li>
|
||||||
|
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}" style="padding-left: 28px"
|
||||||
|
class="flex items-center gap-1.5 rounded-radius py-1.5 pr-3 text-sm text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
<span class="text-on-surface/40 dark:text-on-surface-dark/40">↳</span>
|
||||||
|
<span class="truncate">{{ child.name }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li>
|
||||||
|
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||||
|
class="block truncate rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
{{ group.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if category_groups | length == 0 %}
|
||||||
|
<p class="px-3 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
13
assets/views/shop/cart.html
Normal file
13
assets/views/shop/cart.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="cart-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
<div id="cart-body">
|
||||||
|
{% include "shop/_cart_body.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
42
assets/views/shop/category.html
Normal file
42
assets/views/shop/category.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ category.name }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-8">
|
||||||
|
<header class="space-y-2">
|
||||||
|
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% for crumb in breadcrumbs %}
|
||||||
|
<span class="px-1">/</span>
|
||||||
|
<a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
<span class="px-1">/</span>
|
||||||
|
<span>{{ category.name }}</span>
|
||||||
|
</nav>
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1>
|
||||||
|
{% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %}
|
||||||
|
|
||||||
|
{% if children | length > 0 %}
|
||||||
|
<div class="flex flex-wrap gap-2 pt-1">
|
||||||
|
{% for child in children %}
|
||||||
|
<a href="/category/{{ child.slug }}"
|
||||||
|
class="rounded-full border border-outline px-3 py-1 text-sm font-medium text-on-surface transition hover:border-primary hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:border-primary-dark dark:hover:text-primary-dark">{{ child.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if products | length > 0 %}
|
||||||
|
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{% for product in products %}
|
||||||
|
{% include "shop/_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
|
||||||
|
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
220
assets/views/shop/checkout.html
Normal file
220
assets/views/shop/checkout.html
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if packeta_api_key %}<script src="https://widget.packeta.com/v6/www/js/library.js"></script>{% endif %}
|
||||||
|
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
<form method="post" action="/checkout" hx-boost="false"
|
||||||
|
x-data="{
|
||||||
|
paymentMethod: '',
|
||||||
|
carrier: '',
|
||||||
|
carrierPrice: 0,
|
||||||
|
requiresPoint: false,
|
||||||
|
pointId: '',
|
||||||
|
pointName: '',
|
||||||
|
subtotal: {{ subtotal_cents }},
|
||||||
|
packetaKey: '{{ packeta_api_key }}',
|
||||||
|
fmt(c) { return (c / 100).toFixed(2) },
|
||||||
|
pickPoint() {
|
||||||
|
Packeta.Widget.pick(this.packetaKey, (point) => {
|
||||||
|
if (point) { this.pointId = String(point.id); this.pointName = point.formatedValue || point.name }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
get canSubmit() {
|
||||||
|
return this.paymentMethod && this.carrier && (!this.requiresPoint || this.pointId)
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
class="mt-6 grid gap-8 lg:grid-cols-3">
|
||||||
|
|
||||||
|
<div class="space-y-6 lg:col-span-2">
|
||||||
|
<!-- contact -->
|
||||||
|
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-contact", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="email" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-email", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="email" name="email" type="email" required
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="customer_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="customer_name" name="customer_name" type="text" required
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<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: '+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>
|
||||||
|
<input id="phone" name="phone" type="tel" required autocomplete="tel" inputmode="tel" placeholder="900 000 000"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- shipping address -->
|
||||||
|
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="address" name="address" type="text" required
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="city" name="city" type="text" required
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="zip" name="zip" type="text" required
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<div class="relative" @click.outside="countryOpen = false"
|
||||||
|
x-data="{ countryOpen: false, country: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', opts: [
|
||||||
|
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-de', lang=lang | default(value='sk')) }}', l: '🇩🇪 {{ t(key='country-de', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
|
||||||
|
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
|
||||||
|
<input id="country" name="country" type="text" x-model="country" required @focus="countryOpen = true" @input="countryOpen = true"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
<button type="button" tabindex="-1" @click="countryOpen = !countryOpen"
|
||||||
|
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
||||||
|
class="size-4 transition-transform" :class="countryOpen && 'rotate-180'">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul x-show="countryOpen" x-cloak x-transition
|
||||||
|
class="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-radius border border-outline bg-surface p-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<template x-for="o in filtered" :key="o.v">
|
||||||
|
<li><button type="button" @click="country = o.v; countryOpen = false" x-text="o.l"
|
||||||
|
class="block w-full rounded-radius px-3 py-1.5 text-left text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark"></button></li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- carrier -->
|
||||||
|
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
{% for m in shipping_methods %}
|
||||||
|
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
||||||
|
<span class="flex items-center gap-3">
|
||||||
|
<input type="radio" name="carrier_code" value="{{ m.code }}" required
|
||||||
|
@change="carrier='{{ m.code }}'; carrierPrice={{ m.price_cents }}; requiresPoint={{ m.requires_pickup_point }}; pointId=''; pointName=''"
|
||||||
|
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||||
|
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="tabular-nums text-on-surface/80 dark:text-on-surface-dark/80">{{ m.price }} {{ currency }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- pickup point (carriers that need one, e.g. Packeta) -->
|
||||||
|
<div x-show="requiresPoint" x-cloak class="space-y-2 pt-1">
|
||||||
|
<input type="hidden" name="pickup_point_id" x-model="pointId">
|
||||||
|
<input type="hidden" name="pickup_point_name" x-model="pointName">
|
||||||
|
{% if packeta_api_key %}
|
||||||
|
<button type="button" @click="pickPoint()"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
|
{{ t(key="checkout-pick-point", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
<p x-show="pointName" x-cloak class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">
|
||||||
|
<span class="font-medium">{{ t(key="checkout-chosen-point", lang=lang | default(value='sk')) }}:</span> <span x-text="pointName"></span>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input type="text" x-model="pointName" @input="pointId = pointName"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- payment -->
|
||||||
|
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
||||||
|
<input type="radio" name="payment_method" value="cod" required x-model="paymentMethod"
|
||||||
|
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||||
|
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-cod", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
||||||
|
<input type="radio" name="payment_method" value="bank_transfer" required x-model="paymentMethod"
|
||||||
|
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||||
|
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="note" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<textarea id="note" name="note" rows="3"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- summary -->
|
||||||
|
<aside class="h-fit space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-summary", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<ul class="space-y-2 text-sm">
|
||||||
|
{% for item in items %}
|
||||||
|
<li class="flex justify-between gap-2">
|
||||||
|
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.name }} × {{ item.quantity }}</span>
|
||||||
|
<span class="tabular-nums">{{ item.line_total }} {{ item.currency }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="space-y-1 border-t border-outline pt-3 text-sm dark:border-outline-dark">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="tabular-nums">{{ subtotal }} {{ currency }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-shipping-cost", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="tabular-nums" x-text="fmt(carrierPrice) + ' {{ currency }}'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between border-t border-outline pt-3 text-base font-bold dark:border-outline-dark">
|
||||||
|
<span>{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' {{ currency }}'"></span>
|
||||||
|
</div>
|
||||||
|
<button type="submit" :disabled="!canSubmit"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="checkout-place-order", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
24
assets/views/shop/index.html
Normal file
24
assets/views/shop/index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-8">
|
||||||
|
<header class="space-y-2">
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if products | length > 0 %}
|
||||||
|
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{% for product in products %}
|
||||||
|
{% include "shop/_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
|
||||||
|
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
61
assets/views/shop/order_confirmed.html
Normal file
61
assets/views/shop/order_confirmed.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mx-auto flex size-14 items-center justify-center rounded-full bg-success/15 text-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-7">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="mt-3 text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="mt-1 text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-confirmed-sub", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="flex flex-wrap justify-between gap-2 border-b border-outline pb-3 dark:border-outline-dark">
|
||||||
|
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-number", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="font-mono font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-2 py-3 text-sm">
|
||||||
|
{% for item in items %}
|
||||||
|
<li class="flex justify-between gap-2">
|
||||||
|
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }} × {{ item.quantity }}</span>
|
||||||
|
<span class="tabular-nums">{{ item.line_total }} {{ order.currency }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark">
|
||||||
|
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} {{ order.currency }}</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} {{ order.currency }}</span></div>
|
||||||
|
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
|
||||||
|
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if order.payment_method == "bank_transfer" %}
|
||||||
|
<div class="space-y-2 rounded-radius border border-primary/40 bg-primary/5 p-6 text-sm dark:border-primary-dark/40">
|
||||||
|
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank-instructions", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} {{ order.currency }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-4 text-sm text-on-surface/80 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/80">
|
||||||
|
{{ t(key="payment-cod-note", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/shop" class="inline-flex items-center justify-center rounded-radius border border-outline px-5 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
60
assets/views/shop/show.html
Normal file
60
assets/views/shop/show.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ product.name }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="grid gap-10 lg:grid-cols-2">
|
||||||
|
<!-- gallery -->
|
||||||
|
<div x-data="{ active: 0 }" class="space-y-4">
|
||||||
|
<div class="aspect-square overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{% if images | length > 0 %}
|
||||||
|
{% for image in images %}
|
||||||
|
<img x-show="active === {{ loop.index0 }}" src="/images/{{ image }}" alt="{{ product.name }}" class="size-full object-cover">
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if images | length > 1 %}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for image in images %}
|
||||||
|
<button type="button" @click="active = {{ loop.index0 }}"
|
||||||
|
:class="active === {{ loop.index0 }} ? 'border-primary dark:border-primary-dark' : 'border-outline dark:border-outline-dark'"
|
||||||
|
class="size-16 overflow-hidden rounded-radius border">
|
||||||
|
<img src="/images/{{ image }}" alt="" class="size-full object-cover">
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- details -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
{% if category %}
|
||||||
|
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
|
||||||
|
<p class="text-2xl font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
|
||||||
|
|
||||||
|
{% if product.description %}
|
||||||
|
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if product.stock > 0 %}
|
||||||
|
<form method="post" action="/cart/add" hx-boost="false" class="flex flex-wrap items-end gap-3">
|
||||||
|
<input type="hidden" name="product_id" value="{{ product.id }}">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="quantity" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="quantity", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="quantity" name="quantity" type="number" min="1" max="{{ product.stock }}" value="1"
|
||||||
|
class="w-24 rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center justify-center rounded-radius bg-primary px-5 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -71,7 +71,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 +105,23 @@ 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.") }}
|
||||||
|
|||||||
@@ -68,7 +68,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_test") }}
|
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_test") }}
|
||||||
# When enabled, the sql query will be logged.
|
# 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
|
||||||
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 |
12
flake.nix
12
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;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ 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;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -34,7 +49,22 @@ 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),
|
||||||
// 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
55
migration/src/m20260616_160000_add_parent_to_categories.rs
Normal file
55
migration/src/m20260616_160000_add_parent_to_categories.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Categories {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
ParentId,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FK_NAME: &str = "fk_categories_parent_id";
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
m.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Categories::Table)
|
||||||
|
.add_column(ColumnDef::new(Categories::ParentId).integer().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.create_foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name(FK_NAME)
|
||||||
|
.from(Categories::Table, Categories::ParentId)
|
||||||
|
.to(Categories::Table, Categories::Id)
|
||||||
|
.on_delete(ForeignKeyAction::SetNull)
|
||||||
|
.on_update(ForeignKeyAction::Cascade)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
m.drop_foreign_key(
|
||||||
|
ForeignKey::drop()
|
||||||
|
.name(FK_NAME)
|
||||||
|
.table(Categories::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Categories::Table)
|
||||||
|
.drop_column(Categories::ParentId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Which carrier API (if any) a delivery option maps to. "none" means the
|
||||||
|
// option is fulfilled manually and never calls an external API.
|
||||||
|
add_column(
|
||||||
|
m,
|
||||||
|
"shipping_methods",
|
||||||
|
"carrier",
|
||||||
|
ColType::StringWithDefault("none".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "shipping_methods", "carrier").await
|
||||||
|
}
|
||||||
|
}
|
||||||
23
migration/src/m20260617_000002_add_shipment_to_orders.rs
Normal file
23
migration/src/m20260617_000002_add_shipment_to_orders.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Populated only after an admin manually sends the order to a carrier.
|
||||||
|
add_column(m, "orders", "tracking_number", ColType::StringNull).await?;
|
||||||
|
add_column(m, "orders", "shipment_id", ColType::StringNull).await?;
|
||||||
|
add_column(m, "orders", "label_url", ColType::StringNull).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "orders", "tracking_number").await?;
|
||||||
|
remove_column(m, "orders", "shipment_id").await?;
|
||||||
|
remove_column(m, "orders", "label_url").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
17
migration/src/m20260617_000003_add_phone_to_orders.rs
Normal file
17
migration/src/m20260617_000003_add_phone_to_orders.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Customer contact phone, also passed to carriers for pickup SMS.
|
||||||
|
add_column(m, "orders", "phone", ColType::StringNull).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "orders", "phone").await
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/app.rs
41
src/app.rs
@@ -16,7 +16,14 @@ use std::{path::Path, sync::Arc};
|
|||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers, initializers, models::_entities::users, tasks, workers::downloader::DownloadWorker,
|
controllers::{
|
||||||
|
admin_categories, admin_dashboard, admin_form, admin_login, admin_orders,
|
||||||
|
admin_products, admin_shipping, auth, cart, checkout, home, i18n, media, shop,
|
||||||
|
},
|
||||||
|
initializers,
|
||||||
|
models::_entities::users,
|
||||||
|
tasks,
|
||||||
|
workers::downloader::DownloadWorker,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct App;
|
pub struct App;
|
||||||
@@ -53,24 +60,33 @@ impl Hooks for App {
|
|||||||
Ok(vec![
|
Ok(vec![
|
||||||
Box::new(initializers::view_engine::ViewEngineInitializer),
|
Box::new(initializers::view_engine::ViewEngineInitializer),
|
||||||
Box::new(initializers::admin_seeder::AdminSeeder),
|
Box::new(initializers::admin_seeder::AdminSeeder),
|
||||||
|
Box::new(initializers::shipping_seeder::ShippingSeeder),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn routes(_ctx: &AppContext) -> AppRoutes {
|
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||||
AppRoutes::with_default_routes() // controller routes below
|
AppRoutes::with_default_routes() // feature routes below
|
||||||
.add_route(controllers::auth::routes())
|
// public
|
||||||
.add_route(controllers::admin::routes())
|
.add_route(home::routes())
|
||||||
.add_route(controllers::blog::routes())
|
.add_route(shop::routes())
|
||||||
.add_route(controllers::i18n::routes())
|
.add_route(cart::routes())
|
||||||
.add_route(controllers::media::routes())
|
.add_route(checkout::routes())
|
||||||
.add_route(controllers::pages::routes())
|
// cross-cutting
|
||||||
.add_route(controllers::frontend::routes())
|
.add_route(auth::routes())
|
||||||
|
.add_route(i18n::routes())
|
||||||
|
.add_route(media::routes())
|
||||||
|
// admin
|
||||||
|
.add_route(admin_dashboard::routes())
|
||||||
|
.add_route(admin_login::routes())
|
||||||
|
.add_route(admin_products::routes())
|
||||||
|
.add_route(admin_categories::routes())
|
||||||
|
.add_route(admin_orders::routes())
|
||||||
|
.add_route(admin_shipping::routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
||||||
let upload_root = crate::controllers::media::uploads_root(&ctx.config)?;
|
let upload_root = media::uploads_root(&ctx.config)?;
|
||||||
tokio::fs::create_dir_all(upload_root.join(controllers::media::AUDIO_STORAGE_DIR)).await?;
|
tokio::fs::create_dir_all(upload_root.join(media::IMAGE_STORAGE_DIR)).await?;
|
||||||
tokio::fs::create_dir_all(upload_root.join(controllers::media::IMAGE_STORAGE_DIR)).await?;
|
|
||||||
|
|
||||||
let driver = storage::drivers::local::new_with_prefix(&upload_root)?;
|
let driver = storage::drivers::local::new_with_prefix(&upload_root)?;
|
||||||
Ok(AppContext {
|
Ok(AppContext {
|
||||||
@@ -95,6 +111,7 @@ impl Hooks for App {
|
|||||||
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||||
db::seed::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").display().to_string())
|
db::seed::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").display().to_string())
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::seed::seed_catalog(ctx).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use loco_rs::cli;
|
use loco_rs::cli;
|
||||||
use migration::Migrator;
|
use migration::Migrator;
|
||||||
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<()> {
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
use crate::models::{
|
|
||||||
_entities::{audio_albums, audio_tracks, audit_logs, blog_articles, users},
|
|
||||||
users as users_model,
|
|
||||||
};
|
|
||||||
use loco_rs::prelude::*;
|
|
||||||
use sea_orm::{EntityTrait, PaginatorTrait};
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct DashboardResponse {
|
|
||||||
users: u64,
|
|
||||||
blog_articles: u64,
|
|
||||||
audio_albums: u64,
|
|
||||||
audio_tracks: u64,
|
|
||||||
audit_logs: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn admin_email(ctx: &AppContext) -> Option<&str> {
|
|
||||||
ctx.config
|
|
||||||
.settings
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|settings| settings.get("admin_email"))
|
|
||||||
.and_then(|email| email.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
|
||||||
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::Model> {
|
|
||||||
let user = users_model::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
|
||||||
|
|
||||||
if !is_admin(ctx, &user) {
|
|
||||||
return unauthorized("admin only");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn dashboard(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
|
||||||
current_admin(auth, &ctx).await?;
|
|
||||||
|
|
||||||
format::json(DashboardResponse {
|
|
||||||
users: users::Entity::find().count(&ctx.db).await?,
|
|
||||||
blog_articles: blog_articles::Entity::find().count(&ctx.db).await?,
|
|
||||||
audio_albums: audio_albums::Entity::find().count(&ctx.db).await?,
|
|
||||||
audio_tracks: audio_tracks::Entity::find().count(&ctx.db).await?,
|
|
||||||
audit_logs: audit_logs::Entity::find().count(&ctx.db).await?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
|
||||||
Routes::new()
|
|
||||||
.prefix("/api/admin")
|
|
||||||
.add("/dashboard", get(dashboard))
|
|
||||||
}
|
|
||||||
271
src/controllers/admin_categories.rs
Normal file
271
src/controllers/admin_categories.rs
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
//! Admin category CRUD, including parent/child hierarchy management.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, Set,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::{
|
||||||
|
admin_form::{read_multipart_form, store_image, MultipartForm},
|
||||||
|
i18n::current_lang,
|
||||||
|
media::IMAGE_MAX_BYTES,
|
||||||
|
},
|
||||||
|
shared::{
|
||||||
|
guard,
|
||||||
|
slug::{slugify, unique_slug},
|
||||||
|
},
|
||||||
|
models::{categories, products},
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn category_by_id(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||||
|
categories::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fields parsed from a category form.
|
||||||
|
struct CategoryFields {
|
||||||
|
name: String,
|
||||||
|
slug: String,
|
||||||
|
description: Option<String>,
|
||||||
|
position: i32,
|
||||||
|
published: bool,
|
||||||
|
parent_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_category_fields(
|
||||||
|
ctx: &AppContext,
|
||||||
|
form: &MultipartForm,
|
||||||
|
current_id: Option<i32>,
|
||||||
|
) -> Result<CategoryFields> {
|
||||||
|
let name = form
|
||||||
|
.text("name")
|
||||||
|
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
|
||||||
|
let description = form.text("description");
|
||||||
|
let position = form
|
||||||
|
.text("position")
|
||||||
|
.and_then(|s| s.parse::<i32>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let published = form.checked("published");
|
||||||
|
|
||||||
|
// Resolve the chosen parent, rejecting cycles: a category may not be its
|
||||||
|
// own parent nor be re-parented under one of its descendants.
|
||||||
|
let parent_id = match form.text("parent_id").and_then(|s| s.parse::<i32>().ok()) {
|
||||||
|
Some(parent_id) => {
|
||||||
|
categories::Entity::find_by_id(parent_id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::BadRequest("parent category not found".to_string()))?;
|
||||||
|
if let Some(id) = current_id {
|
||||||
|
if parent_id == id {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"a category cannot be its own parent".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if categories::descendant_ids(&categories::all(ctx).await?, id).contains(&parent_id)
|
||||||
|
{
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"a category cannot be moved under its own descendant".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(parent_id)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let desired = form
|
||||||
|
.text("slug")
|
||||||
|
.map(|s| slugify(&s))
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| slugify(&name));
|
||||||
|
let slug = unique_slug(&desired, |candidate| {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
async move {
|
||||||
|
let mut query =
|
||||||
|
categories::Entity::find().filter(categories::Column::Slug.eq(candidate));
|
||||||
|
if let Some(id) = current_id {
|
||||||
|
query = query.filter(categories::Column::Id.ne(id));
|
||||||
|
}
|
||||||
|
Ok(query.count(&ctx.db).await? > 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(CategoryFields {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
position,
|
||||||
|
published,
|
||||||
|
parent_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the parent-category dropdown options for the category form, as a
|
||||||
|
/// depth-ordered list of `{ id, name, depth }`. When editing, the category
|
||||||
|
/// itself and all of its descendants are excluded to keep the tree acyclic.
|
||||||
|
async fn form_context(
|
||||||
|
ctx: &AppContext,
|
||||||
|
jar: &CookieJar,
|
||||||
|
editing: Option<i32>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let all = categories::all(ctx).await?;
|
||||||
|
let blocked: HashSet<i32> = match editing {
|
||||||
|
Some(id) => {
|
||||||
|
let mut set = categories::descendant_ids(&all, id);
|
||||||
|
set.insert(id);
|
||||||
|
set
|
||||||
|
}
|
||||||
|
None => HashSet::new(),
|
||||||
|
};
|
||||||
|
let parents: Vec<serde_json::Value> = categories::tree(&all)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(category, _)| !blocked.contains(&category.id))
|
||||||
|
.map(|(category, depth)| json!({ "id": category.id, "name": category.name, "depth": depth }))
|
||||||
|
.collect();
|
||||||
|
Ok(json!({ "parents": parents, "lang": current_lang(jar) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn index(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let list = categories::all(&ctx).await?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (category, depth) in categories::tree(&list) {
|
||||||
|
let product_count = products::Entity::find()
|
||||||
|
.filter(products::Column::CategoryId.eq(category.id))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
rows.push(json!({ "category": category, "depth": depth, "product_count": product_count }));
|
||||||
|
}
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/catalog/categories.html",
|
||||||
|
json!({ "categories": rows, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn new(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let mut context = form_context(&ctx, &jar, None).await?;
|
||||||
|
context["category"] = serde_json::Value::Null;
|
||||||
|
format::view(&v, "admin/catalog/category_form.html", context)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn create(
|
||||||
|
auth: auth::JWT,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let form = read_multipart_form(multipart).await?;
|
||||||
|
let fields = parse_category_fields(&ctx, &form, None).await?;
|
||||||
|
let image_id = match form.image {
|
||||||
|
Some(data) => Some(store_image(&ctx, data).await?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
categories::ActiveModel {
|
||||||
|
name: Set(fields.name),
|
||||||
|
slug: Set(fields.slug),
|
||||||
|
description: Set(fields.description),
|
||||||
|
image_id: Set(image_id),
|
||||||
|
position: Set(fields.position),
|
||||||
|
published: Set(fields.published),
|
||||||
|
parent_id: Set(fields.parent_id),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::redirect("/admin/catalog/categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn edit(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let mut context = form_context(&ctx, &jar, Some(id)).await?;
|
||||||
|
context["category"] = json!(category_by_id(&ctx, id).await?);
|
||||||
|
format::view(&v, "admin/catalog/category_form.html", context)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let existing = category_by_id(&ctx, id).await?;
|
||||||
|
let form = read_multipart_form(multipart).await?;
|
||||||
|
let fields = parse_category_fields(&ctx, &form, Some(id)).await?;
|
||||||
|
|
||||||
|
let mut category = existing.into_active_model();
|
||||||
|
category.name = Set(fields.name);
|
||||||
|
category.slug = Set(fields.slug);
|
||||||
|
category.description = Set(fields.description);
|
||||||
|
category.position = Set(fields.position);
|
||||||
|
category.published = Set(fields.published);
|
||||||
|
category.parent_id = Set(fields.parent_id);
|
||||||
|
if let Some(data) = form.image {
|
||||||
|
category.image_id = Set(Some(store_image(&ctx, data).await?));
|
||||||
|
}
|
||||||
|
category.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
format::redirect("/admin/catalog/categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn delete(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
category_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::redirect("/admin/catalog/categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin/catalog/categories", get(index))
|
||||||
|
.add("/admin/catalog/categories/new", get(new))
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/categories",
|
||||||
|
post(create).layer(image_limit.clone()),
|
||||||
|
)
|
||||||
|
.add("/admin/catalog/categories/{id}/edit", get(edit))
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/categories/{id}",
|
||||||
|
post(update).layer(image_limit),
|
||||||
|
)
|
||||||
|
.add("/admin/catalog/categories/{id}/delete", post(delete))
|
||||||
|
}
|
||||||
54
src/controllers/admin_dashboard.rs
Normal file
54
src/controllers/admin_dashboard.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//! Admin dashboard (HTML home + JSON stats).
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{EntityTrait, PaginatorTrait};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{controllers::i18n::current_lang, models::_entities, shared::guard};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DashboardResponse {
|
||||||
|
users: u64,
|
||||||
|
products: u64,
|
||||||
|
categories: u64,
|
||||||
|
orders: u64,
|
||||||
|
audit_logs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON dashboard stats, served under `/api/admin`.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn dashboard_json(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
|
||||||
|
format::json(DashboardResponse {
|
||||||
|
users: _entities::users::Entity::find().count(&ctx.db).await?,
|
||||||
|
products: _entities::products::Entity::find().count(&ctx.db).await?,
|
||||||
|
categories: _entities::categories::Entity::find().count(&ctx.db).await?,
|
||||||
|
orders: _entities::orders::Entity::find().count(&ctx.db).await?,
|
||||||
|
audit_logs: _entities::audit_logs::Entity::find().count(&ctx.db).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTML admin home page.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn dashboard_page(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let admin_user = guard::current_admin(auth, &ctx).await?;
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/index.html",
|
||||||
|
json!({ "admin": admin_user, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin/dashboard", get(dashboard_page))
|
||||||
|
.add("/api/admin/dashboard", get(dashboard_json))
|
||||||
|
}
|
||||||
87
src/controllers/admin_form.rs
Normal file
87
src/controllers/admin_form.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
//! Multipart form handling shared by the product and category admin forms.
|
||||||
|
//!
|
||||||
|
//! Both forms submit a mix of text fields and an optional `image` file part;
|
||||||
|
//! this collects them into an easy-to-query [`MultipartForm`] and stores any
|
||||||
|
//! uploaded image through the configured storage driver.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use axum::extract::Multipart;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
|
use crate::controllers::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR};
|
||||||
|
|
||||||
|
fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|value| {
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
(!value.is_empty()).then_some(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collected multipart form: text fields keyed by name, plus the raw bytes of
|
||||||
|
/// an `image` file part if one was uploaded (an empty file input is ignored).
|
||||||
|
pub(crate) struct MultipartForm {
|
||||||
|
fields: HashMap<String, String>,
|
||||||
|
pub(crate) image: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultipartForm {
|
||||||
|
/// Trimmed value of a text field, `None` when missing or blank.
|
||||||
|
pub(crate) fn text(&self, key: &str) -> Option<String> {
|
||||||
|
normalize_empty(self.fields.get(key).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a checkbox-style field is checked.
|
||||||
|
pub(crate) fn checked(&self, key: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.fields.get(key).map(String::as_str),
|
||||||
|
Some("on" | "true" | "1")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
let mut image = None;
|
||||||
|
|
||||||
|
while let Some(mut field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
|
||||||
|
{
|
||||||
|
let name = field.name().unwrap_or("").to_string();
|
||||||
|
if name == "image" {
|
||||||
|
let mut data = Vec::new();
|
||||||
|
while let Some(chunk) = field
|
||||||
|
.chunk()
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
|
||||||
|
{
|
||||||
|
data.extend_from_slice(&chunk);
|
||||||
|
if data.len() > IMAGE_MAX_BYTES {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"image is larger than {} MB",
|
||||||
|
IMAGE_MAX_BYTES / 1024 / 1024
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !data.is_empty() {
|
||||||
|
image = Some(data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let value = field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
|
||||||
|
fields.insert(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(MultipartForm { fields, image })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an uploaded image's bytes and return its generated filename.
|
||||||
|
pub(crate) async fn store_image(ctx: &AppContext, data: Vec<u8>) -> Result<String> {
|
||||||
|
let extension = detect_image_extension(&data)?;
|
||||||
|
store_upload(ctx, IMAGE_STORAGE_DIR, extension, data).await
|
||||||
|
}
|
||||||
86
src/controllers/admin_login.rs
Normal file
86
src/controllers/admin_login.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
//! Cookie-based admin login/logout pages (separate from the JSON `/api/auth`
|
||||||
|
//! flow used by the SPA/API).
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::auth as auth_controller,
|
||||||
|
models::users::{self, LoginParams},
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
shared::guard,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn login_error(v: &TeraView, jar: &CookieJar) -> Result<Response> {
|
||||||
|
format::view(
|
||||||
|
v,
|
||||||
|
"admin/login.html",
|
||||||
|
json!({
|
||||||
|
"error": "Invalid credentials",
|
||||||
|
"logged_in_admin": false,
|
||||||
|
"lang": current_lang(jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn login_page(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
if guard::logged_in(&ctx, &jar).await {
|
||||||
|
return format::redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/login.html",
|
||||||
|
json!({
|
||||||
|
"error": null,
|
||||||
|
"logged_in_admin": false,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn login(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(params): Form<LoginParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||||
|
return login_error(&v, &jar);
|
||||||
|
};
|
||||||
|
|
||||||
|
if !user.verify_password(¶ms.password) || !guard::is_admin(&ctx, &user) {
|
||||||
|
return login_error(&v, &jar);
|
||||||
|
}
|
||||||
|
|
||||||
|
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||||
|
let token = user
|
||||||
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
|
||||||
|
format::render()
|
||||||
|
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
||||||
|
.redirect("/admin/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn logout() -> Result<Response> {
|
||||||
|
format::render()
|
||||||
|
.cookies(&[auth_controller::clear_auth_cookie()])?
|
||||||
|
.redirect("/admin/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin", get(login_page))
|
||||||
|
.add("/admin/login", get(login_page))
|
||||||
|
.add("/admin/login", post(login))
|
||||||
|
.add("/admin/logout", post(logout))
|
||||||
|
}
|
||||||
215
src/controllers/admin_orders.rs
Normal file
215
src/controllers/admin_orders.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
//! Admin order list, detail, status updates, and manual carrier dispatch.
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
integrations::{self, ShipmentRequest},
|
||||||
|
models::{order_items, orders, shipping_methods},
|
||||||
|
views::checkout as view,
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
shared::{guard, settings},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
||||||
|
|
||||||
|
/// Fallback parcel weight when products carry no weight of their own.
|
||||||
|
const DEFAULT_PARCEL_WEIGHT_GRAMS: i32 = 1000;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct StatusForm {
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn index(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let list = orders::Entity::find()
|
||||||
|
.order_by_desc(orders::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/orders/index.html",
|
||||||
|
json!({ "orders": rows, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the carrier code (`none`/`packeta`/`dpd`/`dhl`) for an order from its
|
||||||
|
/// chosen shipping method, defaulting to `none` when unknown.
|
||||||
|
async fn order_carrier(ctx: &AppContext, order: &orders::Model) -> Result<String> {
|
||||||
|
let Some(code) = order.carrier_code.as_deref() else {
|
||||||
|
return Ok("none".to_string());
|
||||||
|
};
|
||||||
|
Ok(shipping_methods::Entity::find()
|
||||||
|
.filter(shipping_methods::Column::Code.eq(code))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.map(|m| m.carrier)
|
||||||
|
.unwrap_or_else(|| "none".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the order detail page, optionally with a dispatch error banner.
|
||||||
|
async fn render_show(
|
||||||
|
jar: &CookieJar,
|
||||||
|
v: &TeraView,
|
||||||
|
ctx: &AppContext,
|
||||||
|
id: i32,
|
||||||
|
error: Option<String>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let order = orders::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let items = order_items::Entity::find()
|
||||||
|
.filter(order_items::Column::OrderId.eq(order.id))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let carrier = order_carrier(ctx, &order).await?;
|
||||||
|
// The order can be sent only if it maps to a real carrier and hasn't been
|
||||||
|
// dispatched yet.
|
||||||
|
let can_ship = carrier != "none" && order.tracking_number.is_none();
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
v,
|
||||||
|
"admin/orders/show.html",
|
||||||
|
json!({
|
||||||
|
"order": view::detail(
|
||||||
|
&order,
|
||||||
|
settings::get(ctx, "bank_iban").unwrap_or(""),
|
||||||
|
settings::get(ctx, "bank_account_name").unwrap_or(""),
|
||||||
|
),
|
||||||
|
"items": view::items(&items),
|
||||||
|
"statuses": ORDER_STATUSES,
|
||||||
|
"carrier": carrier,
|
||||||
|
"can_ship": can_ship,
|
||||||
|
"ship_error": error,
|
||||||
|
"lang": current_lang(jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn show(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
render_show(&jar, &v, &ctx, id, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update_status(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<StatusForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
if !ORDER_STATUSES.contains(&form.status.as_str()) {
|
||||||
|
return Err(Error::BadRequest("invalid status".to_string()));
|
||||||
|
}
|
||||||
|
let order = orders::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let mut active = order.into_active_model();
|
||||||
|
active.status = Set(form.status);
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
format::redirect(&format!("/admin/orders/{id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually dispatch an order to its carrier. This is the *only* place that
|
||||||
|
/// calls a carrier API, and it is triggered exclusively by an admin clicking
|
||||||
|
/// "Send to carrier" after the goods are verified and ready.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn ship(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let order = orders::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
// Idempotency: never create a second shipment for an already-dispatched order.
|
||||||
|
if order.tracking_number.is_some() {
|
||||||
|
return render_show(
|
||||||
|
&jar,
|
||||||
|
&v,
|
||||||
|
&ctx,
|
||||||
|
id,
|
||||||
|
Some("This order has already been sent to the carrier.".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let carrier = order_carrier(&ctx, &order).await?;
|
||||||
|
let goods_value = (order.total_cents - order.shipping_cents).max(0);
|
||||||
|
let cod_cents = match order.payment_method.as_deref() {
|
||||||
|
Some("cod") => order.total_cents,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let recipient = order
|
||||||
|
.customer_name
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(&order.email);
|
||||||
|
|
||||||
|
let req = ShipmentRequest {
|
||||||
|
order_number: &order.order_number,
|
||||||
|
recipient_name: recipient,
|
||||||
|
email: &order.email,
|
||||||
|
phone: order.phone.as_deref(),
|
||||||
|
address: order.address.as_deref(),
|
||||||
|
city: order.city.as_deref(),
|
||||||
|
zip: order.zip.as_deref(),
|
||||||
|
country: order.country.as_deref(),
|
||||||
|
pickup_point_id: order.pickup_point_id.as_deref(),
|
||||||
|
cod_cents,
|
||||||
|
currency: &order.currency,
|
||||||
|
value_cents: goods_value,
|
||||||
|
weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS,
|
||||||
|
};
|
||||||
|
|
||||||
|
match integrations::create_shipment(&ctx, &carrier, req).await {
|
||||||
|
Ok(result) => {
|
||||||
|
let mut active = order.into_active_model();
|
||||||
|
active.tracking_number = Set(Some(result.tracking_number));
|
||||||
|
active.shipment_id = Set(Some(result.shipment_id));
|
||||||
|
active.label_url = Set(result.label_url);
|
||||||
|
active.status = Set("shipped".to_string());
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
format::redirect(&format!("/admin/orders/{id}"))
|
||||||
|
}
|
||||||
|
// Show the carrier's error in-page rather than a generic error screen,
|
||||||
|
// so the admin can fix the cause and retry.
|
||||||
|
Err(err) => render_show(&jar, &v, &ctx, id, Some(err.to_string())).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin/orders", get(index))
|
||||||
|
.add("/admin/orders/{id}", get(show))
|
||||||
|
.add("/admin/orders/{id}/status", post(update_status))
|
||||||
|
.add("/admin/orders/{id}/ship", post(ship))
|
||||||
|
}
|
||||||
286
src/controllers/admin_products.rs
Normal file
286
src/controllers/admin_products.rs
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
//! Admin product CRUD.
|
||||||
|
|
||||||
|
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
||||||
|
QueryOrder, Set,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::{
|
||||||
|
admin_form::{read_multipart_form, store_image, MultipartForm},
|
||||||
|
i18n::current_lang,
|
||||||
|
media::IMAGE_MAX_BYTES,
|
||||||
|
},
|
||||||
|
shared::{
|
||||||
|
guard,
|
||||||
|
money::parse_price_to_cents,
|
||||||
|
slug::{slugify, unique_slug},
|
||||||
|
},
|
||||||
|
models::{categories, product_images, products},
|
||||||
|
views::shop as view,
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
||||||
|
products::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fields parsed from a product form, ready to apply to an active model.
|
||||||
|
struct ProductFields {
|
||||||
|
name: String,
|
||||||
|
slug: String,
|
||||||
|
description: Option<String>,
|
||||||
|
price_cents: i64,
|
||||||
|
currency: String,
|
||||||
|
sku: Option<String>,
|
||||||
|
stock: i32,
|
||||||
|
category_id: Option<i32>,
|
||||||
|
published: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_product_fields(
|
||||||
|
ctx: &AppContext,
|
||||||
|
form: &MultipartForm,
|
||||||
|
current_id: Option<i32>,
|
||||||
|
) -> Result<ProductFields> {
|
||||||
|
let name = form
|
||||||
|
.text("name")
|
||||||
|
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
|
||||||
|
let price_cents = parse_price_to_cents(
|
||||||
|
form.text("price")
|
||||||
|
.ok_or_else(|| Error::BadRequest("price is required".to_string()))?
|
||||||
|
.as_str(),
|
||||||
|
)?;
|
||||||
|
let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string());
|
||||||
|
let description = form.text("description");
|
||||||
|
let sku = form.text("sku");
|
||||||
|
let stock = form
|
||||||
|
.text("stock")
|
||||||
|
.and_then(|s| s.parse::<i32>().ok())
|
||||||
|
.filter(|n| *n >= 0)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
|
||||||
|
let published = form.checked("published");
|
||||||
|
|
||||||
|
let desired = form
|
||||||
|
.text("slug")
|
||||||
|
.map(|s| slugify(&s))
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| slugify(&name));
|
||||||
|
let slug = unique_slug(&desired, |candidate| {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
async move {
|
||||||
|
let mut query = products::Entity::find().filter(products::Column::Slug.eq(candidate));
|
||||||
|
if let Some(id) = current_id {
|
||||||
|
query = query.filter(products::Column::Id.ne(id));
|
||||||
|
}
|
||||||
|
Ok(query.count(&ctx.db).await? > 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ProductFields {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
price_cents,
|
||||||
|
currency,
|
||||||
|
sku,
|
||||||
|
stock,
|
||||||
|
category_id,
|
||||||
|
published,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn form_context(ctx: &AppContext, jar: &CookieJar) -> Result<serde_json::Value> {
|
||||||
|
let categories = categories::Entity::find()
|
||||||
|
.order_by_asc(categories::Column::Position)
|
||||||
|
.order_by_asc(categories::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
Ok(json!({ "categories": categories, "lang": current_lang(jar) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn index(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let list = products::Entity::find()
|
||||||
|
.order_by_desc(products::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for product in list {
|
||||||
|
let image = product_images::first_for(&ctx, product.id).await?;
|
||||||
|
let category_name = match product.category_id {
|
||||||
|
Some(id) => categories::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.map(|c| c.name),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
rows.push(view::product_card(&product, image, category_name));
|
||||||
|
}
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/catalog/products.html",
|
||||||
|
json!({ "products": rows, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn new(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let mut context = form_context(&ctx, &jar).await?;
|
||||||
|
context["product"] = serde_json::Value::Null;
|
||||||
|
format::view(&v, "admin/catalog/product_form.html", context)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn create(
|
||||||
|
auth: auth::JWT,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let form = read_multipart_form(multipart).await?;
|
||||||
|
let fields = parse_product_fields(&ctx, &form, None).await?;
|
||||||
|
|
||||||
|
let product = products::ActiveModel {
|
||||||
|
name: Set(fields.name),
|
||||||
|
slug: Set(fields.slug),
|
||||||
|
description: Set(fields.description),
|
||||||
|
price_cents: Set(fields.price_cents),
|
||||||
|
currency: Set(fields.currency),
|
||||||
|
sku: Set(fields.sku),
|
||||||
|
stock: Set(fields.stock),
|
||||||
|
view_count: Set(0),
|
||||||
|
published: Set(fields.published),
|
||||||
|
published_at: Set(fields.published.then(|| chrono::Utc::now().into())),
|
||||||
|
category_id: Set(fields.category_id),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(data) = form.image {
|
||||||
|
let filename = store_image(&ctx, data).await?;
|
||||||
|
product_images::ActiveModel {
|
||||||
|
product_id: Set(product.id),
|
||||||
|
image_id: Set(filename),
|
||||||
|
position: Set(0),
|
||||||
|
alt: Set(None),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
format::redirect("/admin/catalog/products")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn edit(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let product = product_by_id(&ctx, id).await?;
|
||||||
|
let image = product_images::first_for(&ctx, id).await?;
|
||||||
|
let mut context = form_context(&ctx, &jar).await?;
|
||||||
|
context["product"] = view::product_form(&product, image);
|
||||||
|
format::view(&v, "admin/catalog/product_form.html", context)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let existing = product_by_id(&ctx, id).await?;
|
||||||
|
let was_published = existing.published;
|
||||||
|
let form = read_multipart_form(multipart).await?;
|
||||||
|
let fields = parse_product_fields(&ctx, &form, Some(id)).await?;
|
||||||
|
|
||||||
|
let mut product = existing.into_active_model();
|
||||||
|
product.name = Set(fields.name);
|
||||||
|
product.slug = Set(fields.slug);
|
||||||
|
product.description = Set(fields.description);
|
||||||
|
product.price_cents = Set(fields.price_cents);
|
||||||
|
product.currency = Set(fields.currency);
|
||||||
|
product.sku = Set(fields.sku);
|
||||||
|
product.stock = Set(fields.stock);
|
||||||
|
product.category_id = Set(fields.category_id);
|
||||||
|
product.published = Set(fields.published);
|
||||||
|
if fields.published && !was_published {
|
||||||
|
product.published_at = Set(Some(chrono::Utc::now().into()));
|
||||||
|
} else if !fields.published {
|
||||||
|
product.published_at = Set(None);
|
||||||
|
}
|
||||||
|
product.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
if let Some(data) = form.image {
|
||||||
|
let filename = store_image(&ctx, data).await?;
|
||||||
|
let next_position = product_images::count_for(&ctx, id).await?;
|
||||||
|
product_images::ActiveModel {
|
||||||
|
product_id: Set(id),
|
||||||
|
image_id: Set(filename),
|
||||||
|
position: Set(next_position),
|
||||||
|
alt: Set(None),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
format::redirect("/admin/catalog/products")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn delete(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
product_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::redirect("/admin/catalog/products")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin/catalog/products", get(index))
|
||||||
|
.add("/admin/catalog/products/new", get(new))
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products",
|
||||||
|
post(create).layer(image_limit.clone()),
|
||||||
|
)
|
||||||
|
.add("/admin/catalog/products/{id}/edit", get(edit))
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products/{id}",
|
||||||
|
post(update).layer(image_limit),
|
||||||
|
)
|
||||||
|
.add("/admin/catalog/products/{id}/delete", post(delete))
|
||||||
|
}
|
||||||
88
src/controllers/admin_shipping.rs
Normal file
88
src/controllers/admin_shipping.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//! Admin management of the built-in delivery options (Packeta, DPD).
|
||||||
|
//!
|
||||||
|
//! The options themselves are fixed and seeded by `initializers::shipping_seeder`
|
||||||
|
//! — they cannot be added or removed here. The admin only sets each one's price
|
||||||
|
//! and toggles whether it is offered at checkout.
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::shipping_methods,
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
shared::{
|
||||||
|
guard,
|
||||||
|
money::{format_price, parse_price_to_cents},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ShippingForm {
|
||||||
|
price: String,
|
||||||
|
enabled: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_checked(value: &Option<String>) -> bool {
|
||||||
|
matches!(value.as_deref(), Some("on" | "true" | "1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn index(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let methods = shipping_methods::Entity::find()
|
||||||
|
.order_by_asc(shipping_methods::Column::Position)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let rows: Vec<serde_json::Value> = methods
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
json!({
|
||||||
|
"id": m.id,
|
||||||
|
"code": m.code,
|
||||||
|
"name": m.name,
|
||||||
|
"price": format_price(m.price_cents),
|
||||||
|
"carrier": m.carrier,
|
||||||
|
"requires_pickup_point": m.requires_pickup_point,
|
||||||
|
"enabled": m.enabled,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/shipping/index.html",
|
||||||
|
json!({ "methods": rows, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<ShippingForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let method = shipping_methods::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let mut active = method.into_active_model();
|
||||||
|
active.price_cents = Set(parse_price_to_cents(&form.price)?);
|
||||||
|
active.enabled = Set(is_checked(&form.enabled));
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
format::redirect("/admin/shipping")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin/shipping", get(index))
|
||||||
|
.add("/admin/shipping/{id}", post(update))
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
mailers::auth::AuthMailer,
|
models::users::{self, LoginParams, RegisterParams},
|
||||||
models::{
|
|
||||||
_entities::users,
|
|
||||||
users::{LoginParams, RegisterParams},
|
|
||||||
},
|
|
||||||
views::auth::{CurrentResponse, LoginResponse},
|
views::auth::{CurrentResponse, LoginResponse},
|
||||||
|
mailers::auth::AuthMailer,
|
||||||
|
shared::guard::is_admin,
|
||||||
};
|
};
|
||||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
@@ -22,18 +20,6 @@ fn get_allow_email_domain_re() -> &'static Regex {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_email(ctx: &AppContext) -> Option<&str> {
|
|
||||||
ctx.config
|
|
||||||
.settings
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|settings| settings.get("admin_email"))
|
|
||||||
.and_then(|email| email.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
|
||||||
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
|
pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
|
||||||
Cookie::build((AUTH_COOKIE, token.to_string()))
|
Cookie::build((AUTH_COOKIE, token.to_string()))
|
||||||
.path("/")
|
.path("/")
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
use crate::{controllers::admin, models::_entities::blog_articles};
|
|
||||||
use chrono::Utc;
|
|
||||||
use loco_rs::prelude::*;
|
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ArticleParams {
|
|
||||||
title: String,
|
|
||||||
content: String,
|
|
||||||
excerpt: Option<String>,
|
|
||||||
published: Option<bool>,
|
|
||||||
featured_image_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct ArticleResponse {
|
|
||||||
id: Uuid,
|
|
||||||
title: String,
|
|
||||||
slug: String,
|
|
||||||
content: String,
|
|
||||||
excerpt: Option<String>,
|
|
||||||
published: bool,
|
|
||||||
author_id: i32,
|
|
||||||
featured_image_id: Option<String>,
|
|
||||||
view_count: i32,
|
|
||||||
created_at: chrono::DateTime<chrono::FixedOffset>,
|
|
||||||
updated_at: chrono::DateTime<chrono::FixedOffset>,
|
|
||||||
published_at: Option<chrono::DateTime<chrono::FixedOffset>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct ArticleListResponse {
|
|
||||||
articles: Vec<ArticleResponse>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<blog_articles::Model> for ArticleResponse {
|
|
||||||
fn from(article: blog_articles::Model) -> Self {
|
|
||||||
Self {
|
|
||||||
id: article.id,
|
|
||||||
title: article.title,
|
|
||||||
slug: article.slug,
|
|
||||||
content: article.content,
|
|
||||||
excerpt: article.excerpt,
|
|
||||||
published: article.published,
|
|
||||||
author_id: article.author_id,
|
|
||||||
featured_image_id: article.featured_image_id,
|
|
||||||
view_count: article.view_count,
|
|
||||||
created_at: article.created_at,
|
|
||||||
updated_at: article.updated_at,
|
|
||||||
published_at: article.published_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn slugify(title: &str) -> String {
|
|
||||||
let mut slug = String::new();
|
|
||||||
let mut last_was_dash = false;
|
|
||||||
|
|
||||||
for ch in title.chars().flat_map(char::to_lowercase) {
|
|
||||||
if ch.is_ascii_alphanumeric() {
|
|
||||||
slug.push(ch);
|
|
||||||
last_was_dash = false;
|
|
||||||
} else if !last_was_dash && !slug.is_empty() {
|
|
||||||
slug.push('-');
|
|
||||||
last_was_dash = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let slug = slug.trim_matches('-').to_string();
|
|
||||||
if slug.is_empty() {
|
|
||||||
Uuid::new_v4().to_string()
|
|
||||||
} else {
|
|
||||||
slug
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
|
|
||||||
published.then(|| Utc::now().into())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
|
|
||||||
blog_articles::Entity::find_by_id(id)
|
|
||||||
.one(&ctx.db)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| Error::NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn public_index(State(ctx): State<AppContext>) -> Result<Response> {
|
|
||||||
let articles = blog_articles::Entity::find()
|
|
||||||
.filter(blog_articles::Column::Published.eq(true))
|
|
||||||
.order_by_desc(blog_articles::Column::PublishedAt)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.map(ArticleResponse::from)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
format::json(ArticleListResponse { articles })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn public_show(Path(slug): Path<String>, State(ctx): State<AppContext>) -> Result<Response> {
|
|
||||||
let article = blog_articles::Entity::find()
|
|
||||||
.filter(blog_articles::Column::Slug.eq(slug))
|
|
||||||
.filter(blog_articles::Column::Published.eq(true))
|
|
||||||
.one(&ctx.db)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| Error::NotFound)?;
|
|
||||||
|
|
||||||
let mut active = article.into_active_model();
|
|
||||||
let next_count = active.view_count.as_ref().to_owned() + 1;
|
|
||||||
active.view_count = Set(next_count);
|
|
||||||
let article = active.update(&ctx.db).await?;
|
|
||||||
|
|
||||||
format::json(ArticleResponse::from(article))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_index(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
|
|
||||||
let articles = blog_articles::Entity::find()
|
|
||||||
.order_by_desc(blog_articles::Column::CreatedAt)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.map(ArticleResponse::from)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
format::json(ArticleListResponse { articles })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_create(
|
|
||||||
auth: auth::JWT,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
Json(params): Json<ArticleParams>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
let admin_user = admin::current_admin(auth, &ctx).await?;
|
|
||||||
let published = params.published.unwrap_or(false);
|
|
||||||
|
|
||||||
let article = blog_articles::ActiveModel {
|
|
||||||
id: Set(Uuid::new_v4()),
|
|
||||||
title: Set(params.title.clone()),
|
|
||||||
slug: Set(slugify(¶ms.title)),
|
|
||||||
content: Set(params.content),
|
|
||||||
excerpt: Set(params.excerpt),
|
|
||||||
published: Set(published),
|
|
||||||
author_id: Set(admin_user.id),
|
|
||||||
featured_image_id: Set(params.featured_image_id),
|
|
||||||
view_count: Set(0),
|
|
||||||
published_at: Set(published_at_for(published)),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.insert(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
format::json(ArticleResponse::from(article))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_update(
|
|
||||||
auth: auth::JWT,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
Json(params): Json<ArticleParams>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
|
|
||||||
let existing = find_article_by_id(&ctx, id).await?;
|
|
||||||
let was_published = existing.published;
|
|
||||||
let published = params.published.unwrap_or(was_published);
|
|
||||||
|
|
||||||
let mut article = existing.into_active_model();
|
|
||||||
article.title = Set(params.title.clone());
|
|
||||||
article.slug = Set(slugify(¶ms.title));
|
|
||||||
article.content = Set(params.content);
|
|
||||||
article.excerpt = Set(params.excerpt);
|
|
||||||
article.published = Set(published);
|
|
||||||
article.featured_image_id = Set(params.featured_image_id);
|
|
||||||
if published && !was_published {
|
|
||||||
article.published_at = Set(published_at_for(true));
|
|
||||||
} else if !published {
|
|
||||||
article.published_at = Set(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let article = article.update(&ctx.db).await?;
|
|
||||||
format::json(ArticleResponse::from(article))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_delete(
|
|
||||||
auth: auth::JWT,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
let article = find_article_by_id(&ctx, id).await?;
|
|
||||||
article.delete(&ctx.db).await?;
|
|
||||||
format::json(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_publish(
|
|
||||||
auth: auth::JWT,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
|
|
||||||
article.published = Set(true);
|
|
||||||
article.published_at = Set(published_at_for(true));
|
|
||||||
let article = article.update(&ctx.db).await?;
|
|
||||||
format::json(ArticleResponse::from(article))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_unpublish(
|
|
||||||
auth: auth::JWT,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
|
|
||||||
article.published = Set(false);
|
|
||||||
article.published_at = Set(None);
|
|
||||||
let article = article.update(&ctx.db).await?;
|
|
||||||
format::json(ArticleResponse::from(article))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
|
||||||
Routes::new()
|
|
||||||
.prefix("/api")
|
|
||||||
.add("/blog", get(public_index))
|
|
||||||
.add("/blog/{slug}", get(public_show))
|
|
||||||
.add("/admin/blog/articles", get(admin_index))
|
|
||||||
.add("/admin/blog/articles", post(admin_create))
|
|
||||||
.add("/admin/blog/articles/{id}", put(admin_update))
|
|
||||||
.add("/admin/blog/articles/{id}", delete(admin_delete))
|
|
||||||
.add("/admin/blog/articles/{id}/publish", post(admin_publish))
|
|
||||||
.add("/admin/blog/articles/{id}/unpublish", post(admin_unpublish))
|
|
||||||
}
|
|
||||||
241
src/controllers/cart.rs
Normal file
241
src/controllers/cart.rs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
use crate::{controllers::i18n::current_lang, shared::money::format_price, models::products};
|
||||||
|
use axum::{http::HeaderMap, response::Redirect};
|
||||||
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use time::Duration as TimeDuration;
|
||||||
|
|
||||||
|
pub(crate) const CART_COOKIE: &str = "cart";
|
||||||
|
const CART_MAX_AGE_DAYS: i64 = 30;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AddForm {
|
||||||
|
product_id: i32,
|
||||||
|
quantity: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct UpdateForm {
|
||||||
|
product_id: i32,
|
||||||
|
quantity: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RemoveForm {
|
||||||
|
product_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the `cart` cookie ("id:qty,id:qty") into `(product_id, quantity)`
|
||||||
|
/// pairs, silently dropping malformed or non-positive entries.
|
||||||
|
pub(crate) fn parse_cart(jar: &CookieJar) -> Vec<(i32, i32)> {
|
||||||
|
let Some(cookie) = jar.get(CART_COOKIE) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
cookie
|
||||||
|
.value()
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let (id, qty) = entry.split_once(':')?;
|
||||||
|
let id = id.trim().parse::<i32>().ok()?;
|
||||||
|
let qty = qty.trim().parse::<i32>().ok()?;
|
||||||
|
(qty > 0).then_some((id, qty))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_cart(items: &[(i32, i32)]) -> String {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|(id, qty)| format!("{id}:{qty}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cart_cookie(value: String) -> Cookie<'static> {
|
||||||
|
Cookie::build((CART_COOKIE, value))
|
||||||
|
.path("/")
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.max_age(TimeDuration::days(CART_MAX_AGE_DAYS))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a published product, returning its current stock cap.
|
||||||
|
async fn published_product(ctx: &AppContext, id: i32) -> Result<Option<products::Model>> {
|
||||||
|
Ok(products::Entity::find_by_id(id)
|
||||||
|
.filter(products::Column::Published.eq(true))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn add(
|
||||||
|
jar: CookieJar,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<AddForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Some(product) = published_product(&ctx, form.product_id).await? else {
|
||||||
|
return Err(Error::NotFound);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut items = parse_cart(&jar);
|
||||||
|
let add_qty = form.quantity.unwrap_or(1).max(1);
|
||||||
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == product.id) {
|
||||||
|
entry.1 = (entry.1 + add_qty).min(product.stock);
|
||||||
|
} else {
|
||||||
|
items.push((product.id, add_qty.min(product.stock)));
|
||||||
|
}
|
||||||
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
|
format::render()
|
||||||
|
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
||||||
|
.redirect("/cart")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update(
|
||||||
|
jar: CookieJar,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<UpdateForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let stock = published_product(&ctx, form.product_id)
|
||||||
|
.await?
|
||||||
|
.map(|p| p.stock)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let mut items = parse_cart(&jar);
|
||||||
|
let clamped = form.quantity.clamp(0, stock);
|
||||||
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.product_id) {
|
||||||
|
entry.1 = clamped;
|
||||||
|
}
|
||||||
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
|
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
||||||
|
cart_response(&ctx, &v, jar, &headers).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn remove(
|
||||||
|
jar: CookieJar,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<RemoveForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let mut items = parse_cart(&jar);
|
||||||
|
items.retain(|(id, _)| *id != form.product_id);
|
||||||
|
|
||||||
|
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
||||||
|
cart_response(&ctx, &v, jar, &headers).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response after a cart mutation: for an htmx request, just the `#cart-body`
|
||||||
|
/// fragment (so the page never fully reloads); otherwise a redirect back to
|
||||||
|
/// `/cart` for no-JS fallback. `jar` must already hold the updated cart cookie.
|
||||||
|
async fn cart_response(
|
||||||
|
ctx: &AppContext,
|
||||||
|
v: &TeraView,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Result<Response> {
|
||||||
|
if !headers.contains_key("HX-Request") {
|
||||||
|
return Ok((jar, Redirect::to("/cart")).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (lines, valid, total) = resolve_cart(ctx, &jar).await?;
|
||||||
|
let currency = lines
|
||||||
|
.first()
|
||||||
|
.and_then(|line| line["currency"].as_str())
|
||||||
|
.unwrap_or("EUR")
|
||||||
|
.to_string();
|
||||||
|
// Persist the re-validated cookie (drops now-invalid lines).
|
||||||
|
let jar = jar.add(cart_cookie(serialize_cart(&valid)));
|
||||||
|
let response = format::view(
|
||||||
|
v,
|
||||||
|
"shop/_cart_body.html",
|
||||||
|
json!({
|
||||||
|
"items": lines,
|
||||||
|
"total": format_price(total),
|
||||||
|
"currency": currency,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
Ok((jar, response).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the cart cookie into priced line items, dropping anything that is no
|
||||||
|
/// longer purchasable and clamping quantities to current stock. Returns the
|
||||||
|
/// (re-validated) lines, the rebuilt cookie value, and the total in cents.
|
||||||
|
pub(crate) async fn resolve_cart(
|
||||||
|
ctx: &AppContext,
|
||||||
|
jar: &CookieJar,
|
||||||
|
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut valid = Vec::new();
|
||||||
|
let mut total: i64 = 0;
|
||||||
|
|
||||||
|
for (id, qty) in parse_cart(jar) {
|
||||||
|
let Some(product) = published_product(ctx, id).await? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let qty = qty.clamp(0, product.stock);
|
||||||
|
if qty == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let line_total = product.price_cents * i64::from(qty);
|
||||||
|
total += line_total;
|
||||||
|
valid.push((product.id, qty));
|
||||||
|
lines.push(json!({
|
||||||
|
"id": product.id,
|
||||||
|
"name": product.name,
|
||||||
|
"slug": product.slug,
|
||||||
|
"price": format_price(product.price_cents),
|
||||||
|
"currency": product.currency,
|
||||||
|
"quantity": qty,
|
||||||
|
"stock": product.stock,
|
||||||
|
"line_total": format_price(line_total),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((lines, valid, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn show(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
|
||||||
|
let currency = lines
|
||||||
|
.first()
|
||||||
|
.and_then(|line| line["currency"].as_str())
|
||||||
|
.unwrap_or("EUR")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Drop any now-invalid lines from the cookie so the badge stays accurate.
|
||||||
|
let rebuilt = serialize_cart(&valid);
|
||||||
|
let response = format::view(
|
||||||
|
&v,
|
||||||
|
"shop/cart.html",
|
||||||
|
json!({
|
||||||
|
"items": lines,
|
||||||
|
"total": format_price(total),
|
||||||
|
"currency": currency,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/cart", get(show))
|
||||||
|
.add("/cart/add", post(add))
|
||||||
|
.add("/cart/update", post(update))
|
||||||
|
.add("/cart/remove", post(remove))
|
||||||
|
}
|
||||||
222
src/controllers/checkout.rs
Normal file
222
src/controllers/checkout.rs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
//! Public checkout flow: the checkout form, placing an order, and the order
|
||||||
|
//! confirmation page.
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use time::Duration as TimeDuration;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::cart::{resolve_cart, CART_COOKIE},
|
||||||
|
models::{order_items, orders, shipping_methods},
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
shared::{money::format_price, settings},
|
||||||
|
views::checkout as view,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CheckoutForm {
|
||||||
|
email: String,
|
||||||
|
phone_prefix: String,
|
||||||
|
phone: String,
|
||||||
|
customer_name: String,
|
||||||
|
address: String,
|
||||||
|
city: String,
|
||||||
|
zip: String,
|
||||||
|
country: String,
|
||||||
|
note: Option<String>,
|
||||||
|
payment_method: String,
|
||||||
|
carrier_code: String,
|
||||||
|
pickup_point_id: Option<String>,
|
||||||
|
pickup_point_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trimmed(value: &str) -> Option<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleared_cart_cookie() -> Cookie<'static> {
|
||||||
|
Cookie::build((CART_COOKIE, ""))
|
||||||
|
.path("/")
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.max_age(TimeDuration::seconds(0))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
|
||||||
|
Ok(shipping_methods::Entity::find()
|
||||||
|
.filter(shipping_methods::Column::Enabled.eq(true))
|
||||||
|
.order_by_asc(shipping_methods::Column::Position)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn checkout_page(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?;
|
||||||
|
if lines.is_empty() {
|
||||||
|
return format::redirect("/cart");
|
||||||
|
}
|
||||||
|
let currency = lines
|
||||||
|
.first()
|
||||||
|
.and_then(|line| line["currency"].as_str())
|
||||||
|
.unwrap_or("EUR")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
json!({
|
||||||
|
"code": m.code,
|
||||||
|
"name": m.name,
|
||||||
|
"price_cents": m.price_cents,
|
||||||
|
"price": format_price(m.price_cents),
|
||||||
|
"requires_pickup_point": m.requires_pickup_point,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"shop/checkout.html",
|
||||||
|
json!({
|
||||||
|
"items": lines,
|
||||||
|
"subtotal": format_price(subtotal),
|
||||||
|
"subtotal_cents": subtotal,
|
||||||
|
"currency": currency,
|
||||||
|
"shipping_methods": methods,
|
||||||
|
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn place_order(
|
||||||
|
jar: CookieJar,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<CheckoutForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?;
|
||||||
|
if valid.is_empty() {
|
||||||
|
return format::redirect("/cart");
|
||||||
|
}
|
||||||
|
let email =
|
||||||
|
trimmed(&form.email).ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
|
||||||
|
// Combine the dialling-code prefix with the local number into one E.164-ish
|
||||||
|
// value (e.g. "+421 900123456").
|
||||||
|
let number =
|
||||||
|
trimmed(&form.phone).ok_or_else(|| Error::BadRequest("phone is required".to_string()))?;
|
||||||
|
let phone = match trimmed(&form.phone_prefix) {
|
||||||
|
Some(prefix) => format!("{prefix} {number}"),
|
||||||
|
None => number,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contact and shipping-address fields are mandatory (also enforced in the
|
||||||
|
// browser via `required`).
|
||||||
|
let require = |value: &str, field: &str| -> Result<String> {
|
||||||
|
trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required")))
|
||||||
|
};
|
||||||
|
let customer_name = require(&form.customer_name, "name")?;
|
||||||
|
let address = require(&form.address, "address")?;
|
||||||
|
let city = require(&form.city, "city")?;
|
||||||
|
let zip = require(&form.zip, "zip")?;
|
||||||
|
let country = require(&form.country, "country")?;
|
||||||
|
|
||||||
|
if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) {
|
||||||
|
return Err(Error::BadRequest("invalid payment method".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the chosen carrier from the enabled methods (price is taken from
|
||||||
|
// the DB, never the form, so the customer can't pick their own fee).
|
||||||
|
let method = shipping_methods::Entity::find()
|
||||||
|
.filter(shipping_methods::Column::Code.eq(&form.carrier_code))
|
||||||
|
.filter(shipping_methods::Column::Enabled.eq(true))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?;
|
||||||
|
|
||||||
|
let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
|
||||||
|
let id = form
|
||||||
|
.pickup_point_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(trimmed)
|
||||||
|
.ok_or_else(|| Error::BadRequest("a pickup point is required".to_string()))?;
|
||||||
|
(Some(id), form.pickup_point_name.as_deref().and_then(trimmed))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let order = orders::place(
|
||||||
|
&ctx,
|
||||||
|
&valid,
|
||||||
|
orders::Checkout {
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
customer_name: Some(customer_name),
|
||||||
|
address: Some(address),
|
||||||
|
city: Some(city),
|
||||||
|
zip: Some(zip),
|
||||||
|
country: Some(country),
|
||||||
|
note: form.note.as_deref().and_then(trimmed),
|
||||||
|
payment_method: form.payment_method,
|
||||||
|
method,
|
||||||
|
pickup_point_id,
|
||||||
|
pickup_point_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::render()
|
||||||
|
.cookies(&[cleared_cart_cookie()])?
|
||||||
|
.redirect(&format!("/orders/{}", order.order_number))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn order_confirmation(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(order_number): Path<String>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let order = orders::Entity::find()
|
||||||
|
.filter(orders::Column::OrderNumber.eq(order_number))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let items = order_items::Entity::find()
|
||||||
|
.filter(order_items::Column::OrderId.eq(order.id))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"shop/order_confirmed.html",
|
||||||
|
json!({
|
||||||
|
"order": view::detail(
|
||||||
|
&order,
|
||||||
|
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
||||||
|
settings::get(&ctx, "bank_account_name").unwrap_or(""),
|
||||||
|
),
|
||||||
|
"items": view::items(&items),
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/checkout", get(checkout_page))
|
||||||
|
.add("/checkout", post(place_order))
|
||||||
|
.add("/orders/{order_number}", get(order_confirmation))
|
||||||
|
}
|
||||||
@@ -1,479 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
controllers::{admin, auth as auth_controller, i18n::current_lang},
|
|
||||||
models::{
|
|
||||||
_entities::{audio_albums, audio_tracks, blog_articles, site_pages},
|
|
||||||
users::{self, LoginParams},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
|
||||||
use chrono::Utc;
|
|
||||||
use loco_rs::prelude::*;
|
|
||||||
use sea_orm::{
|
|
||||||
sea_query::Expr, ActiveModelTrait, ColumnTrait, EntityTrait, Order, QueryFilter, QueryOrder,
|
|
||||||
QuerySelect, Set,
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const ABOUT_SLUG: &str = "about";
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ArticleForm {
|
|
||||||
title: String,
|
|
||||||
content: String,
|
|
||||||
excerpt: Option<String>,
|
|
||||||
published: Option<String>,
|
|
||||||
featured_image_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct AboutForm {
|
|
||||||
title: String,
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn slugify(title: &str) -> String {
|
|
||||||
let mut slug = String::new();
|
|
||||||
let mut last_was_dash = false;
|
|
||||||
|
|
||||||
for ch in title.chars().flat_map(char::to_lowercase) {
|
|
||||||
if ch.is_ascii_alphanumeric() {
|
|
||||||
slug.push(ch);
|
|
||||||
last_was_dash = false;
|
|
||||||
} else if !last_was_dash && !slug.is_empty() {
|
|
||||||
slug.push('-');
|
|
||||||
last_was_dash = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let slug = slug.trim_matches('-').to_string();
|
|
||||||
if slug.is_empty() {
|
|
||||||
Uuid::new_v4().to_string()
|
|
||||||
} else {
|
|
||||||
slug
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
|
|
||||||
published.then(|| Utc::now().into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_checked(value: &Option<String>) -> bool {
|
|
||||||
value
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|value| value == "on" || value == "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_empty(value: Option<String>) -> Option<String> {
|
|
||||||
value.and_then(|value| {
|
|
||||||
let value = value.trim().to_string();
|
|
||||||
if value.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn about_page(ctx: &AppContext) -> Result<site_pages::Model> {
|
|
||||||
site_pages::Entity::find()
|
|
||||||
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
|
|
||||||
.one(&ctx.db)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| Error::NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
|
|
||||||
blog_articles::Entity::find_by_id(id)
|
|
||||||
.one(&ctx.db)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| Error::NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool {
|
|
||||||
let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Ok(jwt_config) = ctx.config.get_jwt_config() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
admin::is_admin(ctx, &user)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn home(
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
let articles = blog_articles::Entity::find()
|
|
||||||
.filter(blog_articles::Column::Published.eq(true))
|
|
||||||
.order_by_desc(blog_articles::Column::PublishedAt)
|
|
||||||
.limit(5)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// A random published song to suggest on the landing page.
|
|
||||||
let featured_track = audio_tracks::Entity::find()
|
|
||||||
.filter(audio_tracks::Column::Published.eq(true))
|
|
||||||
.order_by(Expr::cust("RANDOM()"), Order::Asc)
|
|
||||||
.one(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// A random published album, never the one the suggested song belongs to.
|
|
||||||
let mut album_query =
|
|
||||||
audio_albums::Entity::find().filter(audio_albums::Column::Published.eq(true));
|
|
||||||
if let Some(album_id) = featured_track.as_ref().and_then(|track| track.album_id) {
|
|
||||||
album_query = album_query.filter(audio_albums::Column::Id.ne(album_id));
|
|
||||||
}
|
|
||||||
let featured_album = album_query
|
|
||||||
.order_by(Expr::cust("RANDOM()"), Order::Asc)
|
|
||||||
.one(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"home/index.html",
|
|
||||||
json!({
|
|
||||||
"articles": articles,
|
|
||||||
"featured_track": featured_track,
|
|
||||||
"featured_album": featured_album,
|
|
||||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
|
||||||
"lang": current_lang(&jar),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn about(
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"pages/about.html",
|
|
||||||
json!({
|
|
||||||
"page": about_page(&ctx).await?,
|
|
||||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
|
||||||
"lang": current_lang(&jar),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn blog_index(
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
let articles = blog_articles::Entity::find()
|
|
||||||
.filter(blog_articles::Column::Published.eq(true))
|
|
||||||
.order_by_desc(blog_articles::Column::PublishedAt)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"blog/index.html",
|
|
||||||
json!({
|
|
||||||
"articles": articles,
|
|
||||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
|
||||||
"lang": current_lang(&jar),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn blog_show(
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
Path(slug): Path<String>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
let article = blog_articles::Entity::find()
|
|
||||||
.filter(blog_articles::Column::Slug.eq(slug))
|
|
||||||
.filter(blog_articles::Column::Published.eq(true))
|
|
||||||
.one(&ctx.db)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| Error::NotFound)?;
|
|
||||||
|
|
||||||
let mut active = article.into_active_model();
|
|
||||||
let next_count = active.view_count.as_ref().to_owned() + 1;
|
|
||||||
active.view_count = Set(next_count);
|
|
||||||
let article = active.update(&ctx.db).await?;
|
|
||||||
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"blog/show.html",
|
|
||||||
json!({
|
|
||||||
"article": article,
|
|
||||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
|
||||||
"lang": current_lang(&jar),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_login_page(
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
if logged_in_admin(&ctx, &jar).await {
|
|
||||||
return format::redirect("/admin/dashboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"admin/login.html",
|
|
||||||
json!({
|
|
||||||
"error": null,
|
|
||||||
"logged_in_admin": false,
|
|
||||||
"lang": current_lang(&jar),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_login(
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
Form(params): Form<LoginParams>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
|
||||||
return format::view(
|
|
||||||
&v,
|
|
||||||
"admin/login.html",
|
|
||||||
json!({
|
|
||||||
"error": "Invalid credentials",
|
|
||||||
"logged_in_admin": false,
|
|
||||||
"lang": current_lang(&jar),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if !user.verify_password(¶ms.password) || !admin::is_admin(&ctx, &user) {
|
|
||||||
return format::view(
|
|
||||||
&v,
|
|
||||||
"admin/login.html",
|
|
||||||
json!({
|
|
||||||
"error": "Invalid credentials",
|
|
||||||
"logged_in_admin": false,
|
|
||||||
"lang": current_lang(&jar),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let jwt_secret = ctx.config.get_jwt_config()?;
|
|
||||||
let token = user
|
|
||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
|
||||||
|
|
||||||
format::render()
|
|
||||||
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
|
||||||
.redirect("/admin/dashboard")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_logout() -> Result<Response> {
|
|
||||||
format::render()
|
|
||||||
.cookies(&[auth_controller::clear_auth_cookie()])?
|
|
||||||
.redirect("/admin/login")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_home(
|
|
||||||
auth: auth::JWT,
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
let admin_user = admin::current_admin(auth, &ctx).await?;
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"admin/index.html",
|
|
||||||
json!({ "admin": admin_user, "lang": current_lang(&jar) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_about(
|
|
||||||
auth: auth::JWT,
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"admin/about.html",
|
|
||||||
json!({ "page": about_page(&ctx).await?, "lang": current_lang(&jar) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_about_update(
|
|
||||||
auth: auth::JWT,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
Form(params): Form<AboutForm>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
let mut page = about_page(&ctx).await?.into_active_model();
|
|
||||||
page.title = Set(params.title);
|
|
||||||
page.content = Set(params.content);
|
|
||||||
page.update(&ctx.db).await?;
|
|
||||||
format::redirect("/admin/about")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_articles(
|
|
||||||
auth: auth::JWT,
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
let articles = blog_articles::Entity::find()
|
|
||||||
.order_by_desc(blog_articles::Column::CreatedAt)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"admin/blog/index.html",
|
|
||||||
json!({ "articles": articles, "lang": current_lang(&jar) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_article_new(
|
|
||||||
auth: auth::JWT,
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"admin/blog/new.html",
|
|
||||||
json!({ "lang": current_lang(&jar) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_article_create(
|
|
||||||
auth: auth::JWT,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
Form(params): Form<ArticleForm>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
let admin_user = admin::current_admin(auth, &ctx).await?;
|
|
||||||
let published = is_checked(¶ms.published);
|
|
||||||
|
|
||||||
blog_articles::ActiveModel {
|
|
||||||
id: Set(Uuid::new_v4()),
|
|
||||||
title: Set(params.title.clone()),
|
|
||||||
slug: Set(slugify(¶ms.title)),
|
|
||||||
content: Set(params.content),
|
|
||||||
excerpt: Set(normalize_empty(params.excerpt)),
|
|
||||||
published: Set(published),
|
|
||||||
author_id: Set(admin_user.id),
|
|
||||||
featured_image_id: Set(normalize_empty(params.featured_image_id)),
|
|
||||||
view_count: Set(0),
|
|
||||||
published_at: Set(published_at_for(published)),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.insert(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
format::redirect("/admin/blog/articles")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_article_edit(
|
|
||||||
auth: auth::JWT,
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"admin/blog/edit.html",
|
|
||||||
json!({ "article": article_by_id(&ctx, id).await?, "lang": current_lang(&jar) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_article_update(
|
|
||||||
auth: auth::JWT,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
Form(params): Form<ArticleForm>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
let existing = article_by_id(&ctx, id).await?;
|
|
||||||
let was_published = existing.published;
|
|
||||||
let published = is_checked(¶ms.published);
|
|
||||||
|
|
||||||
let mut article = existing.into_active_model();
|
|
||||||
article.title = Set(params.title.clone());
|
|
||||||
article.slug = Set(slugify(¶ms.title));
|
|
||||||
article.content = Set(params.content);
|
|
||||||
article.excerpt = Set(normalize_empty(params.excerpt));
|
|
||||||
article.published = Set(published);
|
|
||||||
article.featured_image_id = Set(normalize_empty(params.featured_image_id));
|
|
||||||
if published && !was_published {
|
|
||||||
article.published_at = Set(published_at_for(true));
|
|
||||||
} else if !published {
|
|
||||||
article.published_at = Set(None);
|
|
||||||
}
|
|
||||||
article.update(&ctx.db).await?;
|
|
||||||
|
|
||||||
format::redirect("/admin/blog/articles")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn admin_article_delete(
|
|
||||||
auth: auth::JWT,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
article_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
|
||||||
format::redirect("/admin/blog/articles")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
|
||||||
Routes::new()
|
|
||||||
.add("/", get(home))
|
|
||||||
.add("/about", get(about))
|
|
||||||
.add("/blog", get(blog_index))
|
|
||||||
.add("/blog/{slug}", get(blog_show))
|
|
||||||
.add("/admin/login", get(admin_login_page))
|
|
||||||
.add("/admin/login", post(admin_login))
|
|
||||||
.add("/admin/logout", post(admin_logout))
|
|
||||||
.add("/admin", get(admin_login_page))
|
|
||||||
.add("/admin/dashboard", get(admin_home))
|
|
||||||
.add("/admin/about", get(admin_about))
|
|
||||||
.add("/admin/about", post(admin_about_update))
|
|
||||||
.add("/admin/blog/articles", get(admin_articles))
|
|
||||||
.add("/admin/blog/articles/new", get(admin_article_new))
|
|
||||||
.add("/admin/blog/articles", post(admin_article_create))
|
|
||||||
.add("/admin/blog/articles/{id}/edit", get(admin_article_edit))
|
|
||||||
.add("/admin/blog/articles/{id}", post(admin_article_update))
|
|
||||||
.add(
|
|
||||||
"/admin/blog/articles/{id}/delete",
|
|
||||||
post(admin_article_delete),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
30
src/controllers/home.rs
Normal file
30
src/controllers/home.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
//! Public landing page.
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop};
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn index(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let products = shop::featured_products(&ctx, 8).await?;
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"home/index.html",
|
||||||
|
json!({
|
||||||
|
"products": products,
|
||||||
|
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new().add("/", get(index))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,14 @@
|
|||||||
pub mod admin;
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod blog;
|
pub mod admin_categories;
|
||||||
pub mod frontend;
|
pub mod admin_dashboard;
|
||||||
|
pub mod admin_form;
|
||||||
|
pub mod admin_login;
|
||||||
|
pub mod admin_orders;
|
||||||
|
pub mod admin_products;
|
||||||
|
pub mod admin_shipping;
|
||||||
|
pub mod cart;
|
||||||
|
pub mod checkout;
|
||||||
|
pub mod home;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod pages;
|
pub mod shop;
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
use crate::{controllers::admin, models::_entities::site_pages};
|
|
||||||
use loco_rs::prelude::*;
|
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const ABOUT_SLUG: &str = "about";
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct AboutParams {
|
|
||||||
title: String,
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct PageResponse {
|
|
||||||
id: Uuid,
|
|
||||||
slug: String,
|
|
||||||
title: String,
|
|
||||||
content: String,
|
|
||||||
updated_at: chrono::DateTime<chrono::FixedOffset>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<site_pages::Model> for PageResponse {
|
|
||||||
fn from(page: site_pages::Model) -> Self {
|
|
||||||
Self {
|
|
||||||
id: page.id,
|
|
||||||
slug: page.slug,
|
|
||||||
title: page.title,
|
|
||||||
content: page.content,
|
|
||||||
updated_at: page.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_about(ctx: &AppContext) -> Result<site_pages::Model> {
|
|
||||||
site_pages::Entity::find()
|
|
||||||
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
|
|
||||||
.one(&ctx.db)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| Error::NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn about(State(ctx): State<AppContext>) -> Result<Response> {
|
|
||||||
format::json(PageResponse::from(find_about(&ctx).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn update_about(
|
|
||||||
auth: auth::JWT,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
Json(params): Json<AboutParams>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
admin::current_admin(auth, &ctx).await?;
|
|
||||||
|
|
||||||
let page = match find_about(&ctx).await {
|
|
||||||
Ok(page) => {
|
|
||||||
let mut page = page.into_active_model();
|
|
||||||
page.title = Set(params.title);
|
|
||||||
page.content = Set(params.content);
|
|
||||||
page.update(&ctx.db).await?
|
|
||||||
}
|
|
||||||
Err(Error::NotFound) => {
|
|
||||||
site_pages::ActiveModel {
|
|
||||||
id: Set(Uuid::new_v4()),
|
|
||||||
slug: Set(ABOUT_SLUG.to_string()),
|
|
||||||
title: Set(params.title),
|
|
||||||
content: Set(params.content),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.insert(&ctx.db)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
};
|
|
||||||
|
|
||||||
format::json(PageResponse::from(page))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
|
||||||
Routes::new()
|
|
||||||
.prefix("/api")
|
|
||||||
.add("/about", get(about))
|
|
||||||
.add("/admin/about", put(update_about))
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user