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

@@ -1,8 +1,9 @@
<script lang="ts">
import type { SummitLog } from "$lib/models/summit_log";
import { range } from "$lib/util/array_util";
import { displayCategoryName } from "$lib/util/category_util";
import { isSameDay, isToday } from "../../util/date_util";
import { _, date } from "svelte-i18n";
import { _, date, locale } from "svelte-i18n";
interface Props {
logs?: SummitLog[];
colorMap?: Record<string, string>;
@@ -99,7 +100,10 @@
}
function colorKey(a: typeof currentMonthArray, i: number) {
return $_(a[i]?.log?.expand?.trail?.expand?.category?.name ?? "");
return displayCategoryName(
a[i]?.log?.expand?.trail?.expand?.category,
$locale,
);
}
function handleDateClick(date?: Date) {

View File

@@ -8,6 +8,7 @@
children?: Snippet<[any]>;
content?: Snippet;
footer?: Snippet<[any]>;
onclose?: () => void;
}
let {
@@ -17,6 +18,7 @@
children,
content,
footer,
onclose,
}: Props = $props();
export function openModal() {
@@ -42,6 +44,7 @@
{id}
tabindex="-1"
aria-hidden="true"
onclose={() => onclose?.()}
class="{size} max-h-full rounded-xl text-content"
>
<!-- Modal content -->

View File

@@ -1,37 +0,0 @@
<script lang="ts">
import type { Category } from "$lib/models/category";
import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
interface Props {
category: Category;
}
let { category }: Props = $props();
</script>
<div
class="category-card relative rounded-2xl shadow-md max-h-48 aspect-video overflow-hidden cursor-pointer"
>
<img
class="w-full h-full"
src={getFileURL(category, category.img)}
alt=""
/>
<div
class="absolute bottom-0 w-full h-1/2 bg-gradient-to-b from-transparent to-black opacity-50"
></div>
<h5 class="absolute text-white font-bold bottom-4 left-4 text-xl">
{$_(category.name)}
</h5>
</div>
<style>
.category-card img {
object-fit: cover;
transition: 0.25s ease;
}
.category-card:hover img {
scale: 1.075;
}
</style>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import type { Snippet } from "svelte";
import { _ } from "svelte-i18n";
interface Props {
@@ -12,6 +13,7 @@
onconfirm?: () => void
oncancel?: () => void
onalternative?: () => void
children?: Snippet
}
let {
@@ -23,34 +25,52 @@
id = "confirm-modal",
onconfirm,
oncancel,
onalternative
onalternative,
children,
}: Props = $props();
let modal: Modal;
let closingFromAction = false;
export function openModal() {
closingFromAction = false;
modal.openModal();
}
function handleModalClose() {
if (closingFromAction) {
closingFromAction = false;
return;
}
oncancel?.();
}
function cancel() {
closingFromAction = true;
modal.closeModal!();
oncancel?.();
}
function alternativeAction() {
closingFromAction = true;
modal.closeModal!();
onalternative?.();
}
function confirm() {
closingFromAction = true;
modal.closeModal!();
onconfirm?.()
}
</script>
<Modal {id} {title} bind:this={modal}>
<Modal {id} {title} bind:this={modal} onclose={handleModalClose}>
{#snippet content()}
<p>{text}</p>
{#if children}
{@render children()}
{:else}
<p>{text}</p>
{/if}
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">

View File

@@ -10,7 +10,13 @@
formatTimeHHMM,
formatTimeSince,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import {
displayCategoryIcon,
displayCategoryName,
displaySubcategoryIcon,
displaySubcategoryLabel,
} from "$lib/util/category_util";
import { _, locale } from "svelte-i18n";
import TrailDropdown from "../trail/trail_dropdown.svelte";
interface Props {
feedItem: FeedItem;
@@ -26,11 +32,24 @@
const photos = $derived((feedItem.expand.item as Trail).photos);
const location = $derived((feedItem.expand.item as Trail).location);
const category = $derived((feedItem.expand.item as Trail).expand?.category?.name);
const category = $derived(
(feedItem.expand.item as Trail).expand?.category,
);
const subcategory = $derived(
(feedItem.expand.item as Trail).expand?.subcategory,
);
const trails = $derived((feedItem.expand.item as List).trails);
const author = $derived(feedItem.expand.item.expand?.author);
function feedCategoryIcon() {
if (subcategory) {
return displaySubcategoryIcon(subcategory, category);
}
return displayCategoryIcon(category);
}
</script>
<div class="feed-card px-6 py-4 rounded-xl border border-input-border">
@@ -78,7 +97,20 @@
<div class="flex flex-wrap gap-x-8 gap-y-1">
{#if category}
<p>
<i class="fa fa-shapes mr-3"> </i>{$_(category)}
<i
class="fa {feedCategoryIcon()} mr-3"
></i>{displayCategoryName(
category,
$locale,
)}
{#if subcategory}
<span class="text-gray-500">
/ {displaySubcategoryLabel(
subcategory,
$locale,
)}
</span>
{/if}
</p>
{/if}
{#if location}

View File

@@ -6,11 +6,18 @@
import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import PluginMergeSettings from "$lib/components/settings/plugins/plugin_merge_settings.svelte";
import CategoryPicker from "$lib/components/trail/category_picker.svelte";
import type { Category } from "$lib/models/category";
import type { PluginInstance } from "$lib/models/plugin_instance";
import type { ConfigField, PluginProvider } from "$lib/models/plugin_provider";
import type { Subcategory } from "$lib/models/subcategory";
import { plugin_auth_validate, plugin_oauth_start } from "$lib/stores/plugin_instance_store";
import { show_toast } from "$lib/stores/toast_store.svelte";
import {
categoryMappingTargetFromUnknown,
categoryMappingTargetToPickerValue,
type CategoryMappingTarget,
} from "$lib/util/category_util";
import { translatePluginAPIError } from "$lib/util/plugin_error_i18n";
import {
configFieldDescription,
@@ -34,12 +41,13 @@
interface Props {
plugin: PluginProvider;
categories?: Category[];
subcategories?: Subcategory[];
instance?: PluginInstance;
onbeforecategorymappingsave?: (instance: PluginInstanceForm) => Promise<boolean> | boolean;
onsave?: (instance: Partial<PluginInstance>) => Promise<PluginInstance | void> | PluginInstance | void;
}
let { plugin, categories = [], instance, onbeforecategorymappingsave, onsave }: Props = $props();
let { plugin, categories = [], subcategories = [], instance, onbeforecategorymappingsave, onsave }: Props = $props();
let modal: Modal;
let auth: Record<string, string> = $state(initialAuth());
@@ -73,14 +81,6 @@
);
let mergeAvailable = $derived((hostConfig().merge as any)?.available !== false);
let supportsCategoryMapping = $derived(supportsPlanned || supportsCompleted);
let categorySelectItems: SelectItem[] = $derived(
categories
.map((category) => ({
text: $_(category.name),
value: category.id,
}))
.sort((a, b) => a.text.localeCompare(b.text, $locale ?? undefined)),
);
let providerCategorySelectItems: SelectItem[] = $derived(providerCategoryItems());
let canAddCategoryMappingRow = $derived(
categoryMappingRows.every((row) => row.providerCategory && row.category) &&
@@ -143,52 +143,108 @@
function initialCategoryMappingRows(): CategoryMappingRow[] {
return Object.entries(categoryMapping())
.filter(([, category]) => category !== "")
.map(([providerCategory, category]) => ({
.filter(([, target]) => !isBlankCategoryMappingTarget(target))
.map(([providerCategory, target]) => ({
providerCategory,
category: categoryTargetValue(category),
category: categoryPickerValue(target),
}));
}
function manifestCategoryMapping(): Record<string, string> {
function manifestCategoryMapping(): Record<string, CategoryMappingTarget> {
const raw = plugin.hostConfig?.categoryMapping;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
return stringMapping(raw as Record<string, unknown>);
return categoryTargetMapping(raw as Record<string, unknown>);
}
function categoryMapping(): Record<string, string> {
function categoryMapping(): Record<string, CategoryMappingTarget> {
const raw = hostConfig().categoryMapping;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
return stringMapping(raw as Record<string, unknown>);
return categoryTargetMapping(raw as Record<string, unknown>);
}
function categoryMappingsEqual(
left: Record<string, string>,
right: Record<string, string>,
left: Record<string, CategoryMappingTarget>,
right: Record<string, CategoryMappingTarget>,
) {
const leftKeys = Object.keys(left).sort();
const rightKeys = Object.keys(right).sort();
if (leftKeys.length !== rightKeys.length) {
return false;
}
return leftKeys.every((key, index) => key === rightKeys[index] && left[key] === right[key]);
}
function stringMapping(raw: Record<string, unknown>): Record<string, string> {
return Object.fromEntries(
Object.entries(raw)
.filter(([, value]) => typeof value === "string")
.map(([key, value]) => [key, value as string]),
return leftKeys.every(
(key, index) =>
key === rightKeys[index] &&
normalizedCategoryMappingTarget(left[key]) === normalizedCategoryMappingTarget(right[key]),
);
}
function categoryTargetValue(value: string): string {
const match = categories.find((category) => category.id === value || category.name === value);
return match?.id ?? value;
function categoryTargetMapping(raw: Record<string, unknown>): Record<string, CategoryMappingTarget> {
return Object.fromEntries(
Object.entries(raw)
.map(([key, value]) => [
key,
categoryMappingTargetFromUnknown(value),
])
.filter(([, value]) => value !== undefined),
);
}
function isBlankCategoryMappingTarget(target: CategoryMappingTarget): boolean {
if (typeof target === "string") {
return target.trim() === "";
}
return !target.category?.trim() && !target.subcategory?.trim();
}
function normalizedCategoryMappingTarget(target: CategoryMappingTarget): string {
if (typeof target === "string") {
return target.trim();
}
return JSON.stringify({
category: target.category?.trim() ?? "",
subcategory: target.subcategory?.trim() ?? "",
});
}
function categoryPickerValue(target: CategoryMappingTarget): string {
return categoryMappingTargetToPickerValue(
target,
categories,
subcategories,
);
}
function categoryMappingTargetFromPickerValue(value: string): CategoryMappingTarget {
if (value.startsWith("subcategory:")) {
const subcategoryId = value.replace("subcategory:", "");
const subcategory = subcategories.find((candidate) => candidate.id === subcategoryId);
return {
category: subcategory?.category ?? "",
subcategory: subcategoryId,
};
}
if (value.startsWith("category:")) {
return {
category: value.replace("category:", ""),
subcategory: "",
};
}
return "";
}
function categoryPickerCurrentCategoryId(value: string): string | null {
if (value.startsWith("category:")) {
return value.replace("category:", "");
}
if (value.startsWith("subcategory:")) {
const subcategoryId = value.replace("subcategory:", "");
return subcategories.find((candidate) => candidate.id === subcategoryId)?.category ?? null;
}
return null;
}
function providerCategoryItems(): SelectItem[] {
@@ -389,7 +445,7 @@
...currentMergeConfig,
enabled: mergeAvailable && mergeEnabled,
};
const categoryMappingConfig: Record<string, string> = {};
const categoryMappingConfig: Record<string, CategoryMappingTarget> = {};
const assignedProviderCategories = new Set<string>();
for (const row of categoryMappingRows) {
const providerCategory = row.providerCategory.trim();
@@ -397,7 +453,7 @@
continue;
}
assignedProviderCategories.add(providerCategory);
categoryMappingConfig[providerCategory] = row.category;
categoryMappingConfig[providerCategory] = categoryMappingTargetFromPickerValue(row.category);
}
for (const providerCategory of [
...Object.keys(manifestCategoryMapping()),
@@ -651,7 +707,7 @@
{/if}
{/each}
{#if supportsCategoryMapping && categorySelectItems.length > 0}
{#if supportsCategoryMapping && categories.length > 0}
<div class="space-y-2 pt-4 border-t border-input-border">
<div class="flex items-center justify-between gap-3">
<div>
@@ -671,7 +727,7 @@
{#if categoryMappingRows.length > 0}
<div class="hidden md:grid grid-cols-[minmax(0,1.2fr)_minmax(14rem,1fr)_2.75rem] gap-3 text-sm font-medium">
<span>{$_("provider-category")}</span>
<span>{$_("category")}</span>
<span>{$_("category")} / {$_("subcategory")}</span>
<span></span>
</div>
<div
@@ -694,13 +750,18 @@
bind:value={row.providerCategory}
disabled={row.providerCategory !== "" && providerItems.length <= 1}
></SingleSelect>
<SingleSelect
ariaLabel={$_("category")}
<CategoryPicker
value={row.category}
label=""
placeholder={$_("select-category")}
items={categorySelectItems}
bind:value={row.category}
disabled={row.category !== "" && categorySelectItems.length <= 1}
></SingleSelect>
currentCategoryId={categoryPickerCurrentCategoryId(row.category)}
fixedDropdown
onchange={(selection) => {
row.category = selection.subcategory
? `subcategory:${selection.subcategory}`
: `category:${selection.category}`;
}}
></CategoryPicker>
<button
class="btn-icon h-10"
type="button"

View File

@@ -8,7 +8,8 @@
formatHTMLAsText,
formatTimeHHMM,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import { displayCategoryName } from "$lib/util/category_util";
import { _, locale } from "svelte-i18n";
import PhotoGallery from "../photo_gallery.svelte";
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
@@ -159,7 +160,14 @@
</td>
{#if showCategory}
<td>
{$_(log.expand?.trail?.expand?.category?.name ?? "-")}
{#if log.expand?.trail?.expand?.category}
{displayCategoryName(
log.expand.trail.expand.category,
$locale,
)}
{:else}
-
{/if}
</td>
{/if}
{#if showTrail}

View File

@@ -0,0 +1,340 @@
<script module lang="ts">
export type CategoryPickerSelection = {
category: string;
subcategory: string;
};
</script>
<script lang="ts">
import { categories, categories_index } from "$lib/stores/category_store";
import {
categoryPreferences,
category_preferences_index,
} from "$lib/stores/category_preference_store";
import {
subcategories,
subcategories_index,
} from "$lib/stores/subcategory_store";
import {
subcategoryPreferences,
subcategory_preferences_index,
} from "$lib/stores/subcategory_preference_store";
import {
designSelectableCategories,
displayCategoryIcon,
displayCategoryName,
displaySubcategoryBadgeIcon,
displaySubcategoryIcon,
displaySubcategoryLabel,
sortedSubcategoriesByPreference,
subcategoryVisible,
} from "$lib/util/category_util";
import { onDestroy, onMount } from "svelte";
import { _, locale } from "svelte-i18n";
type CategoryPickerItem = {
text: string;
detail?: string;
value: string;
icon: string;
badgeIcon?: string;
};
interface Props {
value?: string;
label?: string;
placeholder?: string;
hiddenInputs?: boolean;
loadData?: boolean;
currentCategoryId?: string | null;
fixedDropdown?: boolean;
onchange?: (selection: CategoryPickerSelection) => void;
}
let {
value = "",
label = $_("category"),
placeholder = $_("category"),
hiddenInputs = false,
loadData = false,
currentCategoryId = null,
fixedDropdown = false,
onchange,
}: Props = $props();
let open = $state(false);
let buttonElement: HTMLButtonElement;
let dropdownElement: HTMLUListElement | undefined = $state();
let dropdownStyle = $state("");
let fixedDropdownListenersAttached = false;
let selectedSubcategoryId = $derived(
value.startsWith("subcategory:") ? value.replace("subcategory:", "") : "",
);
let items: CategoryPickerItem[] = $derived(
designSelectableCategories(
$categories,
$categoryPreferences,
$locale,
currentCategoryId,
).flatMap((category): CategoryPickerItem[] => [
{
text: displayCategoryName(category, $locale),
value: `category:${category.id}`,
icon: displayCategoryIcon(category),
},
...sortedSubcategoriesByPreference(
$subcategories.filter(
(subcategory) =>
subcategory.category === category.id &&
(subcategoryVisible(
subcategory.id,
$subcategoryPreferences,
) ||
subcategory.id === selectedSubcategoryId),
),
$subcategoryPreferences,
$locale,
)
.map((subcategory) => ({
text: displayCategoryName(category, $locale),
detail: displaySubcategoryLabel(subcategory, $locale),
value: `subcategory:${subcategory.id}`,
icon: displaySubcategoryIcon(subcategory, category),
badgeIcon: displaySubcategoryBadgeIcon(subcategory),
})),
]),
);
let selectedItem = $derived(items.find((item) => item.value === value));
let hiddenCategory = $derived(resolveSelection(value)?.category ?? "");
let hiddenSubcategory = $derived(resolveSelection(value)?.subcategory ?? "");
onMount(async () => {
if (!loadData) {
return;
}
await Promise.all([
categories_index(),
subcategories_index(),
category_preferences_index(),
subcategory_preferences_index(),
]);
});
function resolveSelection(nextValue: string): CategoryPickerSelection | undefined {
if (nextValue.startsWith("subcategory:")) {
const subcategoryId = nextValue.replace("subcategory:", "");
const subcategory = $subcategories.find(
(item) => item.id === subcategoryId,
);
if (!subcategory) {
return undefined;
}
return {
category: subcategory.category,
subcategory: subcategory.id,
};
}
if (nextValue.startsWith("category:")) {
return {
category: nextValue.replace("category:", ""),
subcategory: "",
};
}
return undefined;
}
function handlePickerClick(e: MouseEvent) {
e.stopPropagation();
}
function handleWindowClick() {
closePicker();
}
function updateDropdownPosition() {
if (!fixedDropdown || !buttonElement) {
dropdownStyle = "";
return;
}
const rect = buttonElement.getBoundingClientRect();
const viewportPadding = 16;
const left = Math.max(
viewportPadding,
Math.min(rect.left, window.innerWidth - viewportPadding - rect.width),
);
dropdownStyle = [
"position: fixed",
`top: ${rect.bottom + 4}px`,
`left: ${left}px`,
`min-width: ${rect.width}px`,
`max-height: min(18rem, calc(100vh - ${rect.bottom + viewportPadding + 4}px))`,
].join("; ");
}
function handleFixedDropdownResize() {
if (!open || !fixedDropdown) {
return;
}
updateDropdownPosition();
}
function handleFixedDropdownScroll(e: Event) {
if (!open || !fixedDropdown) {
return;
}
if (
e.target instanceof Node &&
dropdownElement?.contains(e.target)
) {
return;
}
closePicker();
}
function attachFixedDropdownListeners() {
if (!fixedDropdown || fixedDropdownListenersAttached) {
return;
}
window.addEventListener("scroll", handleFixedDropdownScroll, true);
window.addEventListener("resize", handleFixedDropdownResize);
fixedDropdownListenersAttached = true;
}
function detachFixedDropdownListeners() {
if (!fixedDropdownListenersAttached) {
return;
}
window.removeEventListener("scroll", handleFixedDropdownScroll, true);
window.removeEventListener("resize", handleFixedDropdownResize);
fixedDropdownListenersAttached = false;
}
function togglePicker() {
if (!open) {
updateDropdownPosition();
open = true;
attachFixedDropdownListeners();
return;
}
closePicker();
}
function closePicker() {
open = false;
detachFixedDropdownListeners();
}
function selectItem(item: CategoryPickerItem) {
const selection = resolveSelection(item.value);
if (!selection) {
return;
}
value = item.value;
onchange?.(selection);
closePicker();
}
onDestroy(() => {
detachFixedDropdownListeners();
});
</script>
<svelte:window onmouseup={handleWindowClick} />
<div class="relative" role="presentation" onmouseup={handlePickerClick}>
{#if hiddenInputs}
<input type="hidden" name="category" value={hiddenCategory} />
<input type="hidden" name="subcategory" value={hiddenSubcategory} />
{/if}
{#if label}
<label for="category-picker" class="text-sm font-medium pb-1">
{label}
</label>
{/if}
<button
id="category-picker"
bind:this={buttonElement}
type="button"
class="relative flex h-10 w-full cursor-pointer items-center justify-between gap-3 rounded-md border border-input-border bg-input-background px-4 pr-10 transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0"
onclick={togglePicker}
>
<span class="flex min-w-0 items-center gap-3">
{#if selectedItem}
<span class="relative w-4 shrink-0 text-center">
<i class="fa {selectedItem.icon}"></i>
{#if selectedItem.badgeIcon}
<i
class="fa {selectedItem.badgeIcon} absolute -right-1 -top-1 text-[8px]"
></i>
{/if}
</span>
<span class="truncate">
{selectedItem.detail?.trim() ?? selectedItem.text.trim()}
</span>
{:else}
<i
class="fa fa-shapes w-4 shrink-0 text-center text-gray-500"
></i>
<span class="truncate text-gray-500">
{placeholder}
</span>
{/if}
</span>
<i
class="fa fa-caret-down absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 transition-transform"
class:rotate-180={open}
></i>
</button>
{#if open}
<ul
bind:this={dropdownElement}
class="{fixedDropdown
? 'fixed z-50 min-w-full w-max max-w-[calc(100vw-2rem)] overflow-y-auto rounded-md border border-input-border bg-menu-background shadow-lg'
: 'absolute z-10 mt-1 max-h-72 min-w-full w-max max-w-[calc(100vw-2rem)] overflow-y-auto rounded-md border border-input-border bg-menu-background shadow-lg'}"
style={dropdownStyle}
>
{#each items as item}
<li>
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus"
class:bg-menu-item-background-focus={item.value === value}
onclick={() => selectItem(item)}
>
<span class="relative w-4 shrink-0 text-center">
<i class="fa {item.icon}"></i>
{#if item.badgeIcon}
<i
class="fa {item.badgeIcon} absolute -right-1 -top-1 text-[8px]"
></i>
{/if}
</span>
<span class="whitespace-nowrap">
{item.text.trim()}
{#if item.detail}
<span class="text-gray-500">
/ {item.detail.trim()}
</span>
{/if}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>

View File

@@ -0,0 +1,167 @@
<script module lang="ts">
export type TrailBulkEditChanges = {
category?: {
category: string;
subcategory: string;
};
difficulty?: "easy" | "moderate" | "difficult";
};
</script>
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import Select from "$lib/components/base/select.svelte";
import CategoryPicker, {
type CategoryPickerSelection,
} from "./category_picker.svelte";
import { _ } from "svelte-i18n";
interface Props {
selectedCount?: number;
initialCategorySelection?: CategoryPickerSelection;
initialDifficulty?: TrailBulkEditChanges["difficulty"];
onapply?: (changes: TrailBulkEditChanges) => Promise<void> | void;
}
let {
selectedCount = 0,
initialCategorySelection,
initialDifficulty,
onapply,
}: Props = $props();
let modal: Modal;
let applyCategory = $state(false);
let applyDifficulty = $state(false);
let categorySelection: CategoryPickerSelection | undefined = $state();
let categoryValue = $state("");
let difficultyValue: "easy" | "moderate" | "difficult" = $state("easy");
let loading = $state(false);
let canApply = $derived(
(applyCategory && categorySelection !== undefined) || applyDifficulty,
);
export function openModal() {
applyCategory = false;
applyDifficulty = false;
categorySelection = initialCategorySelection;
categoryValue = categoryValueFromSelection(initialCategorySelection);
difficultyValue = initialDifficulty ?? "easy";
modal.openModal();
}
function categoryValueFromSelection(selection?: CategoryPickerSelection) {
if (!selection) {
return "";
}
return selection.subcategory
? `subcategory:${selection.subcategory}`
: `category:${selection.category}`;
}
function handleCategoryChange(selection: CategoryPickerSelection) {
categorySelection = selection;
categoryValue = selection.subcategory
? `subcategory:${selection.subcategory}`
: `category:${selection.category}`;
}
async function apply(closeModal: () => void) {
if (!canApply) {
return;
}
const changes: TrailBulkEditChanges = {};
if (applyCategory) {
if (!categorySelection) {
return;
}
changes.category = categorySelection;
}
if (applyDifficulty) {
changes.difficulty = difficultyValue;
}
loading = true;
try {
await onapply?.(changes);
closeModal();
} finally {
loading = false;
}
}
</script>
<Modal
id="trail-bulk-edit-modal"
title={$_("adjust")}
size="md:min-w-sm"
bind:this={modal}
>
{#snippet content()}
<p class="text-sm text-gray-500">
{$_("bulk-edit-selected-trails", {
values: { n: selectedCount },
})}
</p>
<div class="space-y-4">
<label class="flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 accent-primary"
bind:checked={applyCategory}
/>
<span class="font-medium">{$_("category")}</span>
</label>
{#if applyCategory}
<CategoryPicker
value={categoryValue}
label=""
loadData
fixedDropdown
onchange={handleCategoryChange}
></CategoryPicker>
{/if}
<label class="flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 accent-primary"
bind:checked={applyDifficulty}
/>
<span class="font-medium">{$_("difficulty")}</span>
</label>
{#if applyDifficulty}
<Select
value={difficultyValue}
onchange={(value) => (difficultyValue = value)}
items={[
{ text: $_("easy"), value: "easy" },
{ text: $_("moderate"), value: "moderate" },
{ text: $_("difficult"), value: "difficult" },
]}
></Select>
{/if}
</div>
{/snippet}
{#snippet footer({ closeModal })}
<div class="flex items-center gap-4">
<button class="btn-secondary" type="button" onclick={closeModal}>
{$_("cancel")}
</button>
<button
class="btn-primary"
class:btn-disabled={!canApply || loading}
disabled={!canApply || loading}
type="button"
onclick={() => apply(closeModal)}
>
{$_("apply")}
</button>
</div>
{/snippet}
</Modal>

View File

@@ -10,7 +10,13 @@
formatElevation,
formatTimeHHMM,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import {
displayCategoryName,
displaySubcategoryLabel,
displayTrailCategoryBadgeIcon,
displayTrailCategoryIcon,
} from "$lib/util/category_util";
import { _, locale } from "svelte-i18n";
import type { MouseEventHandler } from "svelte/elements";
import Chip from "../base/chip.svelte";
@@ -63,6 +69,7 @@
e.stopPropagation();
expandedTags = !expandedTags;
}
</script>
<div
@@ -202,11 +209,27 @@
<div class="flex gap-x-4 gap-y-1 text-base flex-wrap">
{#if trail.expand?.category?.name || trail.category}
<p>
<i class="fa fa-shapes mr-3"> </i>{$_(
trail.expand?.category?.name ??
trail.category ??
"-",
)}
<span class="relative mr-3 inline-block w-4 text-center">
<i class="fa {displayTrailCategoryIcon(trail)}"></i>
{#if displayTrailCategoryBadgeIcon(trail)}
<i
class="fa {displayTrailCategoryBadgeIcon(
trail,
)} absolute -right-1 -top-1 text-[8px]"
></i>
{/if}
</span>{displayCategoryName(
trail.expand?.category ?? { name: trail.category ?? "" },
$locale,
) || "-"}
{#if trail.expand?.subcategory}
<span class="text-gray-500">
/ {displaySubcategoryLabel(
trail.expand.subcategory,
$locale,
)}
</span>
{/if}
</p>
{/if}
{#if trail.location}

View File

@@ -0,0 +1,693 @@
<script lang="ts">
import type { Category } from "$lib/models/category";
import type { Subcategory } from "$lib/models/subcategory";
import type { TrailFilter } from "$lib/models/trail";
import { categoryPreferences } from "$lib/stores/category_preference_store";
import { subcategoryPreferences } from "$lib/stores/subcategory_preference_store";
import { subcategories } from "$lib/stores/subcategory_store";
import {
noSubcategoryFilterCategory,
noSubcategoryFilterValue,
} from "$lib/util/trail_filter_util";
import {
displayCategoryIcon,
displayCategoryName,
displaySubcategoryBadgeIcon,
displaySubcategoryIcon,
displaySubcategoryLabel,
displaySubcategoryShortBadge,
preferenceForCategory,
sortedCategoriesByPreference,
sortedSubcategoriesByPreference,
subcategoryVisible,
} from "$lib/util/category_util";
import { _, locale } from "svelte-i18n";
import type { SelectItem } from "../base/select.svelte";
interface Props {
categories: Category[];
filter: TrailFilter;
onupdate?: (filter: TrailFilter) => void;
}
type CategorySelectItem = SelectItem & {
icon: string;
};
const CATEGORY_BUTTON_WIDTH = 40;
const CATEGORY_BUTTON_GAP = 8;
const FALLBACK_VISIBLE_CATEGORY_LIMIT = 4;
let { categories, filter = $bindable(), onupdate }: Props = $props();
let categorySelectItems = $derived(
sortedCategoriesByPreference(
categories,
$categoryPreferences,
$locale,
)
.filter(
(c) =>
preferenceForCategory($categoryPreferences, c.id)?.visible !==
false || filter.category.includes(c.id),
)
.map((c) => ({
value: c.id,
text: displayCategoryName(c, $locale),
icon: displayCategoryIcon(c),
})),
);
let orderedCategorySelectItems = $derived(
[...categorySelectItems].sort((a, b) => {
const aSelected = filter.category.includes(a.value);
const bSelected = filter.category.includes(b.value);
if (aSelected === bSelected) {
return 0;
}
return aSelected ? -1 : 1;
}),
);
let categoryListElement: HTMLDivElement | undefined = $state();
let categoryListWidth = $state(0);
let visibleCategoryLimit = $derived(
visibleCategoryLimitForWidth(categoryListWidth),
);
let calculatedVisibleCategoryItems = $derived(
orderedCategorySelectItems.slice(0, visibleCategoryLimit),
);
let calculatedOverflowCategoryItems = $derived(
visibleCategoryLimit >= orderedCategorySelectItems.length
? []
: orderedCategorySelectItems.slice(visibleCategoryLimit),
);
let overflowExpanded = $state(false);
let visibleCategorySnapshot: CategorySelectItem[] = $state([]);
let overflowCategorySnapshot: CategorySelectItem[] = $state([]);
let visibleCategoryItems = $derived(
overflowExpanded
? visibleCategorySnapshot
: calculatedVisibleCategoryItems,
);
let overflowCategoryItems = $derived(
overflowExpanded
? overflowCategorySnapshot
: calculatedOverflowCategoryItems,
);
let overflowHasActiveFilters = $derived(
overflowCategoryItems.some((category) =>
filter.category.includes(category.value),
),
);
let activeHiddenCategories = $derived(
categories
.filter(
(category) =>
filter.category.includes(category.id) &&
preferenceForCategory($categoryPreferences, category.id)
?.visible === false,
)
.map((category) => displayCategoryName(category, $locale)),
);
let hoveredCategoryId: string | undefined = $state();
let categoryTooltip = $state("");
let categoryTooltipStyle = $state("");
let longPressTimer: ReturnType<typeof setTimeout> | undefined;
let longPressCategoryId: string | undefined;
let longPressStart:
| {
x: number;
y: number;
}
| undefined;
let suppressCategoryClick: string | undefined;
let suppressCategoryClickTimer: ReturnType<typeof setTimeout> | undefined;
let hoveredCategoryItem = $derived(
categorySelectItems.find(
(category) => category.value === hoveredCategoryId,
),
);
let selectedSubcategoryIds = $derived(filter.subcategory ?? []);
let hoveredSubcategories = $derived(
hoveredCategoryId
? sortedSubcategoriesByPreference(
$subcategories.filter(
(subcategory) =>
subcategory.category === hoveredCategoryId &&
(subcategoryVisible(
subcategory.id,
$subcategoryPreferences,
) ||
selectedSubcategoryIds.includes(subcategory.id)),
),
$subcategoryPreferences,
$locale,
)
: [],
);
let subcategoryOverlayStyle = $state("");
$effect(() => {
if (!categoryListElement || typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(([entry]) => {
categoryListWidth = entry.contentRect.width;
});
observer.observe(categoryListElement);
return () => observer.disconnect();
});
function visibleCategoryLimitForWidth(width: number) {
if (width <= 0) {
return FALLBACK_VISIBLE_CATEGORY_LIMIT;
}
const slotCount = Math.max(
1,
Math.floor(
(width + CATEGORY_BUTTON_GAP) /
(CATEGORY_BUTTON_WIDTH + CATEGORY_BUTTON_GAP),
),
);
if (orderedCategorySelectItems.length <= slotCount) {
return orderedCategorySelectItems.length;
}
return Math.max(0, slotCount - 1);
}
async function update() {
onupdate?.(filter);
}
function toggleOverflow() {
if (overflowExpanded) {
closeOverflow();
return;
}
visibleCategorySnapshot = calculatedVisibleCategoryItems;
overflowCategorySnapshot = calculatedOverflowCategoryItems;
overflowExpanded = true;
}
function closeOverflow() {
overflowExpanded = false;
visibleCategorySnapshot = [];
overflowCategorySnapshot = [];
hoveredCategoryId = undefined;
subcategoryOverlayStyle = "";
hideFilterTooltip();
}
function toggleCategoryFilter(category: CategorySelectItem) {
if (filter.category.includes(category.value)) {
filter.category = filter.category.filter((id) => id !== category.value);
if (hoveredCategoryId === category.value) {
hoveredCategoryId = undefined;
}
filter.subcategory = selectedSubcategoryIds.filter(
(id) =>
noSubcategoryFilterCategory(id) !== category.value &&
!$subcategories.some(
(subcategory) =>
subcategory.id === id &&
subcategory.category === category.value,
),
);
} else {
filter.category = [...filter.category, category.value];
}
update();
}
function handleCategoryClick(e: MouseEvent, category: CategorySelectItem) {
if (suppressCategoryClick === category.value) {
e.preventDefault();
e.stopPropagation();
clearSuppressedCategoryClick();
return;
}
toggleCategoryFilter(category);
}
function toggleNoSubcategoryFilter(category: CategorySelectItem) {
const value = noSubcategoryFilterValue(category.value);
if (selectedSubcategoryIds.includes(value)) {
filter.subcategory = selectedSubcategoryIds.filter((id) => id !== value);
} else {
if (!filter.category.includes(category.value)) {
filter.category = [...filter.category, category.value];
}
filter.subcategory = [...selectedSubcategoryIds, value];
}
update();
}
function toggleSubcategoryFilter(subcategory: Subcategory) {
if (selectedSubcategoryIds.includes(subcategory.id)) {
filter.subcategory = selectedSubcategoryIds.filter(
(id) => id !== subcategory.id,
);
} else {
if (!filter.category.includes(subcategory.category)) {
filter.category = [...filter.category, subcategory.category];
}
filter.subcategory = [...selectedSubcategoryIds, subcategory.id];
}
update();
}
function showSubcategoryOverlay(
category: CategorySelectItem,
hasSubcategories: boolean,
target: EventTarget | null,
) {
if (!(target instanceof HTMLElement)) {
hoveredCategoryId = undefined;
subcategoryOverlayStyle = "";
hideFilterTooltip();
return;
}
const rect = target.getBoundingClientRect();
showCategoryTooltip(category.text, rect);
if (!hasSubcategories) {
hoveredCategoryId = undefined;
subcategoryOverlayStyle = "";
return;
}
subcategoryOverlayStyle = [
`top: ${rect.bottom}px`,
`left: ${rect.left}px`,
"max-width: calc(100vw - 2rem)",
].join("; ");
hoveredCategoryId = category.value;
}
function hideSubcategoryOverlay(category: CategorySelectItem) {
if (hoveredCategoryId === category.value) {
hoveredCategoryId = undefined;
subcategoryOverlayStyle = "";
}
hideFilterTooltip();
}
function startCategoryLongPress(
e: PointerEvent,
category: CategorySelectItem,
hasSubcategories: boolean,
) {
if (e.pointerType === "mouse" || !hasSubcategories) {
return;
}
clearCategoryLongPress();
longPressCategoryId = category.value;
longPressStart = { x: e.clientX, y: e.clientY };
const target = e.currentTarget;
longPressTimer = setTimeout(() => {
suppressNextCategoryClick(category.value);
showSubcategoryOverlay(category, hasSubcategories, target);
longPressTimer = undefined;
}, 450);
}
function moveCategoryLongPress(e: PointerEvent) {
if (!longPressStart || e.pointerType === "mouse") {
return;
}
const deltaX = Math.abs(e.clientX - longPressStart.x);
const deltaY = Math.abs(e.clientY - longPressStart.y);
if (deltaX > 10 || deltaY > 10) {
clearCategoryLongPress();
}
}
function clearCategoryLongPress() {
if (longPressTimer) {
clearTimeout(longPressTimer);
}
longPressTimer = undefined;
longPressCategoryId = undefined;
longPressStart = undefined;
}
function handleCategoryPointerUp(e: PointerEvent) {
if (
e.pointerType !== "mouse" &&
longPressCategoryId &&
!longPressTimer
) {
e.preventDefault();
}
clearCategoryLongPress();
}
function suppressNextCategoryClick(categoryId: string) {
clearSuppressedCategoryClick();
suppressCategoryClick = categoryId;
suppressCategoryClickTimer = setTimeout(() => {
clearSuppressedCategoryClick();
}, 700);
}
function clearSuppressedCategoryClick() {
if (suppressCategoryClickTimer) {
clearTimeout(suppressCategoryClickTimer);
}
suppressCategoryClickTimer = undefined;
suppressCategoryClick = undefined;
}
function showFilterTooltip(text: string, target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
hideFilterTooltip();
return;
}
showCategoryTooltip(text, target.getBoundingClientRect());
}
function hideFilterTooltip() {
categoryTooltip = "";
categoryTooltipStyle = "";
}
function showCategoryTooltip(text: string, rect: DOMRect) {
const tooltipWidth = text.length * 7 + 20;
const viewportPadding = 8;
const left = Math.max(
viewportPadding,
Math.min(
rect.left + rect.width / 2 - tooltipWidth / 2,
window.innerWidth - viewportPadding - tooltipWidth,
),
);
categoryTooltip = text;
categoryTooltipStyle = [
`top: calc(${rect.top}px + var(--tooltip-offset-top))`,
`left: ${left}px`,
`width: ${tooltipWidth}px`,
"background: var(--tooltip-background)",
"border-radius: var(--tooltip-border-radius)",
"color: var(--tooltip-color)",
"font-size: var(--tooltip-font-size)",
"padding: var(--tooltip-padding)",
].join("; ");
}
function handleSubcategoryOverlayFocusOut(
e: FocusEvent,
category: CategorySelectItem,
) {
const nextTarget = e.relatedTarget;
if (
nextTarget instanceof Node &&
(e.currentTarget as HTMLElement).contains(nextTarget)
) {
return;
}
hideSubcategoryOverlay(category);
}
</script>
{#snippet categoryButton(category: CategorySelectItem)}
{@const selected = filter.category.includes(category.value)}
{@const hasSubcategories = $subcategories.some(
(subcategory) => subcategory.category === category.value,
)}
{@const selectedSubcategoriesForCategory = selectedSubcategoryIds.filter(
(id) =>
noSubcategoryFilterCategory(id) === category.value ||
$subcategories.some(
(subcategory) =>
subcategory.id === id &&
subcategory.category === category.value,
),
)}
{@const noSubcategorySelected = selectedSubcategoryIds.includes(
noSubcategoryFilterValue(category.value),
)}
{@const noSubcategoryInherited =
selected && selectedSubcategoriesForCategory.length === 0}
{@const noSubcategoryActive =
noSubcategorySelected || noSubcategoryInherited}
<div
class="relative shrink-0"
role="presentation"
onmouseenter={(e) =>
showSubcategoryOverlay(
category,
hasSubcategories,
e.currentTarget,
)}
onmouseleave={() => hideSubcategoryOverlay(category)}
onfocusin={(e) =>
showSubcategoryOverlay(
category,
hasSubcategories,
e.currentTarget,
)}
onfocusout={(e) => handleSubcategoryOverlayFocusOut(e, category)}
onpointerdown={(e) =>
startCategoryLongPress(e, category, hasSubcategories)}
onpointermove={moveCategoryLongPress}
onpointerup={handleCategoryPointerUp}
onpointercancel={clearCategoryLongPress}
oncontextmenu={(e) => {
if (suppressCategoryClick === category.value) {
e.preventDefault();
}
}}
>
<button
type="button"
aria-label={category.text}
aria-pressed={selected}
class="relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-input-ring"
class:border-primary={selected}
class:bg-primary={selected}
class:text-white={selected}
class:border-input-border={!selected}
class:bg-input-background={!selected}
class:text-gray-500={!selected}
class:hover:bg-menu-item-background-hover={!selected}
onclick={(e) => handleCategoryClick(e, category)}
>
<i class="fa {category.icon} text-2xl"></i>
{#if selectedSubcategoriesForCategory.length > 0}
<i
class="fa fa-filter absolute left-1 top-1 text-[8px] text-white"
></i>
{/if}
</button>
{#if hoveredCategoryId === category.value && hoveredCategoryItem && hoveredSubcategories.length}
<div class="fixed z-20 min-w-max pt-1" style={subcategoryOverlayStyle}>
<div
class="rounded-md border border-input-border bg-menu-background p-2 shadow-lg"
>
<p class="mb-2 text-xs font-medium text-gray-500">
{hoveredCategoryItem.text}
</p>
<div class="flex items-center gap-2">
<button
type="button"
aria-label={$_("no-subcategory")}
aria-pressed={noSubcategoryActive}
class="relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-input-ring"
class:border-primary={noSubcategoryActive}
class:bg-primary={noSubcategoryActive}
class:text-white={noSubcategoryActive}
class:opacity-70={noSubcategoryInherited}
class:border-input-border={!noSubcategoryActive}
class:bg-input-background={!noSubcategoryActive}
class:text-gray-500={!noSubcategoryActive}
class:hover:bg-menu-item-background-hover={!noSubcategoryActive}
onmouseenter={(e) =>
showFilterTooltip(
$_("no-subcategory"),
e.currentTarget,
)}
onmouseleave={hideFilterTooltip}
onfocus={(e) =>
showFilterTooltip(
$_("no-subcategory"),
e.currentTarget,
)}
onblur={hideFilterTooltip}
onclick={() => toggleNoSubcategoryFilter(category)}
>
<i class="fa {category.icon} text-2xl"></i>
<i
class="fa-regular fa-circle absolute -bottom-1 -right-1 rounded-full bg-background text-[10px] text-content"
class:text-white={noSubcategoryActive}
></i>
</button>
<div class="h-8 border-l border-separator"></div>
{#each hoveredSubcategories as subcategory}
{@const subcategorySelected = selectedSubcategoryIds.includes(subcategory.id)}
{@const subcategoryInherited = selected && selectedSubcategoriesForCategory.length === 0}
{@const subcategoryActive = subcategorySelected || subcategoryInherited}
{@const subcategoryLabel = displaySubcategoryLabel(subcategory, $locale)}
{@const badge = displaySubcategoryShortBadge(
subcategory,
$locale,
)}
{@const badgeIcon = displaySubcategoryBadgeIcon(subcategory)}
<button
type="button"
aria-label={subcategoryLabel}
aria-pressed={subcategoryActive}
class="relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-input-ring"
class:border-primary={subcategoryActive}
class:bg-primary={subcategoryActive}
class:text-white={subcategoryActive}
class:opacity-70={subcategoryInherited}
class:border-input-border={!subcategoryActive}
class:bg-input-background={!subcategoryActive}
class:text-gray-500={!subcategoryActive}
class:hover:bg-menu-item-background-hover={!subcategoryActive}
onmouseenter={(e) =>
showFilterTooltip(
subcategoryLabel,
e.currentTarget,
)}
onmouseleave={hideFilterTooltip}
onfocus={(e) =>
showFilterTooltip(
subcategoryLabel,
e.currentTarget,
)}
onblur={hideFilterTooltip}
onclick={() => toggleSubcategoryFilter(subcategory)}
>
<i
class="fa {displaySubcategoryIcon(
subcategory,
hoveredCategoryItem,
)} text-2xl"
></i>
{#if badgeIcon}
<i
class="fa {badgeIcon} absolute right-0.5 top-0.5 text-[10px] text-gray-500"
class:text-white={subcategoryActive}
></i>
{/if}
{#if badge}
<span
class="absolute -bottom-1 -right-1 max-w-10 truncate rounded-sm border border-input-border bg-background px-0.5 text-[7px] font-semibold leading-3 text-content"
>
{badge}
</span>
{/if}
</button>
{/each}
</div>
</div>
</div>
{/if}
</div>
{/snippet}
<div>
<p class="text-sm font-medium pb-2">{$_("categories")}</p>
<div bind:this={categoryListElement} class="flex gap-2 overflow-visible pb-2">
{#each visibleCategoryItems as category}
{@render categoryButton(category)}
{/each}
{#if overflowCategoryItems.length}
<div
class="relative shrink-0"
onfocusout={(e) => {
const nextTarget = e.relatedTarget;
if (
nextTarget instanceof Node &&
(e.currentTarget as HTMLElement).contains(nextTarget)
) {
return;
}
closeOverflow();
}}
>
<button
type="button"
aria-label={$_("more")}
aria-expanded={overflowExpanded}
class="relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-input-ring"
class:border-primary={overflowHasActiveFilters}
class:bg-primary={overflowHasActiveFilters}
class:text-white={overflowHasActiveFilters}
class:border-input-border={!overflowHasActiveFilters}
class:bg-input-background={!overflowHasActiveFilters}
class:text-gray-500={!overflowHasActiveFilters}
class:hover:bg-menu-item-background-hover={!overflowHasActiveFilters}
onclick={toggleOverflow}
>
<span class="text-xs font-semibold">
+{overflowCategoryItems.length}
</span>
{#if overflowHasActiveFilters}
<i
class="fa fa-filter absolute left-1 top-1 text-[8px] text-white"
></i>
{/if}
</button>
{#if overflowExpanded}
<div
class="absolute right-0 top-full z-10 mt-1 rounded-md border border-input-border bg-menu-background p-2 shadow-lg"
>
<div
class="grid gap-2"
style="grid-template-columns: repeat(4, 2.5rem);"
>
{#each overflowCategoryItems as category}
{@render categoryButton(category)}
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>
{#if categoryTooltip}
<div
class="fixed z-30 pointer-events-none whitespace-nowrap"
style={categoryTooltipStyle}
>
{categoryTooltip}
</div>
{/if}
</div>
{#if activeHiddenCategories.length}
<div
class="mt-3 rounded-xl border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-800 dark:text-yellow-200"
>
<i class="fa fa-warning mr-2"></i>
{$_("category-filter-hidden-active", {
values: {
categories: activeHiddenCategories.join(", "),
},
})}
</div>
{/if}

View File

@@ -26,6 +26,9 @@
import ConfirmModal from "../confirm_modal.svelte";
import ListSearchModal from "../list/list_search_modal.svelte";
import TrailExportModal from "./trail_export_modal.svelte";
import TrailBulkEditModal, {
type TrailBulkEditChanges,
} from "./trail_bulk_edit_modal.svelte";
import TrailSendModal from "./trail_send_modal.svelte";
import TrailShareModal from "./trail_share_modal.svelte";
import {
@@ -51,7 +54,7 @@
toggle?: Snippet<[any]>;
onDelete?: () => void;
onShare?: () => void;
onUpdate?: () => void;
onUpdate?: (updatedTrails?: Trail[]) => void;
onMerge?: (result: MergeResult) => void;
}
@@ -63,6 +66,7 @@
let trailSendModal: TrailSendModal;
let trailShareModal: TrailShareModal;
let trailMergeModal: TrailMergeModal;
let trailBulkEditModal: TrailBulkEditModal;
let lists: List[] = $state([]);
@@ -190,6 +194,81 @@
return true;
}
function allowBulkEdit(): boolean {
return allowPublish();
}
function selectedCategorySelection():
| TrailBulkEditChanges["category"]
| undefined {
if (!trails || trails.size === 0) {
return undefined;
}
const selectedTrails = [...trails];
const firstCategory =
selectedTrails[0]?.category ??
selectedTrails[0]?.expand?.category?.id ??
selectedTrails[0]?.expand?.subcategory?.category ??
"";
const firstSubcategory =
selectedTrails[0]?.subcategory ??
selectedTrails[0]?.expand?.subcategory?.id ??
"";
if (!firstCategory) {
return undefined;
}
const allSame = selectedTrails.every((candidate) => {
const category =
candidate.category ??
candidate.expand?.category?.id ??
candidate.expand?.subcategory?.category ??
"";
const subcategory =
candidate.subcategory ?? candidate.expand?.subcategory?.id ?? "";
return (
category === firstCategory &&
subcategory === firstSubcategory
);
});
if (!allSame) {
return undefined;
}
return {
category: firstCategory,
subcategory: firstSubcategory,
};
}
function selectedDifficulty():
| TrailBulkEditChanges["difficulty"]
| undefined {
if (!trails || trails.size === 0) {
return undefined;
}
const selectedTrails = [...trails];
const firstDifficulty = selectedTrails[0]?.difficulty;
if (!firstDifficulty) {
return undefined;
}
if (
!selectedTrails.every(
(candidate) => candidate.difficulty === firstDifficulty,
)
) {
return undefined;
}
return firstDifficulty;
}
function dropdownItems(): DropdownItem[] {
const separator = (value: string): DropdownItem => ({
text: "",
@@ -202,10 +281,20 @@
if (isMultiselectMode()) {
return [
...(allowBulkEdit()
? [
{
text: $_("adjust"),
value: "bulk-edit",
icon: "pen",
},
]
: []),
...(allowMerge()
? [{ text: $_("link"), value: "merge", icon: "link" }]
: []),
...(allowMerge() && (canExport() || allowListManagement || allowPublish() || allowDelete())
...((allowBulkEdit() || allowMerge()) &&
(canExport() || allowListManagement || allowPublish() || allowDelete())
? [separator("sep-multi-actions")]
: []),
...(canExport()
@@ -288,14 +377,16 @@
]
: []),
...(allowFindSimilarTrails()
? [{
text: $_("find-similar-trails"),
value: "find-similar-trails",
icon: "link",
}]
? [
{
text: $_("find-similar-trails"),
value: "find-similar-trails",
icon: "link",
},
]
: []),
...(allowCopy()
? [
...(allowCopy()
? [
{
text: $_("duplicate"),
value: "copy",
@@ -500,6 +591,8 @@
}
} else if (ddVal == "publish") {
updateTrailsVisibility();
} else if (ddVal == "bulk-edit") {
trailBulkEditModal.openModal();
} else if (ddVal == "delete") {
confirmModal.openModal();
} else if (item.value == "merge") {
@@ -604,6 +697,66 @@
onUpdate?.();
}
async function updateTrailsBulk(changes: TrailBulkEditChanges) {
loading = true;
let updatedCount = 0;
const updatedTrails: Trail[] = [];
for (const cTrail of trails ?? []) {
if (!cTrail || !canEditTrail(cTrail)) continue;
if (!cTrail.expand?.author?.id) continue;
const origTrail: Trail = {
...cTrail,
author: cTrail.expand.author.id,
};
const updatedTrail: Trail = {
...origTrail,
...(changes.category
? {
category: changes.category.category,
subcategory: changes.category.subcategory,
}
: {}),
...(changes.difficulty
? { difficulty: changes.difficulty }
: {}),
};
try {
const updated = await trails_update(
origTrail,
updatedTrail,
undefined,
undefined,
["tags"],
);
updatedTrails.push(updated);
updatedCount += 1;
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: `${$_("error-saving-trail")}: ${cTrail.name}`,
});
}
}
loading = false;
if (updatedCount > 0) {
show_toast({
type: "success",
icon: "check",
text: $_("bulk-edit-updated-trails", {
values: { n: updatedCount },
}),
});
}
onUpdate?.(updatedTrails);
}
async function exportTrails(exportSettings: {
fileFormat: "gpx" | "json";
photos: boolean;
@@ -850,5 +1003,12 @@
bind:this={trailMergeModal}
onmerge={(settings, selection) => mergeTrails(settings, selection)}
></TrailMergeModal>
<TrailBulkEditModal
selectedCount={trails?.size ?? 0}
initialCategorySelection={selectedCategorySelection()}
initialDifficulty={selectedDifficulty()}
bind:this={trailBulkEditModal}
onapply={(changes) => updateTrailsBulk(changes)}
></TrailBulkEditModal>
<MergeDialog/>

View File

@@ -18,6 +18,7 @@
import Search, { type SearchItem } from "../base/search.svelte";
import type { SelectItem } from "../base/select.svelte";
import Slider from "../base/slider.svelte";
import TrailCategoryFilter from "./trail_category_filter.svelte";
interface Props {
categories: Category[];
@@ -37,13 +38,6 @@
onupdate,
}: Props = $props();
let categorySelectItems = $derived(
categories.map((c) => ({
value: c.name,
text: c.name,
})),
);
const radioGroupCompletenessItems: RadioItem[] = [
{ text: $_("completed"), value: "completed" },
{ text: $_("not-completed"), value: "not_completed" },
@@ -66,12 +60,6 @@
onupdate?.(filter);
}
function setCategoryFilter(categories: SelectItem[]) {
filter.category = categories.map((c) => c.value);
update();
}
function setAuthorFilter(item: SearchItem) {
filter.author = item.value.id;
update();
@@ -200,15 +188,11 @@
{#if showTrailSearch}
<hr class="my-4 border-separator" />
{/if}
<MultiSelect
onchange={(value) => setCategoryFilter(value)}
value={categorySelectItems.filter((i) =>
filter.category.includes(i.value),
)}
label={$_("categories")}
items={categorySelectItems}
placeholder={`${$_("filter-categories")}...`}
></MultiSelect>
<TrailCategoryFilter
{categories}
bind:filter
onupdate={update}
/>
<hr class="my-4 border-separator" />
<Combobox
bind:value={getFilterTags, setFilterTags}

View File

@@ -21,6 +21,12 @@
formatElevation,
formatTimeHHMM,
} from "$lib/util/format_util";
import {
displayCategoryIcon,
displayCategoryName,
displaySubcategoryIcon,
displaySubcategoryLabel,
} from "$lib/util/category_util";
import { browser } from "$app/environment";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
@@ -30,7 +36,7 @@
import * as M from "maplibre-gl";
import "photoswipe/style.css";
import { onMount, untrack } from "svelte";
import { _ } from "svelte-i18n";
import { _, locale } from "svelte-i18n";
import Button from "../base/button.svelte";
import Chip from "../base/chip.svelte";
import SkeletonNotificationCard from "../base/skeleton_notification_card.svelte";
@@ -87,6 +93,17 @@
let trail = $state(untrack(() => initTrail));
function trailCategoryIcon() {
if (trail.expand?.subcategory) {
return displaySubcategoryIcon(
trail.expand.subcategory,
trail.expand?.category,
);
}
return displayCategoryIcon(trail.expand?.category);
}
const tabs = [
$_("summit-book"),
$_("photos"),
@@ -747,10 +764,20 @@
>{#if mode == "overview"}
{$_("category")}
{:else}
<i class="fa fa-route"></i>
<i class="fa {trailCategoryIcon()}"></i>
{/if}</span
>
<span class="">{$_(trail.expand.category.name)}</span>
<span class="">
{displayCategoryName(trail.expand.category, $locale)}
{#if trail.expand?.subcategory}
<span class="text-gray-500">
/ {displaySubcategoryLabel(
trail.expand.subcategory,
$locale,
)}
</span>
{/if}
</span>
</div>
{/if}
</section>

View File

@@ -31,7 +31,7 @@
let {
filter = $bindable(null),
trails,
trails = $bindable([]),
pagination = {
page: 1,
totalPages: 1,
@@ -307,7 +307,40 @@
onupdate?.(filter, selection);
}
async function handleTrailsEditDone(resetSelection: boolean = false) {
async function handleTrailsEditDone(
resetSelection: boolean = false,
updatedTrails: Trail[] = [],
) {
if (updatedTrails.length > 0) {
const updatedByID = new Map(
updatedTrails
.filter((trail) => trail.id)
.map((trail) => [trail.id, trail]),
);
// Keep the richer expand fields from the existing (search-derived)
// trail (e.g. author, likes) that trails_update does not return,
// and only overlay the changed values and their expands.
const mergeUpdated = (trail: Trail) => {
const updated = updatedByID.get(trail.id);
if (!updated) return trail;
return {
...trail,
...updated,
expand: { ...trail.expand, ...updated.expand },
};
};
trails = trails.map(mergeUpdated);
if (resetSelection) {
selection = new Set<Trail>();
hoveredTrail = undefined;
} else if (selection) {
selection = new Set([...selection].map(mergeUpdated));
}
// The updated trails already carry the fresh data; skip the refetch
// because the search index is updated asynchronously and would
// return stale categories, overwriting the local update.
return;
}
if (resetSelection) {
selection = new Set<Trail>();
hoveredTrail = undefined;
@@ -356,7 +389,8 @@
onDelete={() => handleTrailsEditDone(true)}
onShare={() => handleTrailsEditDone(false)}
onMerge={() => handleTrailsMergeDone(true)}
onUpdate={() => handleTrailsEditDone(true)}
onUpdate={(updatedTrails) =>
handleTrailsEditDone(true, updatedTrails)}
/>
</div>
{/if}

View File

@@ -11,7 +11,13 @@
formatHTMLAsText,
formatTimeHHMM,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import {
displayCategoryName,
displaySubcategoryLabel,
displayTrailCategoryBadgeIcon,
displayTrailCategoryIcon,
} from "$lib/util/category_util";
import { _, locale } from "svelte-i18n";
import ShareInfo from "../share_info.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import Chip from "../base/chip.svelte";
@@ -57,6 +63,7 @@
e.stopPropagation();
expandedTags = !expandedTags;
}
</script>
<li
@@ -155,9 +162,27 @@
<div class="flex flex-wrap gap-x-8 gap-y-1">
{#if trail.expand?.category?.name || trail.category}
<p>
<i class="fa fa-shapes mr-3"> </i>{$_(
trail.expand?.category?.name ?? trail.category ?? "-",
)}
<span class="relative mr-3 inline-block w-4 text-center">
<i class="fa {displayTrailCategoryIcon(trail)}"></i>
{#if displayTrailCategoryBadgeIcon(trail)}
<i
class="fa {displayTrailCategoryBadgeIcon(
trail,
)} absolute -right-1 -top-1 text-[8px]"
></i>
{/if}
</span>{displayCategoryName(
trail.expand?.category ?? { name: trail.category ?? "" },
$locale,
) || "-"}
{#if trail.expand?.subcategory}
<span class="text-gray-500">
/ {displaySubcategoryLabel(
trail.expand.subcategory,
$locale,
)}
</span>
{/if}
</p>
{/if}
{#if trail.location}

View File

@@ -3,9 +3,10 @@
"Canoeing": "Kanufahren",
"Climbing": "Klettern",
"Hiking": "Wandern",
"Running": "Laufen",
"Other": "Sonstiges",
"Skiing": "Skifahren",
"Walking": "Laufen",
"Walking": "Spazieren",
"about": "Über",
"account-delete-confirm": "Du bist dabei, dein Konto zu löschen. Alle deine Routen werden ebenfalls gelöscht. Möchtest du fortfahren?",
"account-privacy": "Privatsphäre des Kontos",
@@ -17,6 +18,7 @@
"add-waypoint": "Wegpunkt hinzufügen",
"added-trail-to": "Route hinzugefügt zu",
"added-trails-to": "Routen hinzugefügt zu",
"adjust": "Anpassen",
"after": "Nach",
"all-activities": "Alle Aktivitäten",
"allow-auto-geolocate": "Beginne das Zeichnen einer neuen Route am aktuellen Standort",
@@ -31,6 +33,7 @@
"append-waypoint-description": "Kommentar anhängen",
"append-waypoint-photos": "Fotos hinzufügen",
"append-waypoint-title": "Titel anhängen",
"apply": "Anwenden",
"apply-user-settings": "Benutzereinstellungen anwenden",
"attraction": "Sehenswürdigkeit",
"author": "Autor",
@@ -49,6 +52,8 @@
"bicycle-rental": "Fahrradverleih",
"bicycle-shop": "Fahrrad-Reparatur",
"bike-type": "Fahrradtyp",
"bulk-edit-selected-trails": "{n, plural, =1 {1 ausgewählte Route} other {# ausgewählte Routen}}",
"bulk-edit-updated-trails": "{n, plural, =1 {1 Route aktualisiert} other {# Routen aktualisiert}}",
"bus-stop": "Bushaltestelle",
"by": "von",
"calendar": {
@@ -70,6 +75,24 @@
"card": "{n, plural, =1 {Karte} other {Karten}}",
"categories": "Kategorien",
"category": "Kategorie",
"category-preference-visible": "Anzeigen",
"category-preferences": "Kategorie-Einstellungen",
"category-preferences-description": "Definiere, welche Kategorien und Unterkategorien für deine Routen relevant sind und in welcher Reihenfolge Kategorien erscheinen sollen.",
"confirm-disable-category-with-trails-title": "Kategorie ausblenden?",
"confirm-disable-category-intro": "Diese Kategorie wird noch an einigen Stellen verwendet. Prüfe die Konflikte, bevor du sie ausblendest.",
"confirm-disable-category-with-trails": "{count, plural, =1 {1 deiner eigenen Routen verwendet „{name}“.} other {# deiner eigenen Routen verwenden „{name}“.}}",
"confirm-disable-category-active-plugin-mappings": "Aktive Plugins referenzieren diese Kategorie in ihren Zuordnungen: {plugins}.",
"confirm-disable-category-inactive-plugin-mappings": "Vorbereitete, aber inaktive Plugins referenzieren diese Kategorie ebenfalls: {plugins}.",
"confirm-disable-category-anyway": "Diese Kategorie trotzdem ausblenden?",
"confirm-disable-subcategory-with-trails-title": "Unterkategorie ausblenden?",
"confirm-disable-subcategory-intro": "Diese Unterkategorie wird noch an einigen Stellen verwendet. Prüfe die Konflikte, bevor du sie ausblendest.",
"confirm-disable-subcategory-with-trails": "{count, plural, =1 {1 deiner eigenen Routen verwendet „{name}“.} other {# deiner eigenen Routen verwenden „{name}“.}}",
"confirm-disable-subcategory-active-plugin-mappings": "Aktive Plugins referenzieren diese Unterkategorie in ihren Zuordnungen: {plugins}.",
"confirm-disable-subcategory-inactive-plugin-mappings": "Vorbereitete, aber inaktive Plugins referenzieren diese Unterkategorie ebenfalls: {plugins}.",
"confirm-disable-subcategory-anyway": "Diese Unterkategorie trotzdem ausblenden?",
"conflicts": "Konflikte",
"collapse-subcategories": "Unterkategorien einklappen",
"category-filter-hidden-active": "{categories} ist in deinen Kategorie-Einstellungen ausgeblendet.",
"category-mapping": "Kategorie-Zuordnung",
"category-mapping-help": "Entfernte Provider-Kategorien werden bewusst nicht zugeordnet und erhalten beim Import keine Kategorie.",
"change": "Ändern",
@@ -175,6 +198,7 @@
"error-printing-map": "Fehler beim Drucken der Karte",
"error-reading-file": "Fehler beim Lesen der Datei",
"error-saving-list": "Fehler beim Speichern der Liste",
"error-saving-settings": "Fehler beim Speichern der Einstellungen",
"error-saving-trail": "Fehler beim Speichern der Route",
"error-setting-up-plugin": "Fehler beim Einrichten des {provider}-Plugins",
"error-starting-oauth": "Fehler beim Starten der OAuth-Verbindung",
@@ -185,9 +209,14 @@
"error-uploading-trail-to-hammerhead": "Fehler beim Hochladen der Route zu Hammerhead",
"est-duration": "Gesch. Dauer",
"everyone-with-the-link": "Jeder mit dem Link",
"expand-subcategories": "Unterkategorien ausklappen",
"expand-trail-list": "",
"expiration": "",
"expires": "",
"exclude-federated": "Föderierte ausschließen",
"exclude-search": "Von Suche ausschließen",
"include-federated": "Föderierte einschließen",
"include-search": "In Suche einschließen",
"explore": "Erkunden",
"explore-some-trails": "Erkunde einige Routen",
"export": "Exportieren",
@@ -223,6 +252,9 @@
"get-started": "Los geht’s",
"grid": "Gitter",
"grocery-store": "Lebensmittelgeschäft",
"hide-design": "Im Routeneditor ausblenden",
"show-design": "Im Routeneditor auswählbar",
"hammerhead-integration-after-date-hint": "Wenn Ihr Hammerhead Konto bereits mit anderen Trail-Datenbanken wie komoot oder Strava synchronisiert ist, kann die zusätzliche Synchronisierung Ihrer Hammerhead-Daten zu Duplikaten führen. Um dies zu vermeiden, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
"heading": "Überschrift",
"height": "Höhe",
"help": "Hilfe",
@@ -362,6 +394,7 @@
"no-preference": "Keine Präferenz",
"no-results": "Keine Ergebnisse gefunden",
"no-routes-added": "Keine Routen hinzugefügt",
"no-subcategory": "Keine Unterkategorie",
"no-waypoints-yet": "Noch keine Wegpunkte",
"norwegian": "Norwegisch",
"not-a-valid-email-address": "Keine gültige Email-Adresse",
@@ -407,6 +440,7 @@
"profile": "Profil",
"provider-category": "Provider-Kategorie",
"public": "Öffentlich",
"priority": "Priorität",
"public-access": "Öffentlicher Zugriff",
"public-share-everyone": "Jeder im Internet mit dem Link kann diese Route sehen",
"public-share-limited": "Nur Leute mit Zugriff können diese Route sehen",
@@ -503,6 +537,7 @@
"stop-drawing": "Zeichnen beenden",
"stop-editing": "Bearbeiten beenden",
"subway-stop": "U-Bahn Eingang",
"subcategory": "Unterkategorie",
"summit": "Gipfel",
"summit-book": "Gipfelbuch",
"summit-log": "{n, plural, =1 {Gipfelbuch-Eintrag} other {Gipfelbuch-Einträge}}",
@@ -588,6 +623,7 @@
"tram-stop": "Tram Haltestelle",
"unchanged": "unverändert",
"units": "Einheiten",
"unprioritized": "Nicht priorisiert",
"unlink": "Trennen",
"upload-file": "Datei hochladen",
"upload-gpx": "GPX hochladen",
@@ -602,6 +638,7 @@
"username": "Nutzername",
"username-not-unique": "Dieser Nutzername ist bereits vergeben. Bitte versuche es mit einem anderen.",
"view": "Ansehen",
"view-affected-trails": "Betroffene Routen ansehen",
"viewpoint": "Aussichtspunkt",
"visibilty": "",
"visibilty-status": "Sichtbarkeit",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Canoeing",
"Climbing": "Climbing",
"Hiking": "Hiking",
"Running": "Running",
"Other": "Other",
"Skiing": "",
"Walking": "Walking",
@@ -17,6 +18,7 @@
"add-waypoint": "Add Waypoint",
"added-trail-to": "Added trail to",
"added-trails-to": "Added trails to",
"adjust": "Adjust",
"after": "After",
"all-activities": "All activities",
"allow-auto-geolocate": "Begin drawing a new trail from your current location",
@@ -31,6 +33,7 @@
"append-waypoint-description": "Append description",
"append-waypoint-photos": "Add photos",
"append-waypoint-title": "Append title",
"apply": "Apply",
"apply-user-settings": "Apply user settings",
"attraction": "Attraction",
"author": "Author",
@@ -49,6 +52,8 @@
"bicycle-rental": "Bicycle Rental",
"bicycle-shop": "Bicycle Shop",
"bike-type": "Bike Type",
"bulk-edit-selected-trails": "{n, plural, =1 {1 selected trail} other {# selected trails}}",
"bulk-edit-updated-trails": "{n, plural, =1 {1 trail updated} other {# trails updated}}",
"bus-stop": "Bus stop",
"by": "by",
"calendar": {
@@ -70,6 +75,24 @@
"card": "{n, plural, =1 {Card} other {Cards}}",
"categories": "Categories",
"category": "Category",
"category-preference-visible": "Show",
"category-preferences": "Category preferences",
"category-preferences-description": "Define which categories and subcategories are relevant to your trails and the order in which categories appear.",
"confirm-disable-category-with-trails-title": "Hide category?",
"confirm-disable-category-intro": "This category is still used in a few places. Review the conflicts before hiding it.",
"confirm-disable-category-with-trails": "{count, plural, =1 {1 of your own trails uses “{name}”.} other {# of your own trails use “{name}”.}}",
"confirm-disable-category-active-plugin-mappings": "Active plugins reference this category in their mappings: {plugins}.",
"confirm-disable-category-inactive-plugin-mappings": "Prepared but inactive plugins also reference this category: {plugins}.",
"confirm-disable-category-anyway": "Hide this category anyway?",
"confirm-disable-subcategory-with-trails-title": "Hide subcategory?",
"confirm-disable-subcategory-intro": "This subcategory is still used in a few places. Review the conflicts before hiding it.",
"confirm-disable-subcategory-with-trails": "{count, plural, =1 {1 of your own trails uses “{name}”.} other {# of your own trails use “{name}”.}}",
"confirm-disable-subcategory-active-plugin-mappings": "Active plugins reference this subcategory in their mappings: {plugins}.",
"confirm-disable-subcategory-inactive-plugin-mappings": "Prepared but inactive plugins also reference this subcategory: {plugins}.",
"confirm-disable-subcategory-anyway": "Hide this subcategory anyway?",
"conflicts": "Conflicts",
"collapse-subcategories": "Collapse subcategories",
"category-filter-hidden-active": "{categories} is hidden in your category preferences.",
"category-mapping": "Category mapping",
"category-mapping-help": "Removed provider categories are intentionally left unmapped and will be imported without a category.",
"change": "Change",
@@ -175,6 +198,7 @@
"error-printing-map": "Error printing map",
"error-reading-file": "Error reading file",
"error-saving-list": "Error saving list",
"error-saving-settings": "Error saving settings",
"error-saving-trail": "Error saving trail",
"error-setting-up-plugin": "Error setting up {provider} plugin",
"error-starting-oauth": "Error starting OAuth connection",
@@ -185,9 +209,14 @@
"error-uploading-trail-to-hammerhead": "Error uploading trail to Hammerhead",
"est-duration": "Est. duration",
"everyone-with-the-link": "Everyone with the link",
"expand-subcategories": "Expand subcategories",
"expand-trail-list": "Expand trail list",
"expiration": "Expiration",
"expires": "Expires",
"exclude-federated": "Exclude federated",
"exclude-search": "Exclude from search",
"include-federated": "Include federated",
"include-search": "Include in search",
"explore": "Explore",
"explore-some-trails": "Explore some trails",
"export": "Export",
@@ -223,6 +252,9 @@
"get-started": "Get started",
"grid": "Grid",
"grocery-store": "Grocery store",
"hide-design": "Hide in trail editor",
"show-design": "Available in trail editor",
"hammerhead-integration-after-date-hint": "If your hammerhead account is already synced with other trail databases, such as komoot or Strava, start syncing your Hammerhead data may result in duplicates. To avoid this, you can set an start date below, meaning only activities recorded after this date will be synced.",
"heading": "Heading",
"height": "Height",
"help": "Help",
@@ -362,6 +394,7 @@
"no-preference": "No preference",
"no-results": "No results found",
"no-routes-added": "No routes added",
"no-subcategory": "No subcategory",
"no-waypoints-yet": "No waypoints yet",
"norwegian": "Norwegian",
"not-a-valid-email-address": "Not a valid email address",
@@ -407,6 +440,7 @@
"profile": "Profile",
"provider-category": "Provider category",
"public": "Public",
"priority": "Priority",
"public-access": "Public access",
"public-share-everyone": "Everyone on the internet with the link can see this trail",
"public-share-limited": "Only people with access can open the link",
@@ -503,6 +537,7 @@
"stop-drawing": "Stop drawing",
"stop-editing": "Stop editing",
"subway-stop": "Subway entrance",
"subcategory": "Subcategory",
"summit": "Summit",
"summit-book": "Summit Book",
"summit-log": "{n, plural, =1 {Summit log} other {Summit logs}}",
@@ -588,6 +623,7 @@
"tram-stop": "Tram stop",
"unchanged": "unchanged",
"units": "Units",
"unprioritized": "Unprioritized",
"unlink": "Unlink",
"upload-file": "Upload file",
"upload-gpx": "Upload GPX",
@@ -602,6 +638,7 @@
"username": "Username",
"username-not-unique": "This username is already taken. Please try another.",
"view": "View",
"view-affected-trails": "View affected trails",
"viewpoint": "Viewpoint",
"visibilty": "Visibility",
"visibilty-status": "Visibility status",

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
const UserCategoryPreferenceUpsertSchema = z.object({
category: z.string().length(15),
visible: z.boolean(),
});
const UserCategoryPreferenceReorderSchema = z.object({
categories: z.array(z.string().length(15)),
});
export {
UserCategoryPreferenceReorderSchema,
UserCategoryPreferenceUpsertSchema,
};

View File

@@ -158,6 +158,195 @@
* type: string
* description: Tag name
*
* CategoryTranslation:
* type: object
* properties:
* name:
* type: string
* short_name:
* type: string
*
* Category:
* type: object
* required:
* - id
* - name
* properties:
* id:
* type: string
* description: Category ID (15 chars)
* name:
* type: string
* short_name:
* type: string
* nullable: true
* icon:
* type: string
* nullable: true
* translations:
* type: object
* nullable: true
* additionalProperties:
* $ref: '#/components/schemas/CategoryTranslation'
* settings:
* type: object
* nullable: true
* properties:
* wp_merge_enabled:
* type: boolean
* wp_merge_radius:
* type: number
* created:
* type: string
* format: date-time
* updated:
* type: string
* format: date-time
*
* Subcategory:
* type: object
* required:
* - id
* - category
* - name
* properties:
* id:
* type: string
* description: Subcategory ID (15 chars)
* category:
* type: string
* description: Parent category ID (15 chars)
* name:
* type: string
* short_name:
* type: string
* nullable: true
* icon:
* type: string
* nullable: true
* badge_icon:
* type: string
* nullable: true
* translations:
* type: object
* nullable: true
* additionalProperties:
* $ref: '#/components/schemas/CategoryTranslation'
* created:
* type: string
* format: date-time
* updated:
* type: string
* format: date-time
*
* UserCategoryPreference:
* type: object
* required:
* - id
* - user
* - category
* - visible
* properties:
* id:
* type: string
* description: Preference ID (15 chars)
* user:
* type: string
* description: User ID (15 chars)
* category:
* type: string
* description: Category ID (15 chars)
* visible:
* type: boolean
* priority:
* type: integer
* nullable: true
* created:
* type: string
* format: date-time
* updated:
* type: string
* format: date-time
*
* UserCategoryPreferenceUpsertInput:
* type: object
* required:
* - category
* - visible
* properties:
* category:
* type: string
* description: Category ID (15 chars)
* visible:
* type: boolean
*
* UserCategoryPreferenceReorderInput:
* type: object
* required:
* - categories
* properties:
* categories:
* type: array
* items:
* type: string
* description: Category ID (15 chars)
*
* UserSubcategoryPreference:
* type: object
* required:
* - id
* - user
* - subcategory
* - visible
* properties:
* id:
* type: string
* description: Preference ID (15 chars)
* user:
* type: string
* description: User ID (15 chars)
* subcategory:
* type: string
* description: Subcategory ID (15 chars)
* visible:
* type: boolean
* priority:
* type: integer
* nullable: true
* created:
* type: string
* format: date-time
* updated:
* type: string
* format: date-time
*
* UserSubcategoryPreferenceUpsertInput:
* type: object
* required:
* - subcategory
* - visible
* properties:
* subcategory:
* type: string
* description: Subcategory ID (15 chars)
* visible:
* type: boolean
*
* UserSubcategoryPreferenceReorderInput:
* type: object
* required:
* - category
* - subcategories
* properties:
* category:
* type: string
* description: Category ID (15 chars)
* subcategories:
* type: array
* items:
* type: string
* description: Subcategory ID (15 chars)
*
* Trail:
* type: object
* required:
@@ -220,6 +409,9 @@
* category:
* type: string
* description: Category ID (15 chars)
* subcategory:
* type: string
* description: Subcategory ID (15 chars)
* tags:
* type: array
* items:
@@ -290,6 +482,8 @@
* default: 0
* category:
* type: string
* subcategory:
* type: string
* tags:
* type: array
* items:
@@ -348,6 +542,8 @@
* default: 0
* category:
* type: string
* subcategory:
* type: string
* tags:
* type: array
* items:

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
const UserSubcategoryPreferenceUpsertSchema = z.object({
subcategory: z.string().length(15),
visible: z.boolean(),
});
const UserSubcategoryPreferenceReorderSchema = z.object({
category: z.string().length(15),
subcategories: z.array(z.string().length(15)),
});
export {
UserSubcategoryPreferenceReorderSchema,
UserSubcategoryPreferenceUpsertSchema,
};

View File

@@ -21,6 +21,7 @@ const TrailCreateSchema = z.object({
thumbnail: z.number().int().nonnegative().optional(),
like_count: z.number().int().min(0).optional().default(0),
category: z.string().length(15).optional().or(z.literal('')),
subcategory: z.string().length(15).optional().or(z.literal('')),
tags: z.array(z.string()).default([]),
gpx: z.string().optional(),
author: z.string().length(15),
@@ -47,6 +48,7 @@ const TrailUpdateSchema = z.object({
thumbnail: z.number().int().nonnegative().optional(),
like_count: z.number().int().min(0).optional(),
category: z.string().optional(),
subcategory: z.string().optional(),
tags: z.array(z.string()).optional(),
gpx: z.string().optional(),
}) satisfies ZodType<Partial<Trail>>

View File

@@ -1,14 +1,22 @@
interface Category {
id: string;
name: string;
img: string;
short_name?: string | null;
icon?: string | null;
translations?: Record<string, CategoryTranslation> | null;
settings?: Settings | null;
}
interface CategoryTranslation {
name?: string;
short_name?: string;
}
interface Settings {
wp_merge_enabled?: boolean;
wp_merge_radius?: number;
}
export type {Category}
export type {CategoryTranslation}
export type {Settings}

View File

@@ -0,0 +1,9 @@
interface UserCategoryPreference {
id?: string;
user: string;
category: string;
visible?: boolean;
priority?: number | null;
}
export type { UserCategoryPreference };

View File

@@ -24,7 +24,6 @@ class Settings {
bio?: string | null;
mapFocus?: "trails" | "location";
location?: { name: string, lat: number, lon: number } | null;
category?: string;
tilesets?: ({ name: string, url: string }[]) | null
terrain?: { terrain?: string, hillshading?: string } | null;
user?: string;
@@ -39,7 +38,6 @@ class Settings {
user: string,
params?: {
location?: { name: string, lat: number, lon: number }
category?: string
tilesets?: { name: string, url: string }[]
terrain?: { terrain: string, hillshading: string };
}
@@ -49,7 +47,6 @@ class Settings {
this.mapFocus = mapFocus;
this.user = user;
this.location = params?.location;
this.category = params?.category;
this.tilesets = params?.tilesets ?? [];
this.terrain = params?.terrain;
}

View File

@@ -0,0 +1,16 @@
import type { Category, CategoryTranslation } from "./category";
interface Subcategory {
id: string;
category: string;
name: string;
short_name?: string | null;
icon?: string | null;
badge_icon?: string | null;
translations?: Record<string, CategoryTranslation> | null;
expand?: {
category?: Category;
};
}
export type { Subcategory };

View File

@@ -0,0 +1,9 @@
interface UserSubcategoryPreference {
id?: string;
user: string;
subcategory: string;
visible?: boolean;
priority?: number | null;
}
export type { UserSubcategoryPreference };

View File

@@ -3,6 +3,7 @@ import type { Actor } from "./activitypub/actor";
import type { Category } from "./category";
import type { Comment } from "./comment";
import type GPX from "./gpx/gpx";
import type { Subcategory } from "./subcategory";
import type { SummitLog } from "./summit_log";
import type { Tag } from "./tag";
import type { TrailLike } from "./trail_like";
@@ -29,6 +30,7 @@ class Trail {
created?: string;
updated?: string;
category?: string;
subcategory?: string;
tags: string[];
polyline?: string;
domain?: string;
@@ -38,6 +40,7 @@ class Trail {
expand?: {
tags?: Tag[]
category?: Category;
subcategory?: Subcategory;
waypoints_via_trail?: Waypoint[]
summit_logs_via_trail?: SummitLog[]
author?: Actor
@@ -70,6 +73,7 @@ class Trail {
gpx?: string,
gpx_data?: string,
category?: Category,
subcategory?: Subcategory,
waypoints?: Waypoint[],
summit_logs?: SummitLog[],
comments?: Comment[],
@@ -98,10 +102,13 @@ class Trail {
this.photos = params?.photos ?? [];
this.tags = [];
this.gpx = params?.gpx;
this.category = params?.category?.id;
this.subcategory = params?.subcategory?.id;
this.bounding_box_diagonal = params?.bounding_box_diagonal ?? 0;
this.like_count = 0
this.expand = {
category: params?.category,
subcategory: params?.subcategory,
waypoints_via_trail: params?.waypoints ?? [],
summit_logs_via_trail: params?.summit_logs ?? [],
comments_via_trail: params?.comments ?? [],
@@ -129,6 +136,7 @@ class Trail {
public: orig.public,
tags: orig.expand?.tags,
category: orig.expand?.category,
subcategory: orig.expand?.subcategory,
gpx_data: orig.expand?.gpx_data,
waypoints: orig.expand?.waypoints_via_trail?.map(wp => new Waypoint(wp.lat, wp.lon, {
id: cryptoRandomString({ length: 15 }),
@@ -143,6 +151,7 @@ class Trail {
interface TrailFilter {
q: string,
category: string[],
subcategory: string[],
tags: string[],
difficulty: (0 | 1 | 2)[]
author?: string;
@@ -187,6 +196,7 @@ interface TrailBoundingBox {
min_lat: number,
max_lon: number,
min_lon: number,
has_trails?: boolean,
}
@@ -204,6 +214,12 @@ interface TrailSearchResult {
duration: number;
difficulty: 0 | 1 | 2;
category: string;
category_id?: string | null;
category_icon?: string;
subcategory_id?: string | null;
is_federated?: boolean;
federated_category_name?: string | null;
federated_subcategory_name?: string | null;
completed: boolean;
date: number;
created: number;
@@ -238,6 +254,12 @@ export const defaultTrailSearchAttributes = [
"duration",
"difficulty",
"category",
"category_id",
"category_icon",
"subcategory_id",
"is_federated",
"federated_category_name",
"federated_subcategory_name",
"completed",
"date",
"created",

View File

@@ -0,0 +1,159 @@
import type { UserCategoryPreference } from "$lib/models/category_preference";
import type { UserSubcategoryPreference } from "$lib/models/subcategory_preference";
import { Collection } from "$lib/util/api_util";
import type { RequestEvent } from "@sveltejs/kit";
type MeiliFilter = string | string[] | undefined;
type TrailPreferenceCache = {
categories?: Promise<UserCategoryPreference[]>;
subcategories?: Promise<UserSubcategoryPreference[]>;
};
const preferenceCache = new WeakMap<RequestEvent, TrailPreferenceCache>();
function quotedList(ids: string[]) {
return `[${ids.map((id) => `'${id}'`).join(", ")}]`;
}
function meiliFilterParts(filter: unknown): string[] {
if (!filter) {
return [];
}
if (typeof filter === "string") {
return filter.trim() ? [filter] : [];
}
if (Array.isArray(filter)) {
return filter.flatMap((item) => meiliFilterParts(item));
}
return [];
}
function isIdOnlyDetailQuery(parts: string[]) {
return parts.length > 0 && parts.every((part) => /\bid\s+IN\b/i.test(part));
}
async function userCategoryPreferences(event: RequestEvent) {
if (!event.locals.user) {
return [];
}
const cache = cachedTrailPreferences(event);
cache.categories ??= event.locals.pb
.collection(Collection.user_category_preferences)
.getFullList<UserCategoryPreference>({
filter: event.locals.pb.filter("user = {:user}", {
user: event.locals.user.id,
}),
requestKey: null,
});
return cache.categories;
}
async function userSubcategoryPreferences(event: RequestEvent) {
if (!event.locals.user) {
return [];
}
const cache = cachedTrailPreferences(event);
cache.subcategories ??= event.locals.pb
.collection(Collection.user_subcategory_preferences)
.getFullList<UserSubcategoryPreference>({
filter: event.locals.pb.filter("user = {:user}", {
user: event.locals.user.id,
}),
requestKey: null,
});
return cache.subcategories;
}
function cachedTrailPreferences(event: RequestEvent) {
let cache = preferenceCache.get(event);
if (!cache) {
cache = {};
preferenceCache.set(event, cache);
}
return cache;
}
async function trailPreferenceExclusions(event: RequestEvent) {
const [preferences, subcategoryPreferences] = await Promise.all([
userCategoryPreferences(event),
userSubcategoryPreferences(event),
]);
return {
hiddenCategoryIds: preferences
.filter((preference) => preference.visible === false)
.map((preference) => preference.category),
hiddenSubcategoryIds: subcategoryPreferences
.filter((preference) => preference.visible === false)
.map((preference) => preference.subcategory),
};
}
export async function withTrailPreferenceMeiliFilter(
event: RequestEvent,
filter: MeiliFilter,
): Promise<MeiliFilter> {
const parts = meiliFilterParts(filter);
if (!event.locals.user || isIdOnlyDetailQuery(parts)) {
return filter;
}
const { hiddenCategoryIds, hiddenSubcategoryIds } =
await trailPreferenceExclusions(event);
const preferenceParts: string[] = [];
if (hiddenCategoryIds.length) {
preferenceParts.push(
`(category_id IS NULL OR category_id NOT IN ${quotedList(hiddenCategoryIds)})`,
);
}
if (hiddenSubcategoryIds.length) {
preferenceParts.push(
`(subcategory_id IS NULL OR subcategory_id NOT IN ${quotedList(hiddenSubcategoryIds)})`,
);
}
const nextParts = [...parts, ...preferenceParts];
if (!nextParts.length) {
return undefined;
}
return nextParts;
}
function pbNotEqualsAll(field: string, ids: string[]) {
return ids.map((id) => `${field} != "${id}"`).join(" && ");
}
export async function withTrailPreferencePocketBaseFilter(
event: RequestEvent,
filter?: string,
) {
if (!event.locals.user) {
return filter;
}
const { hiddenCategoryIds, hiddenSubcategoryIds } =
await trailPreferenceExclusions(event);
const parts = filter?.trim() ? [filter] : [];
if (hiddenCategoryIds.length) {
parts.push(
`(category = "" || (${pbNotEqualsAll("category", hiddenCategoryIds)}))`,
);
}
if (hiddenSubcategoryIds.length) {
parts.push(
`(subcategory = "" || (${pbNotEqualsAll("subcategory", hiddenSubcategoryIds)}))`,
);
}
return parts.length ? parts.join(" && ") : undefined;
}

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) {

View File

@@ -31,6 +31,9 @@ export enum Collection {
notifications = "notifications",
profile_feed = "profile_feed",
settings = "settings",
subcategories = "subcategories",
user_category_preferences = "user_category_preferences",
user_subcategory_preferences = "user_subcategory_preferences",
summit_logs = "summit_logs",
trail_like = "trail_like",
trail_share = "trail_share",
@@ -162,7 +165,14 @@ export function handleError(e: any) {
return json({ ...e.response, message: e.message, detail: e.originalError.data }, { status: e.status })
} else if (e instanceof SyntaxError) {
return json({ message: "invalid_json" }, { status: 400 })
} else if (e instanceof Error) {
return json({ message: e.message }, { status: 500 })
} else {
return json({ message: e }, { status: 500 })
const message = typeof e?.message === "string"
? e.message
: typeof e === "string"
? e
: "internal_server_error";
return json({ message, detail: e }, { status: 500 })
}
}

View File

@@ -0,0 +1,411 @@
import type { Category } from "$lib/models/category";
import type { UserCategoryPreference } from "$lib/models/category_preference";
import type { Subcategory } from "$lib/models/subcategory";
import type { UserSubcategoryPreference } from "$lib/models/subcategory_preference";
type CategoryDisplayEntity = Pick<Category | Subcategory, "name" | "translations">;
type CategoryShortNameEntity = Pick<
Category | Subcategory,
"name" | "short_name" | "translations"
>;
export type CategoryMappingTarget =
| string
| {
category?: string;
subcategory?: string;
};
export type ResolvedCategoryMappingTarget = {
categoryId?: string;
subcategoryId?: string;
};
export function normalizeCategoryName(name: string): string {
// Best-effort mirror of the backend normalization for resolving ?category= links.
// Full Unicode casefold parity would require a dedicated frontend casefold implementation.
return name
.normalize("NFD")
.replace(/\p{Mn}/gu, "")
.toLowerCase()
.replace(/[\s_-]+/g, " ")
.trim();
}
export function categoryMappingTargetFromUnknown(
value: unknown,
): CategoryMappingTarget | undefined {
if (typeof value === "string") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const raw = value as Record<string, unknown>;
return {
category: typeof raw.category === "string" ? raw.category : "",
subcategory: typeof raw.subcategory === "string" ? raw.subcategory : "",
};
}
function resolveCategoryTargetValue(
value: string,
categories: Category[],
subcategories: Subcategory[],
): ResolvedCategoryMappingTarget {
const trimmed = value.trim();
if (!trimmed) {
return {};
}
const directCategory = categories.find((category) => category.id === trimmed);
if (directCategory) {
return { categoryId: directCategory.id };
}
const directSubcategory = subcategories.find(
(subcategory) => subcategory.id === trimmed,
);
if (directSubcategory) {
return {
categoryId: directSubcategory.category,
subcategoryId: directSubcategory.id,
};
}
if (trimmed.includes("/")) {
const [rawCategory, rawSubcategory] = trimmed.split("/", 2);
const category = categories.find(
(candidate) =>
normalizeCategoryName(candidate.name) ===
normalizeCategoryName(rawCategory),
);
const subcategory = subcategories.find(
(candidate) =>
candidate.category === category?.id &&
normalizeCategoryName(candidate.name) ===
normalizeCategoryName(rawSubcategory),
);
if (subcategory) {
return {
categoryId: subcategory.category,
subcategoryId: subcategory.id,
};
}
}
const namedCategory = categories.find(
(category) =>
normalizeCategoryName(category.name) === normalizeCategoryName(trimmed),
);
return namedCategory ? { categoryId: namedCategory.id } : {};
}
export function resolveCategoryMappingTarget(
target: CategoryMappingTarget,
categories: Category[],
subcategories: Subcategory[],
): ResolvedCategoryMappingTarget {
if (typeof target === "string") {
return resolveCategoryTargetValue(target, categories, subcategories);
}
const categoryValue = resolveCategoryTargetValue(
target.category ?? "",
categories,
subcategories,
);
const subcategoryValue = resolveCategoryTargetValue(
target.subcategory ?? "",
categories,
subcategories,
);
if (subcategoryValue.subcategoryId) {
return subcategoryValue;
}
if (target.subcategory?.trim() && categoryValue.categoryId) {
const subcategory = subcategories.find(
(candidate) =>
candidate.category === categoryValue.categoryId &&
normalizeCategoryName(candidate.name) ===
normalizeCategoryName(target.subcategory ?? ""),
);
if (subcategory) {
return {
categoryId: subcategory.category,
subcategoryId: subcategory.id,
};
}
}
return categoryValue;
}
export function categoryMappingTargetToPickerValue(
target: CategoryMappingTarget,
categories: Category[],
subcategories: Subcategory[],
): string {
const resolved = resolveCategoryMappingTarget(target, categories, subcategories);
if (resolved.subcategoryId) {
return `subcategory:${resolved.subcategoryId}`;
}
if (resolved.categoryId) {
return `category:${resolved.categoryId}`;
}
return "";
}
function localeCandidates(locale?: string | null): string[] {
if (!locale) {
return [];
}
const normalized = locale.trim();
if (!normalized) {
return [];
}
const lower = normalized.toLowerCase();
const base = lower.split("-")[0];
return [...new Set([normalized, lower, base])];
}
export function displayCategoryName(
category?: CategoryDisplayEntity | null,
locale?: string | null,
): string {
if (!category) {
return "";
}
for (const candidate of localeCandidates(locale)) {
const translatedName = category.translations?.[candidate]?.name;
if (translatedName) {
return translatedName;
}
}
// Fall back to the English translation before the raw canonical name.
return category.translations?.["en"]?.name || category.name || "";
}
export function displayCategoryShortName(
category?: CategoryShortNameEntity | null,
locale?: string | null,
): string {
if (!category) {
return "";
}
for (const candidate of localeCandidates(locale)) {
const translatedShortName = category.translations?.[candidate]?.short_name;
if (translatedShortName?.trim()) {
return translatedShortName;
}
}
return category.short_name?.trim() || displayCategoryName(category, locale);
}
export function displayCategoryIcon(
category?: Pick<Category | Subcategory, "icon"> | null,
): string {
const icon = category?.icon?.trim().replace(/^fa-/, "");
return icon ? `fa-${icon}` : "fa-shapes";
}
export function displaySubcategoryIcon(
subcategory?: Pick<Subcategory, "icon"> | null,
parentCategory?: Pick<Category, "icon"> | null,
): string {
return displayCategoryIcon(subcategory?.icon ? subcategory : parentCategory);
}
export function displaySubcategoryBadgeIcon(
subcategory?: Pick<Subcategory, "badge_icon"> | null,
): string {
const icon = subcategory?.badge_icon?.trim().replace(/^fa-/, "");
return icon ? `fa-${icon}` : "";
}
type TrailCategoryIconEntity = {
expand?: {
category?: Pick<Category, "icon"> | null;
subcategory?: Pick<Subcategory, "icon" | "badge_icon"> | null;
} | null;
};
export function displayTrailCategoryIcon(
trail?: TrailCategoryIconEntity | null,
): string {
const subcategory = trail?.expand?.subcategory;
if (subcategory) {
return displaySubcategoryIcon(subcategory, trail?.expand?.category);
}
return displayCategoryIcon(trail?.expand?.category);
}
export function displayTrailCategoryBadgeIcon(
trail?: TrailCategoryIconEntity | null,
): string {
return displaySubcategoryBadgeIcon(trail?.expand?.subcategory);
}
export function displaySubcategoryName(
subcategory?: Subcategory | null,
locale?: string | null,
): string {
return displayCategoryName(subcategory, locale);
}
export function displaySubcategoryLabel(
subcategory?: Subcategory | null,
locale?: string | null,
): string {
return displaySubcategoryName(subcategory, locale);
}
export function displaySubcategoryShortBadge(
subcategory?: Subcategory | null,
locale?: string | null,
): string {
if (!subcategory) {
return "";
}
const shortName = displayCategoryShortName(subcategory, locale);
const label = displaySubcategoryLabel(subcategory, locale);
if (shortName && shortName !== label) {
return shortName.toUpperCase();
}
if (subcategory.short_name?.trim()) {
return subcategory.short_name.trim().toUpperCase();
}
const normalizedLabel = label.trim();
if (normalizedLabel.length <= 5) {
return normalizedLabel.toUpperCase();
}
const words = normalizedLabel.match(/[\p{L}\p{N}]+/gu) ?? [];
if (words.length > 1) {
return words
.map((word) => word.at(0))
.join("")
.slice(0, 5)
.toUpperCase();
}
return normalizedLabel.slice(0, 4).toUpperCase();
}
export function preferenceForCategory(
preferences: UserCategoryPreference[],
categoryId?: string | null,
): UserCategoryPreference | undefined {
return preferences.find((preference) => preference.category === categoryId);
}
export function preferenceForSubcategory(
preferences: UserSubcategoryPreference[],
subcategoryId?: string | null,
): UserSubcategoryPreference | undefined {
return preferences.find(
(preference) => preference.subcategory === subcategoryId,
);
}
export function subcategoryVisible(
subcategoryId: string | undefined | null,
preferences: UserSubcategoryPreference[],
): boolean {
return preferenceForSubcategory(preferences, subcategoryId)?.visible !== false;
}
export function sortedCategoriesByPreference(
categories: Category[],
preferences: UserCategoryPreference[],
locale?: string | null,
): Category[] {
return [...categories].sort((a, b) => {
const aPriority = preferenceForCategory(preferences, a.id)?.priority;
const bPriority = preferenceForCategory(preferences, b.id)?.priority;
const aPrioritized = typeof aPriority === "number" && aPriority > 0;
const bPrioritized = typeof bPriority === "number" && bPriority > 0;
if (aPrioritized && bPrioritized) {
return aPriority - bPriority;
}
if (aPrioritized) {
return -1;
}
if (bPrioritized) {
return 1;
}
return displayCategoryName(a, locale).localeCompare(
displayCategoryName(b, locale),
locale ?? undefined,
{ sensitivity: "base" },
);
});
}
export function sortedSubcategoriesByPreference(
subcategories: Subcategory[],
preferences: UserSubcategoryPreference[],
locale?: string | null,
): Subcategory[] {
return [...subcategories].sort((a, b) => {
const aPriority = preferenceForSubcategory(preferences, a.id)?.priority;
const bPriority = preferenceForSubcategory(preferences, b.id)?.priority;
const aPrioritized = typeof aPriority === "number" && aPriority > 0;
const bPrioritized = typeof bPriority === "number" && bPriority > 0;
if (aPrioritized && bPrioritized && aPriority !== bPriority) {
return aPriority - bPriority;
}
if (aPrioritized && !bPrioritized) {
return -1;
}
if (bPrioritized && !aPrioritized) {
return 1;
}
return displaySubcategoryLabel(a, locale).localeCompare(
displaySubcategoryLabel(b, locale),
locale ?? undefined,
{ sensitivity: "base" },
);
});
}
export function categoryVisibleInDesign(
category: Category,
preferences: UserCategoryPreference[],
currentCategoryId?: string | null,
): boolean {
if (category.id === currentCategoryId) {
return true;
}
return preferenceForCategory(preferences, category.id)?.visible !== false;
}
export function designSelectableCategories(
categories: Category[],
preferences: UserCategoryPreference[],
locale?: string | null,
currentCategoryId?: string | null,
): Category[] {
return sortedCategoriesByPreference(categories, preferences, locale).filter(
(category) =>
categoryVisibleInDesign(category, preferences, currentCategoryId),
);
}

View File

@@ -1,5 +1,17 @@
import type { TrailFilter } from "$lib/models/trail";
const NO_SUBCATEGORY_FILTER_PREFIX = "__no_subcategory__:";
export function noSubcategoryFilterValue(categoryId: string): string {
return `${NO_SUBCATEGORY_FILTER_PREFIX}${categoryId}`;
}
export function noSubcategoryFilterCategory(value: string): string | undefined {
return value.startsWith(NO_SUBCATEGORY_FILTER_PREFIX)
? value.substring(NO_SUBCATEGORY_FILTER_PREFIX.length)
: undefined;
}
const TRAIL_SORT_OPTIONS = new Set([
"name",
"distance",
@@ -95,6 +107,7 @@ export function sanitizeTrailFilter(
...defaultFilter,
q: getString(source.q, defaultFilter.q),
category: getStringArray(source.category, defaultFilter.category),
subcategory: getStringArray(source.subcategory, defaultFilter.subcategory),
tags: getStringArray(source.tags, defaultFilter.tags),
difficulty: parseDifficulty(source.difficulty, defaultFilter.difficulty),
author: getString(source.author, defaultFilter.author),