From baf75222730c1fea5acd3f5bf3484e379c5314dd Mon Sep 17 00:00:00 2001 From: Priec Date: Tue, 16 Jun 2026 16:35:50 +0200 Subject: [PATCH] eshop --- assets/i18n/en/main.ftl | 67 ++ assets/i18n/sk/main.ftl | 67 ++ assets/static/css/app.css | 2 +- assets/views/admin/about.html | 36 - assets/views/admin/audio/albums.html | 81 -- assets/views/admin/audio/new_album.html | 93 -- assets/views/admin/audio/songs.html | 99 -- assets/views/admin/audio/tracks.html | 123 --- assets/views/admin/audio/upload_track.html | 76 -- assets/views/admin/base.html | 213 ++-- assets/views/admin/blog/edit.html | 59 -- assets/views/admin/blog/index.html | 63 -- assets/views/admin/blog/new.html | 60 -- assets/views/admin/catalog/categories.html | 65 ++ assets/views/admin/catalog/category_form.html | 70 ++ assets/views/admin/catalog/product_form.html | 101 ++ assets/views/admin/catalog/products.html | 81 ++ assets/views/admin/index.html | 71 +- assets/views/admin/orders/index.html | 41 + assets/views/admin/orders/show.html | 73 ++ assets/views/audio/album.html | 140 --- assets/views/audio/albums.html | 96 -- assets/views/audio/tracks.html | 76 -- assets/views/base.html | 25 +- assets/views/blog/index.html | 81 -- assets/views/blog/show.html | 56 - assets/views/home/index.html | 197 +--- assets/views/pages/about.html | 45 - assets/views/shop/_card.html | 12 + assets/views/shop/cart.html | 67 ++ assets/views/shop/category.html | 29 + assets/views/shop/checkout.html | 77 ++ assets/views/shop/index.html | 33 + assets/views/shop/order_confirmed.html | 38 + assets/views/shop/show.html | 60 ++ favicon/favicon.ico | Bin 15406 -> 1910 bytes migration/src/lib.rs | 20 +- migration/src/m20260616_123506_categories.rs | 30 + migration/src/m20260616_123524_products.rs | 35 + .../src/m20260616_123550_product_images.rs | 28 + .../src/m20260616_123611_product_tags.rs | 26 + ...te_join_table_products_and_product_tags.rs | 23 + migration/src/m20260616_130610_orders.rs | 35 + migration/src/m20260616_130628_order_items.rs | 29 + .../src/m20260616_131000_drop_audio_tables.rs | 42 + .../m20260616_132000_drop_blog_and_pages.rs | 29 + src/app.rs | 6 +- src/controllers/admin.rs | 14 +- src/controllers/blog.rs | 245 ----- src/controllers/cart.rs | 203 ++++ src/controllers/catalog.rs | 807 ++++++++++++++ src/controllers/frontend.rs | 347 +----- src/controllers/media.rs | 989 +----------------- src/controllers/mod.rs | 5 +- src/controllers/orders.rs | 316 ++++++ src/controllers/pages.rs | 86 -- src/models/_entities/audio_albums.rs | 51 - src/models/_entities/audio_tags.rs | 37 - src/models/_entities/audio_tracks.rs | 58 - src/models/_entities/blog_articles.rs | 42 - src/models/_entities/categories.rs | 33 + src/models/_entities/mod.rs | 13 +- src/models/_entities/order_items.rs | 50 + src/models/_entities/orders.rs | 38 + src/models/_entities/prelude.rs | 13 +- src/models/_entities/product_images.rs | 35 + ..._track_tags.rs => product_product_tags.rs} | 31 +- src/models/_entities/product_tags.rs | 41 + src/models/_entities/products.rs | 77 ++ src/models/_entities/site_pages.rs | 21 - src/models/_entities/users.rs | 16 - src/models/audio_tags.rs | 22 - src/models/{audio_tracks.rs => categories.rs} | 4 +- src/models/mod.rs | 13 +- .../{audio_albums.rs => order_items.rs} | 4 +- src/models/{blog_articles.rs => orders.rs} | 4 +- src/models/product_images.rs | 28 + ..._track_tags.rs => product_product_tags.rs} | 4 +- src/models/product_tags.rs | 28 + src/models/{site_pages.rs => products.rs} | 9 +- tests/models/categories.rs | 31 + tests/models/mod.rs | 7 + tests/models/order_items.rs | 31 + tests/models/orders.rs | 31 + tests/models/product_images.rs | 31 + tests/models/product_tags.rs | 31 + tests/models/products.rs | 31 + 87 files changed, 3270 insertions(+), 3483 deletions(-) delete mode 100644 assets/views/admin/about.html delete mode 100644 assets/views/admin/audio/albums.html delete mode 100644 assets/views/admin/audio/new_album.html delete mode 100644 assets/views/admin/audio/songs.html delete mode 100644 assets/views/admin/audio/tracks.html delete mode 100644 assets/views/admin/audio/upload_track.html delete mode 100644 assets/views/admin/blog/edit.html delete mode 100644 assets/views/admin/blog/index.html delete mode 100644 assets/views/admin/blog/new.html create mode 100644 assets/views/admin/catalog/categories.html create mode 100644 assets/views/admin/catalog/category_form.html create mode 100644 assets/views/admin/catalog/product_form.html create mode 100644 assets/views/admin/catalog/products.html create mode 100644 assets/views/admin/orders/index.html create mode 100644 assets/views/admin/orders/show.html delete mode 100644 assets/views/audio/album.html delete mode 100644 assets/views/audio/albums.html delete mode 100644 assets/views/audio/tracks.html delete mode 100644 assets/views/blog/index.html delete mode 100644 assets/views/blog/show.html delete mode 100644 assets/views/pages/about.html create mode 100644 assets/views/shop/_card.html create mode 100644 assets/views/shop/cart.html create mode 100644 assets/views/shop/category.html create mode 100644 assets/views/shop/checkout.html create mode 100644 assets/views/shop/index.html create mode 100644 assets/views/shop/order_confirmed.html create mode 100644 assets/views/shop/show.html create mode 100644 migration/src/m20260616_123506_categories.rs create mode 100644 migration/src/m20260616_123524_products.rs create mode 100644 migration/src/m20260616_123550_product_images.rs create mode 100644 migration/src/m20260616_123611_product_tags.rs create mode 100644 migration/src/m20260616_123957_create_join_table_products_and_product_tags.rs create mode 100644 migration/src/m20260616_130610_orders.rs create mode 100644 migration/src/m20260616_130628_order_items.rs create mode 100644 migration/src/m20260616_131000_drop_audio_tables.rs create mode 100644 migration/src/m20260616_132000_drop_blog_and_pages.rs delete mode 100644 src/controllers/blog.rs create mode 100644 src/controllers/cart.rs create mode 100644 src/controllers/catalog.rs create mode 100644 src/controllers/orders.rs delete mode 100644 src/controllers/pages.rs delete mode 100644 src/models/_entities/audio_albums.rs delete mode 100644 src/models/_entities/audio_tags.rs delete mode 100644 src/models/_entities/audio_tracks.rs delete mode 100644 src/models/_entities/blog_articles.rs create mode 100644 src/models/_entities/categories.rs create mode 100644 src/models/_entities/order_items.rs create mode 100644 src/models/_entities/orders.rs create mode 100644 src/models/_entities/product_images.rs rename src/models/_entities/{audio_track_tags.rs => product_product_tags.rs} (52%) create mode 100644 src/models/_entities/product_tags.rs create mode 100644 src/models/_entities/products.rs delete mode 100644 src/models/_entities/site_pages.rs delete mode 100644 src/models/audio_tags.rs rename src/models/{audio_tracks.rs => categories.rs} (87%) rename src/models/{audio_albums.rs => order_items.rs} (87%) rename src/models/{blog_articles.rs => orders.rs} (87%) create mode 100644 src/models/product_images.rs rename src/models/{audio_track_tags.rs => product_product_tags.rs} (80%) create mode 100644 src/models/product_tags.rs rename src/models/{site_pages.rs => products.rs} (68%) create mode 100644 tests/models/categories.rs create mode 100644 tests/models/order_items.rs create mode 100644 tests/models/orders.rs create mode 100644 tests/models/product_images.rs create mode 100644 tests/models/product_tags.rs create mode 100644 tests/models/products.rs diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index fb4979f..015df91 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -172,3 +172,70 @@ track-number = Track number track-number-help = Optional - this song's position in the album track list. featured-help = Highlight this song on the site publish-song-now = Publish now - visitors can see it. + +# --- eshop: catalog, shop, cart, orders --- +nav-shop = Shop +admin-products = Products +admin-products-desc = manage the products you sell. +admin-categories = Categories +admin-categories-desc = organise products into categories. +admin-orders = Orders +admin-no-products = No products yet. +admin-no-categories = No categories yet. +new-product = New product +edit-product = Edit product +new-category = New category +edit-category = Edit category +product = Product +name = Name +price = Price +stock = Stock +sku = SKU +currency = Currency +category = Category +no-category = No category +image = Image +slug = URL slug +slug-auto = generated automatically +position = Position +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 diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index bdf2c78..07908b0 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -172,3 +172,70 @@ track-number = Číslo skladby track-number-help = Voliteľné - pozícia skladby v zozname albumu. featured-help = Zvýrazniť túto skladbu na webe publish-song-now = Zverejniť teraz - návštevníci ju uvidia. + +# --- eshop: catalog, shop, cart, orders --- +nav-shop = Obchod +admin-products = Produkty +admin-products-desc = spravovať produkty v ponuke. +admin-categories = Kategórie +admin-categories-desc = usporiadať produkty do kategórií. +admin-orders = Objednávky +admin-no-products = Zatiaľ žiadne produkty. +admin-no-categories = Zatiaľ žiadne kategórie. +new-product = Nový produkt +edit-product = Upraviť produkt +new-category = Nová kategória +edit-category = Upraviť kategóriu +product = Produkt +name = Názov +price = Cena +stock = Sklad +sku = Kód (SKU) +currency = Mena +category = Kategória +no-category = Bez kategórie +image = Obrázok +slug = URL adresa +slug-auto = vygeneruje sa automaticky +position = Poradie +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 diff --git a/assets/static/css/app.css b/assets/static/css/app.css index 3a55741..df8063d 100644 --- a/assets/static/css/app.css +++ b/assets/static/css/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-600:oklch(57.7% .245 27.325);--color-amber-500:oklch(76.9% .188 70.08);--color-green-600:oklch(62.7% .194 149.214);--color-emerald-600:oklch(59.6% .145 163.225);--color-sky-500:oklch(68.5% .169 237.323);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-600:oklch(51.1% .262 276.966);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-surface:var(--color-white);--color-surface-alt:var(--color-slate-100);--color-on-surface:var(--color-slate-700);--color-on-surface-strong:var(--color-slate-900);--color-primary:var(--color-indigo-600);--color-on-primary:var(--color-white);--color-outline:var(--color-slate-300);--color-surface-dark:var(--color-slate-900);--color-surface-dark-alt:var(--color-slate-800);--color-on-surface-dark:var(--color-slate-300);--color-on-surface-dark-strong:var(--color-white);--color-primary-dark:var(--color-indigo-400);--color-on-primary-dark:var(--color-slate-950);--color-outline-dark:var(--color-slate-700);--color-info:var(--color-sky-500);--color-warning:var(--color-amber-500);--color-danger:var(--color-red-600);--radius-radius:.375rem}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-x-0{inset-inline:0}.top-0{top:0}.top-full{top:100%}.right-0{right:0}.z-30{z-index:30}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.min-h-screen{min-height:100vh}.w-56{width:calc(var(--spacing) * 56)}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.flex-1{flex:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-start{justify-content:flex-start}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.overflow-x-auto{overflow-x:auto}.rounded-radius{border-radius:var(--radius-radius)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-danger\/40{border-color:#e4001466}@supports (color:color-mix(in lab, red, red)){.border-danger\/40{border-color:color-mix(in oklab, var(--color-danger) 40%, transparent)}}.border-outline{border-color:var(--color-outline)}.bg-danger\/10{background-color:#e400141a}@supports (color:color-mix(in lab, red, red)){.bg-danger\/10{background-color:color-mix(in oklab, var(--color-danger) 10%, transparent)}}.bg-primary{background-color:var(--color-primary)}.bg-surface{background-color:var(--color-surface)}.bg-surface-alt{background-color:var(--color-surface-alt)}.bg-surface\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-surface\/95{background-color:color-mix(in oklab, var(--color-surface) 95%, transparent)}}.p-2{padding:calc(var(--spacing) * 2)}.p-5{padding:calc(var(--spacing) * 5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-pre-line{white-space:pre-line}.text-danger{color:var(--color-danger)}.text-info{color:var(--color-info)}.text-on-primary{color:var(--color-on-primary)}.text-on-surface{color:var(--color-on-surface)}.text-on-surface-strong{color:var(--color-on-surface-strong)}.text-on-surface\/60{color:#31415899}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/60{color:color-mix(in oklab, var(--color-on-surface) 60%, transparent)}}.text-primary{color:var(--color-primary)}.text-warning{color:var(--color-warning)}.uppercase{text-transform:uppercase}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-surface-alt:hover{background-color:var(--color-surface-alt)}.hover\:text-primary:hover{color:var(--color-primary)}.hover\:opacity-90:hover{opacity:.9}}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-primary:focus{--tw-ring-color:var(--color-primary)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-surface-alt:focus{--tw-ring-offset-color:var(--color-surface-alt)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.aria-\[current\=page\]\:font-semibold[aria-current=page]{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.aria-\[current\=page\]\:text-primary[aria-current=page]{color:var(--color-primary)}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}}.dark\:border-outline-dark:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-outline-dark)}.dark\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-primary-dark)}.dark\:bg-surface-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-surface-dark)}.dark\:bg-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-surface-dark-alt)}.dark\:bg-surface-dark\/95:where([data-theme=dark],[data-theme=dark] *){background-color:#0f172bf2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-surface-dark\/95:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-surface-dark) 95%, transparent)}}.dark\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-primary-dark)}.dark\:text-on-surface-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-surface-dark)}.dark\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-surface-dark-strong)}.dark\:text-on-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *){color:#cad5e299}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 60%, transparent)}}.dark\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-primary-dark)}@media (hover:hover){.dark\:hover\:bg-surface-dark:where([data-theme=dark],[data-theme=dark] *):hover{background-color:var(--color-surface-dark)}.dark\:hover\:bg-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *):hover{background-color:var(--color-surface-dark-alt)}.dark\:hover\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *):hover{color:var(--color-primary-dark)}}.dark\:focus\:ring-primary-dark:where([data-theme=dark],[data-theme=dark] *):focus{--tw-ring-color:var(--color-primary-dark)}.dark\:focus\:ring-offset-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *):focus{--tw-ring-offset-color:var(--color-surface-dark-alt)}.dark\:aria-\[current\=page\]\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{color:var(--color-primary-dark)}}[x-cloak]{display:none!important}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-outline-style:solid}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-600:oklch(57.7% .245 27.325);--color-amber-500:oklch(76.9% .188 70.08);--color-green-600:oklch(62.7% .194 149.214);--color-emerald-600:oklch(59.6% .145 163.225);--color-sky-500:oklch(68.5% .169 237.323);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-600:oklch(51.1% .262 276.966);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-xl:36rem;--container-2xl:42rem;--container-5xl:64rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-relaxed:1.625;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-surface:var(--color-white);--color-surface-alt:var(--color-slate-100);--color-on-surface:var(--color-slate-700);--color-on-surface-strong:var(--color-slate-900);--color-primary:var(--color-indigo-600);--color-on-primary:var(--color-white);--color-outline:var(--color-slate-300);--color-surface-dark:var(--color-slate-900);--color-surface-dark-alt:var(--color-slate-800);--color-on-surface-dark:var(--color-slate-300);--color-on-surface-dark-strong:var(--color-white);--color-primary-dark:var(--color-indigo-400);--color-on-primary-dark:var(--color-slate-950);--color-outline-dark:var(--color-slate-700);--color-info:var(--color-sky-500);--color-success:var(--color-green-600);--color-warning:var(--color-amber-500);--color-danger:var(--color-red-600);--radius-radius:.375rem}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:0}.top-full{top:100%}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:0}.left-0{left:0}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-auto{margin-top:auto}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.aspect-square{aspect-ratio:1}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-14{width:calc(var(--spacing) * 14);height:calc(var(--spacing) * 14)}.size-16{width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16)}.size-24{width:calc(var(--spacing) * 24);height:calc(var(--spacing) * 24)}.size-full{width:100%;height:100%}.h-16{height:calc(var(--spacing) * 16)}.h-fit{height:fit-content}.min-h-screen{min-height:100vh}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-56{width:calc(var(--spacing) * 56)}.w-60{width:calc(var(--spacing) * 60)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.min-w-4{min-width:calc(var(--spacing) * 4)}.flex-1{flex:1}.-translate-x-60{--tw-translate-x:calc(var(--spacing) * -60);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-12>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 12) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 12) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-outline>:not(:last-child)){border-color:var(--color-outline)}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-radius{border-radius:var(--radius-radius)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-danger\/40{border-color:#e4001466}@supports (color:color-mix(in lab, red, red)){.border-danger\/40{border-color:color-mix(in oklab, var(--color-danger) 40%, transparent)}}.border-outline{border-color:var(--color-outline)}.border-primary{border-color:var(--color-primary)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-danger\/10{background-color:#e400141a}@supports (color:color-mix(in lab, red, red)){.bg-danger\/10{background-color:color-mix(in oklab, var(--color-danger) 10%, transparent)}}.bg-primary{background-color:var(--color-primary)}.bg-success\/15{background-color:#00a54426}@supports (color:color-mix(in lab, red, red)){.bg-success\/15{background-color:color-mix(in oklab, var(--color-success) 15%, transparent)}}.bg-surface{background-color:var(--color-surface)}.bg-surface-alt{background-color:var(--color-surface-alt)}.bg-surface\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-surface\/95{background-color:color-mix(in oklab, var(--color-surface) 95%, transparent)}}.object-cover{object-fit:cover}.p-2{padding:calc(var(--spacing) * 2)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-pre-line{white-space:pre-line}.text-danger{color:var(--color-danger)}.text-info{color:var(--color-info)}.text-on-primary{color:var(--color-on-primary)}.text-on-surface{color:var(--color-on-surface)}.text-on-surface-strong{color:var(--color-on-surface-strong)}.text-on-surface\/60{color:#31415899}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/60{color:color-mix(in oklab, var(--color-on-surface) 60%, transparent)}}.text-on-surface\/70{color:#314158b3}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/70{color:color-mix(in oklab, var(--color-on-surface) 70%, transparent)}}.text-on-surface\/80{color:#314158cc}@supports (color:color-mix(in lab, red, red)){.text-on-surface\/80{color:color-mix(in oklab, var(--color-on-surface) 80%, transparent)}}.text-primary{color:var(--color-primary)}.text-success{color:var(--color-success)}.text-warning{color:var(--color-warning)}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}@media (hover:hover){.group-hover\:scale-105:is(:where(.group):hover *){--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}}.file\:mr-3::file-selector-button{margin-right:calc(var(--spacing) * 3)}.file\:rounded-radius::file-selector-button{border-radius:var(--radius-radius)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-primary::file-selector-button{background-color:var(--color-primary)}.file\:px-3::file-selector-button{padding-inline:calc(var(--spacing) * 3)}.file\:py-2::file-selector-button{padding-block:calc(var(--spacing) * 2)}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-on-primary::file-selector-button{color:var(--color-on-primary)}@media (hover:hover){.hover\:border-primary:hover{border-color:var(--color-primary)}.hover\:bg-danger\/10:hover{background-color:#e400141a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-danger\/10:hover{background-color:color-mix(in oklab, var(--color-danger) 10%, transparent)}}.hover\:bg-surface:hover{background-color:var(--color-surface)}.hover\:bg-surface-alt:hover{background-color:var(--color-surface-alt)}.hover\:text-primary:hover{color:var(--color-primary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-75:hover{opacity:.75}.hover\:opacity-90:hover{opacity:.9}}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-primary:focus{--tw-ring-color:var(--color-primary)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-surface-alt:focus{--tw-ring-offset-color:var(--color-surface-alt)}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:outline-primary:focus{outline-color:var(--color-primary)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.aria-\[current\=page\]\:bg-primary[aria-current=page]{background-color:var(--color-primary)}.aria-\[current\=page\]\:font-semibold[aria-current=page]{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.aria-\[current\=page\]\:text-on-primary[aria-current=page]{color:var(--color-on-primary)}.aria-\[current\=page\]\:text-primary[aria-current=page]{color:var(--color-primary)}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:48rem){.md\:ml-60{margin-left:calc(var(--spacing) * 60)}.md\:flex{display:flex}.md\:hidden{display:none}.md\:translate-x-0{--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}:where(.dark\:divide-outline-dark:where([data-theme=dark],[data-theme=dark] *)>:not(:last-child)),.dark\:border-outline-dark:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-outline-dark)}.dark\:border-primary-dark:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-primary-dark)}.dark\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-primary-dark)}.dark\:bg-surface-dark:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-surface-dark)}.dark\:bg-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-surface-dark-alt)}.dark\:bg-surface-dark\/95:where([data-theme=dark],[data-theme=dark] *){background-color:#0f172bf2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-surface-dark\/95:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-surface-dark) 95%, transparent)}}.dark\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-primary-dark)}.dark\:text-on-surface-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-surface-dark)}.dark\:text-on-surface-dark-strong:where([data-theme=dark],[data-theme=dark] *){color:var(--color-on-surface-dark-strong)}.dark\:text-on-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *){color:#cad5e299}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/60:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 60%, transparent)}}.dark\:text-on-surface-dark\/70:where([data-theme=dark],[data-theme=dark] *){color:#cad5e2b3}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/70:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 70%, transparent)}}.dark\:text-on-surface-dark\/80:where([data-theme=dark],[data-theme=dark] *){color:#cad5e2cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-on-surface-dark\/80:where([data-theme=dark],[data-theme=dark] *){color:color-mix(in oklab, var(--color-on-surface-dark) 80%, transparent)}}.dark\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *){color:var(--color-primary-dark)}.dark\:file\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *)::file-selector-button{background-color:var(--color-primary-dark)}.dark\:file\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *)::file-selector-button{color:var(--color-on-primary-dark)}@media (hover:hover){.dark\:hover\:border-primary-dark:where([data-theme=dark],[data-theme=dark] *):hover{border-color:var(--color-primary-dark)}.dark\:hover\:bg-surface-dark:where([data-theme=dark],[data-theme=dark] *):hover{background-color:var(--color-surface-dark)}.dark\:hover\:bg-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *):hover{background-color:var(--color-surface-dark-alt)}.dark\:hover\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *):hover{color:var(--color-primary-dark)}}.dark\:focus\:ring-primary-dark:where([data-theme=dark],[data-theme=dark] *):focus{--tw-ring-color:var(--color-primary-dark)}.dark\:focus\:ring-offset-surface-dark-alt:where([data-theme=dark],[data-theme=dark] *):focus{--tw-ring-offset-color:var(--color-surface-dark-alt)}.dark\:aria-\[current\=page\]\:bg-primary-dark:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{background-color:var(--color-primary-dark)}.dark\:aria-\[current\=page\]\:text-on-primary-dark:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{color:var(--color-on-primary-dark)}.dark\:aria-\[current\=page\]\:text-primary-dark:where([data-theme=dark],[data-theme=dark] *)[aria-current=page]{color:var(--color-primary-dark)}}[x-cloak]{display:none!important}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid} \ No newline at end of file diff --git a/assets/views/admin/about.html b/assets/views/admin/about.html deleted file mode 100644 index 8a6f5b2..0000000 --- a/assets/views/admin/about.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "admin/base.html" %} - -{% block title %}{{ t(key="edit-about", lang=lang | default(value='sk')) }}{% endblock title %} - -{% block content %} -
-
-
-

