eshop
This commit is contained in:
@@ -172,3 +172,70 @@ 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
|
||||||
|
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.
|
||||||
|
cart-title = Cart
|
||||||
|
cart-empty = Your cart is empty.
|
||||||
|
cart-total = Total
|
||||||
|
cart-checkout = Proceed to checkout
|
||||||
|
cart-remove = Remove
|
||||||
|
cart-update = Update
|
||||||
|
cart-continue = Continue shopping
|
||||||
|
checkout-title = Checkout
|
||||||
|
checkout-contact = Contact details
|
||||||
|
checkout-shipping = Shipping address
|
||||||
|
checkout-email = Email
|
||||||
|
checkout-name = Full name
|
||||||
|
checkout-address = Address
|
||||||
|
checkout-city = City
|
||||||
|
checkout-zip = Postal code
|
||||||
|
checkout-country = Country
|
||||||
|
checkout-note = Order note
|
||||||
|
checkout-place-order = Place order
|
||||||
|
checkout-summary = Order summary
|
||||||
|
order-confirmed-title = Thank you for your order!
|
||||||
|
order-confirmed-sub = We have received your order.
|
||||||
|
order-number = Order number
|
||||||
|
order-status = Status
|
||||||
|
order-total = Total
|
||||||
|
order-items = Items
|
||||||
|
order-date = Date
|
||||||
|
order-customer = Customer
|
||||||
|
admin-no-orders = No orders yet.
|
||||||
|
order-status-pending = Pending
|
||||||
|
order-status-paid = Paid
|
||||||
|
order-status-shipped = Shipped
|
||||||
|
order-status-cancelled = Cancelled
|
||||||
|
order-update-status = Update status
|
||||||
|
|||||||
@@ -172,3 +172,70 @@ 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
|
||||||
|
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.
|
||||||
|
cart-title = Košík
|
||||||
|
cart-empty = Váš košík je prázdny.
|
||||||
|
cart-total = Spolu
|
||||||
|
cart-checkout = Pokračovať k pokladni
|
||||||
|
cart-remove = Odstrániť
|
||||||
|
cart-update = Aktualizovať
|
||||||
|
cart-continue = Pokračovať v nákupe
|
||||||
|
checkout-title = Pokladňa
|
||||||
|
checkout-contact = Kontaktné údaje
|
||||||
|
checkout-shipping = Dodacia adresa
|
||||||
|
checkout-email = E-mail
|
||||||
|
checkout-name = Meno a priezvisko
|
||||||
|
checkout-address = Adresa
|
||||||
|
checkout-city = Mesto
|
||||||
|
checkout-zip = PSČ
|
||||||
|
checkout-country = Krajina
|
||||||
|
checkout-note = Poznámka k objednávke
|
||||||
|
checkout-place-order = Odoslať objednávku
|
||||||
|
checkout-summary = Súhrn objednávky
|
||||||
|
order-confirmed-title = Ďakujeme za objednávku!
|
||||||
|
order-confirmed-sub = Vašu objednávku sme prijali.
|
||||||
|
order-number = Číslo objednávky
|
||||||
|
order-status = Stav
|
||||||
|
order-total = Spolu
|
||||||
|
order-items = Položky
|
||||||
|
order-date = Dátum
|
||||||
|
order-customer = Zákazník
|
||||||
|
admin-no-orders = Zatiaľ žiadne objednávky.
|
||||||
|
order-status-pending = Čaká na spracovanie
|
||||||
|
order-status-paid = Zaplatené
|
||||||
|
order-status-shipped = Odoslané
|
||||||
|
order-status-cancelled = Zrušené
|
||||||
|
order-update-status = Zmeniť stav
|
||||||
|
|||||||
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 %}
|
|
||||||
@@ -44,114 +44,127 @@
|
|||||||
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="flex min-h-screen flex-col bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
x-data="{ showSidebar: false }"
|
||||||
<header
|
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
|
||||||
<nav x-data="{ mobile: false }" class="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3">
|
<!-- dark overlay for the open sidebar on small screens -->
|
||||||
<a href="/admin/dashboard"
|
<div x-cloak x-show="showSidebar" x-transition.opacity aria-hidden="true"
|
||||||
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">
|
@click="showSidebar = false"
|
||||||
{{ t(key="admin-title", lang=lang | default(value='sk')) }}
|
class="fixed inset-0 z-30 bg-black/50 md:hidden"></div>
|
||||||
|
|
||||||
|
<!-- sidebar -->
|
||||||
|
<nav aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||||
|
x-bind:class="showSidebar ? 'translate-x-0' : '-translate-x-60'"
|
||||||
|
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-outline bg-surface-alt transition-transform duration-300 md:translate-x-0 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
|
||||||
|
<a href="/admin/dashboard"
|
||||||
|
class="flex h-16 items-center gap-2 border-b border-outline px-6 text-lg font-bold tracking-tight text-on-surface-strong dark:border-outline-dark dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="admin-title", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col gap-1 overflow-y-auto p-4">
|
||||||
|
<a href="/admin/dashboard" data-nav="/admin/dashboard"
|
||||||
|
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/catalog/products" data-nav="/admin/catalog/products"
|
||||||
|
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
{{ t(key="admin-products", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/catalog/categories" data-nav="/admin/catalog/categories"
|
||||||
|
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
{{ t(key="admin-categories", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/orders" data-nav="/admin/orders"
|
||||||
|
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- desktop links -->
|
<div class="border-t border-outline p-4 dark:border-outline-dark">
|
||||||
<ul class="ml-2 hidden items-center gap-1 md:flex">
|
<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">
|
||||||
<li><a href="/admin/dashboard" data-nav="/admin/dashboard" 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="admin-dashboard", lang=lang | default(value='sk')) }}</a></li>
|
{{ t(key="admin-exit", lang=lang | default(value='sk')) }}
|
||||||
<li><a href="/admin/blog/articles" data-nav="/admin/blog" 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="admin-blog", lang=lang | default(value='sk')) }}</a></li>
|
</a>
|
||||||
<li><a href="/admin/audio/albums" data-nav="/admin/audio" 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="admin-audio", lang=lang | default(value='sk')) }}</a></li>
|
<form method="post" action="/admin/logout">
|
||||||
<li><a href="/admin/about" data-nav="/admin/about" 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="admin-about", lang=lang | default(value='sk')) }}</a></li>
|
<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">
|
||||||
<li><a href="/" class="rounded-radius px-3 py-1.5 text-sm font-medium text-info transition hover:bg-surface-alt dark:hover:bg-surface-dark-alt">{{ t(key="admin-exit", lang=lang | default(value='sk')) }}</a></li>
|
{{ t(key="logout", lang=lang | default(value='sk')) }}
|
||||||
<li>
|
</button>
|
||||||
<form method="post" action="/admin/logout">
|
</form>
|
||||||
<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>
|
</div>
|
||||||
</form>
|
</nav>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1">
|
<!-- content column -->
|
||||||
<!-- settings (language + theme) dropdown -->
|
<div class="flex min-h-screen flex-col md:ml-60">
|
||||||
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative">
|
<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="open = !open" :aria-expanded="open"
|
<button type="button" @click="showSidebar = !showSidebar" :aria-expanded="showSidebar"
|
||||||
aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}"
|
aria-label="{{ t(key='menu', 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 md:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
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="size-6">
|
||||||
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" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
</svg>
|
||||||
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" />
|
</button>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div x-show="open" x-cloak @click.outside="open = false"
|
|
||||||
x-transition.origin.top.right
|
|
||||||
class="absolute right-0 mt-2 w-56 rounded-radius border border-outline bg-surface p-2 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
|
||||||
<form method="post" action="/lang" hx-boost="false">
|
|
||||||
<p class="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
|
||||||
{{ t(key="settings-language", lang=lang | default(value='sk')) }}
|
|
||||||
</p>
|
|
||||||
<button type="submit" name="lang" value="en"
|
|
||||||
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
|
||||||
<span>{{ t(key="language-en", lang=lang | default(value='sk')) }}</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>{{ t(key="language-sk", lang=lang | default(value='sk')) }}</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>
|
|
||||||
|
|
||||||
<!-- mobile hamburger -->
|
<span class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
<button type="button" @click="mobile = !mobile" :aria-expanded="mobile"
|
{% block crumb %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
</span>
|
||||||
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">
|
|
||||||
|
<!-- 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"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
stroke="currentColor" class="size-6">
|
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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<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">
|
||||||
<!-- mobile menu panel -->
|
<form method="post" action="/lang" hx-boost="false">
|
||||||
<ul x-show="mobile" x-cloak @click.outside="mobile = false" x-transition
|
<p class="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
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">
|
{{ t(key="settings-language", lang=lang | default(value='sk')) }}
|
||||||
<li><a href="/admin/dashboard" 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="admin-dashboard", lang=lang | default(value='sk')) }}</a></li>
|
</p>
|
||||||
<li><a href="/admin/blog/articles" 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="admin-blog", lang=lang | default(value='sk')) }}</a></li>
|
<button type="submit" name="lang" value="en"
|
||||||
<li><a href="/admin/audio/albums" 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="admin-audio", lang=lang | default(value='sk')) }}</a></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><a href="/admin/about" 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="admin-about", lang=lang | default(value='sk')) }}</a></li>
|
<span>English</span>
|
||||||
<li><a href="/" class="block rounded-radius px-3 py-2 text-sm font-medium text-info hover:bg-surface-alt dark:hover:bg-surface-dark">{{ t(key="admin-exit", lang=lang | default(value='sk')) }}</a></li>
|
{% if lang | default(value='sk') == "en" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||||
<li>
|
</button>
|
||||||
<form method="post" action="/admin/logout">
|
<button type="submit" name="lang" value="sk"
|
||||||
<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>
|
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>
|
</form>
|
||||||
</li>
|
<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">
|
||||||
</ul>
|
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
|
||||||
</nav>
|
</p>
|
||||||
</header>
|
<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>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main class="mx-auto w-full max-w-6xl flex-1 px-4 py-8">
|
<main class="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
|
||||||
{% block content %}{% endblock content %}
|
{% block content %}{% endblock content %}
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
{% extends "admin/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ t(key="edit-article", lang=lang | default(value='sk')) }}{% endblock title %}
|
|
||||||
{% block head %}{% 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>
|
|
||||||
{% 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,60 +0,0 @@
|
|||||||
{% extends "admin/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ t(key="new-article", lang=lang | default(value='sk')) }}{% endblock title %}
|
|
||||||
{% block head %}{% 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>
|
|
||||||
{% endblock content %}
|
|
||||||
65
assets/views/admin/catalog/categories.html
Normal file
65
assets/views/admin/catalog/categories.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% 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">{{ row.category.name }}</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 %}
|
||||||
70
assets/views/admin/catalog/category_form.html
Normal file
70
assets/views/admin/catalog/category_form.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% set editing = category %}
|
||||||
|
{% block title %}{% if editing %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{% if editing %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
|
</h1>
|
||||||
|
<a href="/admin/catalog/categories"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data"
|
||||||
|
action="{% if editing %}/admin/catalog/categories/{{ category.id }}{% else %}/admin/catalog/categories{% endif %}"
|
||||||
|
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="name" name="name" type="text" required value="{% if editing %}{{ category.name }}{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="slug" name="slug" type="text" value="{% if editing %}{{ category.slug }}{% endif %}"
|
||||||
|
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="position" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="position", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="position" name="position" type="number" value="{% if editing %}{{ category.position }}{% else %}0{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<textarea id="description" name="description" rows="4"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if editing and category.description %}{{ category.description }}{% endif %}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{% if editing and category.image_id %}
|
||||||
|
<img src="/images/{{ category.image_id }}" alt="" class="size-24 rounded-radius object-cover">
|
||||||
|
{% endif %}
|
||||||
|
<input id="image" name="image" type="file" accept="image/*"
|
||||||
|
class="block w-full text-sm text-on-surface file:mr-3 file:rounded-radius file:border-0 file:bg-primary file:px-3 file:py-2 file:text-sm file:font-medium file:text-on-primary dark:text-on-surface-dark dark:file:bg-primary-dark dark:file:text-on-primary-dark">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="published" value="on" {% if editing and category.published %}checked{% endif %}
|
||||||
|
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="save", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/catalog/categories"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
101
assets/views/admin/catalog/product_form.html
Normal file
101
assets/views/admin/catalog/product_form.html
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% set editing = product %}
|
||||||
|
{% block title %}{% if editing %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{% if editing %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
|
</h1>
|
||||||
|
<a href="/admin/catalog/products"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data"
|
||||||
|
action="{% if editing %}/admin/catalog/products/{{ product.id }}{% else %}/admin/catalog/products{% endif %}"
|
||||||
|
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="name" name="name" type="text" required value="{% if editing %}{{ product.name }}{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="price" name="price" type="text" inputmode="decimal" required value="{% if editing %}{{ product.price }}{% endif %}"
|
||||||
|
placeholder="0.00"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="currency" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="currency", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="currency" name="currency" type="text" maxlength="3" value="{% if editing %}{{ product.currency }}{% else %}EUR{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm uppercase text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="stock" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="stock" name="stock" type="number" min="0" value="{% if editing %}{{ product.stock }}{% else %}0{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="sku" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="sku" name="sku" type="text" value="{% if editing and product.sku %}{{ product.sku }}{% endif %}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="category_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="category", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<select id="category_id" name="category_id"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
<option value="">{{ t(key="no-category", lang=lang | default(value='sk')) }}</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category.id }}" {% if editing and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="slug" name="slug" type="text" value="{% if editing %}{{ product.slug }}{% endif %}"
|
||||||
|
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<textarea id="description" name="description" rows="5"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if editing and product.description %}{{ product.description }}{% endif %}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{% if editing and product.image %}
|
||||||
|
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
|
||||||
|
{% endif %}
|
||||||
|
<input id="image" name="image" type="file" accept="image/*"
|
||||||
|
class="block w-full text-sm text-on-surface file:mr-3 file:rounded-radius file:border-0 file:bg-primary file:px-3 file:py-2 file:text-sm file:font-medium file:text-on-primary dark:text-on-surface-dark dark:file:bg-primary-dark dark:file:text-on-primary-dark">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="published" value="on" {% if editing and product.published %}checked{% endif %}
|
||||||
|
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="save", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
<a href="/admin/catalog/products"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
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 %}
|
||||||
|
|||||||
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 %}
|
||||||
73
assets/views/admin/orders/show.html
Normal file
73
assets/views/admin/orders/show.html
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ order.order_number }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 class="font-mono text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</h1>
|
||||||
|
<a href="/admin/orders" class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-6 lg:grid-cols-3">
|
||||||
|
<div class="space-y-6 lg:col-span-2">
|
||||||
|
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="product", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="quantity", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3">{{ item.product_name }}</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">{{ item.quantity }}</td>
|
||||||
|
<td class="px-4 py-3 text-right tabular-nums">{{ item.line_total }} {{ order.currency }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot class="border-t border-outline dark:border-outline-dark">
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</td>
|
||||||
|
<td class="px-4 py-3 text-right font-bold tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="space-y-6">
|
||||||
|
<div class="space-y-3 rounded-radius border border-outline bg-surface p-5 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="order-customer", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.customer_name }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.email }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}<br>{{ order.zip }} {{ order.city }}<br>{{ order.country }}</p>
|
||||||
|
</div>
|
||||||
|
{% if order.note %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.note }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/orders/{{ order.id }}/status" class="space-y-3 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<label for="status" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-status", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<select id="status" name="status"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
{% for status in statuses %}
|
||||||
|
<option value="{{ status }}" {% if order.status == status %}selected{% endif %}>{{ t(key="order-status-" ~ status, lang=lang | default(value='sk')) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="order-update-status", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -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 %}
|
|
||||||
@@ -57,10 +57,7 @@
|
|||||||
<!-- desktop links -->
|
<!-- desktop links -->
|
||||||
<ul class="ml-2 hidden items-center gap-1 md:flex">
|
<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="/" 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="/blog" data-nav="/blog" 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-blog", 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>
|
||||||
<li><a href="/audio/albums" data-nav="/audio/albums" 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-audio", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li><a href="/audio/tracks" data-nav="/audio/tracks" 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-songs", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li><a href="/about" data-nav="/about" 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-about", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
{% if logged_in_admin %}
|
{% if logged_in_admin %}
|
||||||
<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><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>
|
||||||
@@ -73,8 +70,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- right side: settings + mobile toggle -->
|
<!-- right side: cart + settings + mobile toggle -->
|
||||||
<div class="ml-auto flex items-center gap-1">
|
<div class="ml-auto flex items-center gap-1">
|
||||||
|
<!-- cart with live item-count badge read from the `cart` cookie -->
|
||||||
|
<a href="/cart" data-nav="/cart"
|
||||||
|
x-data="{ count: 0 }"
|
||||||
|
x-init="count = (function(){ var m = document.cookie.split('; ').find(function(c){return c.indexOf('cart=')===0}); if(!m) return 0; var v = decodeURIComponent(m.split('=')[1]||''); if(!v) return 0; return v.split(',').reduce(function(s,e){ return s + (parseInt(e.split(':')[1])||0) }, 0) })()"
|
||||||
|
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||||
|
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||||
|
class="relative inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
||||||
|
</svg>
|
||||||
|
<span x-show="count > 0" x-cloak x-text="count"
|
||||||
|
class="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-on-primary dark:bg-primary-dark dark:text-on-primary-dark"></span>
|
||||||
|
</a>
|
||||||
<!-- settings (language + theme) dropdown -->
|
<!-- settings (language + theme) dropdown -->
|
||||||
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative">
|
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative">
|
||||||
<button type="button" @click="open = !open" :aria-expanded="open"
|
<button type="button" @click="open = !open" :aria-expanded="open"
|
||||||
@@ -144,10 +154,7 @@
|
|||||||
<ul x-show="mobile" x-cloak @click.outside="mobile = false" x-transition
|
<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">
|
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="/" 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="/blog" 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-blog", 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>
|
||||||
<li><a href="/audio/albums" 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-audio", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li><a href="/audio/tracks" 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-songs", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li><a href="/about" 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-about", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
{% if logged_in_admin %}
|
{% 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><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>
|
<li>
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
12
assets/views/shop/_card.html
Normal file
12
assets/views/shop/_card.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<a href="/shop/{{ product.slug }}"
|
||||||
|
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
|
||||||
|
<div class="aspect-square overflow-hidden bg-surface-alt dark:bg-surface-dark">
|
||||||
|
{% if product.image %}
|
||||||
|
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition group-hover:scale-105">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col gap-1 p-4">
|
||||||
|
<h3 class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||||
|
<p class="mt-auto pt-2 font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
67
assets/views/shop/cart.html
Normal file
67
assets/views/shop/cart.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="cart-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
{% if items | length > 0 %}
|
||||||
|
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="product", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="price", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="quantity", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<a href="/shop/{{ item.slug }}" class="font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">{{ item.price }} {{ item.currency }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<form method="post" action="/cart/update" hx-boost="false" class="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="product_id" value="{{ item.id }}">
|
||||||
|
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
|
||||||
|
class="w-20 rounded-radius border border-outline bg-surface px-2 py-1 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
<button type="submit" class="rounded-radius border border-outline px-2 py-1 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-update", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ item.currency }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<form method="post" action="/cart/remove" hx-boost="false">
|
||||||
|
<input type="hidden" name="product_id" value="{{ item.id }}">
|
||||||
|
<button type="submit" class="text-xs font-medium text-danger hover:underline">{{ t(key="cart-remove", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot class="border-t border-outline dark:border-outline-dark">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-between gap-3">
|
||||||
|
<a href="/shop" class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/checkout" class="inline-flex items-center justify-center rounded-radius bg-primary px-5 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-checkout", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-radius border border-outline px-6 py-16 text-center dark:border-outline-dark">
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="cart-empty", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<a href="/shop" class="mt-4 inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
29
assets/views/shop/category.html
Normal file
29
assets/views/shop/category.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% 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>
|
||||||
|
<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 %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if products | length > 0 %}
|
||||||
|
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{% for product in products %}
|
||||||
|
{% include "shop/_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="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 %}
|
||||||
77
assets/views/shop/checkout.html
Normal file
77
assets/views/shop/checkout.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-8 lg:grid-cols-3">
|
||||||
|
<form method="post" action="/checkout" hx-boost="false" class="space-y-6 lg:col-span-2">
|
||||||
|
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-contact", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="email" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-email", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="email" name="email" type="email" required
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="customer_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="customer_name" name="customer_name" type="text" required
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="address" name="address" type="text" required
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="city" name="city" type="text" required
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="zip" name="zip" type="text" required
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input id="country" name="country" type="text" required
|
||||||
|
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="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>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="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="checkout-place-order", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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="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">{{ total }} {{ currency }}</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
33
assets/views/shop/index.html
Normal file
33
assets/views/shop/index.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% 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 categories | length > 0 %}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for category in categories %}
|
||||||
|
<a href="/category/{{ category.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">{{ category.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if products | length > 0 %}
|
||||||
|
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{% for product in products %}
|
||||||
|
{% include "shop/_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="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 %}
|
||||||
38
assets/views/shop/order_confirmed.html
Normal file
38
assets/views/shop/order_confirmed.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% 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 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>
|
||||||
|
<div>
|
||||||
|
<h1 class="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 text-left 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="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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{% 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 %}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -15,6 +15,15 @@ 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;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -34,6 +43,15 @@ 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),
|
||||||
// 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,16 +60,16 @@ impl Hooks for App {
|
|||||||
AppRoutes::with_default_routes() // controller routes below
|
AppRoutes::with_default_routes() // controller routes below
|
||||||
.add_route(controllers::auth::routes())
|
.add_route(controllers::auth::routes())
|
||||||
.add_route(controllers::admin::routes())
|
.add_route(controllers::admin::routes())
|
||||||
.add_route(controllers::blog::routes())
|
.add_route(controllers::catalog::routes())
|
||||||
|
.add_route(controllers::cart::routes())
|
||||||
|
.add_route(controllers::orders::routes())
|
||||||
.add_route(controllers::i18n::routes())
|
.add_route(controllers::i18n::routes())
|
||||||
.add_route(controllers::media::routes())
|
.add_route(controllers::media::routes())
|
||||||
.add_route(controllers::pages::routes())
|
|
||||||
.add_route(controllers::frontend::routes())
|
.add_route(controllers::frontend::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 = crate::controllers::media::uploads_root(&ctx.config)?;
|
||||||
tokio::fs::create_dir_all(upload_root.join(controllers::media::AUDIO_STORAGE_DIR)).await?;
|
|
||||||
tokio::fs::create_dir_all(upload_root.join(controllers::media::IMAGE_STORAGE_DIR)).await?;
|
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)?;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::models::{
|
use crate::models::{
|
||||||
_entities::{audio_albums, audio_tracks, audit_logs, blog_articles, users},
|
_entities::{audit_logs, categories, orders, products, users},
|
||||||
users as users_model,
|
users as users_model,
|
||||||
};
|
};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
@@ -9,9 +9,9 @@ use serde::Serialize;
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct DashboardResponse {
|
struct DashboardResponse {
|
||||||
users: u64,
|
users: u64,
|
||||||
blog_articles: u64,
|
products: u64,
|
||||||
audio_albums: u64,
|
categories: u64,
|
||||||
audio_tracks: u64,
|
orders: u64,
|
||||||
audit_logs: u64,
|
audit_logs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +43,9 @@ async fn dashboard(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Res
|
|||||||
|
|
||||||
format::json(DashboardResponse {
|
format::json(DashboardResponse {
|
||||||
users: users::Entity::find().count(&ctx.db).await?,
|
users: users::Entity::find().count(&ctx.db).await?,
|
||||||
blog_articles: blog_articles::Entity::find().count(&ctx.db).await?,
|
products: products::Entity::find().count(&ctx.db).await?,
|
||||||
audio_albums: audio_albums::Entity::find().count(&ctx.db).await?,
|
categories: categories::Entity::find().count(&ctx.db).await?,
|
||||||
audio_tracks: audio_tracks::Entity::find().count(&ctx.db).await?,
|
orders: orders::Entity::find().count(&ctx.db).await?,
|
||||||
audit_logs: audit_logs::Entity::find().count(&ctx.db).await?,
|
audit_logs: audit_logs::Entity::find().count(&ctx.db).await?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
203
src/controllers/cart.rs
Normal file
203
src/controllers/cart.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
use crate::{
|
||||||
|
controllers::{catalog::format_price, i18n::current_lang},
|
||||||
|
models::_entities::products,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use time::Duration as TimeDuration;
|
||||||
|
|
||||||
|
pub(crate) const CART_COOKIE: &str = "cart";
|
||||||
|
const CART_MAX_AGE_DAYS: i64 = 30;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AddForm {
|
||||||
|
product_id: i32,
|
||||||
|
quantity: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct UpdateForm {
|
||||||
|
product_id: i32,
|
||||||
|
quantity: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RemoveForm {
|
||||||
|
product_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the `cart` cookie ("id:qty,id:qty") into `(product_id, quantity)`
|
||||||
|
/// pairs, silently dropping malformed or non-positive entries.
|
||||||
|
pub(crate) fn parse_cart(jar: &CookieJar) -> Vec<(i32, i32)> {
|
||||||
|
let Some(cookie) = jar.get(CART_COOKIE) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
cookie
|
||||||
|
.value()
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let (id, qty) = entry.split_once(':')?;
|
||||||
|
let id = id.trim().parse::<i32>().ok()?;
|
||||||
|
let qty = qty.trim().parse::<i32>().ok()?;
|
||||||
|
(qty > 0).then_some((id, qty))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_cart(items: &[(i32, i32)]) -> String {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|(id, qty)| format!("{id}:{qty}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cart_cookie(value: String) -> Cookie<'static> {
|
||||||
|
Cookie::build((CART_COOKIE, value))
|
||||||
|
.path("/")
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.max_age(TimeDuration::days(CART_MAX_AGE_DAYS))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a published product, returning its current stock cap.
|
||||||
|
async fn published_product(ctx: &AppContext, id: i32) -> Result<Option<products::Model>> {
|
||||||
|
Ok(products::Entity::find_by_id(id)
|
||||||
|
.filter(products::Column::Published.eq(true))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn add(
|
||||||
|
jar: CookieJar,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<AddForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Some(product) = published_product(&ctx, form.product_id).await? else {
|
||||||
|
return Err(Error::NotFound);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut items = parse_cart(&jar);
|
||||||
|
let add_qty = form.quantity.unwrap_or(1).max(1);
|
||||||
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == product.id) {
|
||||||
|
entry.1 = (entry.1 + add_qty).min(product.stock);
|
||||||
|
} else {
|
||||||
|
items.push((product.id, add_qty.min(product.stock)));
|
||||||
|
}
|
||||||
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
|
format::render()
|
||||||
|
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
||||||
|
.redirect("/cart")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update(
|
||||||
|
jar: CookieJar,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<UpdateForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let stock = published_product(&ctx, form.product_id)
|
||||||
|
.await?
|
||||||
|
.map(|p| p.stock)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let mut items = parse_cart(&jar);
|
||||||
|
let clamped = form.quantity.clamp(0, stock);
|
||||||
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.product_id) {
|
||||||
|
entry.1 = clamped;
|
||||||
|
}
|
||||||
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
|
format::render()
|
||||||
|
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
||||||
|
.redirect("/cart")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn remove(jar: CookieJar, Form(form): Form<RemoveForm>) -> Result<Response> {
|
||||||
|
let mut items = parse_cart(&jar);
|
||||||
|
items.retain(|(id, _)| *id != form.product_id);
|
||||||
|
|
||||||
|
format::render()
|
||||||
|
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
||||||
|
.redirect("/cart")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the cart cookie into priced line items, dropping anything that is no
|
||||||
|
/// longer purchasable and clamping quantities to current stock. Returns the
|
||||||
|
/// (re-validated) lines, the rebuilt cookie value, and the total in cents.
|
||||||
|
pub(crate) async fn resolve_cart(
|
||||||
|
ctx: &AppContext,
|
||||||
|
jar: &CookieJar,
|
||||||
|
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut valid = Vec::new();
|
||||||
|
let mut total: i64 = 0;
|
||||||
|
|
||||||
|
for (id, qty) in parse_cart(jar) {
|
||||||
|
let Some(product) = published_product(ctx, id).await? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let qty = qty.clamp(0, product.stock);
|
||||||
|
if qty == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let line_total = product.price_cents * i64::from(qty);
|
||||||
|
total += line_total;
|
||||||
|
valid.push((product.id, qty));
|
||||||
|
lines.push(json!({
|
||||||
|
"id": product.id,
|
||||||
|
"name": product.name,
|
||||||
|
"slug": product.slug,
|
||||||
|
"price": format_price(product.price_cents),
|
||||||
|
"currency": product.currency,
|
||||||
|
"quantity": qty,
|
||||||
|
"stock": product.stock,
|
||||||
|
"line_total": format_price(line_total),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((lines, valid, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn show(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
|
||||||
|
let currency = lines
|
||||||
|
.first()
|
||||||
|
.and_then(|line| line["currency"].as_str())
|
||||||
|
.unwrap_or("EUR")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Drop any now-invalid lines from the cookie so the badge stays accurate.
|
||||||
|
let rebuilt = serialize_cart(&valid);
|
||||||
|
let response = format::view(
|
||||||
|
&v,
|
||||||
|
"shop/cart.html",
|
||||||
|
json!({
|
||||||
|
"items": lines,
|
||||||
|
"total": format_price(total),
|
||||||
|
"currency": currency,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/cart", get(show))
|
||||||
|
.add("/cart/add", post(add))
|
||||||
|
.add("/cart/update", post(update))
|
||||||
|
.add("/cart/remove", post(remove))
|
||||||
|
}
|
||||||
807
src/controllers/catalog.rs
Normal file
807
src/controllers/catalog.rs
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::{
|
||||||
|
admin,
|
||||||
|
auth as auth_controller,
|
||||||
|
i18n::current_lang,
|
||||||
|
media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR},
|
||||||
|
},
|
||||||
|
models::{
|
||||||
|
_entities::{categories, product_images, products},
|
||||||
|
users,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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, QuerySelect, Set,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn slugify(value: &str) -> String {
|
||||||
|
let mut slug = String::new();
|
||||||
|
let mut last_was_dash = false;
|
||||||
|
for ch in value.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slug.trim_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a price typed in major units ("12", "12.5", "12.34") into integer
|
||||||
|
/// minor units (cents). Rejects negatives and more than two decimals.
|
||||||
|
fn parse_price_to_cents(value: &str) -> Result<i64> {
|
||||||
|
let value = value.trim().replace(',', ".");
|
||||||
|
let invalid = || Error::BadRequest("invalid price".to_string());
|
||||||
|
let (whole, frac) = match value.split_once('.') {
|
||||||
|
Some((w, f)) => (w, f),
|
||||||
|
None => (value.as_str(), ""),
|
||||||
|
};
|
||||||
|
if frac.len() > 2 || !whole.chars().all(|c| c.is_ascii_digit()) || whole.is_empty() {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
if !frac.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
let whole: i64 = whole.parse().map_err(|_| invalid())?;
|
||||||
|
let cents: i64 = match frac.len() {
|
||||||
|
0 => 0,
|
||||||
|
1 => frac.parse::<i64>().map_err(|_| invalid())? * 10,
|
||||||
|
_ => frac.parse().map_err(|_| invalid())?,
|
||||||
|
};
|
||||||
|
Ok(whole * 100 + cents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render minor units as a human price string, e.g. `1234` -> `"12.34"`.
|
||||||
|
pub(crate) fn format_price(cents: i64) -> String {
|
||||||
|
format!("{}.{:02}", cents / 100, (cents % 100).abs())
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unique_slug<F, Fut>(base: &str, mut exists: F) -> Result<String>
|
||||||
|
where
|
||||||
|
F: FnMut(String) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<bool>>,
|
||||||
|
{
|
||||||
|
let base = if base.is_empty() {
|
||||||
|
"item".to_string()
|
||||||
|
} else {
|
||||||
|
base.to_string()
|
||||||
|
};
|
||||||
|
let mut slug = base.clone();
|
||||||
|
let mut suffix = 2;
|
||||||
|
while exists(slug.clone()).await? {
|
||||||
|
slug = format!("{base}-{suffix}");
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
Ok(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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).
|
||||||
|
struct MultipartForm {
|
||||||
|
fields: HashMap<String, String>,
|
||||||
|
image: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultipartForm {
|
||||||
|
fn text(&self, key: &str) -> Option<String> {
|
||||||
|
normalize_empty(self.fields.get(key).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checked(&self, key: &str) -> bool {
|
||||||
|
matches!(self.fields.get(key).map(String::as_str), Some("on" | "true" | "1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn first_image(ctx: &AppContext, product_id: i32) -> Result<Option<String>> {
|
||||||
|
Ok(product_images::Entity::find()
|
||||||
|
.filter(product_images::Column::ProductId.eq(product_id))
|
||||||
|
.order_by_asc(product_images::Column::Position)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.map(|image| image.image_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shape a product for templates: the model fields plus a formatted price,
|
||||||
|
/// its (optional) primary image filename and category name.
|
||||||
|
fn product_json(
|
||||||
|
product: &products::Model,
|
||||||
|
image: Option<String>,
|
||||||
|
category_name: Option<String>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"id": product.id,
|
||||||
|
"name": product.name,
|
||||||
|
"slug": product.slug,
|
||||||
|
"description": product.description,
|
||||||
|
"price": format_price(product.price_cents),
|
||||||
|
"currency": product.currency,
|
||||||
|
"sku": product.sku,
|
||||||
|
"stock": product.stock,
|
||||||
|
"published": product.published,
|
||||||
|
"image": image,
|
||||||
|
"category_name": category_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latest published products (with primary image), shaped for templates.
|
||||||
|
/// Reused by the home page landing grid.
|
||||||
|
pub(crate) async fn featured_products(
|
||||||
|
ctx: &AppContext,
|
||||||
|
limit: u64,
|
||||||
|
) -> Result<Vec<serde_json::Value>> {
|
||||||
|
let list = products::Entity::find()
|
||||||
|
.filter(products::Column::Published.eq(true))
|
||||||
|
.order_by_desc(products::Column::PublishedAt)
|
||||||
|
.limit(limit)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for product in list {
|
||||||
|
let image = first_image(ctx, product.id).await?;
|
||||||
|
rows.push(product_json(&product, image, None));
|
||||||
|
}
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin: products
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_products(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::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 = first_image(&ctx, product.id).await?;
|
||||||
|
let category_name = match product.category_id {
|
||||||
|
Some(id) => category_by_id(&ctx, id).await.ok().map(|c| c.name),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
rows.push(product_json(&product, image, category_name));
|
||||||
|
}
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/catalog/products.html",
|
||||||
|
json!({ "products": rows, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn product_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 admin_product_new(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let mut context = product_form_context(&ctx, &jar).await?;
|
||||||
|
context["product"] = serde_json::Value::Null;
|
||||||
|
format::view(&v, "admin/catalog/product_form.html", context)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_product_fields(
|
||||||
|
ctx: &AppContext,
|
||||||
|
form: &MultipartForm,
|
||||||
|
current_id: Option<i32>,
|
||||||
|
) -> Result<(String, String, Option<String>, i64, String, Option<String>, i32, Option<i32>, bool)> {
|
||||||
|
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((
|
||||||
|
name, slug, description, price_cents, currency, sku, stock, category_id, published,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_product_create(
|
||||||
|
auth: auth::JWT,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let form = read_multipart_form(multipart).await?;
|
||||||
|
let (name, slug, description, price_cents, currency, sku, stock, category_id, published) =
|
||||||
|
parse_product_fields(&ctx, &form, None).await?;
|
||||||
|
|
||||||
|
let product = products::ActiveModel {
|
||||||
|
name: Set(name),
|
||||||
|
slug: Set(slug),
|
||||||
|
description: Set(description),
|
||||||
|
price_cents: Set(price_cents),
|
||||||
|
currency: Set(currency),
|
||||||
|
sku: Set(sku),
|
||||||
|
stock: Set(stock),
|
||||||
|
view_count: Set(0),
|
||||||
|
published: Set(published),
|
||||||
|
published_at: Set(published.then(|| chrono::Utc::now().into())),
|
||||||
|
category_id: Set(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 admin_product_edit(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let product = product_by_id(&ctx, id).await?;
|
||||||
|
let image = first_image(&ctx, id).await?;
|
||||||
|
let mut context = product_form_context(&ctx, &jar).await?;
|
||||||
|
context["product"] = json!({
|
||||||
|
"id": product.id,
|
||||||
|
"name": product.name,
|
||||||
|
"slug": product.slug,
|
||||||
|
"description": product.description,
|
||||||
|
"price": format_price(product.price_cents),
|
||||||
|
"currency": product.currency,
|
||||||
|
"sku": product.sku,
|
||||||
|
"stock": product.stock,
|
||||||
|
"published": product.published,
|
||||||
|
"category_id": product.category_id,
|
||||||
|
"image": image,
|
||||||
|
});
|
||||||
|
format::view(&v, "admin/catalog/product_form.html", context)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_product_update(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::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 (name, slug, description, price_cents, currency, sku, stock, category_id, published) =
|
||||||
|
parse_product_fields(&ctx, &form, Some(id)).await?;
|
||||||
|
|
||||||
|
let mut product = existing.into_active_model();
|
||||||
|
product.name = Set(name);
|
||||||
|
product.slug = Set(slug);
|
||||||
|
product.description = Set(description);
|
||||||
|
product.price_cents = Set(price_cents);
|
||||||
|
product.currency = Set(currency);
|
||||||
|
product.sku = Set(sku);
|
||||||
|
product.stock = Set(stock);
|
||||||
|
product.category_id = Set(category_id);
|
||||||
|
product.published = Set(published);
|
||||||
|
if published && !was_published {
|
||||||
|
product.published_at = Set(Some(chrono::Utc::now().into()));
|
||||||
|
} else if !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::Entity::find()
|
||||||
|
.filter(product_images::Column::ProductId.eq(id))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await? as i32;
|
||||||
|
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 admin_product_delete(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
product_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::redirect("/admin/catalog/products")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin: categories
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_categories(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let list = categories::Entity::find()
|
||||||
|
.order_by_asc(categories::Column::Position)
|
||||||
|
.order_by_asc(categories::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for category in list {
|
||||||
|
let product_count = products::Entity::find()
|
||||||
|
.filter(products::Column::CategoryId.eq(category.id))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
rows.push(json!({ "category": category, "product_count": product_count }));
|
||||||
|
}
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/catalog/categories.html",
|
||||||
|
json!({ "categories": rows, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_category_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/catalog/category_form.html",
|
||||||
|
json!({ "category": serde_json::Value::Null, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_category_fields(
|
||||||
|
ctx: &AppContext,
|
||||||
|
form: &MultipartForm,
|
||||||
|
current_id: Option<i32>,
|
||||||
|
) -> Result<(String, String, Option<String>, i32, bool)> {
|
||||||
|
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");
|
||||||
|
|
||||||
|
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((name, slug, description, position, published))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_category_create(
|
||||||
|
auth: auth::JWT,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let form = read_multipart_form(multipart).await?;
|
||||||
|
let (name, slug, description, position, published) =
|
||||||
|
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(name),
|
||||||
|
slug: Set(slug),
|
||||||
|
description: Set(description),
|
||||||
|
image_id: Set(image_id),
|
||||||
|
position: Set(position),
|
||||||
|
published: Set(published),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::redirect("/admin/catalog/categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_category_edit(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/catalog/category_form.html",
|
||||||
|
json!({ "category": category_by_id(&ctx, id).await?, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_category_update(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let existing = category_by_id(&ctx, id).await?;
|
||||||
|
let form = read_multipart_form(multipart).await?;
|
||||||
|
let (name, slug, description, position, published) =
|
||||||
|
parse_category_fields(&ctx, &form, Some(id)).await?;
|
||||||
|
|
||||||
|
let mut category = existing.into_active_model();
|
||||||
|
category.name = Set(name);
|
||||||
|
category.slug = Set(slug);
|
||||||
|
category.description = Set(description);
|
||||||
|
category.position = Set(position);
|
||||||
|
category.published = Set(published);
|
||||||
|
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 admin_category_delete(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
category_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::redirect("/admin/catalog/categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public storefront
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn shop_index(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let list = products::Entity::find()
|
||||||
|
.filter(products::Column::Published.eq(true))
|
||||||
|
.order_by_desc(products::Column::PublishedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for product in list {
|
||||||
|
let image = first_image(&ctx, product.id).await?;
|
||||||
|
rows.push(product_json(&product, image, None));
|
||||||
|
}
|
||||||
|
let categories = categories::Entity::find()
|
||||||
|
.filter(categories::Column::Published.eq(true))
|
||||||
|
.order_by_asc(categories::Column::Position)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"shop/index.html",
|
||||||
|
json!({
|
||||||
|
"products": rows,
|
||||||
|
"categories": categories,
|
||||||
|
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn shop_show(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let product = products::Entity::find()
|
||||||
|
.filter(products::Column::Slug.eq(slug))
|
||||||
|
.filter(products::Column::Published.eq(true))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
let mut active = product.clone().into_active_model();
|
||||||
|
active.view_count = Set(product.view_count + 1);
|
||||||
|
let product = active.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
let images = product_images::Entity::find()
|
||||||
|
.filter(product_images::Column::ProductId.eq(product.id))
|
||||||
|
.order_by_asc(product_images::Column::Position)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let category = match product.category_id {
|
||||||
|
Some(id) => category_by_id(&ctx, id).await.ok(),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"shop/show.html",
|
||||||
|
json!({
|
||||||
|
"product": product_json(&product, None, category.as_ref().map(|c| c.name.clone())),
|
||||||
|
"images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(),
|
||||||
|
"category": category,
|
||||||
|
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn shop_category(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let category = categories::Entity::find()
|
||||||
|
.filter(categories::Column::Slug.eq(slug))
|
||||||
|
.filter(categories::Column::Published.eq(true))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
let list = products::Entity::find()
|
||||||
|
.filter(products::Column::CategoryId.eq(category.id))
|
||||||
|
.filter(products::Column::Published.eq(true))
|
||||||
|
.order_by_desc(products::Column::PublishedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for product in list {
|
||||||
|
let image = first_image(&ctx, product.id).await?;
|
||||||
|
rows.push(product_json(&product, image, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"shop/category.html",
|
||||||
|
json!({
|
||||||
|
"category": category,
|
||||||
|
"products": rows,
|
||||||
|
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
||||||
|
Routes::new()
|
||||||
|
// public storefront
|
||||||
|
.add("/shop", get(shop_index))
|
||||||
|
.add("/shop/{slug}", get(shop_show))
|
||||||
|
.add("/category/{slug}", get(shop_category))
|
||||||
|
// admin products
|
||||||
|
.add("/admin/catalog/products", get(admin_products))
|
||||||
|
.add("/admin/catalog/products/new", get(admin_product_new))
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products",
|
||||||
|
post(admin_product_create).layer(image_limit.clone()),
|
||||||
|
)
|
||||||
|
.add("/admin/catalog/products/{id}/edit", get(admin_product_edit))
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products/{id}",
|
||||||
|
post(admin_product_update).layer(image_limit.clone()),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products/{id}/delete",
|
||||||
|
post(admin_product_delete),
|
||||||
|
)
|
||||||
|
// admin categories
|
||||||
|
.add("/admin/catalog/categories", get(admin_categories))
|
||||||
|
.add("/admin/catalog/categories/new", get(admin_category_new))
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/categories",
|
||||||
|
post(admin_category_create).layer(image_limit.clone()),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/categories/{id}/edit",
|
||||||
|
get(admin_category_edit),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/categories/{id}",
|
||||||
|
post(admin_category_update).layer(image_limit),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/categories/{id}/delete",
|
||||||
|
post(admin_category_delete),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,95 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
controllers::{admin, auth as auth_controller, i18n::current_lang},
|
controllers::{admin, auth as auth_controller, i18n::current_lang},
|
||||||
models::{
|
models::users::{self, LoginParams},
|
||||||
_entities::{audio_albums, audio_tracks, blog_articles, site_pages},
|
|
||||||
users::{self, LoginParams},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use chrono::Utc;
|
|
||||||
use loco_rs::prelude::*;
|
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 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 {
|
async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool {
|
||||||
let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else {
|
let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else {
|
||||||
@@ -115,108 +30,13 @@ async fn home(
|
|||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let articles = blog_articles::Entity::find()
|
let products = crate::controllers::catalog::featured_products(&ctx, 8).await?;
|
||||||
.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(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"home/index.html",
|
"home/index.html",
|
||||||
json!({
|
json!({
|
||||||
"articles": articles,
|
"products": products,
|
||||||
"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,
|
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
@@ -307,173 +127,12 @@ async fn admin_home(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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 {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/", get(home))
|
.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", get(admin_login_page))
|
||||||
.add("/admin/login", post(admin_login))
|
.add("/admin/login", post(admin_login))
|
||||||
.add("/admin/logout", post(admin_logout))
|
.add("/admin/logout", post(admin_logout))
|
||||||
.add("/admin", get(admin_login_page))
|
.add("/admin", get(admin_login_page))
|
||||||
.add("/admin/dashboard", get(admin_home))
|
.add("/admin/dashboard", get(admin_home))
|
||||||
.add("/admin/about", get(admin_about))
|
|
||||||
.add("/admin/about", post(admin_about_update))
|
|
||||||
.add("/admin/blog/articles", get(admin_articles))
|
|
||||||
.add("/admin/blog/articles/new", get(admin_article_new))
|
|
||||||
.add("/admin/blog/articles", post(admin_article_create))
|
|
||||||
.add("/admin/blog/articles/{id}/edit", get(admin_article_edit))
|
|
||||||
.add("/admin/blog/articles/{id}", post(admin_article_update))
|
|
||||||
.add(
|
|
||||||
"/admin/blog/articles/{id}/delete",
|
|
||||||
post(admin_article_delete),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod blog;
|
pub mod cart;
|
||||||
|
pub mod catalog;
|
||||||
pub mod frontend;
|
pub mod frontend;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod pages;
|
pub mod orders;
|
||||||
|
|||||||
316
src/controllers/orders.rs
Normal file
316
src/controllers/orders.rs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
use crate::{
|
||||||
|
controllers::{
|
||||||
|
admin,
|
||||||
|
cart::{resolve_cart, CART_COOKIE},
|
||||||
|
catalog::format_price,
|
||||||
|
i18n::current_lang,
|
||||||
|
},
|
||||||
|
models::_entities::{order_items, orders, products},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use time::Duration as TimeDuration;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CheckoutForm {
|
||||||
|
email: String,
|
||||||
|
customer_name: String,
|
||||||
|
address: String,
|
||||||
|
city: String,
|
||||||
|
zip: String,
|
||||||
|
country: String,
|
||||||
|
note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct StatusForm {
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trimmed(value: &str) -> Option<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_order_number() -> String {
|
||||||
|
let suffix = Uuid::new_v4().simple().to_string()[..8].to_uppercase();
|
||||||
|
format!("ORD-{suffix}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleared_cart_cookie() -> Cookie<'static> {
|
||||||
|
Cookie::build((CART_COOKIE, ""))
|
||||||
|
.path("/")
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.max_age(TimeDuration::seconds(0))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn checkout_page(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let (lines, _valid, total) = 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();
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"shop/checkout.html",
|
||||||
|
json!({
|
||||||
|
"items": lines,
|
||||||
|
"total": format_price(total),
|
||||||
|
"currency": currency,
|
||||||
|
"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()))?;
|
||||||
|
|
||||||
|
let txn = ctx.db.begin().await?;
|
||||||
|
|
||||||
|
// Snapshot prices/names and decrement stock atomically. Re-checking stock
|
||||||
|
// inside the transaction guards against it selling out between cart and pay.
|
||||||
|
let mut total: i64 = 0;
|
||||||
|
let mut currency = "EUR".to_string();
|
||||||
|
let mut snapshots = Vec::new();
|
||||||
|
for (product_id, qty) in &valid {
|
||||||
|
let product = products::Entity::find_by_id(*product_id)
|
||||||
|
.filter(products::Column::Published.eq(true))
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
|
||||||
|
if product.stock < *qty {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"not enough stock for {}",
|
||||||
|
product.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
currency = product.currency.clone();
|
||||||
|
let line_total = product.price_cents * i64::from(*qty);
|
||||||
|
total += line_total;
|
||||||
|
|
||||||
|
let mut active = product.clone().into_active_model();
|
||||||
|
active.stock = Set(product.stock - *qty);
|
||||||
|
active.update(&txn).await?;
|
||||||
|
|
||||||
|
snapshots.push((product.id, product.name, product.price_cents, *qty));
|
||||||
|
}
|
||||||
|
|
||||||
|
let order = orders::ActiveModel {
|
||||||
|
order_number: Set(generate_order_number()),
|
||||||
|
email: Set(email),
|
||||||
|
customer_name: Set(trimmed(&form.customer_name)),
|
||||||
|
status: Set("pending".to_string()),
|
||||||
|
total_cents: Set(total),
|
||||||
|
currency: Set(currency),
|
||||||
|
address: Set(trimmed(&form.address)),
|
||||||
|
city: Set(trimmed(&form.city)),
|
||||||
|
zip: Set(trimmed(&form.zip)),
|
||||||
|
country: Set(trimmed(&form.country)),
|
||||||
|
note: Set(form.note.as_deref().and_then(trimmed)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&txn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (product_id, name, unit_price_cents, qty) in snapshots {
|
||||||
|
order_items::ActiveModel {
|
||||||
|
order_id: Set(order.id),
|
||||||
|
product_id: Set(Some(product_id)),
|
||||||
|
product_name: Set(name),
|
||||||
|
unit_price_cents: Set(unit_price_cents),
|
||||||
|
quantity: Set(qty),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&txn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
txn.commit().await?;
|
||||||
|
|
||||||
|
format::render()
|
||||||
|
.cookies(&[cleared_cart_cookie()])?
|
||||||
|
.redirect(&format!("/orders/{}", order.order_number))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn order_with_items(
|
||||||
|
ctx: &AppContext,
|
||||||
|
order: &orders::Model,
|
||||||
|
) -> Result<(serde_json::Value, Vec<serde_json::Value>)> {
|
||||||
|
let items = order_items::Entity::find()
|
||||||
|
.filter(order_items::Column::OrderId.eq(order.id))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let items_json = items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
json!({
|
||||||
|
"product_name": item.product_name,
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"unit_price": format_price(item.unit_price_cents),
|
||||||
|
"line_total": format_price(item.unit_price_cents * i64::from(item.quantity)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let order_json = json!({
|
||||||
|
"id": order.id,
|
||||||
|
"order_number": order.order_number,
|
||||||
|
"email": order.email,
|
||||||
|
"customer_name": order.customer_name,
|
||||||
|
"status": order.status,
|
||||||
|
"total": format_price(order.total_cents),
|
||||||
|
"currency": order.currency,
|
||||||
|
"address": order.address,
|
||||||
|
"city": order.city,
|
||||||
|
"zip": order.zip,
|
||||||
|
"country": order.country,
|
||||||
|
"note": order.note,
|
||||||
|
"created_at": order.created_at.to_rfc3339(),
|
||||||
|
});
|
||||||
|
Ok((order_json, items_json))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 (order_json, items) = order_with_items(&ctx, &order).await?;
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"shop/order_confirmed.html",
|
||||||
|
json!({ "order": order_json, "items": items, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_orders(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::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(|order| {
|
||||||
|
json!({
|
||||||
|
"id": order.id,
|
||||||
|
"order_number": order.order_number,
|
||||||
|
"email": order.email,
|
||||||
|
"status": order.status,
|
||||||
|
"total": format_price(order.total_cents),
|
||||||
|
"currency": order.currency,
|
||||||
|
"created_at": order.created_at.to_rfc3339(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/orders/index.html",
|
||||||
|
json!({ "orders": rows, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_order_show(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
let order = orders::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let (order_json, items) = order_with_items(&ctx, &order).await?;
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/orders/show.html",
|
||||||
|
json!({
|
||||||
|
"order": order_json,
|
||||||
|
"items": items,
|
||||||
|
"statuses": ORDER_STATUSES,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn admin_order_status(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<StatusForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
admin::current_admin(auth, &ctx).await?;
|
||||||
|
if !ORDER_STATUSES.contains(&form.status.as_str()) {
|
||||||
|
return Err(Error::BadRequest("invalid status".to_string()));
|
||||||
|
}
|
||||||
|
let order = orders::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let mut active = order.into_active_model();
|
||||||
|
active.status = Set(form.status);
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
format::redirect(&format!("/admin/orders/{id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/checkout", get(checkout_page))
|
||||||
|
.add("/checkout", post(place_order))
|
||||||
|
.add("/orders/{order_number}", get(order_confirmation))
|
||||||
|
.add("/admin/orders", get(admin_orders))
|
||||||
|
.add("/admin/orders/{id}", get(admin_order_show))
|
||||||
|
.add("/admin/orders/{id}/status", post(admin_order_status))
|
||||||
|
}
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
|
||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
|
||||||
#[sea_orm(table_name = "audio_albums")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
|
||||||
pub id: Uuid,
|
|
||||||
pub title: String,
|
|
||||||
#[sea_orm(unique)]
|
|
||||||
pub slug: String,
|
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub cover_image_id: Option<String>,
|
|
||||||
pub artist: Option<String>,
|
|
||||||
pub release_date: Option<Date>,
|
|
||||||
pub published: bool,
|
|
||||||
pub uploader_id: i32,
|
|
||||||
pub view_count: i32,
|
|
||||||
pub created_at: DateTimeWithTimeZone,
|
|
||||||
pub updated_at: DateTimeWithTimeZone,
|
|
||||||
pub published_at: Option<DateTimeWithTimeZone>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {
|
|
||||||
#[sea_orm(has_many = "super::audio_tracks::Entity")]
|
|
||||||
AudioTracks,
|
|
||||||
#[sea_orm(
|
|
||||||
belongs_to = "super::users::Entity",
|
|
||||||
from = "Column::UploaderId",
|
|
||||||
to = "super::users::Column::Id",
|
|
||||||
on_update = "Cascade",
|
|
||||||
on_delete = "Cascade"
|
|
||||||
)]
|
|
||||||
Users,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::audio_tracks::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::AudioTracks.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::users::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::Users.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
|
||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
|
||||||
#[sea_orm(table_name = "audio_tags")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
|
||||||
pub id: Uuid,
|
|
||||||
#[sea_orm(unique)]
|
|
||||||
pub name: String,
|
|
||||||
#[sea_orm(unique)]
|
|
||||||
pub slug: String,
|
|
||||||
pub created_at: DateTimeWithTimeZone,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {
|
|
||||||
#[sea_orm(has_many = "super::audio_track_tags::Entity")]
|
|
||||||
AudioTrackTags,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::audio_track_tags::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::AudioTrackTags.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::audio_tracks::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
super::audio_track_tags::Relation::AudioTracks.def()
|
|
||||||
}
|
|
||||||
fn via() -> Option<RelationDef> {
|
|
||||||
Some(super::audio_track_tags::Relation::AudioTags.def().rev())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
|
||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
|
||||||
#[sea_orm(table_name = "audio_tracks")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
|
||||||
pub id: Uuid,
|
|
||||||
pub album_id: Option<Uuid>,
|
|
||||||
pub title: String,
|
|
||||||
pub slug: String,
|
|
||||||
pub audio_file_id: String,
|
|
||||||
pub track_number: Option<i32>,
|
|
||||||
pub duration: Option<i32>,
|
|
||||||
pub featured: bool,
|
|
||||||
pub published: bool,
|
|
||||||
pub play_count: i32,
|
|
||||||
pub created_at: DateTimeWithTimeZone,
|
|
||||||
pub updated_at: DateTimeWithTimeZone,
|
|
||||||
pub published_at: Option<DateTimeWithTimeZone>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {
|
|
||||||
#[sea_orm(
|
|
||||||
belongs_to = "super::audio_albums::Entity",
|
|
||||||
from = "Column::AlbumId",
|
|
||||||
to = "super::audio_albums::Column::Id",
|
|
||||||
on_update = "Cascade",
|
|
||||||
on_delete = "SetNull"
|
|
||||||
)]
|
|
||||||
AudioAlbums,
|
|
||||||
#[sea_orm(has_many = "super::audio_track_tags::Entity")]
|
|
||||||
AudioTrackTags,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::audio_albums::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::AudioAlbums.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::audio_track_tags::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::AudioTrackTags.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::audio_tags::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
super::audio_track_tags::Relation::AudioTags.def()
|
|
||||||
}
|
|
||||||
fn via() -> Option<RelationDef> {
|
|
||||||
Some(super::audio_track_tags::Relation::AudioTracks.def().rev())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
|
||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
|
||||||
#[sea_orm(table_name = "blog_articles")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
|
||||||
pub id: Uuid,
|
|
||||||
pub title: String,
|
|
||||||
#[sea_orm(unique)]
|
|
||||||
pub slug: String,
|
|
||||||
#[sea_orm(column_type = "Text")]
|
|
||||||
pub content: String,
|
|
||||||
pub excerpt: Option<String>,
|
|
||||||
pub published: bool,
|
|
||||||
pub author_id: i32,
|
|
||||||
pub featured_image_id: Option<String>,
|
|
||||||
pub view_count: i32,
|
|
||||||
pub created_at: DateTimeWithTimeZone,
|
|
||||||
pub updated_at: DateTimeWithTimeZone,
|
|
||||||
pub published_at: Option<DateTimeWithTimeZone>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {
|
|
||||||
#[sea_orm(
|
|
||||||
belongs_to = "super::users::Entity",
|
|
||||||
from = "Column::AuthorId",
|
|
||||||
to = "super::users::Column::Id",
|
|
||||||
on_update = "Cascade",
|
|
||||||
on_delete = "Cascade"
|
|
||||||
)]
|
|
||||||
Users,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::users::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::Users.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
src/models/_entities/categories.rs
Normal file
33
src/models/_entities/categories.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "categories")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub slug: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub image_id: Option<String>,
|
||||||
|
pub position: i32,
|
||||||
|
pub published: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::products::Entity")]
|
||||||
|
Products,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::products::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Products.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
pub mod audio_albums;
|
|
||||||
pub mod audio_tags;
|
|
||||||
pub mod audio_track_tags;
|
|
||||||
pub mod audio_tracks;
|
|
||||||
pub mod audit_logs;
|
pub mod audit_logs;
|
||||||
pub mod blog_articles;
|
pub mod categories;
|
||||||
pub mod site_pages;
|
pub mod order_items;
|
||||||
|
pub mod orders;
|
||||||
|
pub mod product_images;
|
||||||
|
pub mod product_product_tags;
|
||||||
|
pub mod product_tags;
|
||||||
|
pub mod products;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
50
src/models/_entities/order_items.rs
Normal file
50
src/models/_entities/order_items.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "order_items")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub product_name: String,
|
||||||
|
pub unit_price_cents: i64,
|
||||||
|
pub quantity: i32,
|
||||||
|
pub order_id: i32,
|
||||||
|
pub product_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::orders::Entity",
|
||||||
|
from = "Column::OrderId",
|
||||||
|
to = "super::orders::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Orders,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::products::Entity",
|
||||||
|
from = "Column::ProductId",
|
||||||
|
to = "super::products::Column::Id",
|
||||||
|
on_update = "NoAction",
|
||||||
|
on_delete = "SetNull"
|
||||||
|
)]
|
||||||
|
Products,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::orders::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Orders.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::products::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Products.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/models/_entities/orders.rs
Normal file
38
src/models/_entities/orders.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "orders")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub order_number: String,
|
||||||
|
pub email: String,
|
||||||
|
pub customer_name: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub total_cents: i64,
|
||||||
|
pub currency: String,
|
||||||
|
pub address: Option<String>,
|
||||||
|
pub city: Option<String>,
|
||||||
|
pub zip: Option<String>,
|
||||||
|
pub country: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::order_items::Entity")]
|
||||||
|
OrderItems,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::order_items::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::OrderItems.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||||
|
|
||||||
pub use super::audio_albums::Entity as AudioAlbums;
|
|
||||||
pub use super::audio_tags::Entity as AudioTags;
|
|
||||||
pub use super::audio_track_tags::Entity as AudioTrackTags;
|
|
||||||
pub use super::audio_tracks::Entity as AudioTracks;
|
|
||||||
pub use super::audit_logs::Entity as AuditLogs;
|
pub use super::audit_logs::Entity as AuditLogs;
|
||||||
pub use super::blog_articles::Entity as BlogArticles;
|
pub use super::categories::Entity as Categories;
|
||||||
pub use super::site_pages::Entity as SitePages;
|
pub use super::order_items::Entity as OrderItems;
|
||||||
|
pub use super::orders::Entity as Orders;
|
||||||
|
pub use super::product_images::Entity as ProductImages;
|
||||||
|
pub use super::product_product_tags::Entity as ProductProductTags;
|
||||||
|
pub use super::product_tags::Entity as ProductTags;
|
||||||
|
pub use super::products::Entity as Products;
|
||||||
pub use super::users::Entity as Users;
|
pub use super::users::Entity as Users;
|
||||||
|
|||||||
35
src/models/_entities/product_images.rs
Normal file
35
src/models/_entities/product_images.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "product_images")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub image_id: String,
|
||||||
|
pub position: i32,
|
||||||
|
pub alt: Option<String>,
|
||||||
|
pub product_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::products::Entity",
|
||||||
|
from = "Column::ProductId",
|
||||||
|
to = "super::products::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Products,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::products::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Products.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,43 +4,42 @@ use sea_orm::entity::prelude::*;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
#[sea_orm(table_name = "audio_track_tags")]
|
#[sea_orm(table_name = "product_product_tags")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub track_id: Uuid,
|
pub product_id: i32,
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub tag_id: Uuid,
|
pub product_tag_id: i32,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
pub enum Relation {
|
pub enum Relation {
|
||||||
#[sea_orm(
|
#[sea_orm(
|
||||||
belongs_to = "super::audio_tags::Entity",
|
belongs_to = "super::product_tags::Entity",
|
||||||
from = "Column::TagId",
|
from = "Column::ProductTagId",
|
||||||
to = "super::audio_tags::Column::Id",
|
to = "super::product_tags::Column::Id",
|
||||||
on_update = "Cascade",
|
on_update = "Cascade",
|
||||||
on_delete = "Cascade"
|
on_delete = "Cascade"
|
||||||
)]
|
)]
|
||||||
AudioTags,
|
ProductTags,
|
||||||
#[sea_orm(
|
#[sea_orm(
|
||||||
belongs_to = "super::audio_tracks::Entity",
|
belongs_to = "super::products::Entity",
|
||||||
from = "Column::TrackId",
|
from = "Column::ProductId",
|
||||||
to = "super::audio_tracks::Column::Id",
|
to = "super::products::Column::Id",
|
||||||
on_update = "Cascade",
|
on_update = "Cascade",
|
||||||
on_delete = "Cascade"
|
on_delete = "Cascade"
|
||||||
)]
|
)]
|
||||||
AudioTracks,
|
Products,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<super::audio_tags::Entity> for Entity {
|
impl Related<super::product_tags::Entity> for Entity {
|
||||||
fn to() -> RelationDef {
|
fn to() -> RelationDef {
|
||||||
Relation::AudioTags.def()
|
Relation::ProductTags.def()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<super::audio_tracks::Entity> for Entity {
|
impl Related<super::products::Entity> for Entity {
|
||||||
fn to() -> RelationDef {
|
fn to() -> RelationDef {
|
||||||
Relation::AudioTracks.def()
|
Relation::Products.def()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
41
src/models/_entities/product_tags.rs
Normal file
41
src/models/_entities/product_tags.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "product_tags")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::product_product_tags::Entity")]
|
||||||
|
ProductProductTags,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::product_product_tags::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::ProductProductTags.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::products::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
super::product_product_tags::Relation::Products.def()
|
||||||
|
}
|
||||||
|
fn via() -> Option<RelationDef> {
|
||||||
|
Some(
|
||||||
|
super::product_product_tags::Relation::ProductTags
|
||||||
|
.def()
|
||||||
|
.rev(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/models/_entities/products.rs
Normal file
77
src/models/_entities/products.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "products")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub slug: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub price_cents: i64,
|
||||||
|
pub currency: String,
|
||||||
|
pub sku: Option<String>,
|
||||||
|
pub stock: i32,
|
||||||
|
pub view_count: i32,
|
||||||
|
pub published: bool,
|
||||||
|
pub published_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub category_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::categories::Entity",
|
||||||
|
from = "Column::CategoryId",
|
||||||
|
to = "super::categories::Column::Id",
|
||||||
|
on_update = "NoAction",
|
||||||
|
on_delete = "SetNull"
|
||||||
|
)]
|
||||||
|
Categories,
|
||||||
|
#[sea_orm(has_many = "super::order_items::Entity")]
|
||||||
|
OrderItems,
|
||||||
|
#[sea_orm(has_many = "super::product_images::Entity")]
|
||||||
|
ProductImages,
|
||||||
|
#[sea_orm(has_many = "super::product_product_tags::Entity")]
|
||||||
|
ProductProductTags,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::categories::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Categories.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::order_items::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::OrderItems.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::product_images::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::ProductImages.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::product_product_tags::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::ProductProductTags.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::product_tags::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
super::product_product_tags::Relation::ProductTags.def()
|
||||||
|
}
|
||||||
|
fn via() -> Option<RelationDef> {
|
||||||
|
Some(super::product_product_tags::Relation::Products.def().rev())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
|
||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
|
||||||
#[sea_orm(table_name = "site_pages")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
|
||||||
pub id: Uuid,
|
|
||||||
#[sea_orm(unique)]
|
|
||||||
pub slug: String,
|
|
||||||
pub title: String,
|
|
||||||
#[sea_orm(column_type = "Text")]
|
|
||||||
pub content: String,
|
|
||||||
pub created_at: DateTimeWithTimeZone,
|
|
||||||
pub updated_at: DateTimeWithTimeZone,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {}
|
|
||||||
@@ -29,18 +29,8 @@ pub struct Model {
|
|||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
pub enum Relation {
|
pub enum Relation {
|
||||||
#[sea_orm(has_many = "super::audio_albums::Entity")]
|
|
||||||
AudioAlbums,
|
|
||||||
#[sea_orm(has_many = "super::audit_logs::Entity")]
|
#[sea_orm(has_many = "super::audit_logs::Entity")]
|
||||||
AuditLogs,
|
AuditLogs,
|
||||||
#[sea_orm(has_many = "super::blog_articles::Entity")]
|
|
||||||
BlogArticles,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Related<super::audio_albums::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::AudioAlbums.def()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<super::audit_logs::Entity> for Entity {
|
impl Related<super::audit_logs::Entity> for Entity {
|
||||||
@@ -48,9 +38,3 @@ impl Related<super::audit_logs::Entity> for Entity {
|
|||||||
Relation::AuditLogs.def()
|
Relation::AuditLogs.def()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<super::blog_articles::Entity> for Entity {
|
|
||||||
fn to() -> RelationDef {
|
|
||||||
Relation::BlogArticles.def()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
pub use super::_entities::audio_tags::{ActiveModel, Entity, Model};
|
|
||||||
use sea_orm::entity::prelude::*;
|
|
||||||
pub type AudioTags = Entity;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl ActiveModelBehavior for ActiveModel {
|
|
||||||
async fn before_save<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
|
|
||||||
where
|
|
||||||
C: ConnectionTrait,
|
|
||||||
{
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// implement your read-oriented logic here
|
|
||||||
impl Model {}
|
|
||||||
|
|
||||||
// implement your write-oriented logic here
|
|
||||||
impl ActiveModel {}
|
|
||||||
|
|
||||||
// implement your custom finders, selectors oriented logic here
|
|
||||||
impl Entity {}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
pub use super::_entities::audio_tracks::{ActiveModel, Entity, Model};
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
pub type AudioTracks = Entity;
|
pub use super::_entities::categories::{ActiveModel, Model, Entity};
|
||||||
|
pub type Categories = Entity;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl ActiveModelBehavior for ActiveModel {
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
pub mod _entities;
|
pub mod _entities;
|
||||||
pub mod audio_albums;
|
|
||||||
pub mod audio_tags;
|
|
||||||
pub mod audio_track_tags;
|
|
||||||
pub mod audio_tracks;
|
|
||||||
pub mod audit_logs;
|
pub mod audit_logs;
|
||||||
pub mod blog_articles;
|
|
||||||
pub mod site_pages;
|
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
pub mod categories;
|
||||||
|
pub mod products;
|
||||||
|
pub mod product_images;
|
||||||
|
pub mod product_tags;
|
||||||
|
pub mod product_product_tags;
|
||||||
|
pub mod orders;
|
||||||
|
pub mod order_items;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pub use super::_entities::audio_albums::{ActiveModel, Entity, Model};
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
pub type AudioAlbums = Entity;
|
pub use super::_entities::order_items::{ActiveModel, Model, Entity};
|
||||||
|
pub type OrderItems = Entity;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl ActiveModelBehavior for ActiveModel {
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
pub use super::_entities::blog_articles::{ActiveModel, Entity, Model};
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
pub type BlogArticles = Entity;
|
pub use super::_entities::orders::{ActiveModel, Model, Entity};
|
||||||
|
pub type Orders = Entity;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl ActiveModelBehavior for ActiveModel {
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
28
src/models/product_images.rs
Normal file
28
src/models/product_images.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
pub use super::_entities::product_images::{ActiveModel, Model, Entity};
|
||||||
|
pub type ProductImages = Entity;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
|
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
if !insert && self.updated_at.is_unchanged() {
|
||||||
|
let mut this = self;
|
||||||
|
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||||
|
Ok(this)
|
||||||
|
} else {
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement your read-oriented logic here
|
||||||
|
impl Model {}
|
||||||
|
|
||||||
|
// implement your write-oriented logic here
|
||||||
|
impl ActiveModel {}
|
||||||
|
|
||||||
|
// implement your custom finders, selectors oriented logic here
|
||||||
|
impl Entity {}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
pub use super::_entities::audio_track_tags::{ActiveModel, Entity, Model};
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
pub type AudioTrackTags = Entity;
|
pub use super::_entities::product_product_tags::{ActiveModel, Model, Entity};
|
||||||
|
pub type ProductProductTags = Entity;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl ActiveModelBehavior for ActiveModel {
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
28
src/models/product_tags.rs
Normal file
28
src/models/product_tags.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
pub use super::_entities::product_tags::{ActiveModel, Model, Entity};
|
||||||
|
pub type ProductTags = Entity;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
|
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
if !insert && self.updated_at.is_unchanged() {
|
||||||
|
let mut this = self;
|
||||||
|
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||||
|
Ok(this)
|
||||||
|
} else {
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement your read-oriented logic here
|
||||||
|
impl Model {}
|
||||||
|
|
||||||
|
// implement your write-oriented logic here
|
||||||
|
impl ActiveModel {}
|
||||||
|
|
||||||
|
// implement your custom finders, selectors oriented logic here
|
||||||
|
impl Entity {}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
pub use super::_entities::site_pages::{ActiveModel, Entity, Model};
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
pub type SitePages = Entity;
|
pub use super::_entities::products::{ActiveModel, Model, Entity};
|
||||||
|
pub type Products = Entity;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl ActiveModelBehavior for ActiveModel {
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
@@ -18,6 +18,11 @@ impl ActiveModelBehavior for ActiveModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// implement your read-oriented logic here
|
||||||
impl Model {}
|
impl Model {}
|
||||||
|
|
||||||
|
// implement your write-oriented logic here
|
||||||
impl ActiveModel {}
|
impl ActiveModel {}
|
||||||
|
|
||||||
|
// implement your custom finders, selectors oriented logic here
|
||||||
impl Entity {}
|
impl Entity {}
|
||||||
31
tests/models/categories.rs
Normal file
31
tests/models/categories.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use gitara_web::app::App;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
macro_rules! configure_insta {
|
||||||
|
($($expr:expr),*) => {
|
||||||
|
let mut settings = insta::Settings::clone_current();
|
||||||
|
settings.set_prepend_module_to_snapshot(false);
|
||||||
|
let _guard = settings.bind_to_scope();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_model() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>().await.unwrap();
|
||||||
|
seed::<App>(&boot.app_context).await.unwrap();
|
||||||
|
|
||||||
|
// query your model, e.g.:
|
||||||
|
//
|
||||||
|
// let item = models::posts::Model::find_by_pid(
|
||||||
|
// &boot.app_context.db,
|
||||||
|
// "11111111-1111-1111-1111-111111111111",
|
||||||
|
// )
|
||||||
|
// .await;
|
||||||
|
|
||||||
|
// snapshot the result:
|
||||||
|
// assert_debug_snapshot!(item);
|
||||||
|
}
|
||||||
@@ -1 +1,8 @@
|
|||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
|
mod categories;
|
||||||
|
mod products;
|
||||||
|
mod product_images;
|
||||||
|
mod product_tags;
|
||||||
|
mod orders;
|
||||||
|
mod order_items;
|
||||||
31
tests/models/order_items.rs
Normal file
31
tests/models/order_items.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use gitara_web::app::App;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
macro_rules! configure_insta {
|
||||||
|
($($expr:expr),*) => {
|
||||||
|
let mut settings = insta::Settings::clone_current();
|
||||||
|
settings.set_prepend_module_to_snapshot(false);
|
||||||
|
let _guard = settings.bind_to_scope();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_model() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>().await.unwrap();
|
||||||
|
seed::<App>(&boot.app_context).await.unwrap();
|
||||||
|
|
||||||
|
// query your model, e.g.:
|
||||||
|
//
|
||||||
|
// let item = models::posts::Model::find_by_pid(
|
||||||
|
// &boot.app_context.db,
|
||||||
|
// "11111111-1111-1111-1111-111111111111",
|
||||||
|
// )
|
||||||
|
// .await;
|
||||||
|
|
||||||
|
// snapshot the result:
|
||||||
|
// assert_debug_snapshot!(item);
|
||||||
|
}
|
||||||
31
tests/models/orders.rs
Normal file
31
tests/models/orders.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use gitara_web::app::App;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
macro_rules! configure_insta {
|
||||||
|
($($expr:expr),*) => {
|
||||||
|
let mut settings = insta::Settings::clone_current();
|
||||||
|
settings.set_prepend_module_to_snapshot(false);
|
||||||
|
let _guard = settings.bind_to_scope();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_model() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>().await.unwrap();
|
||||||
|
seed::<App>(&boot.app_context).await.unwrap();
|
||||||
|
|
||||||
|
// query your model, e.g.:
|
||||||
|
//
|
||||||
|
// let item = models::posts::Model::find_by_pid(
|
||||||
|
// &boot.app_context.db,
|
||||||
|
// "11111111-1111-1111-1111-111111111111",
|
||||||
|
// )
|
||||||
|
// .await;
|
||||||
|
|
||||||
|
// snapshot the result:
|
||||||
|
// assert_debug_snapshot!(item);
|
||||||
|
}
|
||||||
31
tests/models/product_images.rs
Normal file
31
tests/models/product_images.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use gitara_web::app::App;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
macro_rules! configure_insta {
|
||||||
|
($($expr:expr),*) => {
|
||||||
|
let mut settings = insta::Settings::clone_current();
|
||||||
|
settings.set_prepend_module_to_snapshot(false);
|
||||||
|
let _guard = settings.bind_to_scope();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_model() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>().await.unwrap();
|
||||||
|
seed::<App>(&boot.app_context).await.unwrap();
|
||||||
|
|
||||||
|
// query your model, e.g.:
|
||||||
|
//
|
||||||
|
// let item = models::posts::Model::find_by_pid(
|
||||||
|
// &boot.app_context.db,
|
||||||
|
// "11111111-1111-1111-1111-111111111111",
|
||||||
|
// )
|
||||||
|
// .await;
|
||||||
|
|
||||||
|
// snapshot the result:
|
||||||
|
// assert_debug_snapshot!(item);
|
||||||
|
}
|
||||||
31
tests/models/product_tags.rs
Normal file
31
tests/models/product_tags.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use gitara_web::app::App;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
macro_rules! configure_insta {
|
||||||
|
($($expr:expr),*) => {
|
||||||
|
let mut settings = insta::Settings::clone_current();
|
||||||
|
settings.set_prepend_module_to_snapshot(false);
|
||||||
|
let _guard = settings.bind_to_scope();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_model() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>().await.unwrap();
|
||||||
|
seed::<App>(&boot.app_context).await.unwrap();
|
||||||
|
|
||||||
|
// query your model, e.g.:
|
||||||
|
//
|
||||||
|
// let item = models::posts::Model::find_by_pid(
|
||||||
|
// &boot.app_context.db,
|
||||||
|
// "11111111-1111-1111-1111-111111111111",
|
||||||
|
// )
|
||||||
|
// .await;
|
||||||
|
|
||||||
|
// snapshot the result:
|
||||||
|
// assert_debug_snapshot!(item);
|
||||||
|
}
|
||||||
31
tests/models/products.rs
Normal file
31
tests/models/products.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use gitara_web::app::App;
|
||||||
|
use loco_rs::testing::prelude::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
macro_rules! configure_insta {
|
||||||
|
($($expr:expr),*) => {
|
||||||
|
let mut settings = insta::Settings::clone_current();
|
||||||
|
settings.set_prepend_module_to_snapshot(false);
|
||||||
|
let _guard = settings.bind_to_scope();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_model() {
|
||||||
|
configure_insta!();
|
||||||
|
|
||||||
|
let boot = boot_test::<App>().await.unwrap();
|
||||||
|
seed::<App>(&boot.app_context).await.unwrap();
|
||||||
|
|
||||||
|
// query your model, e.g.:
|
||||||
|
//
|
||||||
|
// let item = models::posts::Model::find_by_pid(
|
||||||
|
// &boot.app_context.db,
|
||||||
|
// "11111111-1111-1111-1111-111111111111",
|
||||||
|
// )
|
||||||
|
// .await;
|
||||||
|
|
||||||
|
// snapshot the result:
|
||||||
|
// assert_debug_snapshot!(item);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user