sidebar in the admin
This commit is contained in:
@@ -292,6 +292,7 @@ shop-subtitle = browse our products.
|
|||||||
shop-empty = There are no products here yet.
|
shop-empty = There are no products here yet.
|
||||||
categories = Categories
|
categories = Categories
|
||||||
all-products = All products
|
all-products = All products
|
||||||
|
uncategorized = Uncategorized
|
||||||
cart-title = Cart
|
cart-title = Cart
|
||||||
cart-empty = Your cart is empty.
|
cart-empty = Your cart is empty.
|
||||||
cart-total = Total
|
cart-total = Total
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ shop-subtitle = prezrite si našu ponuku produktov.
|
|||||||
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
||||||
categories = Kategórie
|
categories = Kategórie
|
||||||
all-products = Všetky produkty
|
all-products = Všetky produkty
|
||||||
|
uncategorized = Bez kategórie
|
||||||
cart-title = Košík
|
cart-title = Košík
|
||||||
cart-empty = Váš košík je prázdny.
|
cart-empty = Váš košík je prázdny.
|
||||||
cart-total = Spolu
|
cart-total = Spolu
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -54,7 +54,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="mt-4 {{ ui::table_wrap_cls() }}">
|
{% set category_base = "/admin/catalog/products" %}
|
||||||
|
{% set category_suffix = "&audience=" ~ audience %}
|
||||||
|
<div class="mt-4 flex flex-col gap-6 md:flex-row md:items-start">
|
||||||
|
{% include "admin/partials/category_filter.html" %}
|
||||||
|
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
|
||||||
{% if products | length > 0 %}
|
{% if products | length > 0 %}
|
||||||
<table class="{{ ui::table_cls() }}">
|
<table class="{{ ui::table_cls() }}">
|
||||||
<thead class="{{ ui::thead_cls() }}">
|
<thead class="{{ ui::thead_cls() }}">
|
||||||
@@ -138,5 +142,6 @@
|
|||||||
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -43,7 +43,11 @@
|
|||||||
|
|
||||||
<p class="mt-6 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=lang | default(value='sk')) }}</p>
|
<p class="mt-6 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
|
||||||
<div class="mt-3 {{ ui::table_wrap_cls() }}">
|
{% set category_base = "/admin/customers/" ~ customer.id %}
|
||||||
|
{% set category_suffix = "" %}
|
||||||
|
<div class="mt-3 flex flex-col gap-6 md:flex-row md:items-start">
|
||||||
|
{% include "admin/partials/category_filter.html" %}
|
||||||
|
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
|
||||||
{% if products | length > 0 %}
|
{% if products | length > 0 %}
|
||||||
<table class="{{ ui::table_cls() }}">
|
<table class="{{ ui::table_cls() }}">
|
||||||
<thead class="{{ ui::thead_cls() }}">
|
<thead class="{{ ui::thead_cls() }}">
|
||||||
@@ -91,5 +95,6 @@
|
|||||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
75
assets/views/admin/partials/category_filter.html
Normal file
75
assets/views/admin/partials/category_filter.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{# Category-filter sidebar for admin product listings. Clicking a category
|
||||||
|
reloads the page with `?category=<id>` so the table server-side filters to
|
||||||
|
that category and its descendants. Expects in context:
|
||||||
|
- category_groups: [{ id, name, count, children: [{ id, name, count }] }]
|
||||||
|
(from views::shop::admin_category_groups)
|
||||||
|
- selected_category: "all" | "none" | "<id>" — the active filter
|
||||||
|
- total_count, uncategorized_count: ints
|
||||||
|
- category_base: page path, e.g. "/admin/catalog/products"
|
||||||
|
- category_suffix: extra query appended after the category param, e.g.
|
||||||
|
"&audience=business", or "" — set by the including template.
|
||||||
|
|
||||||
|
The link treatment mirrors shop/_sidebar.html (Penguin UI), but active state
|
||||||
|
is server-driven via aria-current (these links share a path, differing only
|
||||||
|
by query, so markActiveNav() can't pick the active one — hence no data-nav).
|
||||||
|
Numeric compare uses `| int(default=0)` because Tera string==number is false. #}
|
||||||
|
{% set sel = selected_category | int(default=0) %}
|
||||||
|
{% set link_cls = "flex flex-1 items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong" %}
|
||||||
|
<aside class="w-full shrink-0 md:w-56">
|
||||||
|
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ t(key="categories", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<a href="{{ category_base }}?category=all{{ category_suffix }}"
|
||||||
|
{% if selected_category == "all" %}aria-current="page"{% endif %} class="{{ link_cls }}">
|
||||||
|
<span class="flex-1 truncate">{{ t(key="all-products", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ total_count }}</span>
|
||||||
|
</a>
|
||||||
|
{% for group in category_groups %}
|
||||||
|
{% set open_group = sel == group.id %}
|
||||||
|
{% for child in group.children %}{% if sel == child.id %}{% set_global open_group = true %}{% endif %}{% endfor %}
|
||||||
|
{% if group.children | length > 0 %}
|
||||||
|
<div x-data="{ open: {% if open_group %}true{% else %}false{% endif %} }" class="flex flex-col">
|
||||||
|
<div class="flex items-stretch">
|
||||||
|
<a href="{{ category_base }}?category={{ group.id }}{{ category_suffix }}"
|
||||||
|
{% if sel == group.id %}aria-current="page"{% endif %} class="{{ link_cls }} rounded-l-radius">
|
||||||
|
<span class="flex-1 truncate">{{ group.name }}</span>
|
||||||
|
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ group.count }}</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
|
||||||
|
aria-label="{{ group.name }}"
|
||||||
|
class="inline-flex w-8 shrink-0 items-center justify-center rounded-r-radius text-on-surface/60 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark/60 dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||||
|
class="size-5 shrink-0 transition-transform rotate-0" x-bind:class="open ? 'rotate-180' : 'rotate-0'" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul x-show="open" x-cloak x-transition class="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-outline pl-1 dark:border-outline-dark">
|
||||||
|
{% for child in group.children %}
|
||||||
|
<li class="flex">
|
||||||
|
<a href="{{ category_base }}?category={{ child.id }}{{ category_suffix }}"
|
||||||
|
{% if sel == child.id %}aria-current="page"{% endif %}
|
||||||
|
class="flex flex-1 items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
|
<span class="flex-1 truncate">{{ child.name }}</span>
|
||||||
|
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ child.count }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ category_base }}?category={{ group.id }}{{ category_suffix }}"
|
||||||
|
{% if sel == group.id %}aria-current="page"{% endif %} class="{{ link_cls }}">
|
||||||
|
<span class="flex-1 truncate">{{ group.name }}</span>
|
||||||
|
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ group.count }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<a href="{{ category_base }}?category=none{{ category_suffix }}"
|
||||||
|
{% if selected_category == "none" %}aria-current="page"{% endif %} class="{{ link_cls }}">
|
||||||
|
<span class="flex-1 truncate">{{ t(key="uncategorized", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ uncategorized_count }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
@@ -21,13 +21,14 @@ use crate::{
|
|||||||
controllers::i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
models::{
|
models::{
|
||||||
account_discount_profiles, account_product_prices, account_product_resolutions,
|
account_discount_profiles, account_product_prices, account_product_resolutions,
|
||||||
discount_profiles, products, _entities::users,
|
categories, discount_profiles, products, _entities::users,
|
||||||
},
|
},
|
||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
money::{format_bp, format_price, parse_price_to_cents},
|
money::{format_bp, format_price, parse_price_to_cents},
|
||||||
pricing,
|
pricing,
|
||||||
},
|
},
|
||||||
|
views::shop as view,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPANY: &str = "company";
|
const COMPANY: &str = "company";
|
||||||
@@ -131,6 +132,12 @@ async fn show(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let all_categories = categories::Entity::find()
|
||||||
|
.order_by_asc(categories::Column::Position)
|
||||||
|
.order_by_asc(categories::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let list = products::Entity::find()
|
let list = products::Entity::find()
|
||||||
.order_by_asc(products::Column::Name)
|
.order_by_asc(products::Column::Name)
|
||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
@@ -143,10 +150,22 @@ async fn show(
|
|||||||
let business = pricing::audience_price_many(&ctx, &list, BUSINESS_AUDIENCE).await?;
|
let business = pricing::audience_price_many(&ctx, &list, BUSINESS_AUDIENCE).await?;
|
||||||
let details = pricing::detail_many(&ctx, &list, Some(&company)).await?;
|
let details = pricing::detail_many(&ctx, &list, Some(&company)).await?;
|
||||||
|
|
||||||
|
// Category sidebar tree (counts over the full, unfiltered list) plus the
|
||||||
|
// active `?category=` filter applied to the rows.
|
||||||
|
let category_ids: Vec<Option<i32>> = list.iter().map(|p| p.category_id).collect();
|
||||||
|
let category_groups = view::admin_category_groups(&all_categories, &category_ids);
|
||||||
|
let selected_category = params
|
||||||
|
.get("category")
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("all")
|
||||||
|
.to_string();
|
||||||
|
let filter = view::category_filter_ids(&all_categories, &selected_category);
|
||||||
|
|
||||||
let rows: Vec<serde_json::Value> = list
|
let rows: Vec<serde_json::Value> = list
|
||||||
.iter()
|
.iter()
|
||||||
.zip(business.iter())
|
.zip(business.iter())
|
||||||
.zip(details.iter())
|
.zip(details.iter())
|
||||||
|
.filter(|((product, _), _)| view::category_filter_keep(&filter, product.category_id))
|
||||||
.map(|((product, b), d)| {
|
.map(|((product, b), d)| {
|
||||||
json!({
|
json!({
|
||||||
"product_id": product.id,
|
"product_id": product.id,
|
||||||
@@ -170,6 +189,10 @@ async fn show(
|
|||||||
"customer": { "id": company.id, "name": company.name, "email": company.email },
|
"customer": { "id": company.id, "name": company.name, "email": company.email },
|
||||||
"profiles": profiles_json,
|
"profiles": profiles_json,
|
||||||
"products": rows,
|
"products": rows,
|
||||||
|
"category_groups": category_groups,
|
||||||
|
"selected_category": selected_category,
|
||||||
|
"total_count": list.len(),
|
||||||
|
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
||||||
"error": params.get("error"),
|
"error": params.get("error"),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -126,6 +126,16 @@ async fn index(
|
|||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
let audience = read_audience(¶ms);
|
let audience = read_audience(¶ms);
|
||||||
|
let all_categories = categories::Entity::find()
|
||||||
|
.order_by_asc(categories::Column::Position)
|
||||||
|
.order_by_asc(categories::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let category_name: HashMap<i32, String> = all_categories
|
||||||
|
.iter()
|
||||||
|
.map(|c| (c.id, c.name.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let list = products::Entity::find()
|
let list = products::Entity::find()
|
||||||
.order_by_desc(products::Column::CreatedAt)
|
.order_by_desc(products::Column::CreatedAt)
|
||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
@@ -133,16 +143,27 @@ async fn index(
|
|||||||
// Effective price each product carries for the active audience, after the
|
// Effective price each product carries for the active audience, after the
|
||||||
// global per-product discount and any profiles assigned to that audience.
|
// global per-product discount and any profiles assigned to that audience.
|
||||||
let effective = pricing::audience_price_many(&ctx, &list, audience).await?;
|
let effective = pricing::audience_price_many(&ctx, &list, audience).await?;
|
||||||
|
|
||||||
|
// Category sidebar tree (counts over the full, unfiltered list) plus the
|
||||||
|
// active `?category=` filter applied to the rows.
|
||||||
|
let category_ids: Vec<Option<i32>> = list.iter().map(|p| p.category_id).collect();
|
||||||
|
let category_groups = view::admin_category_groups(&all_categories, &category_ids);
|
||||||
|
let selected_category = params
|
||||||
|
.get("category")
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("all")
|
||||||
|
.to_string();
|
||||||
|
let filter = view::category_filter_ids(&all_categories, &selected_category);
|
||||||
|
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
for (product, priced) in list.iter().zip(effective.iter()) {
|
for (product, priced) in list.iter().zip(effective.iter()) {
|
||||||
|
if !view::category_filter_keep(&filter, product.category_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let image = product_images::first_for(&ctx, product.id).await?;
|
let image = product_images::first_for(&ctx, product.id).await?;
|
||||||
let category_name = match product.category_id {
|
let category_name = product
|
||||||
Some(id) => categories::Entity::find_by_id(id)
|
.category_id
|
||||||
.one(&ctx.db)
|
.and_then(|id| category_name.get(&id).cloned());
|
||||||
.await?
|
|
||||||
.map(|c| c.name),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
rows.push(product_row(product, priced, image, category_name, audience));
|
rows.push(product_row(product, priced, image, category_name, audience));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +174,10 @@ async fn index(
|
|||||||
"products": rows,
|
"products": rows,
|
||||||
"profiles": load_audience_profiles(&ctx, audience).await?,
|
"profiles": load_audience_profiles(&ctx, audience).await?,
|
||||||
"audience": audience,
|
"audience": audience,
|
||||||
|
"category_groups": category_groups,
|
||||||
|
"selected_category": selected_category,
|
||||||
|
"total_count": list.len(),
|
||||||
|
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,3 +74,85 @@ pub fn sidebar_groups(categories: &[categories::Model]) -> Vec<Value> {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Admin category-filter tree for product listings: like [`sidebar_groups`] but
|
||||||
|
/// each node carries its `id` (links filter by `?category=<id>`) and a `count`
|
||||||
|
/// of matching products — the node's own products plus every descendant's, so a
|
||||||
|
/// parent's count covers its whole subtree. `category_ids` is each product's
|
||||||
|
/// `category_id` (`None` = uncategorized), taken over the full unfiltered list.
|
||||||
|
pub fn admin_category_groups(
|
||||||
|
categories: &[categories::Model],
|
||||||
|
category_ids: &[Option<i32>],
|
||||||
|
) -> Vec<Value> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let mut direct: HashMap<i32, usize> = HashMap::new();
|
||||||
|
for id in category_ids.iter().flatten() {
|
||||||
|
*direct.entry(*id).or_default() += 1;
|
||||||
|
}
|
||||||
|
let subtree_count = |id: i32| -> usize {
|
||||||
|
let mut n = direct.get(&id).copied().unwrap_or(0);
|
||||||
|
for d in crate::models::categories::descendant_ids(categories, id) {
|
||||||
|
n += direct.get(&d).copied().unwrap_or(0);
|
||||||
|
}
|
||||||
|
n
|
||||||
|
};
|
||||||
|
|
||||||
|
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!({ "id": child.id, "name": child.name, "count": subtree_count(child.id) })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
json!({
|
||||||
|
"id": category.id,
|
||||||
|
"name": category.name,
|
||||||
|
"count": subtree_count(category.id),
|
||||||
|
"children": children,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the `?category=` filter param against the category forest into the
|
||||||
|
/// set of `category_id`s a product may have to be shown. Returns:
|
||||||
|
/// - `None` for "all" (no filtering) or an unknown value,
|
||||||
|
/// - `Some(empty set)` for "none" (uncategorized — match products with no
|
||||||
|
/// category; callers treat an empty set as "uncategorized only"),
|
||||||
|
/// - `Some({id} ∪ descendants)` for a numeric category id.
|
||||||
|
pub fn category_filter_ids(
|
||||||
|
categories: &[categories::Model],
|
||||||
|
selected: &str,
|
||||||
|
) -> Option<std::collections::HashSet<i32>> {
|
||||||
|
match selected {
|
||||||
|
"all" => None,
|
||||||
|
"none" => Some(std::collections::HashSet::new()),
|
||||||
|
s => s.parse::<i32>().ok().map(|id| {
|
||||||
|
let mut set = crate::models::categories::descendant_ids(categories, id);
|
||||||
|
set.insert(id);
|
||||||
|
set
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a product with `category_id` passes the filter from
|
||||||
|
/// [`category_filter_ids`]: `None` keeps everything, an empty set keeps only
|
||||||
|
/// uncategorized products, a non-empty set keeps products in those categories.
|
||||||
|
pub fn category_filter_keep(
|
||||||
|
filter: &Option<std::collections::HashSet<i32>>,
|
||||||
|
category_id: Option<i32>,
|
||||||
|
) -> bool {
|
||||||
|
match filter {
|
||||||
|
None => true,
|
||||||
|
Some(set) if set.is_empty() => category_id.is_none(),
|
||||||
|
Some(set) => category_id.is_some_and(|id| set.contains(&id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user