{{ t(key="edit-about", lang=lang | default(value='sk')) }}

-

{{ t(key="update-about-page", lang=lang | default(value='sk')) }}

-
- {{ t(key="view-page", lang=lang | default(value='sk')) }} -
- -
-
-
-
- - -
- -
- - -
- -
- - {{ t(key="cancel", lang=lang | default(value='sk')) }} -
-
-
-
-
-{% endblock content %} diff --git a/assets/views/admin/audio/albums.html b/assets/views/admin/audio/albums.html deleted file mode 100644 index 998607b..0000000 --- a/assets/views/admin/audio/albums.html +++ /dev/null @@ -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 %} -
-
-

{{ t(key="albums-title", lang=lang | default(value='sk')) }}

-

{{ t(key="admin-albums-desc", lang=lang | default(value='sk')) }}

-
- -
- -
-

{{ t(key="admin-albums-before", lang=lang | default(value='sk')) }}

-
- [1] - {{ t(key="admin-albums-step-upload", lang=lang | default(value='sk')) }} -
-
- [2] - {{ t(key="admin-albums-step-create", lang=lang | default(value='sk')) }} -
-
- -
-
- ~/audio/albums/ - {{ albums | length }} {{ t(key="albums-title", lang=lang | default(value='sk')) }} -
-
- {% if albums | length > 0 %} -
- - - - - - - - - - - {% for row in albums %} - - - - - - - {% endfor %} - -
{{ t(key="album", lang=lang | default(value='sk')) }}{{ t(key="status", lang=lang | default(value='sk')) }}{{ t(key="songs-title", lang=lang | default(value='sk')) }}{{ t(key="actions", lang=lang | default(value='sk')) }}
{{ row.album.title }} - {% if row.album.published %} - {{ t(key="published", lang=lang | default(value='sk')) }} - {% else %} - {{ t(key="draft", lang=lang | default(value='sk')) }} - {% endif %} - {{ row.track_count }} - -
-
- {% else %} -
-

{{ t(key="admin-no-albums", lang=lang | default(value='sk')) }}

-

{{ t(key="admin-create-album-empty", lang=lang | default(value='sk')) }}

- -
- {% endif %} -
-
-{% endblock content %} diff --git a/assets/views/admin/audio/new_album.html b/assets/views/admin/audio/new_album.html deleted file mode 100644 index 4fde050..0000000 --- a/assets/views/admin/audio/new_album.html +++ /dev/null @@ -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 %} -
-
-

{{ t(key="new-album", lang=lang | default(value='sk')) }}

-

{{ t(key="admin-new-album-desc", lang=lang | default(value='sk')) }}

-
- -
- -
-
- ~/audio/albums/new -
-
-
-
- - -
- -
- - -
- -
- - -
- -
- - -

{{ t(key="cover-help", lang=lang | default(value='sk')) }}

-
- -
- - -
- -
- -
- - {% if available_tracks | length > 0 %} -
- {% for song in available_tracks %} - - {% endfor %} -
-

{{ t(key="free-songs-help", lang=lang | default(value='sk')) }}

- {% else %} -
-
- - {{ t(key="no-free-songs", lang=lang | default(value='sk')) }} - {{ t(key="upload-song-first", lang=lang | default(value='sk')) }}, - {{ t(key="create-empty-add-later", lang=lang | default(value='sk')) }} - -
-
- {% endif %} -
- - - -
- - {{ t(key="cancel", lang=lang | default(value='sk')) }} -
-
-
-
-{% endblock content %} diff --git a/assets/views/admin/audio/songs.html b/assets/views/admin/audio/songs.html deleted file mode 100644 index 092b1e2..0000000 --- a/assets/views/admin/audio/songs.html +++ /dev/null @@ -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 %} -
-
-

{{ t(key="songs-title-admin", lang=lang | default(value='sk')) }}

-

{{ t(key="admin-songs-desc", lang=lang | default(value='sk')) }}

-
- -
- -
-

{{ t(key="admin-audio-how", lang=lang | default(value='sk')) }}

-
- [1] - {{ t(key="admin-audio-step-upload", lang=lang | default(value='sk')) }} -
-
- [2] - {{ t(key="admin-audio-step-album", lang=lang | default(value='sk')) }} -
-

{{ t(key="admin-audio-note", lang=lang | default(value='sk')) }}

-
- -
-
- ~/audio/songs/ - {{ tracks | length }} {{ t(key="songs-title", lang=lang | default(value='sk')) }} -
-
- {% if tracks | length > 0 %} -
- - - - - - - - - - - {% for track in tracks %} - - - - - - - {% endfor %} - -
{{ t(key="song", lang=lang | default(value='sk')) }}{{ t(key="where", lang=lang | default(value='sk')) }}{{ t(key="status", lang=lang | default(value='sk')) }}{{ t(key="actions", lang=lang | default(value='sk')) }}
{{ track.title }} - {% if track.album_id %} - {{ t(key="in-album", lang=lang | default(value='sk')) }} - {% else %} - {{ t(key="single", lang=lang | default(value='sk')) }} - {% endif %} - - {% if track.published %} - {{ t(key="published", lang=lang | default(value='sk')) }} - {% else %} - {{ t(key="draft", lang=lang | default(value='sk')) }} - {% endif %} - -
- {{ t(key="play", lang=lang | default(value='sk')) }} - {% if track.published %} -
- -
- {% else %} -
- -
- {% endif %} -
- -
-
-
-
- {% else %} -
-

{{ t(key="admin-no-songs", lang=lang | default(value='sk')) }}

-

{{ t(key="admin-upload-first-song", lang=lang | default(value='sk')) }}

- -
- {% endif %} -
-
-{% endblock content %} diff --git a/assets/views/admin/audio/tracks.html b/assets/views/admin/audio/tracks.html deleted file mode 100644 index 7649699..0000000 --- a/assets/views/admin/audio/tracks.html +++ /dev/null @@ -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 %} -
-
-

{{ album.title }}

-

- {{ t(key="album", lang=lang | default(value='sk')) }} · {{ tracks | length }} {{ t(key="songs-title", lang=lang | default(value='sk')) }} · - {% if album.published %}{{ t(key="published", lang=lang | default(value='sk')) }}{% else %}{{ t(key="draft", lang=lang | default(value='sk')) }}{% endif %} -

-
- -
- -
-

{{ t(key="admin-two-ways-title", lang=lang | default(value='sk')) }}

-
- [a] - {{ t(key="admin-two-ways-upload", lang=lang | default(value='sk')) }} -
-
- [b] - {{ t(key="admin-two-ways-pick", lang=lang | default(value='sk')) }} -
-
- -
-
- ~/audio/{{ album.slug }}/tracklist - {{ tracks | length }} {{ t(key="songs-title", lang=lang | default(value='sk')) }} -
-
- {% if available_tracks | length > 0 %} -
-
- - -

