adds tags

This commit is contained in:
Christian Beutel
2025-03-19 22:30:09 +01:00
parent 80275ee153
commit b261ada15c
37 changed files with 728 additions and 73 deletions

View File

@@ -130,7 +130,7 @@ func createTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEv
if err != nil {
return err
}
if err := util.IndexTrail(record, author, client); err != nil {
if err := util.IndexTrail(e.App, record, author, client); err != nil {
return err
}
@@ -160,7 +160,7 @@ func updateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEv
if err != nil {
return err
}
err = util.UpdateTrail(record, author, client)
err = util.UpdateTrail(e.App, record, author, client)
if err != nil {
return err
}
@@ -789,7 +789,7 @@ func bootstrapMeilisearchTrails(app core.App, client meilisearch.ServiceManager)
if err != nil {
return err
}
if err := util.IndexTrail(trail, author, client); err != nil {
if err := util.IndexTrail(app, trail, author, client); err != nil {
return err
}

View File

@@ -29,7 +29,7 @@ func init() {
}
_, err = client.Index("trails").UpdateFilterableAttributes(&[]string{
"_geo", "author", "category", "completed", "date", "difficulty", "distance", "elevation_gain", "elevation_loss", "public", "shares",
"_geo", "author", "category", "completed", "date", "difficulty", "distance", "elevation_gain", "elevation_loss", "public", "shares", "tags",
})
if err != nil {

View File

@@ -0,0 +1,89 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
jsonData := `{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1219621782",
"indexes": [],
"listRule": null,
"name": "tags",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
}`
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1219621782")
if err != nil {
return err
}
return app.Delete(collection)
})
}

View File

@@ -0,0 +1,44 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1219621782")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"createRule": "@request.auth.id != \"\"",
"listRule": "",
"viewRule": ""
}`), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1219621782")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"createRule": null,
"listRule": null,
"viewRule": null
}`), &collection); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -0,0 +1,42 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1219621782")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\""
}`), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1219621782")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"listRule": "",
"viewRule": ""
}`), &collection); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -0,0 +1,44 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// add field
if err := collection.Fields.AddMarshaledJSONAt(14, []byte(`{
"cascadeDelete": false,
"collectionId": "pbc_1219621782",
"hidden": false,
"id": "relation1874629670",
"maxSelect": 999,
"minSelect": 0,
"name": "tags",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}`)); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// remove field
collection.Fields.RemoveById("relation1874629670")
return app.Save(collection)
})
}

View File

