adds tags
This commit is contained in:
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>
|
||||
|
||||
Reference in New Issue
Block a user