{{ t(key="admin-existing-song-help", lang=lang | default(value='sk')) }}

-
- -
-
- {% endif %} - - {% if tracks | length > 0 %} -
- - - - - - - - - - - - {% for track in tracks %} - - - - - - - - {% endfor %} - -
#{{ t(key="song", lang=lang | default(value='sk')) }}{{ t(key="status", lang=lang | default(value='sk')) }}{{ t(key="featured", lang=lang | default(value='sk')) }}{{ t(key="actions", lang=lang | default(value='sk')) }}
{% if track.track_number %}{{ track.track_number }}{% else %}—{% endif %}{{ track.title }} - {% if track.published %} - {{ t(key="published", lang=lang | default(value='sk')) }} - {% else %} - {{ t(key="draft", lang=lang | default(value='sk')) }} - {% endif %} - - {% if track.featured %} - {{ t(key="featured", lang=lang | default(value='sk')) }} - {% else %} - - {% endif %} - -
- {{ t(key="play", lang=lang | default(value='sk')) }} - {% if track.published %} -
- -
- {% else %} -
- -
- {% endif %} -
- -
-
- -
-
-
-
- {% else %} -
-

{{ t(key="admin-album-empty", lang=lang | default(value='sk')) }}

-

{{ t(key="admin-album-empty-help", lang=lang | default(value='sk')) }}

- -
- {% endif %} -
-
-{% endblock content %} diff --git a/assets/views/admin/audio/upload_track.html b/assets/views/admin/audio/upload_track.html deleted file mode 100644 index a75261d..0000000 --- a/assets/views/admin/audio/upload_track.html +++ /dev/null @@ -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 %} -
-
-

{{ t(key="upload-song-title", lang=lang | default(value='sk')) }}

- {% if album %} -

{{ t(key="upload-into-album-help", lang=lang | default(value='sk')) }} "{{ album.title }}".

- {% else %} -

{{ t(key="upload-single-help", lang=lang | default(value='sk')) }}

- {% endif %} -
- -
- -
-
- {% if album %}~/audio/{{ album.slug }}/upload{% else %}~/audio/songs/upload{% endif %} -
-
- {% if album %} -
- {% else %} - - {% endif %} -
- - -

{{ t(key="audio-file-help", lang=lang | default(value='sk')) }}

-
- -
- - -

{{ t(key="title-help", lang=lang | default(value='sk')) }}

-
- - {% if album %} -
- - -

{{ t(key="track-number-help", lang=lang | default(value='sk')) }}

-
- {% endif %} - - - - - -
- - {% if album %} - {{ t(key="cancel", lang=lang | default(value='sk')) }} - {% else %} - {{ t(key="cancel", lang=lang | default(value='sk')) }} - {% endif %} -
-
-
-
-{% endblock content %} diff --git a/assets/views/admin/base.html b/assets/views/admin/base.html index 6d98a51..d2b4d94 100644 --- a/assets/views/admin/base.html +++ b/assets/views/admin/base.html @@ -44,114 +44,127 @@ -
- -
+

+ {{ t(key="settings-theme", lang=lang | default(value='sk')) }} +

+
+ + + +
+ + + -
- {% block content %}{% endblock content %} -
+
+ {% block content %}{% endblock content %} +
+ diff --git a/assets/views/admin/blog/edit.html b/assets/views/admin/blog/edit.html deleted file mode 100644 index d3d3fed..0000000 --- a/assets/views/admin/blog/edit.html +++ /dev/null @@ -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 %} -
-
-
-

{{ t(key="edit-article", lang=lang | default(value='sk')) }}

-
- {{ t(key="back-to-articles", lang=lang | default(value='sk')) }} -
- -
-
-
-
- - -
- -
- - -
- -
- - - -
- -

-
- - - -
- - {{ t(key="cancel", lang=lang | default(value='sk')) }} -
-
-
-
-
-{% endblock content %} diff --git a/assets/views/admin/blog/index.html b/assets/views/admin/blog/index.html deleted file mode 100644 index 009d6f9..0000000 --- a/assets/views/admin/blog/index.html +++ /dev/null @@ -1,63 +0,0 @@ -{% extends "admin/base.html" %} - -{% block title %}{{ t(key="admin-blog-articles", lang=lang | default(value='sk')) }}{% endblock title %} - -{% block content %} -
-
-
-

{{ t(key="admin-blog-articles", lang=lang | default(value='sk')) }}

-

{{ t(key="admin-blog-index-desc", lang=lang | default(value='sk')) }}

-
- {{ t(key="new-article", lang=lang | default(value='sk')) }} -
- -
-
- {% if articles | length > 0 %} -
- - - - - - - - - - {% for article in articles %} - - - - - - {% endfor %} - -
{{ t(key="title", lang=lang | default(value='sk')) }}{{ t(key="status", lang=lang | default(value='sk')) }}{{ t(key="actions", lang=lang | default(value='sk')) }}
{{ article.title }} - {% if article.published %} - {{ t(key="published", lang=lang | default(value='sk')) }} - {% else %} - {{ t(key="draft", lang=lang | default(value='sk')) }} - {% endif %} - -
- {{ t(key="edit", lang=lang | default(value='sk')) }} -
- -
-
-
-
- {% else %} -
-

{{ t(key="admin-no-articles", lang=lang | default(value='sk')) }}

-

{{ t(key="admin-create-first-post", lang=lang | default(value='sk')) }}

- -
- {% endif %} -
-
-
-{% endblock content %} diff --git a/assets/views/admin/blog/new.html b/assets/views/admin/blog/new.html deleted file mode 100644 index 97e70b6..0000000 --- a/assets/views/admin/blog/new.html +++ /dev/null @@ -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 %} -
-
-
-

{{ t(key="new-article", lang=lang | default(value='sk')) }}

-

{{ t(key="admin-blog-create-desc", lang=lang | default(value='sk')) }}

-
- {{ t(key="back-to-articles", lang=lang | default(value='sk')) }} -
- -
-
-
-
- - -
- -
- - -
- -
- - - -
- -

-
- - - -
- - {{ t(key="cancel", lang=lang | default(value='sk')) }} -
-
-
-
-
-{% endblock content %} diff --git a/assets/views/admin/catalog/categories.html b/assets/views/admin/catalog/categories.html new file mode 100644 index 0000000..91126d6 --- /dev/null +++ b/assets/views/admin/catalog/categories.html @@ -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 %} +
+
+

{{ t(key="admin-categories", lang=lang | default(value='sk')) }}

+

{{ t(key="admin-categories-desc", lang=lang | default(value='sk')) }}

+
+ + {{ t(key="new-category", lang=lang | default(value='sk')) }} + +
+ +
+ {% if categories | length > 0 %} + + + + + + + + + + + {% for row in categories %} + + + + + + + {% endfor %} + +
{{ t(key="name", lang=lang | default(value='sk')) }}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{{ t(key="status", lang=lang | default(value='sk')) }}{{ t(key="actions", lang=lang | default(value='sk')) }}
{{ row.category.name }}{{ row.product_count }} + {% if row.category.published %} + {{ t(key="published", lang=lang | default(value='sk')) }} + {% else %} + {{ t(key="draft", lang=lang | default(value='sk')) }} + {% endif %} + +
+ {{ t(key="edit", lang=lang | default(value='sk')) }} +
+ +
+
+
+ {% else %} +
+

{{ t(key="admin-no-categories", lang=lang | default(value='sk')) }}

+ + {{ t(key="new-category", lang=lang | default(value='sk')) }} + +
+ {% endif %} +
+{% endblock content %} diff --git a/assets/views/admin/catalog/category_form.html b/assets/views/admin/catalog/category_form.html new file mode 100644 index 0000000..9face42 --- /dev/null +++ b/assets/views/admin/catalog/category_form.html @@ -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 %} +
+

+ {% if editing %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %} +

+ {{ t(key="cancel", lang=lang | default(value='sk')) }} +
+ +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + {% if editing and category.image_id %} + + {% endif %} + +
+ + + +
+ + {{ t(key="cancel", lang=lang | default(value='sk')) }} +
+
+{% endblock content %} diff --git a/assets/views/admin/catalog/product_form.html b/assets/views/admin/catalog/product_form.html new file mode 100644 index 0000000..c332d71 --- /dev/null +++ b/assets/views/admin/catalog/product_form.html @@ -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 %} +
+

+ {% if editing %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %} +

+ {{ t(key="cancel", lang=lang | default(value='sk')) }} +
+ +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {% if editing and product.image %} + + {% endif %} + +
+ + + +
+ + {{ t(key="cancel", lang=lang | default(value='sk')) }} +
+
+{% endblock content %} diff --git a/assets/views/admin/catalog/products.html b/assets/views/admin/catalog/products.html new file mode 100644 index 0000000..cb32caf --- /dev/null +++ b/assets/views/admin/catalog/products.html @@ -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 %} +
+
+

{{ t(key="admin-products", lang=lang | default(value='sk')) }}

+

{{ t(key="admin-products-desc", lang=lang | default(value='sk')) }}

+
+ + {{ t(key="new-product", lang=lang | default(value='sk')) }} + +
+ +
+ {% if products | length > 0 %} + + + + + + + + + + + + {% for product in products %} + + + + + + + + {% endfor %} + +
{{ t(key="product", lang=lang | default(value='sk')) }}{{ t(key="price", lang=lang | default(value='sk')) }}{{ t(key="stock", lang=lang | default(value='sk')) }}{{ t(key="status", lang=lang | default(value='sk')) }}{{ t(key="actions", lang=lang | default(value='sk')) }}
+
+ {% if product.image %} + + {% else %} +
+ {% endif %} +
+
{{ product.name }}
+ {% if product.category_name %}
{{ product.category_name }}
{% endif %} +
+
+
{{ product.price }} {{ product.currency }}{{ product.stock }} + {% if product.published %} + {{ t(key="published", lang=lang | default(value='sk')) }} + {% else %} + {{ t(key="draft", lang=lang | default(value='sk')) }} + {% endif %} + +
+ {{ t(key="edit", lang=lang | default(value='sk')) }} + {{ t(key="view", lang=lang | default(value='sk')) }} +
+ +
+
+
+ {% else %} +
+

{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}

+ + {{ t(key="new-product", lang=lang | default(value='sk')) }} + +
+ {% endif %} +
+{% endblock content %} diff --git a/assets/views/admin/index.html b/assets/views/admin/index.html index 471d429..faedc94 100644 --- a/assets/views/admin/index.html +++ b/assets/views/admin/index.html @@ -1,60 +1,29 @@ {% extends "admin/base.html" %} {% block title %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock title %} -{% block crumb %}dashboard{% endblock crumb %} +{% block crumb %}{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}{% endblock crumb %} {% block content %} -
-
-

{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}

-

{{ t(key="admin-session", lang=lang | default(value='sk')) }}: {{ admin.email }}

-
- +
+

{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}

+

{{ admin.email }}

-
- - - - - + {% endblock content %} diff --git a/assets/views/admin/orders/index.html b/assets/views/admin/orders/index.html new file mode 100644 index 0000000..439225b --- /dev/null +++ b/assets/views/admin/orders/index.html @@ -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 %} +

{{ t(key="admin-orders", lang=lang | default(value='sk')) }}

+ +
+ {% if orders | length > 0 %} + + + + + + + + + + + + {% for order in orders %} + + + + + + + + {% endfor %} + +
{{ t(key="order-number", lang=lang | default(value='sk')) }}{{ t(key="order-customer", lang=lang | default(value='sk')) }}{{ t(key="order-status", lang=lang | default(value='sk')) }}{{ t(key="order-total", lang=lang | default(value='sk')) }}
{{ order.order_number }}{{ order.email }} + {{ t(key="order-status-" ~ order.status, lang=lang | default(value='sk')) }} + {{ order.total }} {{ order.currency }} + {{ t(key="view", lang=lang | default(value='sk')) }} +
+ {% else %} +
{{ t(key="admin-no-orders", lang=lang | default(value='sk')) }}
+ {% endif %} +
+{% endblock content %} diff --git a/assets/views/admin/orders/show.html b/assets/views/admin/orders/show.html new file mode 100644 index 0000000..a59d235 --- /dev/null +++ b/assets/views/admin/orders/show.html @@ -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 %} + + +
+
+
+ + + + + + + + + + {% for item in items %} + + + + + + {% endfor %} + + + + + + + +
{{ t(key="product", lang=lang | default(value='sk')) }}{{ t(key="quantity", lang=lang | default(value='sk')) }}{{ t(key="order-total", lang=lang | default(value='sk')) }}
{{ item.product_name }}{{ item.quantity }}{{ item.line_total }} {{ order.currency }}
{{ t(key="order-total", lang=lang | default(value='sk')) }}{{ order.total }} {{ order.currency }}
+
+
+ + +
+{% endblock content %} diff --git a/assets/views/audio/album.html b/assets/views/audio/album.html deleted file mode 100644 index 161164d..0000000 --- a/assets/views/audio/album.html +++ /dev/null @@ -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 %} -
-
-

{{ album.title }}

- {% if album.artist %} -

// {{ t(key="album-by", lang=lang | default(value='sk')) }} {{ album.artist }}

- {% endif %} -
-
- {% if tracks | length > 0 %} - - {% endif %} - [ {{ t(key="cd-up", lang=lang | default(value='sk')) }} ] -
-
- -{% if album.cover_image_id %} -
-
- ~/audio/{{ album.slug }}/cover.png -
-
- -
-
-{% endif %} - -{% if album.description %} -
-
- ~/audio/{{ album.slug }}/notes.txt -
-
-

{{ album.description }}

-
-
-{% endif %} - -
-
- ~/audio/{{ album.slug }}/tracklist - {{ tracks | length }} tracks -
-
- {% if tracks | length > 0 %} -
- - // {{ t(key="album-queue-all", lang=lang | default(value='sk')) }} -
- {% for track in tracks %} -
- - - {% if track.track_number %}{{ track.track_number }}{% else %}-{% endif %} - {{ track.title }} - -
- {% endfor %} - {% else %} -

{{ t(key="album-no-tracks", lang=lang | default(value='sk')) }}

- {% endif %} -
-
-{% else %} -
-
-

{{ album.title }}

- {% if album.artist %} -

{{ t(key="album-by", lang=lang | default(value='sk')) }} {{ album.artist }}

- {% endif %} -
-
- {% if tracks | length > 0 %} - - {% endif %} - [ {{ t(key="cd-up", lang=lang | default(value='sk')) }} ] -
-
- -{% if album.cover_image_id %} -
-
- ~/audio/{{ album.slug }}/cover.png -
-
- -
-
-{% endif %} - -{% if album.description %} -
-
- ~/audio/{{ album.slug }}/notes.txt -
-
-

{{ album.description }}

-
-
-{% endif %} - -
-
- ~/audio/{{ album.slug }}/tracklist - {{ tracks | length }} tracks -
-
- {% if tracks | length > 0 %} -
- - // {{ t(key="album-queue-all", lang=lang | default(value='sk')) }} -
- {% for track in tracks %} -
- - - {% if track.track_number %}{{ track.track_number }}{% else %}-{% endif %} - {{ track.title }} - -
- {% endfor %} - {% else %} -

{{ t(key="album-no-tracks", lang=lang | default(value='sk')) }}