@@ -2,6 +2,7 @@ package util
import (
"errors"
"fmt"
"log"
"github.com/meilisearch/meilisearch-go"
@@ -19,6 +20,13 @@ func documentFromTrailRecord(r *core.Record, author *core.Record, includeShares
thumbnail = photos[thumbnailIndex]
}
tagRecords := r.ExpandedAll("tags")
tags := make([]string, len(tagRecords))
for i, v := range tagRecords {
tags[i] = v.GetString("name")
}
document := map[string]interface{}{
"id": r.Id,
"author": r.GetString("author"),
@@ -39,6 +47,7 @@ func documentFromTrailRecord(r *core.Record, author *core.Record, includeShares
"public": r.GetBool("public"),
"thumbnail": thumbnail,
"gpx": r.GetString("gpx"),
"tags": tags,
"_geo": map[string]float64{
"lat": r.GetFloat("lat"),
"lng": r.GetFloat("lon"),
@@ -70,7 +79,12 @@ func documentFromListRecord(r *core.Record, includeShares bool) map[string]inter
return document
}
func IndexTrail(r *core.Record, author *core.Record, client meilisearch.ServiceManager) error {
func IndexTrail(app core.App, r *core.Record, author *core.Record, client meilisearch.ServiceManager) error {
errs := app.ExpandRecord(r, []string{"tags"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
}
documents := []map[string]interface{}{documentFromTrailRecord(r, author, true)}
if _, err := client.Index("trails").AddDocuments(documents); err != nil {
@@ -80,7 +94,12 @@ func IndexTrail(r *core.Record, author *core.Record, client meilisearch.ServiceM
return nil
}
func UpdateTrail(r *core.Record, author *core.Record, client meilisearch.ServiceManager) error {
func UpdateTrail(app core.App, r *core.Record, author *core.Record, client meilisearch.ServiceManager) error {
errs := app.ExpandRecord(r, []string{"tags"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
}
documents := documentFromTrailRecord(r, author, false)
if _, err := client.Index("trails").UpdateDocuments(documents); err != nil {

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { MouseEventHandler } from "svelte/elements";
interface Props {
text: string;
primary?: boolean;
closable?: boolean;
onclick?: MouseEventHandler<HTMLButtonElement>;
}
let { text, primary = true, closable = false, onclick }: Props = $props();
</script>
<div
class="{primary
? 'bg-primary text-white'
: 'bg-background-inverse/10'} px-2 py-1 rounded-full flex items-center gap-1"
>
<span class="text-sm">{text}</span>
{#if closable}
<button
type="button"
aria-label="Close"
{onclick}
class="text-white hover:bg-primary-hover rounded-full w-4 h-4 flex items-center justify-center"
>
<i class="fa fa-close text-xs"></i>
</button>
{/if}
</div>

View File

@@ -2,50 +2,63 @@
export type ComboboxItem = {
text: string;
value: any;
icon: string;
icon?: string;
};
</script>
<script lang="ts">
import type { ChangeEventHandler } from "svelte/elements";
import TextField from "./text_field.svelte";
import { tick } from "svelte";
import Chip from "./chip.svelte";
interface Props {
name?: string;
icon?: string;
label?: string;
value?: string;
value?: string | ComboboxItem[];
items?: ComboboxItem[];
multiple?: boolean;
chips?: boolean;
placeholder?: string;
extraClasses?: string;
onchange?: ChangeEventHandler<HTMLInputElement>;
onupdate?: (q: string) => void;
onclick?: (item: ComboboxItem) => void;
}
let {
name = "",
icon = "",
label = "",
value = $bindable(""),
multiple = false,
value = $bindable(multiple ? [] : undefined),
items = [],
chips = false,
placeholder = "",
extraClasses = "",
onchange,
onupdate,
onclick,
}: Props = $props();
let searching: boolean = $state(false);
let inputValue: string = $state("");
let dropDownOpen = $derived(
value.length > 0 && items.length > 0 && searching,
((multiple && inputValue.length > 0) ||
(!multiple && (value as string)?.length > 0)) &&
items.length > 0 &&
searching,
);
async function onSearchType() {
await tick();
$effect(() => {
items;
makeMatchesBold();
});
async function makeMatchesBold() {
const dropdownMenu = document.querySelector(".menu");
const relevantValue = multiple ? inputValue : (value as string);
if (dropdownMenu) {
for (let i = 0; i < dropdownMenu.children.length; i++) {
const li = dropdownMenu.children[i];
@@ -53,14 +66,18 @@
textNode.innerHTML = items[i].text;
const text = textNode.innerText.replace(
new RegExp(value, "gi"),
new RegExp(relevantValue, "gi"),
(match) => `<strong>${match}</strong>`,
);
textNode.innerHTML = text;
}
}
}
update(value);
async function onSearchType() {
const relevantValue = multiple ? inputValue : (value as string);
update(relevantValue);
}
function update(q: string) {
@@ -69,25 +86,117 @@
function handleItemClick(e: Event, item: ComboboxItem) {
e.stopPropagation();
if (multiple) {
if ((value as ComboboxItem[]).some((i) => i.text == item.text)) {
return;
}
inputValue = "";
value = [...(value as ComboboxItem[]), item];
} else {
value = item.value;
onclick?.(item);
}
}
function handleKeydown(e: KeyboardEvent) {
if (!multiple) {
return;
}
if (e.key == "Enter") {
e.preventDefault();
e.stopPropagation();
if (!inputValue.length) {
return;
}
if ((value as ComboboxItem[]).some((i) => i.text == inputValue)) {
inputValue = "";
return;
}
const matchingItemFromSuggestions = items.find(
(i) => i.text == inputValue,
);
value = [
...(value as ComboboxItem[]),
matchingItemFromSuggestions
? matchingItemFromSuggestions
: {
text: inputValue,
value: null,
},
];
inputValue = "";
} else if (e.key == "Backspace" && inputValue.length == 0) {
value = (value as ComboboxItem[]).filter(
(_, i) => i !== value.length - 1,
);
}
}
function getInputValue() {
if (multiple) {
return inputValue;
} else {
return value as string;
}
}
function setInputValue(v: string) {
if (multiple) {
inputValue = v;
} else {
value = v;
}
}
</script>
<div class="relative {extraClasses}">
<TextField
{#if label.length}
<label for={name} class="text-sm font-medium pb-1">
{label}
</label>
{/if}
<div
class="flex items-center gap-1 flex-wrap bg-input-background border border-input-border rounded-md p-3 transition-colors focus-within:border-input-border-focus focus-within:ring-0 w-full {extraClasses}"
>
{#if multiple}
{#each value as ComboboxItem[] as v, i}
{#if chips}
<Chip
text={v.text}
closable
onclick={(e) => {
value = (value as ComboboxItem[]).filter(
(_, idx) => i != idx,
);
}}
></Chip>
{:else}
<span
>{v.text}{i < (value as ComboboxItem[]).length - 1
? ","
: ""}</span
>
{/if}
{/each}
{/if}
<input
class="flex-1 min-w-24 bg-input-background focus:outline-none"
type="search"
{name}
autocomplete="off"
{icon}
{label}
{placeholder}
bind:value
{onchange}
oninput={onSearchType}
autocomplete="off"
{onchange}
placeholder={value.length ? undefined : placeholder}
onfocusin={() => (searching = true)}
onfocusout={() => (searching = false)}
></TextField>
onkeydown={(e) => handleKeydown(e)}
bind:value={getInputValue, setInputValue}
/>
</div>
{#if dropDownOpen}
<ul
@@ -104,8 +213,9 @@
onmousedown={(e) => handleItemClick(e, item)}
onkeydown={(e) => handleItemClick(e, item)}
>
{#if item.icon}
<i class="fa fa-{item.icon} mr-6"></i>
{/if}
<p class="text-ellipsis">{item.text}</p>
</li>
{/each}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import type { SelectItem } from "./select.svelte";
import Chip from "./chip.svelte";
interface Props {
items?: SelectItem[];
@@ -58,18 +59,11 @@
<span class="text-gray-400">{placeholder}</span>
{/if}
{#each value as item}
<div
class="bg-primary text-white px-2 py-1 rounded-full flex items-center gap-1"
>
<span class="text-sm">{$_(item.text)}</span>
<button
aria-label="Close"
<Chip
text={$_(item.text)}
closable
onclick={(e) => removeItem(e, item)}
class="text-white hover:bg-primary-hover rounded-full w-4 h-4 flex items-center justify-center"
>
<i class="fa fa-close"></i>
</button>
</div>
></Chip>
{/each}
<i
class="fa fa-caret-down absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 transition-transform"

View File

@@ -16,6 +16,7 @@
import { _ } from "svelte-i18n";
import ShareInfo from "../share_info.svelte";
import type { MouseEventHandler } from "svelte/elements";
import Chip from "../base/chip.svelte";
interface Props {
trail: Trail;
@@ -124,6 +125,13 @@
{trail.expand.author.username}
</p>
{/if}
{#if trail.tags?.length}
<div class="flex flex-wrap gap-1 mb-3">
{#each trail.tags ?? [] as t}
<Chip text={t} closable={false} primary={false}></Chip>
{/each}
</div>
{/if}
<div class="flex gap-x-4">
{#if trail.location}
<h5>

View File

@@ -17,6 +17,9 @@
import { pb } from "$lib/pocketbase";
import { searchLocations } from "$lib/stores/search_store";
import { getIconForLocation } from "$lib/util/icon_util";
import { tags_index } from "$lib/stores/tag_store";
import Combobox, { type ComboboxItem } from "../base/combobox.svelte";
import { T } from "@threlte/core";
interface Props {
categories: Category[];
@@ -59,6 +62,8 @@
let citySearchQuery: string = $state("");
let tagItems: ComboboxItem[] = $state([]);
async function update() {
onupdate?.(filter);
}
@@ -133,6 +138,20 @@
update();
}
async function searchTags(q: string) {
const result = await tags_index(q);
tagItems = result.items.map((t) => ({ text: t.name, value: t }));
}
function getFilterTags(): ComboboxItem[] {
return filter.tags.map((t) => ({ text: t, value: t }));
}
function setFilterTags(tags: ComboboxItem[]) {
filter.tags = tags.map((t) => t.text);
update();
}
</script>
<div class="trail-filter p-8 border border-input-border rounded-xl">
@@ -169,6 +188,17 @@
placeholder={`${$_("filter-categories")}...`}
></MultiSelect>
<hr class="my-4 border-separator" />
<Combobox
bind:value={getFilterTags, setFilterTags}
onupdate={searchTags}
placeholder={`${$_("filter-tags")}...`}
items={tagItems}
label={$_("tags")}
multiple
chips
></Combobox>
<hr class="my-4 border-separator" />
{#if pb.authStore.record}
<UserSearch
onclick={(item) => setAuthorFilter(item)}

View File

@@ -42,6 +42,7 @@
import SummitLogTable from "../summit_log/summit_log_table.svelte";
import MapWithElevationMaplibre from "./map_with_elevation_maplibre.svelte";
import TrailTimeline from "./trail_timeline.svelte";
import Chip from "../base/chip.svelte";
interface Props {
initTrail: Trail;
@@ -246,7 +247,7 @@
</h5>
{/if}
{#if trail.expand?.author}
<p class="my-3">
<p class="mt-2 mb-3">
{$_("by")}
<img
class="rounded-full w-8 aspect-square mx-1 inline"
@@ -268,7 +269,14 @@
{/if}
</p>
{/if}
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 mr-8">
{#if trail.expand?.tags && trail.expand.tags.length > 0}
<div class="flex flex-wrap gap-2 text-gray-600">
{#each trail.expand.tags as tag}
<Chip text={tag.name}></Chip>
{/each}
</div>
{/if}
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-2 mr-8">
{#if trail.location}
<h3 class="text-lg">
<i class="fa fa-location-dot mr-2"></i>
@@ -342,15 +350,6 @@
</div>
{/if}
</section>
{#if trail.tags && trail.tags.length > 0}
<hr class="border-separator" />
<section class="flex p-8 gap-4 text-gray-600">
{#each trail.tags as tag}
<span class="py-2 px-4 border rounded-full">{tag}</span>
{/each}
</section>
<hr class="border-separator" />
{/if}
</div>
<section class="trail-info-panel-content px-8">
<div

View File

@@ -12,6 +12,7 @@
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import ShareInfo from "../share_info.svelte";
import Chip from "../base/chip.svelte";
interface Props {
trail: Trail;
@@ -88,6 +89,13 @@
{trail.expand.author.username}
</p>
{/if}
{#if trail.tags?.length}
<div class="flex gap-1 mb-3">
{#each trail.tags ?? [] as t}
<Chip text={t} closable={false} primary={false}></Chip>
{/each}
</div>
{/if}
<div class="flex flex-wrap gap-x-8">
{#if trail.location}
<h5><i class="fa fa-location-dot mr-3"></i>{trail.location}</h5>

View File

@@ -122,6 +122,7 @@
"file-too-big": "Datei {file} ist zu groß (max. {size})",
"filter-categories": "Kategorien filtern",
"filter-difficulty": "Schwierigkeit filtern",
"filter-tags": "",
"finish": "Ziel",
"focus-map-on": "Karte fokussieren auf",
"follow": "Folgen",
@@ -289,6 +290,7 @@
"stop-editing": "Bearbeiten beenden",
"summit-book": "Gipfelbuch",
"table": "Tabelle",
"tags": "",
"text": "Text",
"tilesets": "Tilesets",
"trail": "{n, plural, =1 {Route} other {Routen}}",

View File

@@ -122,6 +122,7 @@
"file-too-big": "File {file} is too big (max. {size})",
"filter-categories": "Filter categories",
"filter-difficulty": "Filter difficulty",
"filter-tags": "Filter tags",
"finish": "Finish",
"focus-map-on": "Focus map on",
"follow": "Follow",
@@ -289,6 +290,7 @@
"stop-editing": "Stop editing",
"summit-book": "Summit Book",
"table": "Table",
"tags": "Tags",
"text": "Text",
"tilesets": "Custom tilesets",
"trail": "{n, plural, =1 {Trail} other {Trails}}",

View File

@@ -122,6 +122,7 @@
"file-too-big": "Archivo {file} es demasiado grande (max. {size})",
"filter-categories": "Filtrar categorías",
"filter-difficulty": "Filtrar dificultad",
"filter-tags": "",
"finish": "Finish",
"focus-map-on": "Centrar mapa sobre",
"follow": "Seguir",
@@ -289,6 +290,7 @@
"stop-editing": "Stop editing",
"summit-book": "Libro de ascensos",
"table": "Tabla",
"tags": "",
"text": "Texto",
"tilesets": "Ficha personalizada",
"trail": "{n, plural, one {}=1 {Ruta} other {Rutas}}",

View File

@@ -122,6 +122,7 @@
"file-too-big": "Le fichier {file} est trop volumineux (max. {size})",
"filter-categories": "Filtrer les catégories",
"filter-difficulty": "Filtrer la difficulté",
"filter-tags": "",
"finish": "Finish",
"focus-map-on": "Centrer la carte sur",
"follow": "Suivre",
@@ -289,6 +290,7 @@
"stop-editing": "Arrêter la modification",
"summit-book": "Liste des ascensions",
"table": "Tableau",
"tags": "",
"text": "Texte",
"tilesets": "Tuiles personnalisés",
"trail": "{n, plural, =1 {Itinéraire} other {Itinéraires}}",

View File

@@ -122,6 +122,7 @@
"file-too-big": "File {file} is too big (max. {size})",
"filter-categories": "Filter categories",
"filter-difficulty": "Filter difficulty",
"filter-tags": "",
"finish": "Finish",
"focus-map-on": "Focus map on",
"follow": "Follow",
@@ -289,6 +290,7 @@
"stop-editing": "Stop editing",
"summit-book": "Csúcspont könyv",
"table": "Táblázat",
"tags": "",
"text": "Szöveg",
"tilesets": "Custom tilesets",
"trail": "{n, plural, =1 {Útvonal} other {Útvonalak}}",

View File

@@ -122,6 +122,7 @@
"file-too-big": "File {file} è troppo grande (max. {size})",
"filter-categories": "Filtrare categorie",
"filter-difficulty": "Filtrare difficoltà",
"filter-tags": "",
"finish": "Finish",
"focus-map-on": "Focus sulla mappa",
"follow": "Seguire",
@@ -289,6 +290,7 @@
"stop-editing": "Stop editing",
"summit-book": "Libro di vetta",
"table": "Tavolo",
"tags": "",
"text": "Testo",
"tilesets": "Riquadri personalizzati",
"trail": "{n, plural, =1 {Percorso} other {Percorsi}}",

View File

@@ -122,6 +122,7 @@
"file-too-big": "File {file} is too big (max. {size})",
"filter-categories": "Filter categories",
"filter-difficulty": "Filter difficulty",
"filter-tags": "",
"finish": "Finish",
"focus-map-on": "Focus map on",
"follow": "Follow",
@@ -289,6 +290,7 @@
"stop-editing": "Stop editing",
"summit-book": "Bergtopboek",
"table": "Tabel",
"tags": "",
"text": "Tekst",
"tilesets": "Custom tilesets",
"trail": "{n, plural, =1 {Wandelroute} other {Wandelroutes}}",

View File

@@ -122,6 +122,7 @@
"file-too-big": "Plik {file} jest za duży (maks. {size})",
"filter-categories": "Filtruj kategorie",
"filter-difficulty": "Filtruj poziom trudności",
"filter-tags": "",
"finish": "Finish",
"focus-map-on": "Skoncentruj mapę na",
"follow": "Obserwuj",
@@ -289,6 +290,7 @@
"stop-editing": "Zakończ edycję",
"summit-book": "Logbook",
"table": "Tabela",
"tags": "",
"text": "Tekst",
"tilesets": "Niestandardowe zestawy płytek",
"trail": "{n, plural, one {Szlak} few {Szlaki} many {Szlaków}=1 {Szlak} other {Szlaki}}",

View File

@@ -122,6 +122,7 @@
"file-too-big": "File {file} is too big (max. {size})",
"filter-categories": "Filtrar categorias",
"filter-difficulty": "Filter difficulty",
"filter-tags": "",
"finish": "Finish",
"focus-map-on": "Centrar mapa em",
"follow": "Follow",
@@ -289,6 +290,7 @@
"stop-editing": "Stop editing",
"summit-book": "Livro da cimeira",
"table": "Tabela",
"tags": "",
"text": "Texto",
"tilesets": "Camada de renderização personalizada",
"trail": "{n, plural, =1 {Percurso} other {Percursos}}",

View File

@@ -122,6 +122,7 @@
"file-too-big": "File {file} is too big (max. {size})",
"filter-categories": "筛选分类",
"filter-difficulty": "Filter difficulty",
"filter-tags": "",
"finish": "Finish",
"focus-map-on": "地图聚焦于",
"follow": "Follow",
@@ -289,6 +290,7 @@
"stop-editing": "Stop editing",
"summit-book": "详细日程",
"table": "表格",
"tags": "",
"text": "文本",
"tilesets": "自定义地图图层",
"trail": "{n, plural, =1 {路线} other {路线}}",

View File

@@ -0,0 +1,15 @@
import { z, ZodType } from "zod";
import type { SummitLog } from "../summit_log";
import type { Tag } from "../tag";
const TagCreateSchema = z.object({
id: z.string().length(15).optional(),
name: z.string()
}) satisfies ZodType<Tag>
const TagUpdateSchema = z.object({
name: z.string().optional()
}) satisfies ZodType<Partial<Tag>>
export { TagCreateSchema, TagUpdateSchema };

View File

@@ -21,6 +21,7 @@ const TrailCreateSchema = z.object({
waypoints: z.array(z.string()).default([]),
summit_logs: z.array(z.string()).default([]),
category: z.string().length(15).optional().or(z.literal('')),
tags: z.array(z.string()).default([]),
gpx: z.string().optional(),
author: z.string().length(15),
@@ -46,6 +47,7 @@ const TrailUpdateSchema = z.object({
waypoints: z.array(z.string()).optional(),
summit_logs: z.array(z.string()).optional(),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
gpx: z.string().optional(),
}) satisfies ZodType<Partial<Trail>>

21
web/src/lib/models/tag.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { Trail } from "./trail";
export class Tag {
id?: string;
name: string;
constructor(name: string) {
this.name = name;
}
}
export class TrailTag {
id?: string;
tag: Tag;
trail: Trail;
constructor(tag: Tag, trail: Trail) {
this.tag = tag;
this.trail = trail;
}
}

View File

@@ -1,6 +1,7 @@
import type { Category } from "./category";
import type { Comment } from "./comment";
import type { SummitLog } from "./summit_log";
import type { Tag } from "./tag";
import type { TrailShare } from "./trail_share";
import type { UserAnonymous } from "./user";
import type { Waypoint } from "./waypoint";
@@ -23,9 +24,11 @@ class Trail {
gpx?: string;
created?: string;
category?: string;
tags: string[];
waypoints: string[];
summit_logs: string[];
expand?: {
tags?: Tag[]
category?: Category;
waypoints?: Waypoint[]
summit_logs?: SummitLog[]
@@ -34,7 +37,6 @@ class Trail {
gpx_data?: string
trail_share_via_trail?: TrailShare[]
}
tags?: string[];
description?: string;
author: string;
@@ -81,6 +83,7 @@ class Trail {
this.photos = params?.photos ?? [];
this.waypoints = [];
this.summit_logs = [];
this.tags = []
this.gpx = params?.gpx;
this.expand = {
category: params?.category,
@@ -89,7 +92,6 @@ class Trail {
comments_via_trail: params?.comments ?? [],
trail_share_via_trail: params?.shares ?? []
}
this.tags = params?.tags ?? []
this.description = params?.description ?? "";
this.created = params?.created;
this.author = "000000000000000"
@@ -99,6 +101,7 @@ class Trail {
interface TrailFilter {
q: string,
category: string[],
tags: string[],
difficulty: ("easy" | "moderate" | "difficult")[]
author?: string;
public?: boolean;
@@ -163,6 +166,7 @@ interface TrailSearchResult {
public: boolean;
thumbnail: string;
shares?: string[];
tags?: string[]
gpx: string;
_geo: {
lat: number,

View File

@@ -0,0 +1,42 @@
import { type Tag } from "$lib/models/tag";
import { APIError } from "$lib/util/api_util";
import type { ListResult } from "pocketbase";
import { writable, type Writable } from "svelte/store";
let tags: Writable<Tag[]> = writable([]);
export async function tags_index(name: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/tag?' + new URLSearchParams({
filter: `name~'${name}'`,
}), {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const response: ListResult<Tag> = await r.json();
tags.set(response.items);
return response;
}
export async function tags_create(tag: Tag) {
let r = await fetch('/api/v1/tag', {
method: 'PUT',
body: JSON.stringify(tag),
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
return await r.json();
}

View File

@@ -11,6 +11,8 @@ import { writable, type Writable } from "svelte/store";
import { summit_logs_create, summit_logs_delete, summit_logs_update } from "./summit_log_store";
import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store";
import { APIError } from "$lib/util/api_util";
import { tags_create } from "./tag_store";
import type { Tag } from "$lib/models/tag";
let trails: Trail[] = []
export const trail: Writable<Trail> = writable(new Trail(""));
@@ -20,7 +22,7 @@ export const editTrail: Writable<Trail> = writable(new Trail(""));
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,summit_logs",
expand: "category,waypoints,summit_logs,tags",
sort: random ? "@random" : "",
}), {
method: 'GET',
@@ -127,7 +129,7 @@ export async function trails_search_bounding_box(northEast: M.LngLat, southWest:
export async function trails_show(id: 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,summit_logs,trail_share_via_trail",
expand: "category,waypoints,summit_logs,trail_share_via_trail,tags",
}), {
method: 'GET',
})
@@ -181,11 +183,19 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl
const model = await summit_logs_create(summitLog, f);
trail.summit_logs.push(model.id!);
}
for (const tag of trail.expand?.tags ?? []) {
if (!tag.id) {
const model = await tags_create(tag)
trail.tags.push(model.id!)
} else {
trail.tags.push(tag.id)
}
}
trail.author = pb.authStore.record!.id
let r = await f(`/api/v1/trail?` + new URLSearchParams({
expand: "category,waypoints,summit_logs,trail_share_via_trail",
expand: "category,waypoints,summit_logs,trail_share_via_trail,tags",
}), {
method: 'PUT',
body: JSON.stringify({ ...trail }),
@@ -263,8 +273,23 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
const success = await summit_logs_delete(deletedSummitLog);
}
const tagUpdates = compareObjectArrays<Tag>(oldTrail.expand?.tags ?? [], newTrail.expand?.tags ?? []);
for (const tag of tagUpdates.added) {
if (!tag.id) {
const model = await tags_create(tag)
newTrail.tags.push(model.id!)
} else {
newTrail.tags.push(tag.id)
}
}
for (const tag of tagUpdates.deleted) {
newTrail.tags = newTrail.tags.filter(t => t != tag.id);
}
let r = await fetch(`/api/v1/trail/${newTrail.id}?` + new URLSearchParams({
expand: "category,waypoints,summit_logs,trail_share_via_trail",
expand: "category,waypoints,summit_logs,trail_share_via_trail,tags",
}), {
method: 'POST',
body: JSON.stringify({ ...newTrail, expand: undefined }),
@@ -430,6 +455,7 @@ async function searchResultToTrailList(hits: Hits<TrailSearchResult>, loadGPX: b
public: h.public,
summit_logs: [],
waypoints: [],
tags: h.tags ?? [],
category: h.category,
created: new Date(h.created * 1000).toISOString(),
date: new Date(h.date * 1000).toISOString(),
@@ -529,6 +555,11 @@ function buildFilterText(filter: TrailFilter, includeGeo: boolean): string {
if (filter.category.length > 0) {
filterText += ` AND category IN [${filter.category.join(",")}]`;
}
if (filter.tags.length > 0) {
filterText += ` AND (${filter.tags.map(t => `tags = '${t}'`).join(" OR ")})`;
}
if (filter.completed !== undefined) {
filterText += ` AND completed = ${filter.completed}`;
}

View File

@@ -31,6 +31,7 @@ export enum Collection {
summit_logs = "summit_logs",
trail_share = "trail_share",
trails = "trails",
tags = "tags",
waypoints = "waypoints",
activities = "activities",
follow_counts = "follow_counts",

View File

@@ -0,0 +1,23 @@
import { TagCreateSchema } from '$lib/models/api/tag_schema';
import type { Tag } from '$lib/models/tag';
import { Collection, create, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
export async function GET(event: RequestEvent) {
try {
const r = await list<Tag>(event, Collection.tags);
return json(r)
} catch (e: any) {
throw handleError(e);
}
}
export async function PUT(event: RequestEvent) {
try {
const r = await create<Tag>(event, TagCreateSchema, Collection.tags)
return json(r);
} catch (e) {
throw handleError(e)
}
}

View File

@@ -0,0 +1,31 @@
import { TagUpdateSchema } from "$lib/models/api/tag_schema";
import type { Tag } from "$lib/models/tag";
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
try {
const r = await show<Tag>(event, Collection.tags)
return json(r)
} catch (e: any) {
throw handleError(e)
}
}
export async function POST(event: RequestEvent) {
try {
const r = await update<Tag>(event, TagUpdateSchema, Collection.tags)
return json(r);
} catch (e: any) {
throw handleError(e)
}
}
export async function DELETE(event: RequestEvent) {
try {
const r = await remove(event, Collection.tags)
return json(r);
} catch (e: any) {
throw handleError(e)
}
}

View File

@@ -10,6 +10,7 @@ export const load: ServerLoad = async ({ params, locals, fetch }) => {
const filter: TrailFilter = {
q: "",
category: [],
tags: [],
difficulty: ["easy", "moderate", "difficult"],
author: "",
public: true,

View File

@@ -6,6 +6,7 @@ export const load: Load = async ({ params, fetch }) => {
const filter: TrailFilter = {
q: "",
category: [],
tags: [],
difficulty: ["easy", "moderate", "difficult"],
author: params.id,
public: true,

View File

@@ -79,6 +79,13 @@
import { backInOut } from "svelte/easing";
import { scale } from "svelte/transition";
import { z } from "zod";
import Combobox, {
type ComboboxItem,
} from "$lib/components/base/combobox.svelte";
import { TagCreateSchema } from "$lib/models/api/tag_schema.js";
import { SvelteSet } from "svelte/reactivity";
import { Tag } from "$lib/models/tag.js";
import { tags_index } from "$lib/stores/tag_store.js";
let { data } = $props();
@@ -117,6 +124,7 @@
}),
)
.optional(),
tags: z.array(TagCreateSchema).optional(),
})
.optional(),
});
@@ -132,6 +140,8 @@
let savedAtLeastOnce = $state(false);
let tagItems: ComboboxItem[] = $state([]);
const {
form,
errors,
@@ -202,7 +212,7 @@
);
setFields(updatedTrail);
}
photoFiles = []
photoFiles = [];
savedAtLeastOnce = true;
show_toast({
@@ -406,8 +416,8 @@
const wp = $formData.expand!.waypoints?.splice(index, 1);
$formData.waypoints.splice(index, 1);
if(!$formData.expand!.waypoints?.length) {
$formData.expand!.waypoints = []
if (!$formData.expand!.waypoints?.length) {
$formData.expand!.waypoints = [];
}
$formData.expand!.waypoints = $formData.expand!.waypoints;
@@ -546,7 +556,11 @@
async function handleMapClick(e: M.MapMouseEvent) {
if (!drawingActive) {
if ((e.originalEvent.target as HTMLElement).tagName.toLowerCase() !== "canvas") {
if (
(
e.originalEvent.target as HTMLElement
).tagName.toLowerCase() !== "canvas"
) {
return;
}
mapPopup?.remove();
@@ -853,6 +867,26 @@
untrack(() => updateTrailOnMap());
}
});
function getTrailTags() {
return (
$formData.expand?.tags?.map((t) => ({
text: t.name,
value: t,
})) ?? []
);
}
function setTrailTags(items: ComboboxItem[]) {
$formData.expand!.tags = items.map((i) =>
i.value ? i.value : new Tag(i.text),
);
}
async function searchTags(q: string) {
const result = await tags_index(q);
tagItems = result.items.map((t) => ({ text: t.name, value: t }));
}
</script>
<svelte:head>
@@ -1009,6 +1043,14 @@
<Datepicker label={$_("date")} bind:value={$formData.date}></Datepicker>
<Textarea name="description" label={$_("describe-your-trail")}
></Textarea>
<Combobox
bind:value={getTrailTags, setTrailTags}
onupdate={searchTags}
items={tagItems}
label={$_("tags")}
multiple
chips
></Combobox>
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-4">
<Select
name="difficulty"
@@ -1036,7 +1078,10 @@
</h3>
<ul>
{#each $formData.expand?.waypoints ?? [] as waypoint, i}
<li onmouseenter={() => openMarkerPopup(waypoint)} onmouseleave={() => openMarkerPopup(waypoint)}>
<li
onmouseenter={() => openMarkerPopup(waypoint)}
onmouseleave={() => openMarkerPopup(waypoint)}
>
<WaypointCard
{waypoint}
mode="edit"

View File

@@ -9,6 +9,7 @@ export const load: ServerLoad = async ({ params, locals, url, fetch }) => {
const filter: TrailFilter = {
q: "",
category: [],
tags: [],
difficulty: ["easy", "moderate", "difficult"],
author: "",
public: true,