Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43562e964a | ||
|
|
f54fd3d717 | ||
|
|
e4f63b3de9 | ||
|
|
95f195a204 | ||
|
|
b88c990873 |
@@ -2,7 +2,7 @@ CONTAINER_NAME=universal-web
|
||||
REVERSE_PROXY_NETWORK=
|
||||
UPLOADS_VOLUME_NAME=universal_web_uploads
|
||||
|
||||
APP_HOST=https://gitara.farmeris.sk
|
||||
APP_HOST=https://eshop.example.com
|
||||
PORT=5150
|
||||
SERVER_BINDING=0.0.0.0
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
gitara.farmeris.sk {
|
||||
eshop.example.com {
|
||||
encode gzip
|
||||
|
||||
@static path /static/*
|
||||
@@ -6,9 +6,9 @@ gitara.farmeris.sk {
|
||||
|
||||
rewrite /favicon.ico /static/favicon/favicon.ico
|
||||
|
||||
reverse_proxy gitara-web:5150
|
||||
reverse_proxy kompress:5150
|
||||
}
|
||||
|
||||
www.gitara.farmeris.sk {
|
||||
redir https://gitara.farmeris.sk{uri} permanent
|
||||
eshop.example.com {
|
||||
redir https://eshop.example.com{uri} permanent
|
||||
}
|
||||
|
||||
60
Cargo.lock
generated
60
Cargo.lock
generated
@@ -1523,36 +1523,6 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitara_web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"fluent-templates",
|
||||
"include_dir",
|
||||
"insta",
|
||||
"loco-rs",
|
||||
"migration",
|
||||
"regex",
|
||||
"rstest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unic-langid",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
@@ -2124,6 +2094,36 @@ dependencies = [
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kompress_eshop"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"fluent-templates",
|
||||
"include_dir",
|
||||
"insta",
|
||||
"loco-rs",
|
||||
"migration",
|
||||
"regex",
|
||||
"rstest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unic-langid",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "gitara_web"
|
||||
name = "kompress_eshop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
default-run = "gitara_web-cli"
|
||||
default-run = "kompress-eshop-cli"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@@ -45,7 +45,7 @@ axum-extra = { version = "0.10", features = ["form"] }
|
||||
bytes = { version = "1" }
|
||||
|
||||
[[bin]]
|
||||
name = "gitara_web-cli"
|
||||
name = "kompress-eshop-cli"
|
||||
path = "src/bin/main.rs"
|
||||
required-features = []
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ WORKDIR /usr/src
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN cargo build --release --bin gitara_web-cli
|
||||
RUN cargo build --release --bin kompress-cli
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -17,9 +17,9 @@ WORKDIR /usr/app
|
||||
|
||||
COPY --from=builder /usr/src/assets assets
|
||||
COPY --from=builder /usr/src/config config
|
||||
COPY --from=builder /usr/src/target/release/gitara_web-cli gitara_web-cli
|
||||
COPY --from=builder /usr/src/target/release/kompress-cli kompress-cli
|
||||
|
||||
ENV LOCO_ENV=production
|
||||
EXPOSE 5150
|
||||
ENTRYPOINT ["/usr/app/gitara_web-cli"]
|
||||
ENTRYPOINT ["/usr/app/kompress-cli"]
|
||||
CMD ["start"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
brand = My guitar
|
||||
brand = Kompress eshop
|
||||
hello-world = Hello world!
|
||||
meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place.
|
||||
meta-description = Kompress eshop
|
||||
nav-home = Home
|
||||
nav-about = About
|
||||
nav-blog = Blog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
brand = My guitar
|
||||
brand = Kompress eshop
|
||||
hello-world = Hello world!
|
||||
meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place.
|
||||
meta-description = Kompress eshop
|
||||
nav-home = Home
|
||||
nav-about = About
|
||||
nav-blog = Blog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
brand = Moja gitara
|
||||
brand = Kompress eshop
|
||||
hello-world = Ahoj svet!
|
||||
meta-description = Osobná stránka gitaristu. Novinky, blog, albumy a skladby na jednom mieste.
|
||||
meta-description = Kompress eshop
|
||||
nav-home = Domov
|
||||
nav-about = O mne
|
||||
nav-blog = Blog
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
{% 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 title %}{% if category %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
||||
{% block crumb %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{% if editing %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}
|
||||
{% if category %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}
|
||||
</h1>
|
||||
<a href="/admin/catalog/categories"
|
||||
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data"
|
||||
action="{% if editing %}/admin/catalog/categories/{{ category.id }}{% else %}/admin/catalog/categories{% endif %}"
|
||||
action="{% if category %}/admin/catalog/categories/{{ category.id }}{% else %}/admin/catalog/categories{% endif %}"
|
||||
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="name" name="name" type="text" required value="{% if editing %}{{ category.name }}{% endif %}"
|
||||
<input id="name" name="name" type="text" required value="{% if category %}{{ category.name }}{% endif %}"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="slug" name="slug" type="text" value="{% if editing %}{{ category.slug }}{% endif %}"
|
||||
<input id="slug" name="slug" type="text" value="{% if category %}{{ category.slug }}{% endif %}"
|
||||
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="position" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="position", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="position" name="position" type="number" value="{% if editing %}{{ category.position }}{% else %}0{% endif %}"
|
||||
<input id="position" name="position" type="number" value="{% if category %}{{ category.position }}{% else %}0{% endif %}"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,7 +42,7 @@
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
<option value="">{{ t(key="no-parent", lang=lang | default(value='sk')) }}</option>
|
||||
{% for parent in parents %}
|
||||
<option value="{{ parent.id }}" {% if editing and category.parent_id == parent.id %}selected{% endif %}>
|
||||
<option value="{{ parent.id }}" {% if category and category.parent_id == parent.id %}selected{% endif %}>
|
||||
{% if parent.depth > 0 %}{% for _ in range(end=parent.depth) %}— {% endfor %}{% endif %}{{ parent.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
@@ -53,12 +52,12 @@
|
||||
<div class="space-y-1.5">
|
||||
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
||||
<textarea id="description" name="description" rows="4"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if editing and category.description %}{{ category.description }}{% endif %}</textarea>
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if category and category.description %}{{ category.description }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
||||
{% if editing and category.image_id %}
|
||||
{% if category and category.image_id %}
|
||||
<img src="/images/{{ category.image_id }}" alt="" class="size-24 rounded-radius object-cover">
|
||||
{% endif %}
|
||||
<input id="image" name="image" type="file" accept="image/*"
|
||||
@@ -66,7 +65,7 @@
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="published" value="on" {% if editing and category.published %}checked{% endif %}
|
||||
<input type="checkbox" name="published" value="on" {% if category and category.published %}checked{% endif %}
|
||||
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||
</label>
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
{% 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 title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
||||
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{% if editing %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}
|
||||
{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}
|
||||
</h1>
|
||||
<a href="/admin/catalog/products"
|
||||
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data"
|
||||
action="{% if editing %}/admin/catalog/products/{{ product.id }}{% else %}/admin/catalog/products{% endif %}"
|
||||
action="{% if product %}/admin/catalog/products/{{ product.id }}{% else %}/admin/catalog/products{% endif %}"
|
||||
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="name" name="name" type="text" required value="{% if editing %}{{ product.name }}{% endif %}"
|
||||
<input id="name" name="name" type="text" required value="{% if product %}{{ product.name }}{% endif %}"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<label for="price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="price" name="price" type="text" inputmode="decimal" required value="{% if editing %}{{ product.price }}{% endif %}"
|
||||
<input id="price" name="price" type="text" inputmode="decimal" required value="{% if product %}{{ product.price }}{% endif %}"
|
||||
placeholder="0.00"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="currency" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="currency", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="currency" name="currency" type="text" maxlength="3" value="{% if editing %}{{ product.currency }}{% else %}EUR{% endif %}"
|
||||
<input id="currency" name="currency" type="text" maxlength="3" value="{% if product %}{{ product.currency }}{% else %}EUR{% endif %}"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm uppercase text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,12 +39,12 @@
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<label for="stock" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="stock" name="stock" type="number" min="0" value="{% if editing %}{{ product.stock }}{% else %}0{% endif %}"
|
||||
<input id="stock" name="stock" type="number" min="0" value="{% if product %}{{ product.stock }}{% else %}0{% endif %}"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="sku" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="sku" name="sku" type="text" value="{% if editing and product.sku %}{{ product.sku }}{% endif %}"
|
||||
<input id="sku" name="sku" type="text" value="{% if product and product.sku %}{{ product.sku }}{% endif %}"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,14 +55,14 @@
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
<option value="">{{ t(key="no-category", lang=lang | default(value='sk')) }}</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}" {% if editing and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
||||
<option value="{{ category.id }}" {% if product and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="slug" name="slug" type="text" value="{% if editing %}{{ product.slug }}{% endif %}"
|
||||
<input id="slug" name="slug" type="text" value="{% if product %}{{ product.slug }}{% endif %}"
|
||||
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
</div>
|
||||
@@ -71,12 +70,12 @@
|
||||
<div class="space-y-1.5">
|
||||
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
||||
<textarea id="description" name="description" rows="5"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if editing and product.description %}{{ product.description }}{% endif %}</textarea>
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if product and product.description %}{{ product.description }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
||||
{% if editing and product.image %}
|
||||
{% if product and product.image %}
|
||||
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
|
||||
{% endif %}
|
||||
<input id="image" name="image" type="file" accept="image/*"
|
||||
@@ -84,7 +83,7 @@
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="published" value="on" {% if editing and product.published %}checked{% endif %}
|
||||
<input type="checkbox" name="published" value="on" {% if product and product.published %}checked{% endif %}
|
||||
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||
</label>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{# Site-wide category sidebar contents, served as an htmx partial and swapped
|
||||
into the <aside> in base.html. `category_tree` is a depth-ordered flat list
|
||||
of { name, slug, depth }; nesting is shown via left indentation. Active state
|
||||
is set client-side by markActiveNav() via data-nav + aria-current. #}
|
||||
{# Site-wide category menu, served as an htmx partial and swapped into the
|
||||
<aside> in base.html. `category_groups` is a two-level list of top-level
|
||||
categories, each `{ name, slug, children: [{ name, slug }] }`. A category
|
||||
with children is expandable (accordion); one without is a plain link.
|
||||
Active state is set client-side by markActiveNav() via data-nav +
|
||||
aria-current; groups auto-expand when the current page is the category or
|
||||
one of its subcategories. #}
|
||||
<p class="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{{ t(key="categories", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
@@ -12,16 +15,46 @@
|
||||
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
</li>
|
||||
{% for item in category_tree %}
|
||||
{% for group in category_groups %}
|
||||
{% if group.children | length > 0 %}
|
||||
<li x-data="{ open: false }"
|
||||
x-init="open = ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}].some(s => location.pathname === '/category/' + s)">
|
||||
<div class="flex items-stretch">
|
||||
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||
class="flex-1 truncate rounded-l-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||
{{ group.name }}
|
||||
</a>
|
||||
<button type="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-label="{{ group.name }}"
|
||||
class="inline-flex w-8 shrink-0 items-center justify-center rounded-r-radius text-on-surface/60 transition hover:bg-surface hover:text-primary dark:text-on-surface-dark/60 dark:hover:bg-surface-dark dark:hover:text-primary-dark">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
||||
class="size-4 transition-transform" :class="open && 'rotate-90'">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul x-show="open" x-cloak x-transition class="mt-0.5 flex flex-col gap-0.5">
|
||||
{% for child in group.children %}
|
||||
<li>
|
||||
<a href="/category/{{ item.slug }}" data-nav="/category/{{ item.slug }}" style="padding-left: {{ 12 + item.depth * 16 }}px"
|
||||
class="flex items-center gap-1.5 rounded-radius py-1.5 pr-3 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||
{% if item.depth > 0 %}<span class="text-on-surface/40 dark:text-on-surface-dark/40">↳</span>{% endif %}
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}" style="padding-left: 28px"
|
||||
class="flex items-center gap-1.5 rounded-radius py-1.5 pr-3 text-sm text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||
<span class="text-on-surface/40 dark:text-on-surface-dark/40">↳</span>
|
||||
<span class="truncate">{{ child.name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if category_tree | length == 0 %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||
class="block truncate rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||
{{ group.name }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if category_groups | length == 0 %}
|
||||
<p class="px-3 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -71,7 +71,7 @@ mailer:
|
||||
# Database Configuration
|
||||
database:
|
||||
# Database connection URI
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/gitara_web_development") }}
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_development") }}
|
||||
# When enabled, the sql query will be logged.
|
||||
enable_logging: false
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
|
||||
@@ -68,7 +68,7 @@ mailer:
|
||||
# Database Configuration
|
||||
database:
|
||||
# Database connection URI
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/gitara_web_test") }}
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_test") }}
|
||||
# When enabled, the sql query will be logged.
|
||||
enable_logging: false
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
gitara-web:
|
||||
container_name: gitara-web
|
||||
kompress:
|
||||
container_name: kompress
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -9,9 +9,9 @@ services:
|
||||
env_file:
|
||||
- .env.production
|
||||
volumes:
|
||||
- gitara_web_data:/usr/app/data
|
||||
- kompress_eshop_data:/usr/app/data
|
||||
networks:
|
||||
- gitara-net
|
||||
- kompress_eshop-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:5150/_ping"]
|
||||
@@ -21,10 +21,10 @@ services:
|
||||
start_period: 20s
|
||||
|
||||
networks:
|
||||
gitara-net:
|
||||
kompress_eshop-net:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
gitara_web_data:
|
||||
kompress_eshop_data:
|
||||
external: true
|
||||
name: gitara_web_data
|
||||
name: kompress_eshop_data
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#[allow(unused_imports)]
|
||||
use loco_rs::{cli::playground, prelude::*};
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
|
||||
12
flake.nix
12
flake.nix
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "Development Nix flake for the gitara_web (kompress_eshop) loco-rs app";
|
||||
description = "Development Nix flake for the kompress (kompress_eshop) loco-rs app";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
@@ -41,8 +41,8 @@
|
||||
cargo = pkgs.rust-bin.stable.latest.minimal;
|
||||
rustc = pkgs.rust-bin.stable.latest.minimal;
|
||||
};
|
||||
gitara-web = rustPlatform.buildRustPackage {
|
||||
pname = "gitara_web";
|
||||
kompress = rustPlatform.buildRustPackage {
|
||||
pname = "kompress_eshop";
|
||||
inherit version;
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
@@ -57,7 +57,7 @@
|
||||
];
|
||||
|
||||
# Build only the application binary.
|
||||
cargoBuildFlags = [ "--bin" "gitara_web-cli" ];
|
||||
cargoBuildFlags = [ "--bin" "kompress-eshop-cli" ];
|
||||
# Tests need a database/runtime environment; skip during the build.
|
||||
doCheck = false;
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
};
|
||||
in
|
||||
{
|
||||
gitara-web = gitara-web;
|
||||
default = gitara-web;
|
||||
kompress = kompress;
|
||||
default = kompress;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub mod users;
|
||||
@@ -1 +0,0 @@
|
||||
pub mod audit_logs;
|
||||
26
src/app.rs
26
src/app.rs
@@ -16,8 +16,14 @@ use std::{path::Path, sync::Arc};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::{
|
||||
account, admin, cart, checkout, home, i18n, initializers, media,
|
||||
models::_entities::users, shop, tasks, workers::downloader::DownloadWorker,
|
||||
controllers::{
|
||||
admin_categories, admin_dashboard, admin_form, admin_login, admin_orders,
|
||||
admin_products, admin_shipping, auth, cart, checkout, home, i18n, media, shop,
|
||||
},
|
||||
initializers,
|
||||
models::_entities::users,
|
||||
tasks,
|
||||
workers::downloader::DownloadWorker,
|
||||
};
|
||||
|
||||
pub struct App;
|
||||
@@ -54,7 +60,6 @@ impl Hooks for App {
|
||||
Ok(vec![
|
||||
Box::new(initializers::view_engine::ViewEngineInitializer),
|
||||
Box::new(initializers::admin_seeder::AdminSeeder),
|
||||
Box::new(initializers::shipping_seeder::ShippingSeeder),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -66,16 +71,16 @@ impl Hooks for App {
|
||||
.add_route(cart::routes())
|
||||
.add_route(checkout::routes())
|
||||
// cross-cutting
|
||||
.add_route(account::routes())
|
||||
.add_route(auth::routes())
|
||||
.add_route(i18n::routes())
|
||||
.add_route(media::routes())
|
||||
// admin
|
||||
.add_route(admin::routes())
|
||||
.add_route(admin::login::routes())
|
||||
.add_route(admin::products::routes())
|
||||
.add_route(admin::categories::routes())
|
||||
.add_route(admin::orders::routes())
|
||||
.add_route(admin::shipping::routes())
|
||||
.add_route(admin_dashboard::routes())
|
||||
.add_route(admin_login::routes())
|
||||
.add_route(admin_products::routes())
|
||||
.add_route(admin_categories::routes())
|
||||
.add_route(admin_orders::routes())
|
||||
.add_route(admin_shipping::routes())
|
||||
}
|
||||
|
||||
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
||||
@@ -105,6 +110,7 @@ impl Hooks for App {
|
||||
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
db::seed::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").display().to_string())
|
||||
.await?;
|
||||
crate::seed::seed_catalog(ctx).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use loco_rs::cli;
|
||||
use migration::Migrator;
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod order_items;
|
||||
pub mod orders;
|
||||
pub mod shipping_methods;
|
||||
@@ -11,14 +11,16 @@ use sea_orm::{
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
admin::form::{read_multipart_form, store_image, MultipartForm},
|
||||
controllers::{
|
||||
admin_form::{read_multipart_form, store_image, MultipartForm},
|
||||
i18n::current_lang,
|
||||
media::IMAGE_MAX_BYTES,
|
||||
},
|
||||
shared::{
|
||||
guard,
|
||||
slug::{slugify, unique_slug},
|
||||
},
|
||||
shop::models::{categories, products},
|
||||
models::{categories, products},
|
||||
};
|
||||
|
||||
async fn category_by_id(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||
@@ -1,13 +1,4 @@
|
||||
//! Admin area. Each surface lives in its own submodule; this module holds the
|
||||
//! dashboard (HTML home + JSON stats) and is the entry point for admin routes.
|
||||
|
||||
pub mod categories;
|
||||
pub mod form;
|
||||
pub mod login;
|
||||
pub mod models;
|
||||
pub mod orders;
|
||||
pub mod products;
|
||||
pub mod shipping;
|
||||
//! Admin dashboard (HTML home + JSON stats).
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
@@ -15,7 +6,7 @@ use sea_orm::{EntityTrait, PaginatorTrait};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{i18n::current_lang, models::_entities, shared::guard};
|
||||
use crate::{controllers::i18n::current_lang, models::_entities, shared::guard};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DashboardResponse {
|
||||
@@ -9,7 +9,7 @@ use std::collections::HashMap;
|
||||
use axum::extract::Multipart;
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR};
|
||||
use crate::controllers::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR};
|
||||
|
||||
fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
@@ -6,8 +6,9 @@ use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
account::{self as auth_controller, models::users::{self, LoginParams}},
|
||||
i18n::current_lang,
|
||||
controllers::auth as auth_controller,
|
||||
models::users::{self, LoginParams},
|
||||
controllers::i18n::current_lang,
|
||||
shared::guard,
|
||||
};
|
||||
|
||||
@@ -7,11 +7,9 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
checkout::{
|
||||
models::{order_items, orders},
|
||||
view,
|
||||
},
|
||||
i18n::current_lang,
|
||||
views::checkout as view,
|
||||
controllers::i18n::current_lang,
|
||||
shared::{guard, settings},
|
||||
};
|
||||
|
||||
@@ -10,18 +10,18 @@ use sea_orm::{
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
admin::form::{read_multipart_form, store_image, MultipartForm},
|
||||
controllers::{
|
||||
admin_form::{read_multipart_form, store_image, MultipartForm},
|
||||
i18n::current_lang,
|
||||
media::IMAGE_MAX_BYTES,
|
||||
},
|
||||
shared::{
|
||||
guard,
|
||||
money::parse_price_to_cents,
|
||||
slug::{slugify, unique_slug},
|
||||
},
|
||||
shop::{
|
||||
models::{categories, product_images, products},
|
||||
view,
|
||||
},
|
||||
views::shop as view,
|
||||
};
|
||||
|
||||
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
||||
@@ -7,8 +7,8 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
checkout::models::shipping_methods,
|
||||
i18n::current_lang,
|
||||
models::shipping_methods,
|
||||
controllers::i18n::current_lang,
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_price, parse_price_to_cents},
|
||||
@@ -1,9 +1,6 @@
|
||||
pub mod models;
|
||||
pub mod view;
|
||||
|
||||
use crate::{
|
||||
account::models::users::{self, LoginParams, RegisterParams},
|
||||
account::view::{CurrentResponse, LoginResponse},
|
||||
models::users::{self, LoginParams, RegisterParams},
|
||||
views::auth::{CurrentResponse, LoginResponse},
|
||||
mailers::auth::AuthMailer,
|
||||
shared::guard::is_admin,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{i18n::current_lang, shared::money::format_price, shop::models::products};
|
||||
use crate::{controllers::i18n::current_lang, shared::money::format_price, models::products};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
@@ -8,18 +8,12 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use time::Duration as TimeDuration;
|
||||
|
||||
pub mod models;
|
||||
pub mod view;
|
||||
|
||||
use crate::{
|
||||
cart::{resolve_cart, CART_COOKIE},
|
||||
checkout::models::{
|
||||
order_items,
|
||||
orders::{self, Checkout},
|
||||
shipping_methods,
|
||||
},
|
||||
i18n::current_lang,
|
||||
controllers::cart::{resolve_cart, CART_COOKIE},
|
||||
models::{order_items, orders, shipping_methods},
|
||||
controllers::i18n::current_lang,
|
||||
shared::{money::format_price, settings},
|
||||
views::checkout as view,
|
||||
};
|
||||
|
||||
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
||||
@@ -145,7 +139,7 @@ async fn place_order(
|
||||
let order = orders::place(
|
||||
&ctx,
|
||||
&valid,
|
||||
Checkout {
|
||||
orders::Checkout {
|
||||
email,
|
||||
customer_name: trimmed(&form.customer_name),
|
||||
address: trimmed(&form.address),
|
||||
@@ -4,7 +4,7 @@ use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{i18n::current_lang, shared::guard, shop};
|
||||
use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop};
|
||||
|
||||
#[debug_handler]
|
||||
async fn index(
|
||||
14
src/controllers/mod.rs
Normal file
14
src/controllers/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
pub mod auth;
|
||||
pub mod admin_categories;
|
||||
pub mod admin_dashboard;
|
||||
pub mod admin_form;
|
||||
pub mod admin_login;
|
||||
pub mod admin_orders;
|
||||
pub mod admin_products;
|
||||
pub mod admin_shipping;
|
||||
pub mod cart;
|
||||
pub mod checkout;
|
||||
pub mod home;
|
||||
pub mod i18n;
|
||||
pub mod media;
|
||||
pub mod shop;
|
||||
@@ -6,13 +6,11 @@ use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
||||
use serde_json::json;
|
||||
|
||||
pub mod models;
|
||||
pub mod view;
|
||||
|
||||
use crate::{
|
||||
i18n::current_lang,
|
||||
controllers::i18n::current_lang,
|
||||
shared::guard,
|
||||
shop::models::{categories, product_images, products},
|
||||
models::{categories, product_images, products},
|
||||
views::shop as view,
|
||||
};
|
||||
|
||||
/// Shape a list of products into card rows, loading each one's primary image.
|
||||
@@ -53,7 +51,7 @@ async fn category_sidebar(
|
||||
&v,
|
||||
"shop/_sidebar.html",
|
||||
json!({
|
||||
"category_tree": view::sidebar_rows(&categories::tree(&published)),
|
||||
"category_groups": view::sidebar_groups(&published),
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
@@ -1,17 +1,19 @@
|
||||
---
|
||||
- id: 1
|
||||
- id: 2
|
||||
pid: 11111111-1111-1111-1111-111111111111
|
||||
email: user1@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
|
||||
name: user1
|
||||
theme: light
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
||||
- id: 2
|
||||
- id: 3
|
||||
pid: 22222222-2222-2222-2222-222222222222
|
||||
email: user2@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
|
||||
name: user2
|
||||
theme: light
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use async_trait::async_trait;
|
||||
use loco_rs::prelude::*;
|
||||
use loco_rs::hash;
|
||||
use sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
|
||||
|
||||
use crate::account::models::users::{self, RegisterParams};
|
||||
use crate::models::users::{self, RegisterParams};
|
||||
|
||||
pub struct AdminSeeder;
|
||||
|
||||
@@ -18,7 +20,19 @@ impl Initializer for AdminSeeder {
|
||||
|
||||
if email.is_empty() || password.is_empty() {
|
||||
tracing::warn!("ADMIN_EMAIL / ADMIN_PASSWORD not set in .env; admin not seeded");
|
||||
} else if users::Model::find_by_email(&ctx.db, &email).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Ok(user) = users::Model::find_by_email(&ctx.db, &email).await {
|
||||
// User exists — update password so .env is always the source of truth.
|
||||
let hash = hash::hash_password(&password)
|
||||
.map_err(|e| Error::Message(e.to_string()))?;
|
||||
let mut am = user.into_active_model();
|
||||
am.password = Set(hash);
|
||||
am.name = Set(name);
|
||||
am.update(&ctx.db).await?;
|
||||
tracing::info!(admin = %email, "admin password synced from .env");
|
||||
} else {
|
||||
users::Model::create_with_password(
|
||||
&ctx.db,
|
||||
&RegisterParams {
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod admin_seeder;
|
||||
pub mod shipping_seeder;
|
||||
pub mod view_engine;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
|
||||
use crate::models::_entities::shipping_methods;
|
||||
|
||||
/// (code, display name, price in cents, requires a pickup point)
|
||||
const CARRIERS: [(&str, &str, i64, bool); 3] = [
|
||||
("packeta", "Packeta", 300, true),
|
||||
("dpd", "DPD", 450, false),
|
||||
("dhl", "DHL", 500, false),
|
||||
];
|
||||
|
||||
pub struct ShippingSeeder;
|
||||
|
||||
#[async_trait]
|
||||
impl Initializer for ShippingSeeder {
|
||||
fn name(&self) -> String {
|
||||
"shipping-seeder".to_string()
|
||||
}
|
||||
|
||||
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
|
||||
for (position, (code, name, price_cents, requires_pickup_point)) in
|
||||
CARRIERS.iter().enumerate()
|
||||
{
|
||||
let exists = shipping_methods::Entity::find()
|
||||
.filter(shipping_methods::Column::Code.eq(*code))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.is_some();
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
shipping_methods::ActiveModel {
|
||||
code: Set((*code).to_string()),
|
||||
name: Set((*name).to_string()),
|
||||
price_cents: Set(*price_cents),
|
||||
requires_pickup_point: Set(*requires_pickup_point),
|
||||
enabled: Set(true),
|
||||
position: Set(position as i32),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
21
src/lib.rs
21
src/lib.rs
@@ -1,22 +1,11 @@
|
||||
pub mod app;
|
||||
pub mod controllers;
|
||||
pub mod data;
|
||||
pub mod initializers;
|
||||
pub mod mailers;
|
||||
pub mod models;
|
||||
pub mod tasks;
|
||||
pub mod workers;
|
||||
|
||||
// Cross-cutting helpers shared by every feature.
|
||||
pub mod seed;
|
||||
pub mod shared;
|
||||
|
||||
// Feature slices: each owns its routes, handlers, view-shaping and the model
|
||||
// methods/services specific to it. Generated sea-orm entities stay shared in
|
||||
// `models::_entities`.
|
||||
pub mod account;
|
||||
pub mod admin;
|
||||
pub mod cart;
|
||||
pub mod checkout;
|
||||
pub mod home;
|
||||
pub mod i18n;
|
||||
pub mod media;
|
||||
pub mod shop;
|
||||
pub mod tasks;
|
||||
pub mod views;
|
||||
pub mod workers;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::account::models::users;
|
||||
use crate::models::users;
|
||||
|
||||
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
||||
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
//! Shared data layer: the sea-orm entities generated by `loco generate`.
|
||||
//! Shared data layer: SeaORM entities and their hand-written model extensions.
|
||||
//!
|
||||
//! These structs cross-reference each other (relations) and are regenerated as
|
||||
//! a unit, so they live here centrally. The hand-written model methods,
|
||||
//! services and view-shaping that use them live in the feature slices
|
||||
//! (`shop::models`, `checkout::models`, `account::models`, …).
|
||||
//! `_entities/` contains auto-generated SeaORM code (regenerated as a unit).
|
||||
//! The sibling files contain hand-written model impls: ActiveModelBehavior,
|
||||
//! finder methods, business logic, and query helpers.
|
||||
|
||||
pub mod _entities;
|
||||
|
||||
pub mod audit_logs;
|
||||
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 shipping_methods;
|
||||
pub mod users;
|
||||
|
||||
178
src/seed.rs
Normal file
178
src/seed.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
//! Catalog seed data — run via `cargo loco seed`.
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
|
||||
use crate::{
|
||||
models::_entities::{categories, products},
|
||||
shared::slug::slugify,
|
||||
};
|
||||
|
||||
// -- Categories -----------------------------------------------------------
|
||||
|
||||
struct CategorySeed {
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
position: i32,
|
||||
}
|
||||
|
||||
const CATEGORIES: &[CategorySeed] = &[
|
||||
CategorySeed {
|
||||
name: "Electronics",
|
||||
description: "Audio, computing, and smart devices",
|
||||
position: 0,
|
||||
},
|
||||
CategorySeed {
|
||||
name: "Accessories",
|
||||
description: "Cables, adapters, cases, and everyday essentials",
|
||||
position: 1,
|
||||
},
|
||||
CategorySeed {
|
||||
name: "Home & Office",
|
||||
description: "Ergonomic furniture, lighting, and desk organization",
|
||||
position: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// -- Products -------------------------------------------------------------
|
||||
|
||||
struct ProductSeed {
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
price_cents: i64,
|
||||
stock: i32,
|
||||
category_slug: &'static str,
|
||||
sku: Option<&'static str>,
|
||||
}
|
||||
|
||||
const PRODUCTS: &[ProductSeed] = &[
|
||||
ProductSeed {
|
||||
name: "Wireless Headphones",
|
||||
description: "Over-ear Bluetooth headphones with active noise cancelling, 30-hour battery life, and plush memory-foam cushions.",
|
||||
price_cents: 7_999,
|
||||
stock: 25,
|
||||
category_slug: "electronics",
|
||||
sku: Some("WH-1000"),
|
||||
},
|
||||
ProductSeed {
|
||||
name: "Mechanical Keyboard",
|
||||
description: "Tenkeyless mechanical keyboard with hot-swappable switches, per-key RGB backlight, and a detachable USB-C cable.",
|
||||
price_cents: 12_999,
|
||||
stock: 15,
|
||||
category_slug: "electronics",
|
||||
sku: Some("MK-TKL-RGB"),
|
||||
},
|
||||
ProductSeed {
|
||||
name: "USB-C Hub",
|
||||
description: "7-in-1 USB-C hub with HDMI 4K output, 100 W power delivery pass-through, SD card reader, and three USB-A 3.2 ports.",
|
||||
price_cents: 3_499,
|
||||
stock: 40,
|
||||
category_slug: "accessories",
|
||||
sku: Some("USBC-HUB7"),
|
||||
},
|
||||
ProductSeed {
|
||||
name: "Laptop Stand",
|
||||
description: "Adjustable aluminium laptop stand with ventilated surface. Supports laptops from 10\u{201d} to 17\u{201d}.",
|
||||
price_cents: 4_999,
|
||||
stock: 30,
|
||||
category_slug: "accessories",
|
||||
sku: Some("LS-ALU-01"),
|
||||
},
|
||||
ProductSeed {
|
||||
name: "Desk Lamp",
|
||||
description: "LED desk lamp with 5 colour temperatures, stepless brightness control, and a flexible gooseneck arm.",
|
||||
price_cents: 3_999,
|
||||
stock: 20,
|
||||
category_slug: "home-office",
|
||||
sku: Some("DL-5CT"),
|
||||
},
|
||||
ProductSeed {
|
||||
name: "Ergonomic Mouse",
|
||||
description: "Vertical wireless ergonomic mouse with 6 buttons, adjustable DPI up to 4 000, and a sculpted thumb rest.",
|
||||
price_cents: 5_999,
|
||||
stock: 18,
|
||||
category_slug: "electronics",
|
||||
sku: Some("EM-VW-01"),
|
||||
},
|
||||
ProductSeed {
|
||||
name: "Webcam Privacy Cover",
|
||||
description: "Ultra-thin sliding webcam cover compatible with laptops, tablets, and external monitors. Pack of 3.",
|
||||
price_cents: 599,
|
||||
stock: 100,
|
||||
category_slug: "accessories",
|
||||
sku: Some("WPC-3PK"),
|
||||
},
|
||||
ProductSeed {
|
||||
name: "Cable Organizer Set",
|
||||
description: "Silicone cable management kit with 6 magnetic clips, 4 velcro straps, and an under-desk cable tray.",
|
||||
price_cents: 1_299,
|
||||
stock: 50,
|
||||
category_slug: "home-office",
|
||||
sku: Some("COS-MAG"),
|
||||
},
|
||||
];
|
||||
|
||||
// -- Public API -----------------------------------------------------------
|
||||
|
||||
/// Insert starter categories and products. Called from the `seed()` hook.
|
||||
pub async fn seed_catalog(ctx: &AppContext) -> Result<()> {
|
||||
for cat in CATEGORIES {
|
||||
let slug = slugify(cat.name);
|
||||
let exists = categories::Entity::find()
|
||||
.filter(categories::Column::Slug.eq(&slug))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.is_some();
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
categories::ActiveModel {
|
||||
name: Set(cat.name.to_string()),
|
||||
slug: Set(slug),
|
||||
description: Set(Some(cat.description.to_string())),
|
||||
position: Set(cat.position),
|
||||
published: Set(true),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for item in PRODUCTS {
|
||||
let product_slug = slugify(item.name);
|
||||
let exists = products::Entity::find()
|
||||
.filter(products::Column::Slug.eq(&product_slug))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.is_some();
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cat_slug = slugify(item.category_slug);
|
||||
let category = categories::Entity::find()
|
||||
.filter(categories::Column::Slug.eq(&cat_slug))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
products::ActiveModel {
|
||||
name: Set(item.name.to_string()),
|
||||
slug: Set(product_slug),
|
||||
description: Set(Some(item.description.to_string())),
|
||||
price_cents: Set(item.price_cents),
|
||||
currency: Set("EUR".to_string()),
|
||||
sku: Set(item.sku.map(|s| s.to_string())),
|
||||
stock: Set(item.stock),
|
||||
published: Set(true),
|
||||
published_at: Set(Some(now.into())),
|
||||
category_id: Set(category.map(|c| c.id)),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::account::models::users;
|
||||
use crate::account::AUTH_COOKIE;
|
||||
use crate::models::users;
|
||||
use crate::controllers::auth::AUTH_COOKIE;
|
||||
use crate::shared::settings;
|
||||
|
||||
/// Is `user` the configured admin (settings.admin_email)?
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod categories;
|
||||
pub mod product_images;
|
||||
pub mod product_product_tags;
|
||||
pub mod product_tags;
|
||||
pub mod products;
|
||||
5
src/views/mod.rs
Normal file
5
src/views/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! JSON view-shaping structs for API responses and templates.
|
||||
|
||||
pub mod auth;
|
||||
pub mod checkout;
|
||||
pub mod shop;
|
||||
@@ -45,12 +45,24 @@ pub fn product_form(product: &products::Model, image: Option<String>) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Depth-ordered `{ name, slug, depth }` rows for the storefront sidebar,
|
||||
/// rendered as an indented flat list.
|
||||
pub fn sidebar_rows(tree: &[(categories::Model, usize)]) -> Vec<Value> {
|
||||
tree.iter()
|
||||
.map(|(category, depth)| {
|
||||
json!({ "name": category.name, "slug": category.slug, "depth": depth })
|
||||
/// Two-level grouping for the storefront sidebar menu: each top-level category
|
||||
/// as `{ name, slug, children: [{ name, slug }] }`, with its direct
|
||||
/// subcategories nested under `children` (empty when the category has none).
|
||||
/// Siblings are ordered by position then name on both levels.
|
||||
pub fn sidebar_groups(categories: &[categories::Model]) -> Vec<Value> {
|
||||
let mut top: Vec<&categories::Model> = categories
|
||||
.iter()
|
||||
.filter(|c| c.parent_id.is_none())
|
||||
.collect();
|
||||
top.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
|
||||
|
||||
top.into_iter()
|
||||
.map(|category| {
|
||||
let children: Vec<Value> = crate::models::categories::children_of(categories, category.id)
|
||||
.into_iter()
|
||||
.map(|child| json!({ "name": child.name, "slug": child.slug }))
|
||||
.collect();
|
||||
json!({ "name": category.name, "slug": category.slug, "children": children })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
281
structure.md
Normal file
281
structure.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Project Structure & How to Scale It
|
||||
|
||||
This is a [Loco](https://loco.rs) app (Rust, on top of Axum + SeaORM). It uses
|
||||
the **standard Loco layer layout**. This document explains *why* that layout
|
||||
scales and *how* you add things as the shop grows, so you never have to guess
|
||||
where a new piece of code belongs.
|
||||
|
||||
---
|
||||
|
||||
## 1. The mental model: layers, not features
|
||||
|
||||
Loco organizes code by **what kind of thing it is** (a layer), not by which
|
||||
feature it belongs to. The top-level dirs under `src/` are the layers:
|
||||
|
||||
```
|
||||
src/
|
||||
├── app.rs # The wiring hub: registers routes, workers, initializers, tasks
|
||||
├── lib.rs # Declares which modules exist (pub mod ...)
|
||||
├── bin/main.rs # Binary entrypoint (you rarely touch this)
|
||||
│
|
||||
├── controllers/ # HTTP layer: routes + request handlers
|
||||
├── models/ # Data layer: DB entities + business logic
|
||||
│ └── _entities/ # AUTO-GENERATED SeaORM structs — never hand-edit
|
||||
├── views/ # Presentation layer: shapes data into JSON for templates
|
||||
├── mailers/ # Email sending + email templates (.t files)
|
||||
├── workers/ # Background jobs (async, off the request path)
|
||||
├── tasks/ # CLI tasks (`cargo loco task ...`)
|
||||
├── initializers/ # Runs once at boot (seeders, view engine setup, ...)
|
||||
├── fixtures/ # Seed data (YAML) for `cargo loco db seed`
|
||||
├── data/ # Misc static/loaded data
|
||||
└── shared/ # Cross-cutting helpers used by many layers
|
||||
```
|
||||
|
||||
Supporting dirs outside `src/`:
|
||||
|
||||
```
|
||||
migration/ # SeaORM migrations (one file per schema change)
|
||||
config/ # development.yaml / test.yaml / production.yaml
|
||||
assets/ # Tera templates (views/), i18n (.ftl), static files, CSS
|
||||
tests/ # requests/ models/ workers/ tasks/ + snapshot .snap files
|
||||
```
|
||||
|
||||
### Why layers scale
|
||||
|
||||
The instinct is often "put everything for the shop in one folder." That feels
|
||||
nice early, but it fights the framework: Loco's codegen, conventions, and docs
|
||||
all assume layers. By staying with layers you get:
|
||||
|
||||
1. **`loco generate` just works.** Scaffolding lands in the right place; you
|
||||
never hand-move files. (This is exactly why the project moved *back* to this
|
||||
layout in the `loco straucture` commit.)
|
||||
2. **Each layer has one reason to change.** A routing change touches only
|
||||
`controllers/`. A schema change touches `migration/` + `models/_entities/`. A
|
||||
"make the price display differently" change touches only `views/`. Bugs stay
|
||||
contained.
|
||||
3. **New contributors (and AI tools) navigate by convention**, not by reading
|
||||
the whole tree.
|
||||
|
||||
The trade-off — "I have to open 3 dirs to see the whole shop feature" — is
|
||||
solved below with naming, not folders.
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature grouping without folders: the naming convention
|
||||
|
||||
You still get the "everything for X in one glance" benefit, via **filename
|
||||
prefixes** inside the flat `controllers/` dir:
|
||||
|
||||
```
|
||||
controllers/
|
||||
├── home.rs ┐
|
||||
├── shop.rs │ public storefront
|
||||
├── cart.rs │
|
||||
├── checkout.rs ┘
|
||||
│
|
||||
├── admin_dashboard.rs ┐
|
||||
├── admin_products.rs │
|
||||
├── admin_categories.rs│ admin area — `admin_` prefix groups them
|
||||
├── admin_orders.rs │
|
||||
├── admin_shipping.rs │
|
||||
├── admin_login.rs │
|
||||
├── admin_form.rs ┘
|
||||
│
|
||||
├── auth.rs ┐
|
||||
├── i18n.rs │ cross-cutting
|
||||
└── media.rs ┘
|
||||
```
|
||||
|
||||
In your editor's file list, `admin_*` sorts together — you see the whole admin
|
||||
surface at once, but `loco generate` and Loco conventions still see flat
|
||||
controllers. Best of both.
|
||||
|
||||
**Rule of thumb:** prefix = the "feature area." Add `admin_returns.rs`, not a
|
||||
`returns/` folder.
|
||||
|
||||
---
|
||||
|
||||
## 3. How a request flows through the layers
|
||||
|
||||
Trace the shop index (`GET /shop`) to see how layers cooperate — this is the
|
||||
pattern every feature follows:
|
||||
|
||||
```
|
||||
Browser → app.rs routes() # 1. router dispatches /shop to shop::index
|
||||
→ controllers/shop.rs # 2. handler: query DB, gather data
|
||||
→ models/products.rs # 3. data layer: products::Entity::find()...
|
||||
→ views/shop.rs # 4. shape Model → JSON (product_card)
|
||||
→ shared/guard.rs # 5. cross-cutting: is admin logged in?
|
||||
→ assets/views/shop/index.html # 6. Tera renders the JSON
|
||||
→ HTML response
|
||||
```
|
||||
|
||||
Concretely, from `controllers/shop.rs`:
|
||||
|
||||
```rust
|
||||
use crate::{
|
||||
models::{categories, product_images, products}, // data layer
|
||||
views::shop as view, // presentation layer
|
||||
shared::guard, // cross-cutting
|
||||
controllers::i18n::current_lang,
|
||||
};
|
||||
|
||||
async fn index(...) -> Result<Response> {
|
||||
let list = products::Entity::find() // query (models)
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.all(&ctx.db).await?;
|
||||
|
||||
format::view(&v, "shop/index.html", json!({ // render
|
||||
"products": product_rows(&ctx, list).await?, // shaped by views::shop
|
||||
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
The controller is a thin coordinator. It does **not** contain business logic —
|
||||
that lives in `models/`. It does **not** build HTML strings — that's `views/` +
|
||||
templates. Keeping controllers thin is the single biggest factor in whether this
|
||||
stays scalable.
|
||||
|
||||
---
|
||||
|
||||
## 4. The models layer: the one piece of Loco that surprises people
|
||||
|
||||
There are **two files per database table**, and they have different jobs:
|
||||
|
||||
```
|
||||
models/
|
||||
├── _entities/products.rs ← AUTO-GENERATED. The raw table struct
|
||||
│ (columns, relations). Regenerated as a unit
|
||||
│ whenever the schema changes. NEVER hand-edit.
|
||||
│
|
||||
└── products.rs ← HAND-WRITTEN. Re-exports the entity, then adds
|
||||
your behavior on top of it.
|
||||
```
|
||||
|
||||
Your `models/products.rs` shows the pattern exactly:
|
||||
|
||||
```rust
|
||||
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
// lifecycle hooks, e.g. touch updated_at before save
|
||||
}
|
||||
|
||||
impl Model {} // read-oriented logic (your finders that return data)
|
||||
impl ActiveModel {} // write-oriented logic (validation, mutation)
|
||||
impl Entity {} // custom queries / selectors
|
||||
```
|
||||
|
||||
**Why two files:** the schema is machine-owned (so codegen can overwrite
|
||||
`_entities/` safely), but your logic is human-owned (so it survives
|
||||
regeneration). The `pub use` bridge means the rest of the app imports
|
||||
`crate::models::products` and never has to know `_entities` exists.
|
||||
|
||||
**How to apply when scaling:** put domain logic on the model, not in the
|
||||
controller. "Is this product low on stock?" → a method on `products::Model`.
|
||||
"Recalculate order total" → a method on `orders`. As features pile up, this is
|
||||
what keeps controllers from turning back into the 900-line god-files this
|
||||
project deliberately escaped.
|
||||
|
||||
---
|
||||
|
||||
## 5. Where new code goes — a decision table
|
||||
|
||||
When you build the next thing, find the row and follow it:
|
||||
|
||||
| You want to add... | Touch these |
|
||||
|---------------------------------------------|------------------------------------------------------------------------|
|
||||
| A new page / endpoint | `controllers/<area>.rs` (+ register `routes()` in `app.rs`) |
|
||||
| A new admin screen | `controllers/admin_<thing>.rs` (prefix!) + `assets/views/admin/...` |
|
||||
| A new database table | `cargo loco generate model <name> ...` → migration + `_entities` + wrapper |
|
||||
| A schema change to an existing table | `cargo loco generate migration <name> ...`, then rebuild & migrate |
|
||||
| Business logic / a custom query | a method in `models/<entity>.rs` (not the controller) |
|
||||
| Reshaping data for a template | `views/<area>.rs` |
|
||||
| An HTML template / partial | `assets/views/<area>/...html` |
|
||||
| A reusable helper (money, slugs, auth) | `shared/<helper>.rs` |
|
||||
| Something slow (resize image, send batch) | `workers/<name>.rs` (+ register in `app.rs` `connect_workers`) |
|
||||
| A transactional email | `mailers/<name>.rs` + `mailers/<name>/<event>/{subject,html,text}.t` |
|
||||
| One-time-at-boot setup / seeding | `initializers/<name>.rs` (+ register in `app.rs` `initializers`) |
|
||||
| A CLI maintenance command | `tasks/<name>.rs` (+ register in `app.rs` `register_tasks`) |
|
||||
| A cross-cutting config value | `shared/settings.rs` + `config/*.yaml` |
|
||||
|
||||
---
|
||||
|
||||
## 6. `app.rs` is the wiring hub — the one file you revisit constantly
|
||||
|
||||
Every new route, worker, initializer, and task is *registered* here. It's the
|
||||
table of contents for the whole backend:
|
||||
|
||||
```rust
|
||||
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||
AppRoutes::with_default_routes()
|
||||
.add_route(shop::routes()) // ← every new controller's routes()
|
||||
.add_route(admin_products::routes())// gets one line here
|
||||
// ...
|
||||
}
|
||||
|
||||
async fn initializers(...) -> Result<Vec<Box<dyn Initializer>>> {
|
||||
Ok(vec![ /* AdminSeeder, ShippingSeeder, ViewEngine ... */ ])
|
||||
}
|
||||
|
||||
async fn connect_workers(...) { queue.register(DownloadWorker::build(ctx)).await?; }
|
||||
fn register_tasks(tasks: &mut Tasks) { /* tasks-inject */ }
|
||||
```
|
||||
|
||||
If you add a controller and the route 404s, the usual cause is: **you forgot the
|
||||
`add_route` line in `app.rs`.** Same shape for workers/initializers/tasks.
|
||||
|
||||
---
|
||||
|
||||
## 7. Scaling checklist (the habits that keep this healthy)
|
||||
|
||||
As the shop grows, these are the things that decide whether the codebase stays
|
||||
pleasant or rots:
|
||||
|
||||
1. **Keep controllers thin.** They query, gather, and render. Logic goes to
|
||||
`models/`, shaping goes to `views/`. If a handler exceeds ~80 lines, extract.
|
||||
2. **One controller file per feature area, prefix-grouped.** Don't let
|
||||
`admin_products.rs` start handling orders. Split by area, not convenience.
|
||||
3. **Never edit `models/_entities/`.** Change the schema via a migration and
|
||||
regenerate. Your logic in the sibling wrapper survives.
|
||||
4. **Push slow/optional work to `workers/`.** Image processing, bulk emails,
|
||||
external API calls — off the request path so pages stay fast.
|
||||
5. **Reuse via `shared/` and model methods**, not copy-paste. You already do
|
||||
this well: `money` (integer cents everywhere), `guard` (one source of truth
|
||||
for admin auth), `slug`.
|
||||
6. **Every schema change is a migration file**, never a manual DB edit — so
|
||||
`test`, `development`, and `production` stay reproducible from
|
||||
`config/*.yaml` + `migration/`.
|
||||
7. **Mirror new code with a test** in `tests/{models,requests,...}/`. The
|
||||
snapshot tests (`.snap`) catch accidental output changes for free.
|
||||
|
||||
### When a feature genuinely outgrows a single file
|
||||
|
||||
If one area gets huge (say `shop` becomes 5+ concerns), you have two
|
||||
Loco-friendly options — both keep the layout intact:
|
||||
|
||||
- **Split by sub-area with more prefixes:** `shop_catalog.rs`,
|
||||
`shop_search.rs`, `shop_reviews.rs`.
|
||||
- **Promote a layer file to a folder module:** turn `controllers/shop.rs` into
|
||||
`controllers/shop/mod.rs` + `controllers/shop/{listing,detail,search}.rs`.
|
||||
Loco doesn't care; `mod.rs` just re-exports a `routes()`.
|
||||
|
||||
What you should *not* do is recreate top-level vertical slices (a `src/shop/`
|
||||
holding its own controllers+models+views). That's the layout this project
|
||||
already tried and reverted — it breaks `loco generate` and fights the framework.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Layers, not features.** `controllers/ models/ views/ ...` is deliberate and
|
||||
is what makes `loco generate` and Loco conventions work for you.
|
||||
- **Group features by filename prefix** (`admin_*`) inside the flat layers.
|
||||
- **Controllers are thin coordinators**; logic lives on models, shaping in views.
|
||||
- **`_entities/` is machine-owned; the sibling model file is yours.**
|
||||
- **`app.rs` registers everything** — add a line there for each new route/worker/task.
|
||||
- **Scale by adding files within layers**, splitting busy files into more
|
||||
prefixes or `mod.rs` folders — never by going back to vertical slices.
|
||||
@@ -1,4 +1,4 @@
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use serial_test::serial;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use serial_test::serial;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use serial_test::serial;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use serial_test::serial;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use serial_test::serial;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use serial_test::serial;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use serial_test::serial;
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ use insta::assert_debug_snapshot;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
|
||||
use serial_test::serial;
|
||||
use gitara_web::{
|
||||
account::models::users::{self, Model, RegisterParams},
|
||||
use kompress_eshop::{
|
||||
models::users::{self, Model, RegisterParams},
|
||||
app::App,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use insta::{assert_debug_snapshot, with_settings};
|
||||
use loco_rs::testing::prelude::*;
|
||||
use rstest::rstest;
|
||||
use serial_test::serial;
|
||||
use gitara_web::{account::models::users, app::App};
|
||||
use kompress_eshop::{models::users, app::App};
|
||||
|
||||
use super::prepare_data;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use loco_rs::{app::AppContext, TestServer};
|
||||
use gitara_web::{account::models::users, account::view::LoginResponse};
|
||||
use kompress_eshop::{models::users, views::auth::LoginResponse};
|
||||
|
||||
const USER_EMAIL: &str = "test@loco.com";
|
||||
const USER_PASSWORD: &str = "1234";
|
||||
|
||||
Reference in New Issue
Block a user