- {% endif %} -
-
-{% endif %} -{% endblock content %} diff --git a/assets/views/audio/albums.html b/assets/views/audio/albums.html deleted file mode 100644 index 3e63bc6..0000000 --- a/assets/views/audio/albums.html +++ /dev/null @@ -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 %} -
-
-

{{ t(key="audio-title", lang=lang | default(value='sk')) }}

-

// {{ albums | length }} {{ t(key="audio-sub", lang=lang | default(value='sk')) }}

-
- -
- -{% if albums | length > 0 %} -
- {% for album in albums %} -
-
- ~/audio/{{ album.slug }}/ - {{ t(key="album", lang=lang | default(value='sk')) }} -
-
- {% if album.cover_image_id %} - - {% endif %} -

{{ album.title }}

- {% if album.artist %} -

{{ album.artist }}

- {% endif %} - {% if album.description %} -

{{ album.description }}

- {% endif %} -
- - {{ t(key="audio-open", lang=lang | default(value='sk')) }} -
-
-
- {% endfor %} -
-{% else %} -
-

{{ t(key="audio-no-albums", lang=lang | default(value='sk')) }}

-
-{% endif %} -{% else %} -
-
-

{{ t(key="audio-title", lang=lang | default(value='sk')) }}

-

{{ albums | length }} {{ t(key="audio-sub", lang=lang | default(value='sk')) }}

-
- -
- -{% if albums | length > 0 %} -
- {% for album in albums %} -
-
- ~/audio/{{ album.slug }}/ - {{ t(key="album", lang=lang | default(value='sk')) }} -
-
- {% if album.cover_image_id %} - - {% endif %} -

{{ album.title }}

- {% if album.artist %} -

{{ album.artist }}

- {% endif %} - {% if album.description %} -

{{ album.description }}

- {% endif %} -
- - {{ t(key="audio-open", lang=lang | default(value='sk')) }} -
-
-
- {% endfor %} -
-{% else %} -
-

{{ t(key="audio-no-albums", lang=lang | default(value='sk')) }}

-
-{% endif %} -{% endif %} -{% endblock content %} diff --git a/assets/views/audio/tracks.html b/assets/views/audio/tracks.html deleted file mode 100644 index aba8721..0000000 --- a/assets/views/audio/tracks.html +++ /dev/null @@ -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 %} -
-
-

{{ t(key="songs-title", lang=lang | default(value='sk')) }}

-

// {{ tracks | length }} {{ t(key="songs-sub", lang=lang | default(value='sk')) }}

-
-
- {% if tracks | length > 0 %} - - {% endif %} - [ {{ t(key="songs-albums", lang=lang | default(value='sk')) }} ] -
-
- -
-
- ~/audio/playlist.m3u - {{ tracks | length }} tracks -
-
- {% if tracks | length > 0 %} - {% for track in tracks %} -
- - {{ track.title }} -
- {% endfor %} - {% else %} -

{{ t(key="songs-no-tracks", lang=lang | default(value='sk')) }}

- {% endif %} -
-
-{% else %} -
-
-

{{ t(key="songs-title", lang=lang | default(value='sk')) }}

-

{{ tracks | length }} {{ t(key="songs-sub", lang=lang | default(value='sk')) }}

-
-
- {% if tracks | length > 0 %} - - {% endif %} - [ {{ t(key="songs-albums", lang=lang | default(value='sk')) }} ] -
-
- -
-
- ~/audio/playlist.m3u - {{ tracks | length }} tracks -
-
- {% if tracks | length > 0 %} - {% for track in tracks %} -
- - {{ track.title }} -
- {% endfor %} - {% else %} -

{{ t(key="songs-no-tracks", lang=lang | default(value='sk')) }}

- {% endif %} -
-
-{% endif %} -{% endblock content %} diff --git a/assets/views/base.html b/assets/views/base.html index e9656c0..e5a9f89 100644 --- a/assets/views/base.html +++ b/assets/views/base.html @@ -57,10 +57,7 @@ - +
+ + + + + + +
- {{ featured_track.title }} -
-
- - {% endif %} - {% if featured_album %} -
-
- ~/audio/{{ featured_album.slug }}/ - {{ t(key="album", lang=lang | default(value='sk')) }} -
-
- {% if featured_album.cover_image_id %} - - {% endif %} -

{{ featured_album.title }}

- {% if featured_album.artist %} -

{{ featured_album.artist }}

- {% endif %} - {% if featured_album.description %} -

{{ featured_album.description }}

- {% endif %} -
- - {{ t(key="audio-open", lang=lang | default(value='sk')) }} -
-
-
- {% endif %} -
- -{% endif %} - -
-

# {{ t(key="home-recent", lang=lang | default(value='sk')) }} ({{ articles | length }})

- {% if articles | length > 0 %} -
- {% for article in articles %} - + + {% if products | length > 0 %} +
+
+

{{ t(key="shop-title", lang=lang | default(value='sk')) }}

+ {{ t(key="cart-continue", lang=lang | default(value='sk')) }} → +
+
+ {% for product in products %} + {% include "shop/_card.html" %} {% endfor %}
- {% else %} -
-

{{ t(key="home-no-posts", lang=lang | default(value='sk')) }}

-
+
{% endif %} -
-{% else %} -
-
-

{{ t(key="home-title", lang=lang | default(value='sk')) }}

-

{{ t(key="home-sub", lang=lang | default(value='sk')) }}

-
- -
- -{% if featured_track or featured_album %} -
-

# {{ t(key="home-picks", lang=lang | default(value='sk')) }}

-
- {% if featured_track %} -
-
- ~/audio/tracks/{{ featured_track.slug }} - {{ t(key="song", lang=lang | default(value='sk')) }} -
-
-
- - {{ featured_track.title }} -
-
-
- {% endif %} - {% if featured_album %} -
-
- ~/audio/{{ featured_album.slug }}/ - {{ t(key="album", lang=lang | default(value='sk')) }} -
-
- {% if featured_album.cover_image_id %} - - {% endif %} -

{{ featured_album.title }}

- {% if featured_album.artist %} -

{{ featured_album.artist }}

- {% endif %} - {% if featured_album.description %} -

{{ featured_album.description }}

- {% endif %} -
- - {{ t(key="audio-open", lang=lang | default(value='sk')) }} -
-
-
- {% endif %} -
-
-{% endif %} - -
-

# {{ t(key="home-recent", lang=lang | default(value='sk')) }} ({{ articles | length }})

- {% if articles | length > 0 %} -
- {% for article in articles %} - - {% endfor %} -
- {% else %} -
-

{{ t(key="home-no-posts", lang=lang | default(value='sk')) }}

-
- {% endif %} -
-{% endif %} + {% endblock content %} diff --git a/assets/views/pages/about.html b/assets/views/pages/about.html deleted file mode 100644 index 7419ef9..0000000 --- a/assets/views/pages/about.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ page.title }}{% endblock title %} -{% block crumb %}about{% endblock crumb %} - -{% block content %} -{% if logged_in_admin %} -
-
-

{{ page.title }}

-

// {{ t(key="about-sub", lang=lang | default(value='sk')) }}

-
- -
- -
-
- ~/about.txt - {{ t(key="readonly", lang=lang | default(value='sk')) }} -
-
-
{{ page.content }}
-
-
-{% else %} -
-
-

{{ page.title }}

-

{{ t(key="about-sub", lang=lang | default(value='sk')) }}

-
-
- -
-
- ~/about.txt - {{ t(key="readonly", lang=lang | default(value='sk')) }} -
-
-
{{ page.content }}
-
-
-{% endif %} -{% endblock content %} diff --git a/assets/views/shop/_card.html b/assets/views/shop/_card.html new file mode 100644 index 0000000..fb1e553 --- /dev/null +++ b/assets/views/shop/_card.html @@ -0,0 +1,12 @@ + +
+ {% if product.image %} + {{ product.name }} + {% endif %} +
+
+

{{ product.name }}

+

{{ product.price }} {{ product.currency }}

+
+
diff --git a/assets/views/shop/cart.html b/assets/views/shop/cart.html new file mode 100644 index 0000000..6e4cc8c --- /dev/null +++ b/assets/views/shop/cart.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block title %}{{ t(key="cart-title", lang=lang | default(value='sk')) }}{% endblock title %} + +{% block content %} +
+

{{ t(key="cart-title", lang=lang | default(value='sk')) }}

+ + {% if items | length > 0 %} +
+ + + + + + + + + + + + {% for item in items %} + + + + + + + + {% endfor %} + + + + + + + + +
{{ t(key="product", lang=lang | default(value='sk')) }}{{ t(key="price", lang=lang | default(value='sk')) }}{{ t(key="quantity", lang=lang | default(value='sk')) }}{{ t(key="cart-total", lang=lang | default(value='sk')) }}
+ {{ item.name }} + {{ item.price }} {{ item.currency }} +
+ + + +
+
{{ item.line_total }} {{ item.currency }} +
+ + +
+
{{ t(key="cart-total", lang=lang | default(value='sk')) }}{{ total }} {{ currency }}
+
+ + + {% else %} +
+

{{ t(key="cart-empty", lang=lang | default(value='sk')) }}

+ {{ t(key="cart-continue", lang=lang | default(value='sk')) }} +
+ {% endif %} +
+{% endblock content %} diff --git a/assets/views/shop/category.html b/assets/views/shop/category.html new file mode 100644 index 0000000..d0dc634 --- /dev/null +++ b/assets/views/shop/category.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block title %}{{ category.name }}{% endblock title %} + +{% block content %} +
+
+ +

{{ category.name }}

+ {% if category.description %}

{{ category.description }}

{% endif %} +
+ + {% if products | length > 0 %} +
+ {% for product in products %} + {% include "shop/_card.html" %} + {% endfor %} +
+ {% else %} +
+ {{ t(key="shop-empty", lang=lang | default(value='sk')) }} +
+ {% endif %} +
+{% endblock content %} diff --git a/assets/views/shop/checkout.html b/assets/views/shop/checkout.html new file mode 100644 index 0000000..069ae2f --- /dev/null +++ b/assets/views/shop/checkout.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} + +{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %} + +{% block content %} +

{{ t(key="checkout-title", lang=lang | default(value='sk')) }}

+ +
+
+
+ {{ t(key="checkout-contact", lang=lang | default(value='sk')) }} +
+ + +
+
+ + +
+
+ +
+ {{ t(key="checkout-shipping", lang=lang | default(value='sk')) }} +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + +
+{% endblock content %} diff --git a/assets/views/shop/index.html b/assets/views/shop/index.html new file mode 100644 index 0000000..84a1bf5 --- /dev/null +++ b/assets/views/shop/index.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %} + +{% block content %} +
+
+

{{ t(key="shop-title", lang=lang | default(value='sk')) }}

+

{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}

+
+ + {% if categories | length > 0 %} +
+ {% for category in categories %} + {{ category.name }} + {% endfor %} +
+ {% endif %} + + {% if products | length > 0 %} +
+ {% for product in products %} + {% include "shop/_card.html" %} + {% endfor %} +
+ {% else %} +
+ {{ t(key="shop-empty", lang=lang | default(value='sk')) }} +
+ {% endif %} +
+{% endblock content %} diff --git a/assets/views/shop/order_confirmed.html b/assets/views/shop/order_confirmed.html new file mode 100644 index 0000000..4bb12bc --- /dev/null +++ b/assets/views/shop/order_confirmed.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block title %}{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}{% endblock title %} + +{% block content %} +
+
+ + + +
+
+

{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}

+

{{ t(key="order-confirmed-sub", lang=lang | default(value='sk')) }}

+
+ +
+
+ {{ t(key="order-number", lang=lang | default(value='sk')) }} + {{ order.order_number }} +
+
    + {% for item in items %} +
  • + {{ item.product_name }} × {{ item.quantity }} + {{ item.line_total }} {{ order.currency }} +
  • + {% endfor %} +
+
+ {{ t(key="order-total", lang=lang | default(value='sk')) }} + {{ order.total }} {{ order.currency }} +
+
+ + {{ t(key="cart-continue", lang=lang | default(value='sk')) }} +
+{% endblock content %} diff --git a/assets/views/shop/show.html b/assets/views/shop/show.html new file mode 100644 index 0000000..d5a4c22 --- /dev/null +++ b/assets/views/shop/show.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} + +{% block title %}{{ product.name }}{% endblock title %} + +{% block content %} +
+ +
+
+ {% if images | length > 0 %} + {% for image in images %} + {{ product.name }} + {% endfor %} + {% endif %} +
+ {% if images | length > 1 %} +
+ {% for image in images %} + + {% endfor %} +
+ {% endif %} +
+ + +
+ {% if category %} + {{ category.name }} + {% endif %} +

{{ product.name }}

+

{{ product.price }} {{ product.currency }}

+ + {% if product.description %} +
{{ product.description }}
+ {% endif %} + + {% if product.stock > 0 %} +
+ +
+ + +
+ +
+

{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}

+ {% else %} +

{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}

