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:
58
web/src/lib/stores/category_preference_store.ts
Normal file
58
web/src/lib/stores/category_preference_store.ts
Normal 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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
60
web/src/lib/stores/subcategory_preference_store.ts
Normal file
60
web/src/lib/stores/subcategory_preference_store.ts
Normal 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();
|
||||
}
|
||||
31
web/src/lib/stores/subcategory_store.ts
Normal file
31
web/src/lib/stores/subcategory_store.ts
Normal 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[];
|
||||
}
|
||||
@@ -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 } : {})
|
||||
}), {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user