mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 21:33:02 +01:00
* add missing translations and translate page titles * fix: actually use the declared localized variables * lint and prettier fixes * add missing translations for toasts and confirms * use components for shift/enter keys, add pluralization for photos, and fix primary photo conditional * remove prop defaults since we're computing these anyways
153 lines
5.0 KiB
Vue
153 lines
5.0 KiB
Vue
<template>
|
|
<div class="flex flex-col gap-1">
|
|
<Label :for="id" class="px-1">
|
|
{{ $t("global.labels") }}
|
|
</Label>
|
|
|
|
<TagsInput
|
|
v-model="modelValue"
|
|
class="w-full gap-0 px-0"
|
|
:display-value="v => shortenedLabels.find(l => l.id === v)?.name ?? 'Loading...'"
|
|
>
|
|
<div class="flex flex-wrap items-center gap-2 px-3">
|
|
<TagsInputItem v-for="item in modelValue" :key="item" :value="item">
|
|
<TagsInputItemText />
|
|
<TagsInputItemDelete />
|
|
</TagsInputItem>
|
|
</div>
|
|
|
|
<ComboboxRoot v-model="modelValue" v-model:open="open" class="w-full" :ignore-filter="true">
|
|
<ComboboxAnchor as-child>
|
|
<ComboboxInput v-model="searchTerm" :placeholder="$t('components.label.selector.select_labels')" as-child>
|
|
<TagsInputInput
|
|
:id="id"
|
|
class="w-full px-3"
|
|
:class="modelValue.length > 0 ? 'mt-2' : ''"
|
|
@focus="open = true"
|
|
/>
|
|
</ComboboxInput>
|
|
</ComboboxAnchor>
|
|
|
|
<ComboboxPortal>
|
|
<ComboboxContent :side-offset="4" position="popper" class="z-50">
|
|
<CommandList
|
|
position="popper"
|
|
class="mt-2 w-[--reka-popper-anchor-width] rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
|
>
|
|
<CommandEmpty />
|
|
<CommandGroup>
|
|
<CommandItem
|
|
v-for="label in filteredLabels"
|
|
:key="label.value"
|
|
:value="label.value"
|
|
@select.prevent="
|
|
ev => {
|
|
if (typeof ev.detail.value === 'string') {
|
|
if (ev.detail.value === 'create-item') {
|
|
void createAndAdd(searchTerm);
|
|
} else {
|
|
if (!modelValue.includes(ev.detail.value)) {
|
|
modelValue = [...modelValue, ev.detail.value];
|
|
}
|
|
}
|
|
searchTerm = '';
|
|
}
|
|
}
|
|
"
|
|
>
|
|
{{ label.label }}
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</ComboboxContent>
|
|
</ComboboxPortal>
|
|
</ComboboxRoot>
|
|
</TagsInput>
|
|
</div>
|
|
</template>
|
|
<script setup lang="ts">
|
|
import { useI18n } from "vue-i18n";
|
|
import { ComboboxAnchor, ComboboxContent, ComboboxInput, ComboboxPortal, ComboboxRoot } from "reka-ui";
|
|
import { computed, ref } from "vue";
|
|
import fuzzysort from "fuzzysort";
|
|
import { toast } from "@/components/ui/sonner";
|
|
import { CommandEmpty, CommandGroup, CommandItem, CommandList } from "~/components/ui/command";
|
|
import {
|
|
TagsInput,
|
|
TagsInputInput,
|
|
TagsInputItem,
|
|
TagsInputItemDelete,
|
|
TagsInputItemText,
|
|
} from "@/components/ui/tags-input";
|
|
import type { LabelOut } from "~/lib/api/types/data-contracts";
|
|
|
|
const { t } = useI18n();
|
|
|
|
const id = useId();
|
|
|
|
const api = useUserApi();
|
|
|
|
const emit = defineEmits(["update:modelValue"]);
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: Array as () => string[],
|
|
default: null,
|
|
},
|
|
labels: {
|
|
type: Array as () => LabelOut[],
|
|
required: true,
|
|
},
|
|
});
|
|
|
|
const modelValue = useVModel(props, "modelValue", emit);
|
|
|
|
const open = ref(false);
|
|
const searchTerm = ref("");
|
|
|
|
const shortenedLabels = computed(() => {
|
|
return props.labels.map(l => ({
|
|
...l,
|
|
name: l.name.length > 20 ? `${l.name.substring(0, 20)}...` : l.name,
|
|
}));
|
|
});
|
|
|
|
const filteredLabels = computed(() => {
|
|
const filtered = fuzzysort
|
|
.go(searchTerm.value, shortenedLabels.value, { key: "name", all: true })
|
|
.map(l => ({
|
|
value: l.obj.id,
|
|
label: l.obj.name,
|
|
}))
|
|
.filter(i => !modelValue.value.includes(i.value));
|
|
|
|
if (searchTerm.value.trim() !== "") {
|
|
filtered.push({ value: "create-item", label: `${t("global.create")} ${searchTerm.value}` });
|
|
}
|
|
|
|
return filtered;
|
|
});
|
|
|
|
const createAndAdd = async (name: string) => {
|
|
if (name.length > 50) {
|
|
toast.error(t("components.label.create_modal.toast.label_name_too_long"));
|
|
return;
|
|
}
|
|
const { error, data } = await api.labels.create({
|
|
name,
|
|
color: "", // Future!
|
|
description: "",
|
|
});
|
|
|
|
if (error) {
|
|
toast.error(t("components.label.create_modal.toast.create_failed"));
|
|
return;
|
|
}
|
|
|
|
toast.success(t("components.label.create_modal.toast.create_success"));
|
|
|
|
modelValue.value = [...modelValue.value, data.id];
|
|
};
|
|
|
|
// TODO: when reka-ui 2 is release use hook to set cursor to end when label is added with click
|
|
</script>
|