feat: Refine trail category model (#1059)

* feat: advanced trail categories

* rename remote_category

* fix merge issues

* subcategories for plugins mapping

* fix refresh

* remove vertical trail filter category scrolling

* cleanup

* add confirm modal for disabling a category

* Fix federation issues

* fix review findings

* redesign category settings page

* remove prio badge

* fix subcategory badge layout

* optimize subcategory settings layout

* further settings page layout optimization

* fix subcategory icon position

* fix

* update docs

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
slothful-vassal
2026-06-29 03:16:26 +02:00
committed by GitHub
parent 50cb6c7f5f
commit 3b8f00fd26
116 changed files with 9363 additions and 453 deletions

View File

@@ -0,0 +1,58 @@
import type { UserCategoryPreference } from "$lib/models/category_preference";
import { APIError } from "$lib/util/api_util";
import { writable, type Writable } from "svelte/store";
export const categoryPreferences: Writable<UserCategoryPreference[]> = writable(
[],
);
export async function category_preferences_index(
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
const r = await f("/api/v1/user-category-preference", {
method: "GET",
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
const response = (await r.json()) as UserCategoryPreference[];
categoryPreferences.set(response);
return response;
}
export async function category_preferences_save(
preference: Pick<UserCategoryPreference, "category" | "visible">,
) {
const r = await fetch("/api/v1/user-category-preference", {
method: "PUT",
body: JSON.stringify(preference),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
const response = (await r.json()) as UserCategoryPreference;
categoryPreferences.update((preferences) => [
...preferences.filter((item) => item.id !== response.id),
response,
]);
return response;
}
export async function category_preferences_reorder(categories: string[]) {
const r = await fetch("/api/v1/user-category-preference/reorder", {
method: "POST",
body: JSON.stringify({ categories }),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
return await r.json();
}

View File

@@ -79,7 +79,7 @@ export async function lists_search_filter(filter: ListFilter, page: number = 1,
export async function lists_show(id: string, handle?: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f(`/api/v1/list/${id}?` + new URLSearchParams({
expand: "author,trails,trails.author,trails.category,trails.tags,list_share_via_list.actor",
expand: "author,trails,trails.author,trails.category,trails.subcategory,trails.subcategory.category,trails.tags,list_share_via_list.actor",
...(handle ? { handle } : {})
}), {
method: 'GET',
@@ -291,4 +291,4 @@ export async function searchResultToLists(hits: Hits<ListSearchResult>): Promise
}
return lists
}
}

View File

@@ -118,7 +118,7 @@ export async function profile_stats_index(handle: string, filter: SummitLogFilte
const r = await f(`/api/v1/profile/${handle}/stats?` + new URLSearchParams({
filter: filterText,
expand: "trail.category,author",
expand: "trail.category,trail.subcategory,trail.subcategory.category,author",
sort: "+date",
}), {
method: 'GET',
@@ -155,4 +155,4 @@ export async function profile_follows_index(handle: string, type: "followers" |
follows = result;
return { ...fetchedFollows, items: result };
}
}

View File

@@ -0,0 +1,60 @@
import type { UserSubcategoryPreference } from "$lib/models/subcategory_preference";
import { APIError } from "$lib/util/api_util";
import { writable, type Writable } from "svelte/store";
export const subcategoryPreferences: Writable<UserSubcategoryPreference[]> =
writable([]);
export async function subcategory_preferences_index(
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
const r = await f("/api/v1/user-subcategory-preference", {
method: "GET",
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
const response = (await r.json()) as UserSubcategoryPreference[];
subcategoryPreferences.set(response);
return response;
}
export async function subcategory_preferences_save(
preference: Pick<UserSubcategoryPreference, "subcategory" | "visible">,
) {
const r = await fetch("/api/v1/user-subcategory-preference", {
method: "PUT",
body: JSON.stringify(preference),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
const response = (await r.json()) as UserSubcategoryPreference;
subcategoryPreferences.update((preferences) => [
...preferences.filter((item) => item.id !== response.id),
response,
]);
return response;
}
export async function subcategory_preferences_reorder(
category: string,
subcategories: string[],
) {
const r = await fetch("/api/v1/user-subcategory-preference/reorder", {
method: "POST",
body: JSON.stringify({ category, subcategories }),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
return await r.json();
}

View File

@@ -0,0 +1,31 @@
import type { Subcategory } from "$lib/models/subcategory";
import { APIError } from "$lib/util/api_util";
import { type ListResult } from "pocketbase";
import { writable, type Writable } from "svelte/store";
export const subcategories: Writable<Subcategory[]> = writable([]);
export async function subcategories_index(
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
const r = await f(
"/api/v1/subcategory?" +
new URLSearchParams({
perPage: "-1",
expand: "category",
sort: "category,name",
}),
{
method: "GET",
},
);
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
const response: ListResult<Subcategory> = await r.json();
subcategories.set(response.items);
return response.items as Subcategory[];
}

View File

@@ -13,7 +13,7 @@ export async function summit_logs_index(filter?: SummitLogFilter, handle?: strin
const r = await f('/api/v1/summit-log?' + new URLSearchParams({
...(filter ? { filter: buildFilterText(filter) } : {}),
perPage: "-1",
expand: "trail.category,author",
expand: "trail.category,trail.subcategory,trail.subcategory.category,author",
sort: "+date",
...(handle ? { handle } : {})
}), {

View File

@@ -1,16 +1,19 @@
import type { SummitLog } from "$lib/models/summit_log";
import type { Tag } from "$lib/models/tag";
import { MAP_MAX_POLYLINES } from "$lib/config/map";
import { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailFilterValues, type TrailSearchResult } from "$lib/models/trail";
import { defaultTrailSearchAttributes, Trail, type TrailBoundingBox, type TrailFilter, type TrailFilterValues, type TrailSearchResult } from "$lib/models/trail";
import type { Waypoint } from "$lib/models/waypoint";
import { APIError } from "$lib/util/api_util";
import { deepEqual } from "$lib/util/deep_util";
import { getFileURL, objectToFormData } from "$lib/util/file_util";
import { noSubcategoryFilterCategory } from "$lib/util/trail_filter_util";
import * as M from "maplibre-gl";
import type { Hits } from "meilisearch";
import { type AuthRecord, type ListResult, type RecordModel } from "pocketbase";
import { get, writable, type Writable } from "svelte/store";
import { summit_logs_create, summit_logs_delete, summit_logs_update } from "./summit_log_store";
import { categories } from "./category_store";
import { subcategories } from "./subcategory_store";
import { tags_create } from "./tag_store";
import { currentUser } from "./user_store";
import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store";
@@ -18,7 +21,7 @@ import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint
export async function trails_index(perPage: number = 21, random: boolean = false, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/trail?' + new URLSearchParams({
"perPage": perPage.toString(),
expand: "category,waypoints_via_trail,summit_logs_via_trail,tags",
expand: "category,subcategory,subcategory.category,waypoints_via_trail,summit_logs_via_trail,tags",
sort: random ? "@random" : "",
}), {
method: 'GET',
@@ -322,7 +325,7 @@ export async function trails_search_bounding_box(
export async function trails_show(id: string, handle?: string, share?: string, loadGPX?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f(`/api/v1/trail/${id}?` + new URLSearchParams({
expand: "category,waypoints_via_trail,summit_logs_via_trail,summit_logs_via_trail.author,trail_share_via_trail.actor,trail_like_via_trail,tags,author",
expand: "category,subcategory,subcategory.category,waypoints_via_trail,summit_logs_via_trail,summit_logs_via_trail.author,trail_share_via_trail.actor,trail_like_via_trail,tags,author",
...(handle ? { handle } : {}),
...(share ? { share } : {})
}), {
@@ -393,7 +396,7 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl
}
let r = await f(`/api/v1/trail/form?` + new URLSearchParams({
expand: "category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags",
expand: "category,subcategory,subcategory.category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags",
}), {
method: 'PUT',
body: formData,
@@ -520,7 +523,7 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
const updateUrl = `/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({
expand: "category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags",
expand: "category,subcategory,subcategory.category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags",
});
let r = await fetch(updateUrl, {
@@ -625,7 +628,7 @@ export async function trails_get_filter_values(f: (url: RequestInfo | URL, confi
}
export async function trails_get_bounding_box(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<TrailFilterValues> {
export async function trails_get_bounding_box(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<TrailBoundingBox> {
const r = await f('/api/v1/trail/bounding-box', {
method: 'GET',
})
@@ -687,9 +690,18 @@ export async function fetchGPX(trail: { gpx?: string } & Record<string, any>, f:
export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<Trail[]> {
const trails: Trail[] = []
const categoryById = new Map(get(categories).map((category) => [category.id, category]));
const subcategoryById = new Map(
get(subcategories).map((subcategory) => [subcategory.id, subcategory]),
);
for (const h of hits) {
const created = Number(h.created || 0);
const date = Number(h.date || 0);
const category = h.category_id ? categoryById.get(h.category_id) : undefined;
const subcategory = h.subcategory_id
? subcategoryById.get(h.subcategory_id)
: undefined;
const t: Trail & RecordModel = {
collectionId: "trails",
collectionName: "trails",
@@ -702,7 +714,8 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
summit_logs: [],
waypoints: [],
tags: h.tags ?? [],
category: h.category,
category: h.category_id ?? "",
subcategory: h.subcategory_id ?? "",
created: new Date(created * 1000).toISOString(),
date: new Date(date * 1000).toISOString(),
description: h.description,
@@ -723,6 +736,14 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
thumbnail: 0,
like_count: h.like_count,
expand: {
category: category ?? (h.category
? {
id: h.category_id ?? "",
name: h.category,
icon: h.category_icon,
}
: undefined),
subcategory,
author: {
collectionId: "activitypub_actors",
is_local: (h.domain?.length ?? 0) == 0,
@@ -838,9 +859,52 @@ function buildFilterText(user: AuthRecord, filter: TrailFilter, includeGeo: bool
filterText += ` AND date <= ${new Date(filter.endDate).getTime() / 1000}`
}
if (filter.category.length > 0) {
const categoryValues = filter.category.map(category => `'${category}'`).join(", ");
filterText += ` AND category IN [${categoryValues}]`;
const selectedSubcategoryIds = filter.subcategory ?? [];
if (filter.category.length > 0 || selectedSubcategoryIds.length > 0) {
const selectedNoSubcategoryCategoryIds = selectedSubcategoryIds
.map(noSubcategoryFilterCategory)
.filter((category): category is string => category !== undefined);
const selectedRealSubcategoryIds = selectedSubcategoryIds.filter(
(id) => noSubcategoryFilterCategory(id) === undefined,
);
const selectedSubcategoryParentIds = new Set(
get(subcategories)
.filter((subcategory) => selectedRealSubcategoryIds.includes(subcategory.id))
.map((subcategory) => subcategory.category),
);
for (const categoryId of selectedNoSubcategoryCategoryIds) {
selectedSubcategoryParentIds.add(categoryId);
}
const categoriesWithoutSubcategoryFilter = filter.category.filter(
(category) => !selectedSubcategoryParentIds.has(category),
);
const categoryParts: string[] = [];
if (categoriesWithoutSubcategoryFilter.length > 0) {
const categoryValues = categoriesWithoutSubcategoryFilter
.map(category => `'${category}'`)
.join(", ");
categoryParts.push(`category_id IN [${categoryValues}]`);
}
if (selectedNoSubcategoryCategoryIds.length > 0) {
const noSubcategoryParts = selectedNoSubcategoryCategoryIds.map(
(category) =>
`(category_id = '${category}' AND subcategory_id IS NULL)`,
);
categoryParts.push(...noSubcategoryParts);
}
if (selectedRealSubcategoryIds.length > 0) {
const subcategoryValues = selectedRealSubcategoryIds
.map(subcategory => `'${subcategory}'`)
.join(", ");
categoryParts.push(`subcategory_id IN [${subcategoryValues}]`);
}
if (categoryParts.length > 0) {
filterText += ` AND (${categoryParts.join(" OR ")})`;
}
}
if (filter.tags.length > 0) {