+ {% endif %} +
+
+{% endblock content %} diff --git a/favicon/favicon.ico b/favicon/favicon.ico index d5e9d4c3dfe69c5cee161380b315237b03789895..8e7ae0e9512d638894f98884c3a202ee72b24ad0 100644 GIT binary patch literal 1910 zcma)-3vg8B7017Ozvq3w_Ur z-#Pbu=bS}Ig3I?2yiP*PI6^^$(4`>QLeoKo{r`96;KuytZic-mXi7n!`{{pv6g(1 z!h>IwB|VA|QU1e2i6;10d^>bcG+6aYd8GEe(%{CEruyVr!Cvj<^xzO9Eh7Y<9Au>R zV}&1RwX~gwef$159bWEK3GTg!<@0HRKN%LhqhA&8^r_;dgu;Kbm($vf1W%hUA#;Lxqif83 zs>EN}9I*bJ)RmSrBd5XsL>kKn1_j%b(!}3D%|o1?86_l{A^1WD%e~;IbC9zG9h%ry z$LXyrv0OR9{JyQ%yy02fyMZ^tNH4^4Zjj*mE=IPt>dNYOeS7_pkh`tJv+7cYdK7$i z0HGLzNWy0`tXhi?BAW&l;de4 z_d|TW?V3^__spNJ)0OJ4X~J6+u#BZsibJc5{TYwD=9lYi{g!&)UiFTpZ#~B8t}_H5 z?GyB9yJI{Kby%9e_1cAFw!VLq(Jsj0P!i6gURUB94E3AyRZ$k@lt&F!uCNVKZRu)_ z!rc{yTC&BrXFd{gm&9%T=fHg}$!Y7TrM;D3x@dV`lCoJ{>C9qz5SaZv3U8@1)JN|! z)ax$c)YAx-8J3YsF*1oPg|RHp=9Jy&na+mt$jm<#2UaxO`pYR!6B$E2ohLXCvF3A< zGzQF1)0}plv-H<@MZ9&(J!?*dCUg${AVH1_l0ghU1SzIhJ4OKNdH&uh#rGy$^Q~cc z&a5n@3%r7|z#JpEKV@mH?-%(`?kJCZ{X3^SB@v3cbj1T zH8Z;DG|bXLEH}W}oa#5!){cnxa^sB2Yva=<&CA_!J<0+%wqTYUtDb&&BXAStMLPyu z^Y9p>XQAFwpkd$-48zPwVfi@d;4tV@Mvg#yhZ14$#m36gwXw@4FNjrDh9H;YYi-!0 zu?4f;_?0te9jdCVZ40^gL%#Q9RemxDbqszppwSFMgBc0+Wi;_V^kH3F(Aj)w)+OJL z Ofn>Q`A)_35a>))#oZ~poF#y23(PVSnjD{`txPSo&!y-QLichfaBh4U>K%`HsL)6rd@*Ex><3k>#-1B+3b*V2c_oWWHV-LiY?tvgq*^keUNf-3g zx!1KnvvM%KVCy@v3KggXH#uNehb8u9f0IXJ0VOq z8#r^?fMH$+!$v2^_td@HHNCKr|@-lgKtKXW5} zVJD>3V!bsdA&O^#kZ3;ertJp#^7kP|SKWDMxt_n33*KbP8`?KZo6dbKYPbM2i4(?! z$ABnn21pt@fahG@+*AL4*ZSq>R;!j>0=erDXlz*CIt}8)rLp%b)AL!$s+J+vb(cE~ zYd->I-LdnUOh>0Yqu?S-s2$Z)?|#=Vm+MdY^LGZK&LCk{*7F*(eV<%!`!GeI2)JLl zdp$E43{UCIg{!2RtpDJObYy5e(xJ-8o+?tAuZr~!-u5$R20w}sy|u*1lotONPd9O2 zepMaqQEV{M^2`&GGP_2HiS|88c~ZtNT)=$ZCnfSx%ik_X8Z!umheGD3-`7EXW?XW% zi7qYM9U3q18WR$KcKE0X)f9@?uz`*vTs21?X<7eylzj)pl;Yf1eh?UW8^M=hfaknp zJ{#i8dgMHPknhpvW=FJr`%PNGcA(jJy64WgiGPE{$%i3%>YFf*QwBr*1%DqgB6;Y8 zZ1wheq`pBH8sX{5mkF9)aW;$AMGb3W>REz#~}sCE9!Hel~majGDQ+ zEh6_d2)Av6SnTg4te1uDojm;w@Sz)lZe0aztefjP3H<7#;1jNgA^vP`Z|b<0EJWz3M@Tf#) zZ|b_6E~DVpHnK*b7w-qUljPn3;FNC#DZV3>Gj~G9k`9n9JPYZyXF=R>9t8F0Kv>@i zk;>WN9mu}nJtl68*Jw8NZnhuhUTo1ThY}}aj!!uP33z4{ZUgTL*yaUmAPw_fQga*( z&F4Vg*a^ag9UyIb8ze*)zXQUWqYx>b3f|+C*N6FwOCP4bJ9XmnH%w2Sc?{UNHsZQY z1IzI;(3Ep!wG5{Jv|VJL@LDsiI0R&dkGKb6lq&jr4I{r(5{A=Hbje<<2|lg44h)Xogt68Nw4} zKoi=5JLwgWE_oN^%iagostFEn+(xy~VZQDNuxYkO?{v{$O&8T;Wq5YFBY z%v5D=Z|c08u4VR?KtsbD1**ojwc6%$SEQI{q@B4{+aO9@3_%fzkdmeZg~N*VIS(bXHl|7G%Rbspnu^CXjY>iC`!yZP1PpQXO)A>QVI%VF(gVS&cCOTK0YX3 zekjJg?t>pqfBgtDaQ&=VeFfAjKLFLVEw{AR;-6KYB9Q3{er5}`nfEl(#|M>5whwkR9pG79 z+c=W)@_*-=HmjJXw-$b`GUS8ORCH4yH#_@SRj-R@=9iAqne7);hFp;A^Se0$&Fo$q z>0<$dp=5~8>NteoG}srm?sS=XF`69IN3*>u&gF8SHd-C)Wa>Wj_ZmQ55p)Jd|kv%cY? z|MUzN!caeEWp8ctRi7ovkMRoT?DZJSSuxm)>DO0#f0XZA3y_a_glH)?l;0mtf=>7I z5fT^Y5tG1SO`ygZg2G~rK7oON?{REk?;+U^w?cG@j{* z*+o0|V!Qu>87576(~TXn5W7pA-3si=JrEFY2ah22)#m~wF%Q#D1EBL%q`s-1QMCQ% zSi~1&2)|T;`Ht7InDgYEPR0S?<_TcWMEsIqJc#85CE8(FfaH4$ z#rL@fR@jdUA;KzJq;NZFvz9as~M=X%0r~%I)89YCVW$s7!?r$ot=+D&5Wlf0FUZ)~PN-fv{5jfuvAs9K+ zjn}?~xY?UPH7tZ7W0Juq%81 zqVit;gy5`EI8F)POSR+tp1%_a=1Z_gF1E3B)^1>DOa{+j0VEb~c4M#nd1ru!zH^EH zh(~dz>;=DQBMkRT{c5N;9hv2UKSC8Q5xsDGH}RcTwA+mrlUO3{d)$2yiSI2=Z4H+4hf-yWZP;t<+maiq;1i{CnokaEn1Fy;N7@Yex zQ#OOxIvtedYd}$Z6bwt+!9;ND`gZjHG>9;7#Elm~ghDV)a!#9m0D_XTU{na_)=2;O zZ@vAQJBN-*9+2&*|0T?H%1wXnL_?Fp_R<$4avyOmt z-U-k*wS#f_d5|?A_C6o+|Hkt`@K7=0vIKu8BF1OzvX>x)WrNXSau^;U`oY65t&kGP zeYQXQf{{^gKrX7Ah3}!1S*<81+>%hT1AJ%(@QpS@lH*liPdftq*+)T8eGFvtT0y_` z92k~g1l1zMv|YzRGQSn1^~fU->>4qAg5xU?&y+Q`1FK*?1Tu?ZSg`ts;XwpTHuM`t z5M^EN&#c(}Marz>kV58i9GBdhGr*a33{rBJK`QRiAZr8;3U5v;2wklpu4x5X-6_y4 z>HyWu7DzT#Lt^GUh||vm-sCMHZ+`ceejqX6eh3wc#B~G$n5EqwA-c}NeiHxw>H}P_ z{UxR1wbh)tZ8r#4!9)Mka7^;uycEta3BvwpIf#8B#SO!9LOP{C|6u|Vzy+e4LiVH(E=Jze0gKk;4g$tS!`N{Qh~Qd*UAhVR6XcPQ zN0H!Jieo{(^diK|${`|JVC*lyd)na_Ec4OLKfGPqc=j7neP_3j%!|nR5Wa^9iG?>0 z_xxag@a7!He4l~)euUuZH46D3UyLCP#s-HYwrT(-=23d>DUf6C$eP~=Ddv&9`E5us zO!+n;McLCPwCHURBZno$ z++aJ`LA104M)-!qa4&Bd<>L?Isd1ppbbt&wB(8lq&ab#X%I!bK(0I0+r7YeS&E=A< zY6E(3U9P9)GhX$~Sn!sg-nA#$u;9R)jKyv5$riuu_FaZKAzF9_Sb0s5V3-Y2X<3kx zt^q|x0cfnHC}kiwl|p)6BfjTa-FYOKzVmyw!kRvyj^EEX;;NdO-7|G7E?v{D=>pZt z%b;k&F*5lTU>7U_fo(o0ER#T=Q-QpZ!_A{f48_3Cssqln{SYU2u0r#Vl{dIE9EEG$ zbEf7~FB)ICd{g&Amz(oat?Y91UJ877DXaH^-cbuW>;pCOfNE8kceu<2cTHc3K`A@&{|5sV5CQHJ62FU!d<#K)GI*vTrG;a`Qp!_$`^N;Y?GSx z7Uwk*gT~^-ez5-}R9H3&d$B*UXTAM-UF6#TT=ANdlk(QT^PP3mCt%+2F_CklJ?3b;QyqS04x~@MNtj-F3R^c}qQ+~Hhn+pnq{gzalyPusd59v$YkF%`B=G5se zg&(4H6HZ!TD7+<6=N?H&mJz7`_izj{=QtA8#)2-5DG#}1D{{F7$R`^=iA@podC%43 zY5+r)J=mN-sU2guq0KA=kT6=;7qF=(xJf4$YQO_y2llU!>hygmpNnI9&y z_!?}-$ATU8wZ8<0B9C1Aq?J;o3ACAcAW>T(U6%QcKxrlT-R~j>(gs>X_8e`d?H^K= zsavAV1hLxsH8V{{z7g=B7Qg)i_cA@BGuz6v#+<7nxgI1MEAVB8@0eUkrLS+?W5t3W zwYA84U2n3bisi!c^h%zd@{He+=gQK=>0xRo_ka+XA zrn1JJ!Lgdxn99;m%w;uGQSpg>3q2^dBz2{1e3H47CAH6^b925NH9`Krd_&b+EV2Dm zXsr6&_#{olgKRv>=W)ob(0L|PqO|BUj=Ew8EyaAnH&T7wD_HQIS5Vq@j}U?GN&5FN zbsG#VPbCVof~g$ai3p~t%PW}ogJ-Z9Jp5C?8Zsho!2YxGFuwIhM{22z3ZO~1jtEKn z$}^CAWtcyA8O0-dz;T$~`1nBGk<=kx0ZhTOo>cr_7 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 + } +} diff --git a/migration/src/m20260616_123524_products.rs b/migration/src/m20260616_123524_products.rs new file mode 100644 index 0000000..d51d8be --- /dev/null +++ b/migration/src/m20260616_123524_products.rs @@ -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 + } +} diff --git a/migration/src/m20260616_123550_product_images.rs b/migration/src/m20260616_123550_product_images.rs new file mode 100644 index 0000000..9257cc1 --- /dev/null +++ b/migration/src/m20260616_123550_product_images.rs @@ -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 + } +} diff --git a/migration/src/m20260616_123611_product_tags.rs b/migration/src/m20260616_123611_product_tags.rs new file mode 100644 index 0000000..8709012 --- /dev/null +++ b/migration/src/m20260616_123611_product_tags.rs @@ -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 + } +} diff --git a/migration/src/m20260616_123957_create_join_table_products_and_product_tags.rs b/migration/src/m20260616_123957_create_join_table_products_and_product_tags.rs new file mode 100644 index 0000000..666d7d9 --- /dev/null +++ b/migration/src/m20260616_123957_create_join_table_products_and_product_tags.rs @@ -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 + } +} diff --git a/migration/src/m20260616_130610_orders.rs b/migration/src/m20260616_130610_orders.rs new file mode 100644 index 0000000..f8bd533 --- /dev/null +++ b/migration/src/m20260616_130610_orders.rs @@ -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 + } +} diff --git a/migration/src/m20260616_130628_order_items.rs b/migration/src/m20260616_130628_order_items.rs new file mode 100644 index 0000000..d9fb8e1 --- /dev/null +++ b/migration/src/m20260616_130628_order_items.rs @@ -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 + } +} diff --git a/migration/src/m20260616_131000_drop_audio_tables.rs b/migration/src/m20260616_131000_drop_audio_tables.rs new file mode 100644 index 0000000..5a8300d --- /dev/null +++ b/migration/src/m20260616_131000_drop_audio_tables.rs @@ -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(()) + } +} diff --git a/migration/src/m20260616_132000_drop_blog_and_pages.rs b/migration/src/m20260616_132000_drop_blog_and_pages.rs new file mode 100644 index 0000000..342dff8 --- /dev/null +++ b/migration/src/m20260616_132000_drop_blog_and_pages.rs @@ -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(()) + } +} diff --git a/src/app.rs b/src/app.rs index 2ec3700..138b5b0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -60,16 +60,16 @@ impl Hooks for App { AppRoutes::with_default_routes() // controller routes below .add_route(controllers::auth::routes()) .add_route(controllers::admin::routes()) - .add_route(controllers::blog::routes()) + .add_route(controllers::catalog::routes()) + .add_route(controllers::cart::routes()) + .add_route(controllers::orders::routes()) .add_route(controllers::i18n::routes()) .add_route(controllers::media::routes()) - .add_route(controllers::pages::routes()) .add_route(controllers::frontend::routes()) } async fn after_context(ctx: AppContext) -> Result { let upload_root = crate::controllers::media::uploads_root(&ctx.config)?; - tokio::fs::create_dir_all(upload_root.join(controllers::media::AUDIO_STORAGE_DIR)).await?; tokio::fs::create_dir_all(upload_root.join(controllers::media::IMAGE_STORAGE_DIR)).await?; let driver = storage::drivers::local::new_with_prefix(&upload_root)?; diff --git a/src/controllers/admin.rs b/src/controllers/admin.rs index 51d8516..eb21ad4 100644 --- a/src/controllers/admin.rs +++ b/src/controllers/admin.rs @@ -1,5 +1,5 @@ use crate::models::{ - _entities::{audio_albums, audio_tracks, audit_logs, blog_articles, users}, + _entities::{audit_logs, categories, orders, products, users}, users as users_model, }; use loco_rs::prelude::*; @@ -9,9 +9,9 @@ use serde::Serialize; #[derive(Debug, Serialize)] struct DashboardResponse { users: u64, - blog_articles: u64, - audio_albums: u64, - audio_tracks: u64, + products: u64, + categories: u64, + orders: u64, audit_logs: u64, } @@ -43,9 +43,9 @@ async fn dashboard(auth: auth::JWT, State(ctx): State) -> Result, - published: Option, - featured_image_id: Option, -} - -#[derive(Debug, Serialize)] -struct ArticleResponse { - id: Uuid, - title: String, - slug: String, - content: String, - excerpt: Option, - published: bool, - author_id: i32, - featured_image_id: Option, - view_count: i32, - created_at: chrono::DateTime, - updated_at: chrono::DateTime, - published_at: Option>, -} - -#[derive(Debug, Serialize)] -struct ArticleListResponse { - articles: Vec, -} - -impl From 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> { - published.then(|| Utc::now().into()) -} - -async fn find_article_by_id(ctx: &AppContext, id: Uuid) -> Result { - 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) -> Result { - 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, State(ctx): State) -> Result { - 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) -> Result { - 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, - Json(params): Json, -) -> Result { - 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, - State(ctx): State, - Json(params): Json, -) -> Result { - 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, - State(ctx): State, -) -> Result { - 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, - State(ctx): State, -) -> Result { - 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, - State(ctx): State, -) -> Result { - 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)) -} diff --git a/src/controllers/cart.rs b/src/controllers/cart.rs new file mode 100644 index 0000000..783d68f --- /dev/null +++ b/src/controllers/cart.rs @@ -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, +} + +#[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::().ok()?; + let qty = qty.trim().parse::().ok()?; + (qty > 0).then_some((id, qty)) + }) + .collect() +} + +fn serialize_cart(items: &[(i32, i32)]) -> String { + items + .iter() + .map(|(id, qty)| format!("{id}:{qty}")) + .collect::>() + .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> { + 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, + Form(form): Form, +) -> Result { + 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, + Form(form): Form, +) -> Result { + 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) -> Result { + 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, 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, + State(ctx): State, +) -> Result { + 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)) +} diff --git a/src/controllers/catalog.rs b/src/controllers/catalog.rs new file mode 100644 index 0000000..c5a7d30 --- /dev/null +++ b/src/controllers/catalog.rs @@ -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) -> Option { + 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 { + 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::().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(base: &str, mut exists: F) -> Result +where + F: FnMut(String) -> Fut, + Fut: std::future::Future>, +{ + 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, + image: Option>, +} + +impl MultipartForm { + fn text(&self, key: &str) -> Option { + 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 { + 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) -> Result { + 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::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::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound) +} + +async fn first_image(ctx: &AppContext, product_id: i32) -> Result> { + 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, + category_name: Option, +) -> 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> { + 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, + State(ctx): State, +) -> Result { + 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 { + 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, + State(ctx): State, +) -> Result { + 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, +) -> Result<(String, String, Option, i64, String, Option, i32, Option, 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::().ok()) + .filter(|n| *n >= 0) + .unwrap_or(0); + let category_id = form.text("category_id").and_then(|s| s.parse::().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, + multipart: Multipart, +) -> Result { + 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, + Path(id): Path, + State(ctx): State, +) -> Result { + 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, + State(ctx): State, + multipart: Multipart, +) -> Result { + 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, + State(ctx): State, +) -> Result { + 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, + State(ctx): State, +) -> Result { + 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, + State(ctx): State, +) -> Result { + 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, +) -> Result<(String, String, Option, 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::().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, + multipart: Multipart, +) -> Result { + 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, + Path(id): Path, + State(ctx): State, +) -> Result { + 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, + State(ctx): State, + multipart: Multipart, +) -> Result { + 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, + State(ctx): State, +) -> Result { + 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, + State(ctx): State, +) -> Result { + 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, + Path(slug): Path, + State(ctx): State, +) -> Result { + 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::>(), + "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, + Path(slug): Path, + State(ctx): State, +) -> Result { + 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), + ) +} diff --git a/src/controllers/frontend.rs b/src/controllers/frontend.rs index a6d2b6c..03266c9 100644 --- a/src/controllers/frontend.rs +++ b/src/controllers/frontend.rs @@ -1,95 +1,10 @@ use crate::{ controllers::{admin, auth as auth_controller, i18n::current_lang}, - models::{ - _entities::{audio_albums, audio_tracks, blog_articles, site_pages}, - users::{self, LoginParams}, - }, + models::users::{self, LoginParams}, }; use axum_extra::extract::cookie::CookieJar; -use chrono::Utc; use loco_rs::prelude::*; -use sea_orm::{ - sea_query::Expr, ActiveModelTrait, ColumnTrait, EntityTrait, Order, QueryFilter, QueryOrder, - QuerySelect, Set, -}; -use serde::Deserialize; use serde_json::json; -use uuid::Uuid; - -const ABOUT_SLUG: &str = "about"; - -#[derive(Debug, Deserialize)] -struct ArticleForm { - title: String, - content: String, - excerpt: Option, - published: Option, - featured_image_id: Option, -} - -#[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> { - published.then(|| Utc::now().into()) -} - -fn is_checked(value: &Option) -> bool { - value - .as_deref() - .is_some_and(|value| value == "on" || value == "true") -} - -fn normalize_empty(value: Option) -> Option { - 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::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::Entity::find_by_id(id) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound) -} async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool { let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else { @@ -115,108 +30,13 @@ async fn home( ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { - let articles = blog_articles::Entity::find() - .filter(blog_articles::Column::Published.eq(true)) - .order_by_desc(blog_articles::Column::PublishedAt) - .limit(5) - .all(&ctx.db) - .await?; - - // A random published song to suggest on the landing page. - let featured_track = audio_tracks::Entity::find() - .filter(audio_tracks::Column::Published.eq(true)) - .order_by(Expr::cust("RANDOM()"), Order::Asc) - .one(&ctx.db) - .await?; - - // A random published album, never the one the suggested song belongs to. - let mut album_query = - audio_albums::Entity::find().filter(audio_albums::Column::Published.eq(true)); - if let Some(album_id) = featured_track.as_ref().and_then(|track| track.album_id) { - album_query = album_query.filter(audio_albums::Column::Id.ne(album_id)); - } - let featured_album = album_query - .order_by(Expr::cust("RANDOM()"), Order::Asc) - .one(&ctx.db) - .await?; + let products = crate::controllers::catalog::featured_products(&ctx, 8).await?; format::view( &v, "home/index.html", json!({ - "articles": articles, - "featured_track": featured_track, - "featured_album": featured_album, - "logged_in_admin": logged_in_admin(&ctx, &jar).await, - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn about( - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - 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, - State(ctx): State, -) -> Result { - 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, - Path(slug): Path, - State(ctx): State, -) -> Result { - 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, + "products": products, "logged_in_admin": logged_in_admin(&ctx, &jar).await, "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, - State(ctx): State, -) -> Result { - 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, - Form(params): Form, -) -> Result { - 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, - State(ctx): State, -) -> Result { - 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, - State(ctx): State, -) -> Result { - 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, - Form(params): Form, -) -> Result { - 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, - Path(id): Path, - State(ctx): State, -) -> Result { - 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, - State(ctx): State, - Form(params): Form, -) -> Result { - 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, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - article_by_id(&ctx, id).await?.delete(&ctx.db).await?; - format::redirect("/admin/blog/articles") -} - pub fn routes() -> Routes { Routes::new() .add("/", get(home)) - .add("/about", get(about)) - .add("/blog", get(blog_index)) - .add("/blog/{slug}", get(blog_show)) .add("/admin/login", get(admin_login_page)) .add("/admin/login", post(admin_login)) .add("/admin/logout", post(admin_logout)) .add("/admin", get(admin_login_page)) .add("/admin/dashboard", get(admin_home)) - .add("/admin/about", get(admin_about)) - .add("/admin/about", post(admin_about_update)) - .add("/admin/blog/articles", get(admin_articles)) - .add("/admin/blog/articles/new", get(admin_article_new)) - .add("/admin/blog/articles", post(admin_article_create)) - .add("/admin/blog/articles/{id}/edit", get(admin_article_edit)) - .add("/admin/blog/articles/{id}", post(admin_article_update)) - .add( - "/admin/blog/articles/{id}/delete", - post(admin_article_delete), - ) } diff --git a/src/controllers/media.rs b/src/controllers/media.rs index b8b4535..c242cb7 100644 --- a/src/controllers/media.rs +++ b/src/controllers/media.rs @@ -1,58 +1,18 @@ -use crate::{ - controllers::{admin, auth as auth_controller, i18n::current_lang}, - models::{ - _entities::{audio_albums, audio_tracks}, - users, - }, -}; +use crate::controllers::admin; use axum::{ body::Body, extract::{DefaultBodyLimit, Multipart}, - http::{ - header::{self, HeaderMap}, - StatusCode, - }, + http::header, }; -use axum_extra::extract::cookie::CookieJar; use bytes::Bytes; -use chrono::{NaiveDate, Utc}; use loco_rs::{config::Config, prelude::*}; -use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, - QueryOrder, Set, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::{ - path::{Path as StdPath, PathBuf}, - str::FromStr, -}; -use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom}; +use serde::Serialize; +use std::path::{Path as StdPath, PathBuf}; use uuid::Uuid; -const AUDIO_MAX_BYTES: usize = 50 * 1024 * 1024; -const IMAGE_MAX_BYTES: usize = 10 * 1024 * 1024; -pub const AUDIO_STORAGE_DIR: &str = "audio"; +pub(crate) const IMAGE_MAX_BYTES: usize = 10 * 1024 * 1024; pub const IMAGE_STORAGE_DIR: &str = "images"; -/// Album-create form, parsed manually from `multipart/form-data` so the page -/// can both upload a cover image and submit any number of `track_ids` -/// checkboxes (a urlencoded `Form` can't deserialize repeated keys into a Vec). -struct ParsedAlbumForm { - title: Option, - artist: Option, - release_date: Option, - description: Option, - published: bool, - cover: Option>, - track_ids: Vec, -} - -#[derive(Debug, Deserialize)] -struct AlbumSongForm { - track_id: Uuid, -} - #[derive(Debug, Serialize)] struct UploadResponse { filename: String, @@ -71,39 +31,6 @@ pub fn uploads_root(config: &Config) -> Result { .ok_or_else(|| Error::string("settings.uploads_root must be configured")) } -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 normalize_empty(value: Option) -> Option { - value.and_then(|value| { - let value = value.trim().to_string(); - if value.is_empty() { - None - } else { - Some(value) - } - }) -} - fn safe_filename(filename: &str) -> Result<&str> { if filename.is_empty() || filename.contains('/') @@ -115,18 +42,6 @@ fn safe_filename(filename: &str) -> Result<&str> { Ok(filename) } -fn audio_content_type(extension: &str) -> &'static str { - match extension { - "aac" => "audio/aac", - "flac" => "audio/flac", - "m4a" => "audio/mp4", - "ogg" => "audio/ogg", - "wav" => "audio/wav", - "webm" => "audio/webm", - _ => "audio/mpeg", - } -} - fn image_content_type(extension: &str) -> &'static str { match extension { "gif" => "image/gif", @@ -137,35 +52,7 @@ fn image_content_type(extension: &str) -> &'static str { } } -fn detect_audio_extension(data: &[u8]) -> Result<&'static str> { - if data.len() < 12 { - return Err(Error::BadRequest("audio file is too small".to_string())); - } - if data.starts_with(b"ID3") || (data[0] == 0xff && (data[1] & 0xe0) == 0xe0) { - return Ok("mp3"); - } - if data.starts_with(b"RIFF") && &data[8..12] == b"WAVE" { - return Ok("wav"); - } - if data.starts_with(b"OggS") { - return Ok("ogg"); - } - if data.starts_with(b"fLaC") { - return Ok("flac"); - } - if data.len() >= 12 && &data[4..8] == b"ftyp" { - return Ok("m4a"); - } - if data.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) { - return Ok("webm"); - } - if data.starts_with(&[0xff, 0xf1]) || data.starts_with(&[0xff, 0xf9]) { - return Ok("aac"); - } - Err(Error::BadRequest("unsupported audio format".to_string())) -} - -fn detect_image_extension(data: &[u8]) -> Result<&'static str> { +pub(crate) fn detect_image_extension(data: &[u8]) -> Result<&'static str> { if data.len() < 12 { return Err(Error::BadRequest("image file is too small".to_string())); } @@ -220,209 +107,7 @@ async fn read_multipart_file(mut multipart: Multipart, max_bytes: usize) -> Resu )) } -async fn read_track_upload( - mut multipart: Multipart, -) -> Result<(Vec, Option, Option, bool, bool)> { - let mut data = None; - let mut title = None; - let mut track_number = None; - let mut featured = false; - let mut published = false; - - 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 == "file" { - let mut file = Vec::new(); - while let Some(chunk) = field - .chunk() - .await - .map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))? - { - file.extend_from_slice(&chunk); - if file.len() > AUDIO_MAX_BYTES { - return Err(Error::BadRequest("file is larger than 50 MB".to_string())); - } - } - data = Some(file); - } else { - let value = field - .text() - .await - .map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?; - match name.as_str() { - "title" => title = normalize_empty(Some(value)), - "track_number" => { - track_number = value - .trim() - .parse::() - .ok() - .filter(|number| *number > 0) - } - "featured" => featured = value == "on" || value == "true" || value == "1", - "published" => published = value == "on" || value == "true" || value == "1", - _ => {} - } - } - } - - let data = - data.ok_or_else(|| Error::BadRequest("multipart field `file` is required".to_string()))?; - if data.is_empty() { - return Err(Error::BadRequest("empty file upload".to_string())); - } - - Ok((data, title, track_number, featured, published)) -} - -/// Parse the new-album `multipart/form-data` body: text fields, an optional -/// cover image file, and zero or more `track_ids` checkbox values. -async fn read_album_form(mut multipart: Multipart) -> Result { - let mut form = ParsedAlbumForm { - title: None, - artist: None, - release_date: None, - description: None, - published: false, - cover: None, - track_ids: Vec::new(), - }; - - 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 == "cover" { - 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!( - "cover image is larger than {} MB", - IMAGE_MAX_BYTES / 1024 / 1024 - ))); - } - } - // An unselected file input still sends an empty `cover` part. - if !data.is_empty() { - form.cover = Some(data); - } - } else { - let value = field - .text() - .await - .map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?; - match name.as_str() { - "title" => form.title = normalize_empty(Some(value)), - "artist" => form.artist = normalize_empty(Some(value)), - "release_date" => form.release_date = normalize_empty(Some(value)), - "description" => form.description = normalize_empty(Some(value)), - "published" => { - form.published = value == "on" || value == "true" || value == "1"; - } - "track_ids" => { - let trimmed = value.trim(); - if !trimmed.is_empty() { - let id = Uuid::parse_str(trimmed) - .map_err(|_| Error::BadRequest("invalid song selection".to_string()))?; - form.track_ids.push(id); - } - } - _ => {} - } - } - } - - Ok(form) -} - -async fn unique_album_slug(ctx: &AppContext, title: &str) -> Result { - let base = slugify(title); - let mut slug = base.clone(); - let mut suffix = 2; - - while audio_albums::Entity::find() - .filter(audio_albums::Column::Slug.eq(&slug)) - .count(&ctx.db) - .await? - > 0 - { - slug = format!("{base}-{suffix}"); - suffix += 1; - } - - Ok(slug) -} - -async fn unique_track_slug( - ctx: &AppContext, - album_id: Option, - title: &str, -) -> Result { - let base = slugify(title); - let mut slug = base.clone(); - let mut suffix = 2; - - loop { - let mut query = audio_tracks::Entity::find().filter(audio_tracks::Column::Slug.eq(&slug)); - query = if let Some(album_id) = album_id { - query.filter(audio_tracks::Column::AlbumId.eq(album_id)) - } else { - query.filter(audio_tracks::Column::AlbumId.is_null()) - }; - - if query.count(&ctx.db).await? == 0 { - break; - } - slug = format!("{base}-{suffix}"); - suffix += 1; - } - - Ok(slug) -} - -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 album_by_id(ctx: &AppContext, id: Uuid) -> Result { - audio_albums::Entity::find_by_id(id) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound) -} - -async fn track_by_id(ctx: &AppContext, id: Uuid) -> Result { - audio_tracks::Entity::find_by_id(id) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound) -} - -async fn store_upload( +pub(crate) async fn store_upload( ctx: &AppContext, folder: &str, extension: &str, @@ -471,615 +156,6 @@ async fn image_serve( .map_err(Error::from) } -#[debug_handler] -async fn audio_upload( - auth: auth::JWT, - State(ctx): State, - multipart: Multipart, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let data = read_multipart_file(multipart, AUDIO_MAX_BYTES).await?; - let extension = detect_audio_extension(&data)?; - let size = data.len(); - let filename = store_upload(&ctx, AUDIO_STORAGE_DIR, extension, data).await?; - - format::json(UploadResponse { - url: format!("/audio/stream/{filename}"), - filename, - size, - }) -} - -#[debug_handler] -async fn public_albums( - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - let albums = audio_albums::Entity::find() - .filter(audio_albums::Column::Published.eq(true)) - .order_by_desc(audio_albums::Column::PublishedAt) - .all(&ctx.db) - .await?; - - format::view( - &v, - "audio/albums.html", - json!({ - "albums": albums, - "logged_in_admin": logged_in_admin(&ctx, &jar).await, - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn public_album( - jar: CookieJar, - ViewEngine(v): ViewEngine, - Path(slug): Path, - State(ctx): State, -) -> Result { - let album = audio_albums::Entity::find() - .filter(audio_albums::Column::Slug.eq(slug)) - .filter(audio_albums::Column::Published.eq(true)) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound)?; - - let mut active = album.clone().into_active_model(); - active.view_count = Set(album.view_count + 1); - let album = active.update(&ctx.db).await?; - - let tracks = audio_tracks::Entity::find() - .filter(audio_tracks::Column::AlbumId.eq(album.id)) - .filter(audio_tracks::Column::Published.eq(true)) - .order_by_asc(audio_tracks::Column::TrackNumber) - .order_by_asc(audio_tracks::Column::Title) - .all(&ctx.db) - .await?; - - format::view( - &v, - "audio/album.html", - json!({ - "album": album, - "tracks": tracks, - "logged_in_admin": logged_in_admin(&ctx, &jar).await, - "lang": current_lang(&jar), - }), - ) -} - -/// Published tracks of an album as JSON, so the audio listing page can -/// queue a whole album without navigating to its detail page. -#[debug_handler] -async fn public_album_tracks( - Path(slug): Path, - State(ctx): State, -) -> Result { - let album = audio_albums::Entity::find() - .filter(audio_albums::Column::Slug.eq(slug)) - .filter(audio_albums::Column::Published.eq(true)) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound)?; - - let tracks = audio_tracks::Entity::find() - .filter(audio_tracks::Column::AlbumId.eq(album.id)) - .filter(audio_tracks::Column::Published.eq(true)) - .order_by_asc(audio_tracks::Column::TrackNumber) - .order_by_asc(audio_tracks::Column::Title) - .all(&ctx.db) - .await?; - - let items: Vec = tracks - .into_iter() - .map(|t| { - json!({ - "src": format!("/audio/tracks/{}/stream", t.id), - "title": t.title, - }) - }) - .collect(); - - format::json(json!({ "tracks": items })) -} - -#[debug_handler] -async fn public_tracks( - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - let tracks = audio_tracks::Entity::find() - .filter(audio_tracks::Column::Published.eq(true)) - .order_by_desc(audio_tracks::Column::PublishedAt) - .order_by_desc(audio_tracks::Column::CreatedAt) - .all(&ctx.db) - .await?; - - format::view( - &v, - "audio/tracks.html", - json!({ - "tracks": tracks, - "logged_in_admin": logged_in_admin(&ctx, &jar).await, - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn admin_albums( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let albums = audio_albums::Entity::find() - .order_by_desc(audio_albums::Column::CreatedAt) - .all(&ctx.db) - .await?; - let mut rows = Vec::new(); - for album in albums { - let track_count = audio_tracks::Entity::find() - .filter(audio_tracks::Column::AlbumId.eq(album.id)) - .count(&ctx.db) - .await?; - rows.push(json!({ "album": album, "track_count": track_count })); - } - - format::view( - &v, - "admin/audio/albums.html", - json!({ "albums": rows, "lang": current_lang(&jar) }), - ) -} - -#[debug_handler] -async fn admin_tracks( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let tracks = audio_tracks::Entity::find() - .order_by_desc(audio_tracks::Column::CreatedAt) - .all(&ctx.db) - .await?; - - format::view( - &v, - "admin/audio/songs.html", - json!({ "tracks": tracks, "lang": current_lang(&jar) }), - ) -} - -#[debug_handler] -async fn admin_album_new( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let available_tracks = audio_tracks::Entity::find() - .filter(audio_tracks::Column::AlbumId.is_null()) - .order_by_asc(audio_tracks::Column::Title) - .all(&ctx.db) - .await?; - format::view( - &v, - "admin/audio/new_album.html", - json!({ - "available_tracks": available_tracks, - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn admin_album_create( - auth: auth::JWT, - State(ctx): State, - multipart: Multipart, -) -> Result { - let admin_user = admin::current_admin(auth, &ctx).await?; - let form = read_album_form(multipart).await?; - - let title = form - .title - .ok_or_else(|| Error::BadRequest("album title is required".to_string()))?; - let release_date = form - .release_date - .and_then(|date| NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok()); - - // Store the uploaded cover (if any) and keep its filename as cover_image_id. - let cover_image_id = match form.cover { - Some(data) => { - let extension = detect_image_extension(&data)?; - Some(store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?) - } - None => None, - }; - - let album = audio_albums::ActiveModel { - id: Set(Uuid::new_v4()), - title: Set(title.clone()), - slug: Set(unique_album_slug(&ctx, &title).await?), - description: Set(form.description), - cover_image_id: Set(cover_image_id), - artist: Set(form.artist), - release_date: Set(release_date), - published: Set(form.published), - uploader_id: Set(admin_user.id), - view_count: Set(0), - published_at: Set(form.published.then(|| Utc::now().into())), - ..Default::default() - } - .insert(&ctx.db) - .await?; - - for track_id in form.track_ids { - let track = track_by_id(&ctx, track_id).await?; - if track.album_id.is_some() { - return Err(Error::BadRequest( - "selected song already belongs to an album".to_string(), - )); - } - - let mut active = track.into_active_model(); - active.album_id = Set(Some(album.id)); - active.update(&ctx.db).await?; - } - - format::redirect("/admin/audio/albums") -} - -#[debug_handler] -async fn admin_album_tracks( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - Path(album_id): Path, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let album = album_by_id(&ctx, album_id).await?; - let tracks = audio_tracks::Entity::find() - .filter(audio_tracks::Column::AlbumId.eq(album_id)) - .order_by_asc(audio_tracks::Column::TrackNumber) - .order_by_asc(audio_tracks::Column::Title) - .all(&ctx.db) - .await?; - let available_tracks = audio_tracks::Entity::find() - .filter(audio_tracks::Column::AlbumId.is_null()) - .order_by_asc(audio_tracks::Column::Title) - .all(&ctx.db) - .await?; - - format::view( - &v, - "admin/audio/tracks.html", - json!({ - "album": album, - "tracks": tracks, - "available_tracks": available_tracks, - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn admin_album_add_track( - auth: auth::JWT, - Path(album_id): Path, - State(ctx): State, - Form(params): Form, -) -> Result { - admin::current_admin(auth, &ctx).await?; - album_by_id(&ctx, album_id).await?; - let track = track_by_id(&ctx, params.track_id).await?; - - if track.album_id.is_some() { - return Err(Error::BadRequest( - "song already belongs to an album".to_string(), - )); - } - - let mut active = track.into_active_model(); - active.album_id = Set(Some(album_id)); - active.update(&ctx.db).await?; - - format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) -} - -#[debug_handler] -async fn admin_track_remove_from_album( - auth: auth::JWT, - Path(id): Path, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let track = track_by_id(&ctx, id).await?; - let album_id = track.album_id; - let mut active = track.into_active_model(); - active.album_id = Set(None); - active.track_number = Set(None); - active.update(&ctx.db).await?; - - if let Some(album_id) = album_id { - format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) - } else { - format::redirect("/admin/audio/tracks") - } -} - -#[debug_handler] -async fn admin_track_upload_form( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - Path(album_id): Path, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - format::view( - &v, - "admin/audio/upload_track.html", - json!({ "album": album_by_id(&ctx, album_id).await?, "lang": current_lang(&jar) }), - ) -} - -#[debug_handler] -async fn admin_song_upload_form( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - format::view( - &v, - "admin/audio/upload_track.html", - json!({ "album": null, "lang": current_lang(&jar) }), - ) -} - -async fn create_uploaded_track( - ctx: &AppContext, - album_id: Option, - multipart: Multipart, -) -> Result { - let (data, title, track_number, featured, published) = read_track_upload(multipart).await?; - let extension = detect_audio_extension(&data)?; - let filename = store_upload(ctx, AUDIO_STORAGE_DIR, extension, data).await?; - let title = title.unwrap_or_else(|| { - filename - .trim_end_matches(&format!(".{extension}")) - .to_string() - }); - - audio_tracks::ActiveModel { - id: Set(Uuid::new_v4()), - album_id: Set(album_id), - title: Set(title.clone()), - slug: Set(unique_track_slug(ctx, album_id, &title).await?), - audio_file_id: Set(filename), - track_number: Set(track_number), - duration: Set(None), - featured: Set(featured), - published: Set(published), - play_count: Set(0), - published_at: Set(published.then(|| Utc::now().into())), - ..Default::default() - } - .insert(&ctx.db) - .await - .map_err(Error::from) -} - -#[debug_handler] -async fn admin_track_upload( - auth: auth::JWT, - Path(album_id): Path, - State(ctx): State, - multipart: Multipart, -) -> Result { - admin::current_admin(auth, &ctx).await?; - album_by_id(&ctx, album_id).await?; - - create_uploaded_track(&ctx, Some(album_id), multipart).await?; - - format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) -} - -#[debug_handler] -async fn admin_song_upload( - auth: auth::JWT, - State(ctx): State, - multipart: Multipart, -) -> Result { - admin::current_admin(auth, &ctx).await?; - create_uploaded_track(&ctx, None, multipart).await?; - format::redirect("/admin/audio/tracks") -} - -#[debug_handler] -async fn admin_track_delete( - auth: auth::JWT, - Path(id): Path, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let track = track_by_id(&ctx, id).await?; - let album_id = track.album_id; - let _ = ctx - .storage - .delete(StdPath::new(&format!( - "{AUDIO_STORAGE_DIR}/{}", - track.audio_file_id - ))) - .await; - track.delete(&ctx.db).await?; - if let Some(album_id) = album_id { - format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) - } else { - format::redirect("/admin/audio/tracks") - } -} - -#[debug_handler] -async fn admin_track_publish( - auth: auth::JWT, - Path(id): Path, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let track = track_by_id(&ctx, id).await?; - let album_id = track.album_id; - let mut active = track.into_active_model(); - active.published = Set(true); - active.published_at = Set(Some(Utc::now().into())); - active.update(&ctx.db).await?; - - if let Some(album_id) = album_id { - format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) - } else { - format::redirect("/admin/audio/tracks") - } -} - -#[debug_handler] -async fn admin_track_unpublish( - auth: auth::JWT, - Path(id): Path, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let track = track_by_id(&ctx, id).await?; - let album_id = track.album_id; - let mut active = track.into_active_model(); - active.published = Set(false); - active.published_at = Set(None); - active.update(&ctx.db).await?; - - if let Some(album_id) = album_id { - format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) - } else { - format::redirect("/admin/audio/tracks") - } -} - -async fn stream_audio_file( - config: &Config, - filename: &str, - headers: &HeaderMap, -) -> Result { - let filename = safe_filename(filename)?; - let path = uploads_root(config)?.join(AUDIO_STORAGE_DIR).join(filename); - let mut file = tokio::fs::File::open(&path) - .await - .map_err(|_| Error::NotFound)?; - let total_len = file.metadata().await?.len(); - let extension = filename.rsplit('.').next().unwrap_or("mp3"); - let content_type = audio_content_type(extension); - - let (status, start, end) = parse_range(headers, total_len)?; - let len = end.saturating_sub(start) + 1; - file.seek(SeekFrom::Start(start)).await?; - - let mut body = vec![0; len as usize]; - file.read_exact(&mut body).await?; - - let mut builder = Response::builder() - .status(status) - .header(header::CONTENT_TYPE, content_type) - .header(header::ACCEPT_RANGES, "bytes") - .header(header::CONTENT_LENGTH, len.to_string()); - - if status == StatusCode::PARTIAL_CONTENT { - builder = builder.header( - header::CONTENT_RANGE, - format!("bytes {start}-{end}/{total_len}"), - ); - } - - builder.body(Body::from(body)).map_err(Error::from) -} - -fn parse_range(headers: &HeaderMap, total_len: u64) -> Result<(StatusCode, u64, u64)> { - if total_len == 0 { - return Ok((StatusCode::OK, 0, 0)); - } - - let Some(range_header) = headers.get(header::RANGE) else { - return Ok((StatusCode::OK, 0, total_len - 1)); - }; - let range = range_header - .to_str() - .map_err(|_| Error::BadRequest("invalid range header".to_string()))?; - let Some(range) = range.strip_prefix("bytes=") else { - return Err(Error::BadRequest("invalid range header".to_string())); - }; - let Some((start, end)) = range.split_once('-') else { - return Err(Error::BadRequest("invalid range header".to_string())); - }; - - let suffix_range = start.is_empty(); - let start = if suffix_range { - let suffix = u64::from_str(end) - .map_err(|_| Error::BadRequest("invalid range header".to_string()))?; - total_len.saturating_sub(suffix) - } else { - u64::from_str(start).map_err(|_| Error::BadRequest("invalid range header".to_string()))? - }; - let end = if suffix_range || end.is_empty() { - total_len - 1 - } else { - u64::from_str(end).map_err(|_| Error::BadRequest("invalid range header".to_string()))? - }; - - if start >= total_len || end >= total_len || start > end { - return Err(Error::CustomError( - StatusCode::RANGE_NOT_SATISFIABLE, - loco_rs::controller::ErrorDetail::new("range-not-satisfiable", "range not satisfiable"), - )); - } - - Ok((StatusCode::PARTIAL_CONTENT, start, end)) -} - -#[debug_handler] -async fn raw_audio_stream( - Path(filename): Path, - headers: HeaderMap, - State(ctx): State, -) -> Result { - stream_audio_file(&ctx.config, &filename, &headers).await -} - -#[debug_handler] -async fn track_stream( - Path(id): Path, - headers: HeaderMap, - State(ctx): State, -) -> Result { - let track = track_by_id(&ctx, id).await?; - if !track.published { - return Err(Error::NotFound); - } - - let mut active = track.clone().into_active_model(); - active.play_count = Set(track.play_count + 1); - let track = active.update(&ctx.db).await?; - - stream_audio_file(&ctx.config, &track.audio_file_id, &headers).await -} - pub fn routes() -> Routes { Routes::new() .add( @@ -1087,55 +163,4 @@ pub fn routes() -> Routes { post(image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)), ) .add("/images/{filename}", get(image_serve)) - .add( - "/audio/upload", - post(audio_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)), - ) - .add("/audio/stream/{filename}", get(raw_audio_stream)) - .add("/audio/albums", get(public_albums)) - .add("/audio/albums/{slug}", get(public_album)) - .add("/audio/albums/{slug}/tracks", get(public_album_tracks)) - .add("/audio/tracks", get(public_tracks)) - .add("/audio/tracks/{id}/stream", get(track_stream)) - .add("/admin/audio/albums", get(admin_albums)) - .add("/admin/audio/albums/create", get(admin_album_new)) - .add( - "/admin/audio/albums/create", - post(admin_album_create).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)), - ) - .add("/admin/audio/tracks", get(admin_tracks)) - .add("/admin/audio/tracks/upload", get(admin_song_upload_form)) - .add( - "/admin/audio/tracks/upload-file", - post(admin_song_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)), - ) - .add( - "/admin/audio/albums/{album_id}/tracks", - get(admin_album_tracks), - ) - .add( - "/admin/audio/albums/{album_id}/tracks/add", - post(admin_album_add_track), - ) - .add( - "/admin/audio/albums/{album_id}/tracks/upload", - get(admin_track_upload_form), - ) - .add( - "/admin/audio/albums/{album_id}/tracks/upload-file", - post(admin_track_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)), - ) - .add( - "/admin/audio/tracks/{id}/publish", - post(admin_track_publish), - ) - .add( - "/admin/audio/tracks/{id}/unpublish", - post(admin_track_unpublish), - ) - .add( - "/admin/audio/tracks/{id}/remove-from-album", - post(admin_track_remove_from_album), - ) - .add("/admin/audio/tracks/{id}/delete", post(admin_track_delete)) } diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 452b78d..6042e9c 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,7 +1,8 @@ pub mod admin; pub mod auth; -pub mod blog; +pub mod cart; +pub mod catalog; pub mod frontend; pub mod i18n; pub mod media; -pub mod pages; +pub mod orders; diff --git a/src/controllers/orders.rs b/src/controllers/orders.rs new file mode 100644 index 0000000..8dfe119 --- /dev/null +++ b/src/controllers/orders.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +struct StatusForm { + status: String, +} + +fn trimmed(value: &str) -> Option { + 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, + State(ctx): State, +) -> Result { + 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, + Form(form): Form, +) -> Result { + 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)> { + 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, + Path(order_number): Path, + State(ctx): State, +) -> Result { + 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, + State(ctx): State, +) -> Result { + admin::current_admin(auth, &ctx).await?; + let list = orders::Entity::find() + .order_by_desc(orders::Column::CreatedAt) + .all(&ctx.db) + .await?; + let rows: Vec = 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, + Path(id): Path, + State(ctx): State, +) -> Result { + 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, + State(ctx): State, + Form(form): Form, +) -> Result { + 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)) +} diff --git a/src/controllers/pages.rs b/src/controllers/pages.rs deleted file mode 100644 index 998564c..0000000 --- a/src/controllers/pages.rs +++ /dev/null @@ -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, -} - -impl From 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::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) -> Result { - format::json(PageResponse::from(find_about(&ctx).await?)) -} - -#[debug_handler] -async fn update_about( - auth: auth::JWT, - State(ctx): State, - Json(params): Json, -) -> Result { - 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)) -} diff --git a/src/models/_entities/audio_albums.rs b/src/models/_entities/audio_albums.rs deleted file mode 100644 index c92b088..0000000 --- a/src/models/_entities/audio_albums.rs +++ /dev/null @@ -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, - pub cover_image_id: Option, - pub artist: Option, - pub release_date: Option, - pub published: bool, - pub uploader_id: i32, - pub view_count: i32, - pub created_at: DateTimeWithTimeZone, - pub updated_at: DateTimeWithTimeZone, - pub published_at: Option, -} - -#[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 for Entity { - fn to() -> RelationDef { - Relation::AudioTracks.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Users.def() - } -} diff --git a/src/models/_entities/audio_tags.rs b/src/models/_entities/audio_tags.rs deleted file mode 100644 index 2edad2f..0000000 --- a/src/models/_entities/audio_tags.rs +++ /dev/null @@ -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 for Entity { - fn to() -> RelationDef { - Relation::AudioTrackTags.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - super::audio_track_tags::Relation::AudioTracks.def() - } - fn via() -> Option { - Some(super::audio_track_tags::Relation::AudioTags.def().rev()) - } -} diff --git a/src/models/_entities/audio_tracks.rs b/src/models/_entities/audio_tracks.rs deleted file mode 100644 index 4edc6c9..0000000 --- a/src/models/_entities/audio_tracks.rs +++ /dev/null @@ -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, - pub title: String, - pub slug: String, - pub audio_file_id: String, - pub track_number: Option, - pub duration: Option, - pub featured: bool, - pub published: bool, - pub play_count: i32, - pub created_at: DateTimeWithTimeZone, - pub updated_at: DateTimeWithTimeZone, - pub published_at: Option, -} - -#[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 for Entity { - fn to() -> RelationDef { - Relation::AudioAlbums.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::AudioTrackTags.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - super::audio_track_tags::Relation::AudioTags.def() - } - fn via() -> Option { - Some(super::audio_track_tags::Relation::AudioTracks.def().rev()) - } -} diff --git a/src/models/_entities/blog_articles.rs b/src/models/_entities/blog_articles.rs deleted file mode 100644 index 91345db..0000000 --- a/src/models/_entities/blog_articles.rs +++ /dev/null @@ -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, - pub published: bool, - pub author_id: i32, - pub featured_image_id: Option, - pub view_count: i32, - pub created_at: DateTimeWithTimeZone, - pub updated_at: DateTimeWithTimeZone, - pub published_at: Option, -} - -#[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 for Entity { - fn to() -> RelationDef { - Relation::Users.def() - } -} diff --git a/src/models/_entities/categories.rs b/src/models/_entities/categories.rs new file mode 100644 index 0000000..baef6ba --- /dev/null +++ b/src/models/_entities/categories.rs @@ -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, + pub image_id: Option, + 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 for Entity { + fn to() -> RelationDef { + Relation::Products.def() + } +} diff --git a/src/models/_entities/mod.rs b/src/models/_entities/mod.rs index 2d97cfa..04ca0ab 100644 --- a/src/models/_entities/mod.rs +++ b/src/models/_entities/mod.rs @@ -2,11 +2,12 @@ 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 blog_articles; -pub mod site_pages; +pub mod categories; +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; diff --git a/src/models/_entities/order_items.rs b/src/models/_entities/order_items.rs new file mode 100644 index 0000000..20b8bb9 --- /dev/null +++ b/src/models/_entities/order_items.rs @@ -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, +} + +#[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 for Entity { + fn to() -> RelationDef { + Relation::Orders.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Products.def() + } +} diff --git a/src/models/_entities/orders.rs b/src/models/_entities/orders.rs new file mode 100644 index 0000000..adc4ed3 --- /dev/null +++ b/src/models/_entities/orders.rs @@ -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, + pub status: String, + pub total_cents: i64, + pub currency: String, + pub address: Option, + pub city: Option, + pub zip: Option, + pub country: Option, + #[sea_orm(column_type = "Text", nullable)] + pub note: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::order_items::Entity")] + OrderItems, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::OrderItems.def() + } +} diff --git a/src/models/_entities/prelude.rs b/src/models/_entities/prelude.rs index 694ea74..00f718a 100644 --- a/src/models/_entities/prelude.rs +++ b/src/models/_entities/prelude.rs @@ -1,10 +1,11 @@ //! `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::blog_articles::Entity as BlogArticles; -pub use super::site_pages::Entity as SitePages; +pub use super::categories::Entity as Categories; +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; diff --git a/src/models/_entities/product_images.rs b/src/models/_entities/product_images.rs new file mode 100644 index 0000000..fc3e673 --- /dev/null +++ b/src/models/_entities/product_images.rs @@ -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, + 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 for Entity { + fn to() -> RelationDef { + Relation::Products.def() + } +} diff --git a/src/models/_entities/audio_track_tags.rs b/src/models/_entities/product_product_tags.rs similarity index 52% rename from src/models/_entities/audio_track_tags.rs rename to src/models/_entities/product_product_tags.rs index cccfe9f..e096714 100644 --- a/src/models/_entities/audio_track_tags.rs +++ b/src/models/_entities/product_product_tags.rs @@ -4,43 +4,42 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[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 { #[sea_orm(primary_key, auto_increment = false)] - pub track_id: Uuid, + pub product_id: i32, #[sea_orm(primary_key, auto_increment = false)] - pub tag_id: Uuid, - pub created_at: DateTimeWithTimeZone, + pub product_tag_id: i32, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm( - belongs_to = "super::audio_tags::Entity", - from = "Column::TagId", - to = "super::audio_tags::Column::Id", + belongs_to = "super::product_tags::Entity", + from = "Column::ProductTagId", + to = "super::product_tags::Column::Id", on_update = "Cascade", on_delete = "Cascade" )] - AudioTags, + ProductTags, #[sea_orm( - belongs_to = "super::audio_tracks::Entity", - from = "Column::TrackId", - to = "super::audio_tracks::Column::Id", + belongs_to = "super::products::Entity", + from = "Column::ProductId", + to = "super::products::Column::Id", on_update = "Cascade", on_delete = "Cascade" )] - AudioTracks, + Products, } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::AudioTags.def() + Relation::ProductTags.def() } } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::AudioTracks.def() + Relation::Products.def() } } diff --git a/src/models/_entities/product_tags.rs b/src/models/_entities/product_tags.rs new file mode 100644 index 0000000..f8a0101 --- /dev/null +++ b/src/models/_entities/product_tags.rs @@ -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 for Entity { + fn to() -> RelationDef { + Relation::ProductProductTags.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::product_product_tags::Relation::Products.def() + } + fn via() -> Option { + Some( + super::product_product_tags::Relation::ProductTags + .def() + .rev(), + ) + } +} diff --git a/src/models/_entities/products.rs b/src/models/_entities/products.rs new file mode 100644 index 0000000..d50e94c --- /dev/null +++ b/src/models/_entities/products.rs @@ -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, + pub price_cents: i64, + pub currency: String, + pub sku: Option, + pub stock: i32, + pub view_count: i32, + pub published: bool, + pub published_at: Option, + pub category_id: Option, +} + +#[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 for Entity { + fn to() -> RelationDef { + Relation::Categories.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::OrderItems.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ProductImages.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ProductProductTags.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::product_product_tags::Relation::ProductTags.def() + } + fn via() -> Option { + Some(super::product_product_tags::Relation::Products.def().rev()) + } +} diff --git a/src/models/_entities/site_pages.rs b/src/models/_entities/site_pages.rs deleted file mode 100644 index 6b6b15b..0000000 --- a/src/models/_entities/site_pages.rs +++ /dev/null @@ -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 {} diff --git a/src/models/_entities/users.rs b/src/models/_entities/users.rs index 3ab969a..810f84d 100644 --- a/src/models/_entities/users.rs +++ b/src/models/_entities/users.rs @@ -29,18 +29,8 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { - #[sea_orm(has_many = "super::audio_albums::Entity")] - AudioAlbums, #[sea_orm(has_many = "super::audit_logs::Entity")] AuditLogs, - #[sea_orm(has_many = "super::blog_articles::Entity")] - BlogArticles, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::AudioAlbums.def() - } } impl Related for Entity { @@ -48,9 +38,3 @@ impl Related for Entity { Relation::AuditLogs.def() } } - -impl Related for Entity { - fn to() -> RelationDef { - Relation::BlogArticles.def() - } -} diff --git a/src/models/audio_tags.rs b/src/models/audio_tags.rs deleted file mode 100644 index f24284c..0000000 --- a/src/models/audio_tags.rs +++ /dev/null @@ -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(self, _db: &C, _insert: bool) -> std::result::Result - 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 {} diff --git a/src/models/audio_tracks.rs b/src/models/categories.rs similarity index 87% rename from src/models/audio_tracks.rs rename to src/models/categories.rs index bf25553..12e6cf8 100644 --- a/src/models/audio_tracks.rs +++ b/src/models/categories.rs @@ -1,6 +1,6 @@ -pub use super::_entities::audio_tracks::{ActiveModel, Entity, Model}; 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] impl ActiveModelBehavior for ActiveModel { diff --git a/src/models/mod.rs b/src/models/mod.rs index 54d600a..3fcd45a 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,9 +1,10 @@ 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 blog_articles; -pub mod site_pages; 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; diff --git a/src/models/audio_albums.rs b/src/models/order_items.rs similarity index 87% rename from src/models/audio_albums.rs rename to src/models/order_items.rs index d84e730..5597344 100644 --- a/src/models/audio_albums.rs +++ b/src/models/order_items.rs @@ -1,6 +1,6 @@ -pub use super::_entities::audio_albums::{ActiveModel, Entity, Model}; 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] impl ActiveModelBehavior for ActiveModel { diff --git a/src/models/blog_articles.rs b/src/models/orders.rs similarity index 87% rename from src/models/blog_articles.rs rename to src/models/orders.rs index cb2187f..3802429 100644 --- a/src/models/blog_articles.rs +++ b/src/models/orders.rs @@ -1,6 +1,6 @@ -pub use super::_entities::blog_articles::{ActiveModel, Entity, Model}; 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] impl ActiveModelBehavior for ActiveModel { diff --git a/src/models/product_images.rs b/src/models/product_images.rs new file mode 100644 index 0000000..8754380 --- /dev/null +++ b/src/models/product_images.rs @@ -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(self, _db: &C, insert: bool) -> std::result::Result + 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 {} diff --git a/src/models/audio_track_tags.rs b/src/models/product_product_tags.rs similarity index 80% rename from src/models/audio_track_tags.rs rename to src/models/product_product_tags.rs index e3a2520..0a62737 100644 --- a/src/models/audio_track_tags.rs +++ b/src/models/product_product_tags.rs @@ -1,6 +1,6 @@ -pub use super::_entities::audio_track_tags::{ActiveModel, Entity, Model}; 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] impl ActiveModelBehavior for ActiveModel { diff --git a/src/models/product_tags.rs b/src/models/product_tags.rs new file mode 100644 index 0000000..aca2784 --- /dev/null +++ b/src/models/product_tags.rs @@ -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(self, _db: &C, insert: bool) -> std::result::Result + 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 {} diff --git a/src/models/site_pages.rs b/src/models/products.rs similarity index 68% rename from src/models/site_pages.rs rename to src/models/products.rs index 3216597..264e879 100644 --- a/src/models/site_pages.rs +++ b/src/models/products.rs @@ -1,6 +1,6 @@ -pub use super::_entities::site_pages::{ActiveModel, Entity, Model}; 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] impl ActiveModelBehavior for ActiveModel { @@ -18,6 +18,11 @@ impl ActiveModelBehavior for ActiveModel { } } +// implement your read-oriented logic here impl Model {} + +// implement your write-oriented logic here impl ActiveModel {} + +// implement your custom finders, selectors oriented logic here impl Entity {} diff --git a/tests/models/categories.rs b/tests/models/categories.rs new file mode 100644 index 0000000..e368f28 --- /dev/null +++ b/tests/models/categories.rs @@ -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::().await.unwrap(); + seed::(&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); +} diff --git a/tests/models/mod.rs b/tests/models/mod.rs index 5975988..3fa7d4b 100644 --- a/tests/models/mod.rs +++ b/tests/models/mod.rs @@ -1 +1,8 @@ mod users; + +mod categories; +mod products; +mod product_images; +mod product_tags; +mod orders; +mod order_items; \ No newline at end of file diff --git a/tests/models/order_items.rs b/tests/models/order_items.rs new file mode 100644 index 0000000..e368f28 --- /dev/null +++ b/tests/models/order_items.rs @@ -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::().await.unwrap(); + seed::(&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); +} diff --git a/tests/models/orders.rs b/tests/models/orders.rs new file mode 100644 index 0000000..e368f28 --- /dev/null +++ b/tests/models/orders.rs @@ -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::().await.unwrap(); + seed::(&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); +} diff --git a/tests/models/product_images.rs b/tests/models/product_images.rs new file mode 100644 index 0000000..e368f28 --- /dev/null +++ b/tests/models/product_images.rs @@ -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::().await.unwrap(); + seed::(&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); +} diff --git a/tests/models/product_tags.rs b/tests/models/product_tags.rs new file mode 100644 index 0000000..e368f28 --- /dev/null +++ b/tests/models/product_tags.rs @@ -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::().await.unwrap(); + seed::(&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); +} diff --git a/tests/models/products.rs b/tests/models/products.rs new file mode 100644 index 0000000..e368f28 --- /dev/null +++ b/tests/models/products.rs @@ -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::().await.unwrap(); + seed::(&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); +}