adds tags
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
89
db/migrations/1742409454_created_tags.go
Normal file
89
db/migrations/1742409454_created_tags.go
Normal 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)
|
||||
})
|
||||
}
|
||||
44
db/migrations/1742411241_updated_tags.go
Normal file
44
db/migrations/1742411241_updated_tags.go
Normal 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)
|
||||
})
|
||||
}
|
||||
42
db/migrations/1742411270_updated_tags.go
Normal file
42
db/migrations/1742411270_updated_tags.go
Normal 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)
|
||||
})
|
||||
}
|
||||
44
db/migrations/1742412356_updated_trails.go
Normal file
44
db/migrations/1742412356_updated_trails.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
30
web/src/lib/components/base/chip.svelte
Normal file
30
web/src/lib/components/base/chip.svelte
Normal 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>
|
||||
@@ -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();
|
||||
value = item.value;
|
||||
onclick?.(item);
|
||||
|
||||
if (multiple) {
|
||||
if ((value as ComboboxItem[]).some((i) => i.text == item.text)) {
|
||||
return;
|
||||
}
|
||||
inputValue = "";
|
||||
value = [...(value as ComboboxItem[]), item];
|
||||
} else {
|
||||
value = item.value;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
type="search"
|
||||
{name}
|
||||
autocomplete="off"
|
||||
{icon}
|
||||
{label}
|
||||
{placeholder}
|
||||
bind:value
|
||||
{onchange}
|
||||
oninput={onSearchType}
|
||||
onfocusin={() => (searching = true)}
|
||||
onfocusout={() => (searching = false)}
|
||||
></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}
|
||||
oninput={onSearchType}
|
||||
autocomplete="off"
|
||||
{onchange}
|
||||
placeholder={value.length ? undefined : placeholder}
|
||||
onfocusin={() => (searching = true)}
|
||||
onfocusout={() => (searching = false)}
|
||||
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)}
|
||||
>
|
||||
<i class="fa fa-{item.icon} mr-6"></i>
|
||||
|
||||
{#if item.icon}
|
||||
<i class="fa fa-{item.icon} mr-6"></i>
|
||||
{/if}
|
||||
<p class="text-ellipsis">{item.text}</p>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -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"
|
||||
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
|
||||
text={$_(item.text)}
|
||||
closable
|
||||
onclick={(e) => removeItem(e, item)}
|
||||
></Chip>
|
||||
{/each}
|
||||
<i
|
||||
class="fa fa-caret-down absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 transition-transform"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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 {路线}}",
|
||||
|
||||
15
web/src/lib/models/api/tag_schema.ts
Normal file
15
web/src/lib/models/api/tag_schema.ts
Normal 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 };
|
||||
@@ -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
21
web/src/lib/models/tag.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
42
web/src/lib/stores/tag_store.ts
Normal file
42
web/src/lib/stores/tag_store.ts
Normal 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();
|
||||
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
23
web/src/routes/api/v1/tag/+server.ts
Normal file
23
web/src/routes/api/v1/tag/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
31
web/src/routes/api/v1/tag/[id]/+server.ts
Normal file
31
web/src/routes/api/v1/tag/[id]/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user