migrate pages to shadcn (#628)

* feat: migrate tools page and label generator to shadcn

* chore: lint issues

* feat: also do profile page

* feat: shadcn 404 page

* feat: login page shadcn

* fix: daisyui ironically breaks the z height for the login page

* feat: componentise the language selector and add it to the login page

* feat: use nuxtlink

* feat: card and table made more shadcn

* feat: shadcn statscard

* chore: lint

* feat: shadcn labelchip and locationcard

* feat: shadcn locations page

* refactor: remove unused new item page

* chore: lint

* feat: shadcn item card

* fix: wrapping of location and lint

* feat: ctrl enter in text area in form submits form

* feat: begin shadcn locations page and remove pageqrcode comp in favour of integrating it into labelmaker

* chore: lint + remove unused code

* fix: remove uneeded margin

* feat: shadcn labels page and fix some issues with location

* feat: shadcn scanner

* chore: lint

* feat: begin shadcning item pages

* feat: shadcn maintenance page

* feat: begin shadcn search page

* fix: quick switch blurry text and crashing page when switching + incorrect z height for create menu

* feat: finish shadcn search page

* chore: lint

* feat: shadcn edit item page

* fix: quickmenumodal bug

* feat: shadcn item details page

* feat: remove all non-color related daisyui classes

* fix: type error

* fix: quick menu modal again :(
This commit is contained in:
Tonya
2025-04-20 08:58:03 +01:00
committed by GitHub
parent 400bc3f341
commit cbaf483788
114 changed files with 2818 additions and 2479 deletions

View File

@@ -29,7 +29,7 @@ module.exports = {
"vue/no-v-html": 0, "vue/no-v-html": 0,
"@typescript-eslint/consistent-type-imports": "error", "@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/ban-ts-comment": 0, "@typescript-eslint/ban-ts-comment": 0,
"tailwindcss/no-custom-classname": 0, "tailwindcss/no-custom-classname": "warn",
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
"error", "error",
{ {

View File

@@ -777,14 +777,6 @@
text-transform: none !important; text-transform: none !important;
} }
.btn {
text-transform: none !important;
}
.tooltip {
overflow-wrap: break-word;
}
/* transparent subtle scrollbar */ /* transparent subtle scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 0.2em; width: 0.2em;

View File

@@ -1,123 +0,0 @@
<script lang="ts" setup>
import MdiPlus from "~icons/mdi/plus";
const ctx = useAuthContext();
const api = useUserApi();
async function logout() {
const { error } = await ctx.logout(api);
if (error) {
return;
}
navigateTo("/");
}
const links = [
{
name: "Home",
href: "/home",
},
{
name: "Items",
href: "/items",
},
{
name: "Logout",
action: logout,
last: true,
},
];
const modals = reactive({
item: false,
location: false,
label: false,
});
const dropdown = [
{
name: "Item / Asset",
action: () => {
modals.item = true;
},
},
{
name: "Location",
action: () => {
modals.location = true;
},
},
{
name: "Label",
action: () => {
modals.label = true;
},
},
];
</script>
<template>
<!--
Confirmation Modal is a singleton used by all components so we render
it here to ensure it's always available. Possibly could move this further
up the tree
-->
<ModalConfirm />
<ItemCreateModal v-model="modals.item" />
<LabelCreateModal v-model="modals.label" />
<LocationCreateModal v-model="modals.location" />
<div class="absolute top-0 -z-10 h-80 max-h-96 w-full bg-neutral shadow-xl"></div>
<BaseContainer cmp="header" class="max-w-none py-6">
<BaseContainer>
<NuxtLink to="/home">
<h2 class="mt-1 flex text-4xl font-bold tracking-tight text-neutral-content sm:text-5xl lg:text-6xl">
HomeB
<AppLogo class="-mb-4 w-12" />
x
</h2>
</NuxtLink>
<div class="ml-1 mt-2 space-x-2 text-lg text-neutral-content/75">
<template v-for="link in links">
<NuxtLink
v-if="!link.action"
:key="link.name"
class="italic transition-colors duration-200 hover:text-base-content"
:to="link.href"
>
{{ link.name }}
</NuxtLink>
<button
v-else
:key="link.name + 'link'"
for="location-form-modal"
class="italic transition-colors duration-200 hover:text-base-content"
@click="link.action"
>
{{ link.name }}
</button>
<span v-if="!link.last" :key="link.name"> / </span>
</template>
</div>
<div class="mt-6 flex">
<div class="dropdown">
<label tabindex="0" class="btn btn-primary btn-sm">
<span>
<MdiPlus class="-ml-1 mr-1" />
</span>
Create
</label>
<ul tabindex="0" class="dropdown-content menu rounded-box w-52 bg-base-100 p-2 shadow">
<li v-for="btn in dropdown" :key="btn.name">
<button @click="btn.action">
{{ btn.name }}
</button>
</li>
</ul>
</div>
</div>
</BaseContainer>
</BaseContainer>
</template>

View File

@@ -1,11 +1,12 @@
<template> <template>
<BaseModal v-model="dialog"> <Dialog dialog-id="import">
<template #title> {{ $t("components.app.import_dialog.title") }} </template> <DialogContent>
<p> <DialogHeader>
{{ $t("components.app.import_dialog.description") }} <DialogTitle>{{ $t("components.app.import_dialog.title") }}</DialogTitle>
</p> <DialogDescription> {{ $t("components.app.import_dialog.description") }} </DialogDescription>
<div class="alert alert-warning mt-4 shadow-lg"> </DialogHeader>
<div>
<div class="flex gap-2 rounded bg-destructive p-2 text-destructive-foreground shadow-lg">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="mb-auto size-6 shrink-0 stroke-current" class="mb-auto size-6 shrink-0 stroke-current"
@@ -23,31 +24,30 @@
{{ $t("components.app.import_dialog.change_warning") }} {{ $t("components.app.import_dialog.change_warning") }}
</span> </span>
</div> </div>
</div>
<form @submit.prevent="submitCsvFile"> <form class="flex flex-col gap-4" @submit.prevent="submitCsvFile">
<div class="flex flex-col gap-2 py-6"> <Input ref="importRef" type="file" accept=".csv,.tsv" @change="setFile" />
<input ref="importRef" type="file" class="hidden" accept=".csv,.tsv" @change="setFile" />
<BaseButton type="button" @click="uploadCsv"> <DialogFooter>
<MdiUpload class="mr-2 size-5" /> <Button type="submit" :disabled="!importCsv"> {{ $t("global.submit") }} </Button>
{{ $t("components.app.import_dialog.upload") }} </DialogFooter>
</BaseButton> </form>
<p class="-mb-5 pt-4 text-center"> </DialogContent>
{{ importCsv?.name }} </Dialog>
</p>
</div>
<div class="modal-action">
<BaseButton type="submit" :disabled="!importCsv"> {{ $t("global.submit") }} </BaseButton>
</div>
</form>
</BaseModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { toast } from "@/components/ui/sonner"; import { toast } from "@/components/ui/sonner";
import MdiUpload from "~icons/mdi/upload"; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type Props = { type Props = {
modelValue: boolean; modelValue: boolean;
}; };
@@ -81,10 +81,6 @@
importCsv.value = result.files[0]; importCsv.value = result.files[0];
} }
function uploadCsv() {
importRef.value?.click();
}
async function submitCsvFile() { async function submitCsvFile() {
if (!importCsv.value) { if (!importCsv.value) {
toast.error("Please select a file to import."); toast.error("Please select a file to import.");

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed } from "vue";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { fmtDate } from "~~/composables/use-formatters";
import { useViewPreferences } from "~~/composables/use-preferences";
defineProps({
includeText: {
type: Boolean,
default: true,
},
});
const preferences = useViewPreferences();
function setLanguage(lang: string) {
preferences.value.language = lang;
}
const dateExample = computed(() => {
return fmtDate(new Date(Date.now() - 15 * 60000), "relative");
});
</script>
<template>
<div class="w-full" :class="{ 'p-5 pt-0': includeText }">
<Label v-if="includeText" for="language"> {{ $t("profile.language") }} </Label>
<Select
id="language"
v-model="$i18n.locale"
@update:model-value="
event => {
setLanguage(event as string);
}
"
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="lang in $i18n.availableLocales" :key="lang" :value="lang">
{{ $t(`languages.${lang}`) }} ({{ $t(`languages.${lang}`, 1, { locale: lang }) }})
</SelectItem>
</SelectContent>
</Select>
<p v-if="includeText" class="m-2 text-sm">
{{ $t("profile.example") }}: {{ $t("global.created") }} {{ dateExample }}
</p>
</div>
</template>

View File

@@ -7,7 +7,12 @@
<p>{{ $t("components.app.outdated.current_version") }}: {{ current }}</p> <p>{{ $t("components.app.outdated.current_version") }}: {{ current }}</p>
<p>{{ $t("components.app.outdated.latest_version") }}: {{ latest }}</p> <p>{{ $t("components.app.outdated.latest_version") }}: {{ latest }}</p>
<p> <p>
<a href="https://github.com/sysadminsmedia/homebox/releases" target="_blank" rel="noopener" class="link"> <a
href="https://github.com/sysadminsmedia/homebox/releases"
target="_blank"
rel="noopener"
class="underline hover:text-primary"
>
{{ $t("components.app.outdated.new_version_available_link") }} {{ $t("components.app.outdated.new_version_available_link") }}
</a> </a>
</p> </p>
@@ -30,8 +35,8 @@
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogAction, AlertDialogAction,
} from "~/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { useDialog } from "~/components/ui/dialog-provider"; import { useDialog } from "@/components/ui/dialog-provider";
const props = defineProps<{ const props = defineProps<{
status: { status: {

View File

@@ -38,12 +38,19 @@
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
const item = props.actions.filter(item => 'shortcut' in item).find(item => item.shortcut === e.key); const item = props.actions.filter(item => 'shortcut' in item).find(item => item.shortcut === e.key);
if (item) { if (item) {
e.preventDefault();
openDialog(item.dialogId); openDialog(item.dialogId);
} }
// if esc is pressed, close the dialog
if (e.key === 'Escape') {
e.preventDefault();
closeDialog('quick-menu');
}
} }
" "
/> />
<CommandList> <CommandList>
<CommandSeparator />
<CommandEmpty>{{ t("components.quick_menu.no_results") }}</CommandEmpty> <CommandEmpty>{{ t("components.quick_menu.no_results") }}</CommandEmpty>
<CommandGroup :heading="t('global.create')"> <CommandGroup :heading="t('global.create')">
<CommandItem <CommandItem
@@ -51,7 +58,8 @@
:key="`$global.create_${i + 1}`" :key="`$global.create_${i + 1}`"
:value="create.text" :value="create.text"
@select=" @select="
() => { e => {
e.preventDefault();
openDialog(create.dialogId); openDialog(create.dialogId);
} }
" "

View File

@@ -1,77 +0,0 @@
<template>
<NuxtLink
v-if="to"
v-bind="attributes"
ref="submitBtn"
class="btn"
:class="{
loading: loading,
'btn-sm': size === 'sm',
'btn-lg': size === 'lg',
}"
:style="upper ? '' : 'text-transform: none'"
>
<label v-if="$slots.icon" class="swap swap-rotate mr-2" :class="{ 'swap-active': isHover }">
<slot name="icon" />
</label>
<slot />
</NuxtLink>
<button
v-else
v-bind="attributes"
ref="submitBtn"
class="btn"
:class="{
loading: loading,
'btn-sm': size === 'sm',
'btn-lg': size === 'lg',
}"
:style="upper ? '' : 'text-transform: none'"
>
<label v-if="$slots.icon" class="swap swap-rotate mr-2" :class="{ 'swap-active': isHover }">
<slot name="icon" />
</label>
<slot />
</button>
</template>
<script setup lang="ts">
type Sizes = "sm" | "md" | "lg";
const props = defineProps({
upper: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
size: {
type: String as () => Sizes,
default: "md",
},
to: {
type: String as () => string | null,
default: null,
},
});
const attributes = computed(() => {
if (props.to) {
return {
to: props.to,
};
}
return {
disabled: props.disabled || props.loading,
};
});
const submitBtn = ref(null);
const isHover = useElementHover(submitBtn);
</script>

View File

@@ -1,13 +1,12 @@
<template> <template>
<div class="card rounded-lg bg-base-100 shadow-xl"> <Card class="overflow-hidden shadow-xl">
<div v-if="$slots.title" class="px-4 py-5 sm:px-6"> <CardHeader v-if="$slots.title" class="px-4 py-5 sm:px-6">
<component :is="collapsable ? 'button' : 'div'" v-on="collapsable ? { click: toggle } : {}"> <component :is="collapsable ? 'button' : 'div'" v-on="collapsable ? { click: toggle } : {}">
<h3 class="flex items-center text-lg font-medium leading-6"> <h3 class="flex items-center text-lg font-medium leading-6">
<slot name="title"></slot> <slot name="title"></slot>
<template v-if="collapsable"> <template v-if="collapsable">
<span class="swap swap-rotate ml-2" :class="`${collapsed ? 'swap-active' : ''}`"> <span class="ml-2 transition-transform" :class="{ 'rotate-180': collapsed }">
<MdiChevronRight class="swap-on size-6" /> <MdiChevronDown class="size-6" />
<MdiChevronDown class="swap-off size-6" />
</span> </span>
</template> </template>
</h3> </h3>
@@ -20,22 +19,22 @@
<slot name="title-actions"></slot> <slot name="title-actions"></slot>
</template> </template>
</div> </div>
</div> </CardHeader>
<div <CardContent
:class="{ :class="{
'max-h-[9000px]': collapsable && !collapsed, 'max-h-[9000px]': collapsable && !collapsed,
'max-h-0 overflow-hidden': collapsed, 'max-h-0 overflow-hidden': collapsed,
}" }"
class="transition-[max-height] duration-200" class="p-0 transition-[max-height] duration-200"
> >
<slot /> <slot />
</div> </CardContent>
</div> </Card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import MdiChevronDown from "~icons/mdi/chevron-down"; import MdiChevronDown from "~icons/mdi/chevron-down";
import MdiChevronRight from "~icons/mdi/chevron-right"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
defineProps<{ defineProps<{
collapsable?: boolean; collapsable?: boolean;

View File

@@ -1,110 +0,0 @@
<template>
<div class="z-[999]">
<input :id="modalId" v-model="modal" type="checkbox" class="modal-toggle" />
<div
class="modal overflow-visible sm:modal-middle"
:class="{ 'modal-bottom': !props.modalTop }"
:modal-top="props.modalTop"
>
<div ref="modalBox" class="modal-box relative overflow-x-hidden overflow-y-scroll">
<button
v-if="props.showCloseButton"
:for="modalId"
class="btn btn-circle btn-sm absolute right-2 top-2"
@click="close"
>
</button>
<h3 class="text-lg font-bold">
<slot name="title"></slot>
</h3>
<slot> </slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits(["cancel", "update:modelValue"]);
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
/**
* in readonly mode the modal only `emits` a "cancel" event to indicate
* that the modal was closed via the "x" button. The parent component is
* responsible for closing the modal.
*/
readonly: {
type: Boolean,
default: false,
},
showCloseButton: {
type: Boolean,
default: true,
},
clickOutsideToClose: {
type: Boolean,
default: false,
},
modalTop: {
type: Boolean,
default: false,
},
});
const modalBox = ref();
function escClose(e: KeyboardEvent) {
if (e.key === "Escape") {
close();
}
}
if (props.clickOutsideToClose) {
onClickOutside(modalBox, () => {
close();
});
}
function close() {
if (props.readonly) {
emit("cancel");
return;
}
modal.value = false;
}
const modalId = useId();
const modal = useVModel(props, "modelValue", emit);
watchEffect(() => {
if (modal.value) {
document.addEventListener("keydown", escClose);
} else {
document.removeEventListener("keydown", escClose);
}
});
</script>
<style lang="css" scoped>
@media (max-width: 640px) {
.modal[modal-top="true"] {
align-items: start;
}
.modal[modal-top="true"] :where(.modal-box) {
max-width: none;
--tw-translate-y: 2.5rem /* 40px */;
--tw-scale-x: 1;
--tw-scale-y: 1;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate))
skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
width: 100%;
border-top-left-radius: 0px;
border-top-right-radius: 0px;
}
}
</style>

View File

@@ -1,16 +1,16 @@
<template> <template>
<div class="pb-3"> <div class="pb-3">
<h3 <CardTitle
class="flex items-center text-3xl font-bold tracking-tight" class="flex items-center"
:class="{ :class="{
'text-neutral-content': dark, 'text-neutral-content': dark,
}" }"
> >
<slot /> <slot />
</h3> </CardTitle>
<p v-if="$slots.description" class="mt-2 max-w-4xl text-sm text-base-content"> <CardDescription v-if="$slots.description">
<slot name="description" /> <slot name="description" />
</p> </CardDescription>
<div v-if="$slots.after"> <div v-if="$slots.after">
<slot name="after" /> <slot name="after" />
</div> </div>
@@ -18,6 +18,8 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { CardDescription, CardTitle } from "@/components/ui/card";
defineProps({ defineProps({
dark: { dark: {
type: Boolean, type: Boolean,

View File

@@ -8,10 +8,30 @@
<slot></slot> <slot></slot>
</p> </p>
</div> </div>
<BaseButton class="btn-primary mt-auto" @click="$emit('action')"> <template v-if="to">
<slot name="button"> <NuxtLink class="mt-auto" :to="to" :class="buttonVariants({ size: 'lg' })">
<slot name="title"></slot> <slot name="button">
</slot> <slot name="title"></slot>
</BaseButton> </slot>
</NuxtLink>
</template>
<template v-else>
<Button class="mt-auto" size="lg" @click="$emit('action')">
<slot name="button">
<slot name="title"></slot>
</slot>
</Button>
</template>
</div> </div>
</template> </template>
<script setup lang="ts">
import { defineProps } from "vue";
import { Button, buttonVariants } from "@/components/ui/button";
defineProps<{
to?: string;
}>();
defineEmits(["action"]);
</script>

View File

@@ -1,175 +0,0 @@
<template>
<div ref="menu" class="form-control w-full">
<label class="label">
<span class="label-text">{{ label }}</span>
</label>
<div class="dropdown dropdown-top sm:dropdown-end">
<div class="relative">
<input
v-model="internalSearch"
tabindex="0"
class="input flex w-full flex-wrap items-center rounded-lg border border-gray-400"
@keyup.enter="selectFirst"
/>
<button
v-if="!!modelValue && Object.keys(modelValue).length !== 0"
style="transform: translateY(-50%)"
class="btn btn-circle btn-xs no-animation absolute right-2 top-1/2"
@click="clear"
>
x
</button>
</div>
<ul
tabindex="0"
style="display: inline"
class="dropdown-content menu z-[9999] mb-1 max-h-60 w-full overflow-y-scroll rounded border border-gray-400 bg-base-100 shadow"
>
<li v-for="(obj, idx) in filtered" :key="idx">
<div type="button" @click="select(obj)">
<slot name="display" v-bind="{ item: obj }">
{{ extractor(obj, itemText) }}
</slot>
</div>
</li>
<li class="hidden first:flex">
<button disabled>
{{ noResultsText }}
</button>
</li>
</ul>
</div>
</div>
</template>
<script lang="ts" setup>
type ItemsObject = {
text?: string;
value?: string;
[key: string]: unknown;
};
interface Props {
label: string;
modelValue: string | ItemsObject | null | undefined;
items: ItemsObject[] | string[];
itemText?: keyof ItemsObject;
itemSearch?: keyof ItemsObject | null;
itemValue?: keyof ItemsObject;
search?: string;
noResultsText?: string;
}
const emit = defineEmits(["update:modelValue", "update:search"]);
const props = withDefaults(defineProps<Props>(), {
label: "",
modelValue: "",
items: () => [],
itemText: "text",
search: "",
itemSearch: null,
itemValue: "value",
noResultsText: "No Results Found",
});
const searchKey = computed(() => props.itemSearch || props.itemText);
function clear() {
select(value.value);
}
const internalSearch = ref("");
watch(
() => props.search,
val => {
internalSearch.value = val;
}
);
watch(
() => internalSearch.value,
val => {
emit("update:search", val);
}
);
function extractor(obj: string | ItemsObject, key: string | number): string {
if (typeof obj === "string") {
return obj;
}
return obj[key] as string;
}
const value = useVModel(props, "modelValue", emit);
const usingObjects = computed(() => {
return props.items.length > 0 && typeof props.items[0] === "object";
});
/**
* isStrings is a type guard function to check if the items are an array of string
*/
function isStrings(_arr: string[] | ItemsObject[]): _arr is string[] {
return !usingObjects.value;
}
function selectFirst() {
if (filtered.value.length > 0) {
select(filtered.value[0]);
}
}
watch(
value,
() => {
if (value.value) {
if (typeof value.value === "string") {
internalSearch.value = value.value;
} else {
internalSearch.value = value.value[searchKey.value] as string;
}
}
},
{
immediate: true,
}
);
function select(obj: Props["modelValue"]) {
if (isStrings(props.items)) {
if (obj === value.value) {
value.value = "";
return;
}
// @ts-ignore
value.value = obj;
} else {
if (obj === value.value) {
value.value = {};
return;
}
// @ts-ignore
value.value = obj;
}
}
const filtered = computed(() => {
if (!internalSearch.value || internalSearch.value === "") {
return props.items;
}
if (isStrings(props.items)) {
return props.items.filter(item => item.toLowerCase().includes(internalSearch.value.toLowerCase()));
} else {
return props.items.filter(item => {
if (searchKey.value && searchKey.value in item) {
return (item[searchKey.value] as string).toLowerCase().includes(internalSearch.value.toLowerCase());
}
return false;
});
}
});
</script>

View File

@@ -1,188 +0,0 @@
<template>
<div>
<Combobox v-model="value">
<ComboboxLabel class="label">
<span class="label-text">{{ label }}</span>
</ComboboxLabel>
<div class="relative">
<ComboboxInput
:display-value="i => extractDisplay(i as SupportValues)"
class="input input-bordered w-full"
@change="search = $event.target.value"
/>
<button
v-if="!!value"
type="button"
class="absolute inset-y-0 right-6 flex items-center rounded-r-md px-2 focus:outline-none"
@click="clear"
>
<MdiClose class="size-5" />
</button>
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none">
<MdiChevronDown class="size-5" />
</ComboboxButton>
<ComboboxOptions
v-if="computedItems.length > 0"
class="card dropdown-content absolute z-10 mt-2 max-h-60 w-full overflow-auto rounded-md border border-gray-400 bg-base-100"
>
<ComboboxOption
v-for="item in computedItems"
:key="item.id"
v-slot="{ active, selected }"
:value="item.value"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9 transition-colors duration-75 ease-in-out',
active ? 'bg-primary text-primary-content' : 'text-base-content',
]"
>
<slot name="display" v-bind="{ item: item, selected, active }">
<span :class="['block truncate', selected && 'font-semibold']">
{{ item.display }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4 text-primary',
active ? 'text-primary-content' : 'bg-primary',
]"
>
<MdiCheck class="size-5" aria-hidden="true" />
</span>
</slot>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</div>
</template>
<script setup lang="ts">
import lunr from "lunr";
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
ComboboxButton,
ComboboxLabel,
} from "@headlessui/vue";
import MdiClose from "~icons/mdi/close";
import MdiChevronDown from "~icons/mdi/chevron-down";
import MdiCheck from "~icons/mdi/check";
type SupportValues = string | { [key: string]: any };
type ComboItem = {
display: string;
value: SupportValues;
id: number;
};
type Props = {
label: string;
modelValue: SupportValues | null | undefined;
items: {
id: string;
treeString: string;
}[];
display?: string;
multiple?: boolean;
};
const emit = defineEmits(["update:modelValue", "update:search"]);
const props = withDefaults(defineProps<Props>(), {
label: "",
modelValue: "",
display: "text",
multiple: false,
});
function clear() {
emit("update:modelValue", null);
}
const search = ref("");
const value = useVModel(props, "modelValue", emit);
function extractDisplay(item?: SupportValues): string {
if (!item) {
return "";
}
if (typeof item === "string") {
return item;
}
if (props.display in item) {
return item[props.display] as string;
}
// Try these options as well
const fallback = ["name", "title", "display", "value"];
for (let i = 0; i < fallback.length; i++) {
const key = fallback[i];
if (key in item) {
return item[key] as string;
}
}
return "";
}
function lunrFactory() {
return lunr(function () {
this.ref("id");
this.field("display");
for (let i = 0; i < props.items.length; i++) {
const item = props.items[i];
const display = extractDisplay(item);
this.add({ id: i, display });
}
});
}
const index = ref<ReturnType<typeof lunrFactory>>(lunrFactory());
watchEffect(() => {
if (props.items) {
index.value = lunrFactory();
}
});
const computedItems = computed<ComboItem[]>(() => {
const list: ComboItem[] = [];
const matches = index.value.search("*" + search.value + "*");
const resultIDs = [];
for (let i = 0; i < matches.length; i++) {
const match = matches[i];
const item = props.items[parseInt(match.ref)];
const display = extractDisplay(item);
list.push({ id: i, display, value: item });
resultIDs.push(item.id);
}
/**
* Supplementary search,
* Resolve the issue of language not being supported
*/
for (let i = 0; i < props.items.length; i++) {
const item = props.items[i];
if (resultIDs.find(item_ => item_ === item.id) !== undefined) {
continue;
}
if (item.treeString.includes(search.value)) {
const display = extractDisplay(item);
list.push({ id: i, display, value: item });
}
}
return list;
});
</script>

View File

@@ -1,21 +1,22 @@
<template> <template>
<div v-if="!inline" class="form-control w-full"> <div v-if="!inline" class="flex w-full items-center gap-1.5">
<label class="label cursor-pointer"> <Checkbox :id="id" v-model="value" class="size-6" />
<input v-model="value" type="checkbox" class="checkbox checkbox-primary" /> <Label :for="id" class="cursor-pointer">
<span class="label-text"> {{ label }}</span> {{ label }}
</label> </Label>
</div> </div>
<div v-else class="label cursor-pointer sm:grid sm:grid-cols-4 sm:items-start sm:gap-4"> <div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
<label> <Label :for="id" class="flex w-full cursor-pointer px-1 py-2">
<span class="label-text"> {{ label }}
{{ label }} </Label>
</span> <Checkbox :id="id" v-model="value" class="size-6" />
</label>
<input v-model="value" type="checkbox" class="checkbox checkbox-primary" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Boolean, type: Boolean,
@@ -32,4 +33,6 @@
}); });
const value = useVModel(props, "modelValue"); const value = useVModel(props, "modelValue");
const id = useId();
</script> </script>

View File

@@ -1,29 +1,11 @@
<template> <template>
<div v-if="!inline" class="form-control w-full"> <div v-if="!inline" class="flex w-full flex-col">
<label class="label"> <Label class="cursor-pointer"> {{ label }} </Label>
<span class="label-text"> {{ label }} </span> <VueDatePicker v-model="selected" :enable-time-picker="false" clearable :dark="isDark" :format="formatDate" />
</label>
<VueDatePicker
v-model="selected"
:enable-time-picker="false"
clearable
:dark="isDark"
:teleport="true"
:format="formatDate"
/>
</div> </div>
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4"> <div v-else class="sm:flex sm:items-start sm:gap-4">
<label class="label"> <Label class="flex w-full cursor-pointer px-1 py-2"> {{ label }} </Label>
<span class="label-text"> {{ label }} </span> <VueDatePicker v-model="selected" :enable-time-picker="false" clearable :dark="isDark" :format="formatDate" />
</label>
<VueDatePicker
v-model="selected"
:enable-time-picker="false"
clearable
:dark="isDark"
:teleport="true"
:format="formatDate"
/>
</div> </div>
</template> </template>
@@ -32,6 +14,8 @@
import VueDatePicker from "@vuepic/vue-datepicker"; import VueDatePicker from "@vuepic/vue-datepicker";
import "@vuepic/vue-datepicker/dist/main.css"; import "@vuepic/vue-datepicker/dist/main.css";
import * as datelib from "~/lib/datelib/datelib"; import * as datelib from "~/lib/datelib/datelib";
import { Label } from "@/components/ui/label";
const emit = defineEmits(["update:modelValue", "update:text"]); const emit = defineEmits(["update:modelValue", "update:text"]);
const props = defineProps({ const props = defineProps({

View File

@@ -1,120 +0,0 @@
<template>
<div ref="menu" class="form-control w-full">
<label class="label">
<span class="label-text">{{ label }}</span>
</label>
<div class="dropdown dropdown-top sm:dropdown-end">
<div tabindex="0" class="flex min-h-[48px] w-full flex-wrap gap-2 rounded-lg border border-base-content/20 p-4">
<span v-for="itm in value" :key="itm.id" class="badge">
{{ itm.name }}
</span>
<button
v-if="value.length > 0"
type="button"
class="absolute inset-y-0 right-6 flex items-center rounded-r-md px-2 focus:outline-none"
@click="clear"
>
<MdiClose class="size-5" />
</button>
</div>
<div
tabindex="0"
style="display: inline"
class="dropdown-content menu z-[9999] mb-1 w-full rounded border border-base-content/20 bg-base-100 shadow"
>
<div class="m-2">
<input v-model="search" placeholder="Search…" class="input input-bordered input-sm w-full" />
</div>
<ul class="max-h-60 overflow-y-scroll">
<li
v-for="(obj, idx) in filteredItems"
:key="idx"
:class="{
bordered: selected.includes(obj.id),
}"
>
<button type="button" @click="toggle(obj.id)">
{{ obj.name }}
</button>
</li>
<li v-if="!filteredItems.some(itm => itm.name === search) && search.length > 0">
<button type="button" @click="createAndAdd(search)">{{ $t("global.create") }} {{ search }}</button>
</li>
</ul>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { toast } from "@/components/ui/sonner";
import MdiClose from "~icons/mdi/close";
const emit = defineEmits(["update:modelValue"]);
const props = defineProps({
label: {
type: String,
default: "",
},
modelValue: {
type: Array as () => any[],
default: null,
},
items: {
type: Array as () => any[],
required: true,
},
selectFirst: {
type: Boolean,
default: false,
},
});
const value = useVModel(props, "modelValue", emit);
const search = ref("");
const filteredItems = computed(() => {
if (!search.value) {
return props.items;
}
return props.items.filter(item => {
return item.name.toLowerCase().includes(search.value.toLowerCase());
});
});
function clear() {
value.value = [];
}
const selected = computed<string[]>(() => {
return value.value.map(itm => itm.id);
});
function toggle(uniqueField: string) {
const item = props.items.find(itm => itm.id === uniqueField);
if (selected.value.includes(item.id)) {
value.value = value.value.filter(itm => itm.id !== item.id);
} else {
value.value = [...value.value, item];
}
}
const api = useUserApi();
async function createAndAdd(name: string) {
const { error, data } = await api.labels.create({
name,
color: "", // Future!
description: "",
});
if (error) {
console.error(error);
toast.error(`Failed to create label: ${name}`);
} else {
value.value = [...value.value, data];
}
}
</script>

View File

@@ -1,19 +1,26 @@
<template> <template>
<div class="relative"> <div class="relative">
<FormTextField v-model="value" placeholder="Password" :label="label" :type="inputType"> </FormTextField> <FormTextField v-model="value" placeholder="Password" :label="label" :type="inputType"> </FormTextField>
<button <TooltipProvider :delay-duration="0">
type="button" <Tooltip>
class="tooltip absolute right-3 top-6 mb-3 ml-1 mt-auto inline-flex justify-center p-1" <TooltipTrigger as-child>
data-tip="Toggle Password Show" <button
@click="toggle()" type="button"
> class="absolute right-3 top-6 mb-3 ml-1 mt-auto inline-flex justify-center p-1"
<MdiEye name="mdi-eye" class="size-5" /> @click="toggle()"
</button> >
<MdiEye name="mdi-eye" class="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>Toggle Password Show</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import MdiEye from "~icons/mdi/eye"; import MdiEye from "~icons/mdi/eye";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
type Props = { type Props = {
modelValue: string; modelValue: string;

View File

@@ -1,103 +0,0 @@
<template>
<div class="form-control w-full">
<label class="label">
<span class="label-text">{{ label }}</span>
</label>
<select v-model="selectedIdx" class="select select-bordered">
<option disabled selected>Pick one</option>
<option v-for="(obj, idx) in items" :key="name != '' ? obj[name] : obj" :value="idx">
{{ name != "" ? obj[name] : obj }}
</option>
</select>
<!-- <label class="label">
<span class="label-text-alt">Alt label</span>
<span class="label-text-alt">Alt label</span>
</label> -->
</div>
</template>
<script lang="ts" setup>
const emit = defineEmits(["update:modelValue", "update:value"]);
const props = defineProps({
label: {
type: String,
default: "",
},
modelValue: {
type: [Object, String] as any,
default: null,
},
items: {
type: Array as () => any[],
required: true,
},
name: {
type: String,
default: "name",
},
valueKey: {
type: String,
default: null,
},
value: {
type: String,
default: "",
},
compareKey: {
type: String,
default: null,
},
});
const selectedIdx = ref(-1);
const internalSelected = useVModel(props, "modelValue", emit);
const internalValue = useVModel(props, "value", emit);
watch(
selectedIdx,
newVal => {
if (newVal === -1) {
return;
}
if (props.value) {
internalValue.value = props.items[newVal][props.valueKey];
}
internalSelected.value = props.items[newVal];
},
{ immediate: true }
);
watch(
[internalSelected, () => props.value],
() => {
if (props.valueKey) {
const idx = props.items.findIndex(item => compare(item, internalValue.value));
selectedIdx.value = idx;
return;
}
const idx = props.items.findIndex(item => compare(item, internalSelected.value));
selectedIdx.value = idx;
},
{ immediate: true }
);
function compare(a: any, b: any): boolean {
if (a === b) {
return true;
}
if (props.valueKey) {
return a[props.valueKey] === b;
}
// Try compare key
if (props.compareKey && a && b) {
return a[props.compareKey] === b[props.compareKey];
}
return JSON.stringify(a) === JSON.stringify(b);
}
</script>

View File

@@ -3,37 +3,39 @@
<Label :for="id" class="flex w-full px-1"> <Label :for="id" class="flex w-full px-1">
<span>{{ label }}</span> <span>{{ label }}</span>
<span class="grow"></span> <span class="grow"></span>
<span <span :class="{ 'text-red-600': isLengthInvalid }">
:class="{ {{ lengthIndicator }}
'text-red-600':
typeof value === 'string' &&
((maxLength !== -1 && value.length > maxLength) || (minLength !== -1 && value.length < minLength)),
}"
>
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
</span> </span>
</Label> </Label>
<Textarea :id="id" v-model="value" :placeholder="placeholder" class="min-h-[112px] w-full resize-none" /> <Textarea
:id="id"
v-model="value"
:placeholder="placeholder"
class="min-h-[112px] w-full resize-none"
@keydown="handleKeyDown"
/>
</div> </div>
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4"> <div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
<Label :for="id" class="flex w-full px-1 py-2"> <Label :for="id" class="flex w-full px-1 py-2">
<span>{{ label }}</span> <span>{{ label }}</span>
<span class="grow"></span> <span class="grow"></span>
<span <span :class="{ 'text-red-600': isLengthInvalid }">
:class="{ {{ lengthIndicator }}
'text-red-600':
typeof value === 'string' &&
((maxLength !== -1 && value.length > maxLength) || (minLength !== -1 && value.length < minLength)),
}"
>
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
</span> </span>
</Label> </Label>
<Textarea :id="id" v-model="value" autosize :placeholder="placeholder" class="col-span-3 mt-2 w-full resize-none" /> <Textarea
:id="id"
v-model="value"
autosize
:placeholder="placeholder"
class="col-span-3 mt-2 w-full resize-none"
@keydown="handleKeyDown"
/>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from "vue";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
@@ -68,4 +70,35 @@
const id = useId(); const id = useId();
const value = useVModel(props, "modelValue"); const value = useVModel(props, "modelValue");
const isLengthInvalid = computed(() => {
if (typeof value.value !== "string") return false;
const len = value.value.length;
const max = props.maxLength;
const min = props.minLength;
// invalid if max length exists and is exceeded OR min length exists and is not met
return (max !== -1 && len > max) || (min !== -1 && len < min);
});
const lengthIndicator = computed(() => {
if (typeof value.value !== "string") return "";
const max = props.maxLength;
if (max !== -1) {
return `${value.value.length}/${max}`;
}
return "";
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.key === "Enter") {
// find the closest ancestor form element
const targetElement = event.target as HTMLElement;
const form = targetElement.closest("form");
if (form) {
event.preventDefault();
form.requestSubmit();
}
}
};
</script> </script>

View File

@@ -1,35 +0,0 @@
<template>
<div v-if="!inline" class="form-control w-full">
<label class="label cursor-pointer">
<input v-model="value" type="checkbox" class="toggle toggle-primary" />
<span class="label-text"> {{ label }}</span>
</label>
</div>
<div v-else class="label cursor-pointer sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
<label>
<span class="label-text">
{{ label }}
</span>
</label>
<input v-model="value" type="checkbox" class="toggle toggle-primary" />
</div>
</template>
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
inline: {
type: Boolean,
default: false,
},
label: {
type: String,
default: "",
},
});
const value = useVModel(props, "modelValue");
</script>

View File

@@ -9,13 +9,29 @@
<MdiPaperclip class="size-5 shrink-0 text-gray-400" aria-hidden="true" /> <MdiPaperclip class="size-5 shrink-0 text-gray-400" aria-hidden="true" />
<span class="ml-2 w-0 flex-1 truncate"> {{ attachment.document.title }}</span> <span class="ml-2 w-0 flex-1 truncate"> {{ attachment.document.title }}</span>
</div> </div>
<div class="ml-4 shrink-0"> <div class="ml-4 flex shrink-0 gap-2">
<a class="tooltip mr-2" data-tip="Download" :href="attachmentURL(attachment.id)" target="_blank"> <TooltipProvider :delay-duration="0">
<MdiDownload class="size-5" /> <Tooltip>
</a> <TooltipTrigger as-child>
<a class="tooltip" data-tip="Open" :href="attachmentURL(attachment.id)" target="_blank"> <a
<MdiOpenInNew class="size-5" /> :class="buttonVariants({ size: 'icon' })"
</a> :href="attachmentURL(attachment.id)"
:download="attachment.document.title"
>
<MdiDownload />
</a>
</TooltipTrigger>
<TooltipContent> Download </TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<a :class="buttonVariants({ size: 'icon' })" :href="attachmentURL(attachment.id)" target="_blank">
<MdiOpenInNew />
</a>
</TooltipTrigger>
<TooltipContent> Open in new tab </TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
</li> </li>
</ul> </ul>
@@ -26,6 +42,8 @@
import MdiPaperclip from "~icons/mdi/paperclip"; import MdiPaperclip from "~icons/mdi/paperclip";
import MdiDownload from "~icons/mdi/download"; import MdiDownload from "~icons/mdi/download";
import MdiOpenInNew from "~icons/mdi/open-in-new"; import MdiOpenInNew from "~icons/mdi/open-in-new";
import { buttonVariants } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
const props = defineProps({ const props = defineProps({
attachments: { attachments: {

View File

@@ -1,52 +1,67 @@
<template> <template>
<NuxtLink class="group card rounded-md border border-gray-300" :to="`/item/${item.id}`"> <Card class="overflow-hidden">
<div class="relative h-[200px]"> <NuxtLink :to="`/item/${item.id}`">
<img <div class="relative h-[200px]">
v-if="imageUrl" <img v-if="imageUrl" class="h-[200px] w-full object-cover shadow-md" loading="lazy" :src="imageUrl" alt="" />
class="h-[200px] w-full rounded-t border-gray-300 object-cover shadow-sm" <div class="absolute inset-x-1 bottom-1">
loading="lazy" <Badge class="text-wrap bg-neutral text-neutral-content hover:bg-neutral/90 hover:underline">
:src="imageUrl" <NuxtLink v-if="item.location" :to="`/location/${item.location.id}`">
alt="" {{ locationString }}
/> </NuxtLink>
<div class="absolute inset-x-1 bottom-1 text-wrap"> </Badge>
<NuxtLink
v-if="item.location"
class="badge h-auto rounded-md text-sm shadow-md hover:link"
:to="`/location/${item.location.id}`"
>
{{ locationString }}
</NuxtLink>
</div>
</div>
<div class="col-span-4 flex grow flex-col gap-y-1 rounded-b bg-base-100 p-4 pt-2">
<h2 class="line-clamp-2 text-ellipsis text-wrap text-lg font-bold">{{ item.name }}</h2>
<div class="divider my-0"></div>
<div class="flex gap-2">
<div v-if="item.insured" class="tooltip z-10" data-tip="Insured">
<MdiShieldCheck class="size-5 text-primary" />
</div>
<div v-if="item.archived" class="tooltip z-10" data-tip="Archived">
<MdiArchive class="size-5 text-red-700" />
</div>
<div class="grow"></div>
<div class="tooltip" data-tip="Quantity">
<span class="badge badge-primary badge-sm h-5 text-xs">
{{ item.quantity }}
</span>
</div> </div>
</div> </div>
<Markdown class="mb-2 line-clamp-3 text-ellipsis" :source="item.description" /> <div class="col-span-4 flex grow flex-col gap-y-1 bg-base-100 p-4 pt-2">
<div class="-mr-1 mt-auto flex flex-wrap justify-end gap-2"> <h2 class="line-clamp-2 text-ellipsis text-wrap text-lg font-bold">{{ item.name }}</h2>
<LabelChip v-for="label in top3" :key="label.id" :label="label" size="sm" /> <Separator class="mb-1" />
<TooltipProvider :delay-duration="0">
<div class="flex items-center gap-2">
<Tooltip v-if="item.insured">
<TooltipTrigger>
<MdiShieldCheck class="size-5 text-primary" />
</TooltipTrigger>
<TooltipContent>
{{ $t("global.insured") }}
</TooltipContent>
</Tooltip>
<Tooltip v-if="item.archived">
<TooltipTrigger>
<MdiArchive class="size-5 text-red-700" />
</TooltipTrigger>
<TooltipContent>
{{ $t("global.archived") }}
</TooltipContent>
</Tooltip>
<div class="grow"></div>
<Tooltip>
<TooltipTrigger>
<Badge>
{{ item.quantity }}
</Badge>
</TooltipTrigger>
<TooltipContent>
{{ $t("global.quantity") }}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<Markdown class="mb-2 line-clamp-3 text-ellipsis" :source="item.description" />
<div class="-mr-1 mt-auto flex flex-wrap justify-end gap-2">
<LabelChip v-for="label in top3" :key="label.id" :label="label" size="sm" />
</div>
</div> </div>
</div> </NuxtLink>
</NuxtLink> </Card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ItemOut, ItemSummary } from "~~/lib/api/types/data-contracts"; import type { ItemOut, ItemSummary } from "~~/lib/api/types/data-contracts";
import MdiShieldCheck from "~icons/mdi/shield-check"; import MdiShieldCheck from "~icons/mdi/shield-check";
import MdiArchive from "~icons/mdi/archive"; import MdiArchive from "~icons/mdi/archive";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Separator } from "@/components/ui/separator";
const api = useUserApi(); const api = useUserApi();

View File

@@ -0,0 +1,139 @@
<template>
<div class="flex flex-col gap-1">
<Label :for="id" class="px-1">
{{ label }}
</Label>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
<span>
<slot name="display" v-bind="{ item: value }">
{{ displayValue(value) || placeholder }}
</slot>
</span>
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-[--reka-popper-anchor-width] p-0">
<Command :ignore-filter="true">
<CommandInput v-model="search" :placeholder="searchPlaceholder" :display-value="_ => ''" />
<CommandEmpty>
{{ noResultsText }}
</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem v-for="item in filtered" :key="itemKey(item)" :value="itemKey(item)" @select="select(item)">
<Check :class="cn('mr-2 h-4 w-4', isSelected(item) ? 'opacity-100' : 'opacity-0')" />
<slot name="display" v-bind="{ item }">
{{ displayValue(item) }}
</slot>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { Check, ChevronsUpDown } from "lucide-vue-next";
import fuzzysort from "fuzzysort";
import { useVModel } from "@vueuse/core";
import { Button } from "~/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
import { Label } from "~/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { cn } from "~/lib/utils";
import { useId } from "#imports";
type ItemsObject = {
[key: string]: unknown;
};
interface Props {
label: string;
modelValue: string | ItemsObject | null | undefined;
items: ItemsObject[] | string[];
itemText?: string;
itemValue?: string;
search?: string;
searchPlaceholder?: string;
noResultsText?: string;
placeholder?: string;
}
const emit = defineEmits(["update:modelValue", "update:search"]);
const props = withDefaults(defineProps<Props>(), {
label: "",
modelValue: "",
items: () => [],
itemText: "text",
itemValue: "value",
search: "",
searchPlaceholder: "Type to search...",
noResultsText: "No Results Found",
placeholder: "Select...",
});
const id = useId();
const open = ref(false);
const search = ref(props.search);
const value = useVModel(props, "modelValue", emit);
watch(
() => props.search,
val => {
search.value = val;
}
);
watch(
() => search.value,
val => {
emit("update:search", val);
}
);
function isStrings(arr: string[] | ItemsObject[]): arr is string[] {
return typeof arr[0] === "string";
}
function displayValue(item: string | ItemsObject | null | undefined): string {
if (!item) return "";
if (typeof item === "string") return item;
return (item[props.itemText] as string) || "";
}
function itemKey(item: string | ItemsObject): string {
if (typeof item === "string") return item;
return (item[props.itemValue] as string) || displayValue(item);
}
function isSelected(item: string | ItemsObject): boolean {
if (!value.value) return false;
if (typeof item === "string") return value.value === item;
if (typeof value.value === "string") return itemKey(item) === value.value;
return itemKey(item) === itemKey(value.value);
}
function select(item: string | ItemsObject) {
if (isSelected(item)) {
value.value = null;
} else {
value.value = item;
}
open.value = false;
}
const filtered = computed(() => {
if (!search.value) return props.items;
if (isStrings(props.items)) {
return props.items.filter(item => item.toLowerCase().includes(search.value.toLowerCase()));
} else {
// Fuzzy search on itemText
return fuzzysort.go(search.value, props.items, { key: props.itemText, all: true }).map(i => i.obj);
}
});
</script>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ViewType } from "~~/composables/use-preferences"; import type { ViewType } from "~~/composables/use-preferences";
import type { ItemSummary } from "~~/lib/api/types/data-contracts"; import type { ItemSummary } from "~~/lib/api/types/data-contracts";
import MdiDotsVertical from "~icons/mdi/dots-vertical";
import MdiCardTextOutline from "~icons/mdi/card-text-outline"; import MdiCardTextOutline from "~icons/mdi/card-text-outline";
import MdiTable from "~icons/mdi/table"; import MdiTable from "~icons/mdi/table";
import { Button, ButtonGroup } from "@/components/ui/button";
type Props = { type Props = {
view?: ViewType; view?: ViewType;
@@ -28,27 +28,24 @@
<template> <template>
<section> <section>
<BaseSectionHeader class="mb-2 flex items-center justify-between"> <BaseSectionHeader class="mb-2 mt-4 flex items-center justify-between">
{{ $t("components.item.view.selectable.items") }} {{ $t("components.item.view.selectable.items") }}
<template #description> <template #description>
<div v-if="!viewSet" class="dropdown dropdown-left dropdown-hover"> <div v-if="!viewSet">
<label tabindex="0" class="btn btn-ghost m-1"> <ButtonGroup>
<MdiDotsVertical class="size-7" /> <Button size="sm" :variant="itemView === 'card' ? 'default' : 'outline'" @click="setViewPreference('card')">
</label> <MdiCardTextOutline class="size-5" />
<ul tabindex="0" class="dropdown-content menu rounded-box w-32 bg-base-100 p-2 shadow"> {{ $t("components.item.view.selectable.card") }}
<li> </Button>
<button @click="setViewPreference('card')"> <Button
<MdiCardTextOutline class="size-5" /> size="sm"
{{ $t("components.item.view.selectable.card") }} :variant="itemView === 'table' ? 'default' : 'outline'"
</button> @click="setViewPreference('table')"
</li> >
<li> <MdiTable class="size-5" />
<button @click="setViewPreference('table')"> {{ $t("components.item.view.selectable.table") }}
<MdiTable class="size-5" /> </Button>
{{ $t("components.item.view.selectable.table") }} </ButtonGroup>
</button>
</li>
</ul>
</div> </div>
</template> </template>
</BaseSectionHeader> </BaseSectionHeader>

View File

@@ -1,6 +1,6 @@
import type { ItemSummary } from "~~/lib/api/types/data-contracts"; import type { ItemSummary } from "~~/lib/api/types/data-contracts";
export type TableHeader = { export type TableHeaderType = {
text: string; text: string;
value: keyof ItemSummary; value: keyof ItemSummary;
sortable?: boolean; sortable?: boolean;

View File

@@ -1,12 +1,58 @@
<template> <template>
<Dialog dialog-id="item-table-settings">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ $t("components.item.view.table.table_settings") }}</DialogTitle>
</DialogHeader>
<div>{{ $t("components.item.view.table.headers") }}</div>
<div class="flex flex-col">
<div v-for="(h, i) in headers" :key="h.value" class="flex flex-row items-center gap-1">
<Button size="icon" class="size-6" variant="ghost" :disabled="i === 0" @click="moveHeader(i, i - 1)">
<MdiArrowUp />
</Button>
<Button
size="icon"
class="size-6"
variant="ghost"
:disabled="i === headers.length - 1"
@click="moveHeader(i, i + 1)"
>
<MdiArrowDown />
</Button>
<Checkbox :id="h.value" :model-value="h.enabled" @update:model-value="toggleHeader(h.value)" />
<label class="text-sm" :for="h.value"> {{ $t(h.text) }} </label>
</div>
</div>
<div class="flex flex-col gap-2">
<Label> {{ $t("components.item.view.table.rows_per_page") }} </Label>
<Select :model-value="pagination.rowsPerPage" @update:model-value="pagination.rowsPerPage = Number($event)">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="10">10</SelectItem>
<SelectItem :value="25">25</SelectItem>
<SelectItem :value="50">50</SelectItem>
<SelectItem :value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button @click="closeDialog('item-table-settings')"> {{ $t("global.save") }} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
<BaseCard> <BaseCard>
<table class="table w-full"> <Table class="w-full">
<thead> <TableHeader>
<tr> <TableRow>
<th <TableHead
v-for="h in headers.filter(h => h.enabled)" v-for="h in headers.filter(h => h.enabled)"
:key="h.value" :key="h.value"
class="text-no-transform cursor-pointer bg-neutral text-sm text-neutral-content" class="text-no-transform cursor-pointer bg-neutral text-sm text-neutral-content hover:bg-neutral/90"
@click="sortBy(h.value)" @click="sortBy(h.value)"
> >
<div <div
@@ -20,24 +66,21 @@
<template v-if="typeof h === 'string'">{{ h }}</template> <template v-if="typeof h === 'string'">{{ h }}</template>
<template v-else>{{ $t(h.text) }}</template> <template v-else>{{ $t(h.text) }}</template>
<div <div
v-if="sortByProperty === h.value" :data-swap="pagination.descending"
:class="`inline-flex ${sortByProperty === h.value ? '' : 'opacity-0'}`" :class="{ 'opacity-0': sortByProperty !== h.value }"
class="transition-transform duration-300 data-[swap=true]:rotate-180"
> >
<span class="swap swap-rotate" :class="{ 'swap-active': pagination.descending }"> <MdiArrowUp class="size-5" />
<MdiArrowDown class="swap-on size-5" />
<MdiArrowUp class="swap-off size-5" />
</span>
</div> </div>
</div> </div>
</th> </TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
<tr v-for="(d, i) in data" :key="d.id" class="hover cursor-pointer" @click="navigateTo(`/item/${d.id}`)"> <TableRow v-for="(d, i) in data" :key="d.id" class="cursor-pointer" @click="navigateTo(`/item/${d.id}`)">
<td <TableCell
v-for="h in headers.filter(h => h.enabled)" v-for="h in headers.filter(h => h.enabled)"
:key="`${h.value}-${i}`" :key="`${h.value}-${i}`"
class="bg-base-100"
:class="{ :class="{
'text-center': h.align === 'center', 'text-center': h.align === 'center',
'text-right': h.align === 'right', 'text-right': h.align === 'right',
@@ -45,7 +88,7 @@
}" }"
> >
<template v-if="h.type === 'name'"> <template v-if="h.type === 'name'">
<NuxtLink class="hover text-wrap" :to="`/item/${d.id}`"> <NuxtLink class="text-wrap" :to="`/item/${d.id}`">
{{ d.name }} {{ d.name }}
</NuxtLink> </NuxtLink>
</template> </template>
@@ -57,7 +100,7 @@
<MdiClose v-else class="inline size-5 text-red-500" /> <MdiClose v-else class="inline size-5 text-red-500" />
</template> </template>
<template v-else-if="h.type === 'location'"> <template v-else-if="h.type === 'location'">
<NuxtLink v-if="d.location" class="hover:link" :to="`/location/${d.location.id}`"> <NuxtLink v-if="d.location" class="hover:underline" :to="`/location/${d.location.id}`">
{{ d.location.name }} {{ d.location.name }}
</NuxtLink> </NuxtLink>
</template> </template>
@@ -67,76 +110,69 @@
<slot v-else :name="cell(h)" v-bind="{ item: d }"> <slot v-else :name="cell(h)" v-bind="{ item: d }">
{{ extractValue(d, h.value) }} {{ extractValue(d, h.value) }}
</slot> </slot>
</td> </TableCell>
</tr> </TableRow>
</tbody> </TableBody>
</table> </Table>
<div <div
class="flex items-center justify-end gap-3 border-t p-3" class="flex items-center justify-between gap-2 border-t p-3"
:class="{ :class="{
hidden: disableControls, hidden: disableControls,
}" }"
> >
<div class="dropdown dropdown-top dropdown-hover"> <Button class="size-10 p-0" variant="outline" @click="openDialog('item-table-settings')">
<label tabindex="0" class="btn btn-square btn-outline btn-sm m-1"> <MdiTableCog />
<MdiTableCog /> </Button>
</label> <Pagination
<ul tabindex="0" class="dropdown-content rounded-box flex w-64 flex-col gap-2 bg-base-100 p-2 pl-3 shadow"> v-slot="{ page }"
<li>Headers:</li> :items-per-page="pagination.rowsPerPage"
<li v-for="(h, i) in headers" :key="h.value" class="flex flex-row items-center gap-1"> :total="props.items.length"
<button :sibling-count="2"
class="btn btn-square btn-ghost btn-xs" @update:page="pagination.page = $event"
:class="{ >
'btn-disabled': i === 0, <PaginationList v-slot="{ pageItems }" class="flex items-center gap-1">
}" <PaginationFirst />
@click="moveHeader(i, i - 1)" <template v-for="(item, index) in pageItems">
> <PaginationListItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
<MdiArrowUp /> <Button class="size-10 p-0" :variant="item.value === page ? 'default' : 'outline'">
</button> {{ item.value }}
<button </Button>
class="btn btn-square btn-ghost btn-xs" </PaginationListItem>
:class="{ <PaginationEllipsis v-else :key="item.type" :index="index" />
'btn-disabled': i === headers.length - 1, </template>
}" <PaginationLast />
@click="moveHeader(i, i + 1)" </PaginationList>
> </Pagination>
<MdiArrowDown /> <Button class="invisible hidden size-10 p-0 md:block">
</button> <!-- properly centre the pagination buttons -->
<input </Button>
:id="h.value"
type="checkbox"
class="checkbox checkbox-primary"
:checked="h.enabled"
@change="toggleHeader(h.value)"
/>
<label class="label-text" :for="h.value"> {{ $t(h.text) }} </label>
</li>
</ul>
</div>
<div class="hidden md:block">{{ $t("components.item.view.table.rows_per_page") }}</div>
<select v-model.number="pagination.rowsPerPage" class="select select-primary select-sm">
<option :value="10">10</option>
<option :value="25">25</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
<div class="btn-group">
<button :disabled="!hasPrev" class="btn btn-sm" @click="prev()">«</button>
<button class="btn btn-sm">{{ $t("components.item.view.table.page") }} {{ pagination.page }}</button>
<button :disabled="!hasNext" class="btn btn-sm" @click="next()">»</button>
</div>
</div> </div>
</BaseCard> </BaseCard>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TableData, TableHeader } from "./Table.types"; import type { TableData, TableHeaderType } from "./Table.types";
import type { ItemSummary } from "~~/lib/api/types/data-contracts"; import type { ItemSummary } from "~~/lib/api/types/data-contracts";
import MdiArrowDown from "~icons/mdi/arrow-down"; import MdiArrowDown from "~icons/mdi/arrow-down";
import MdiArrowUp from "~icons/mdi/arrow-up"; import MdiArrowUp from "~icons/mdi/arrow-up";
import MdiCheck from "~icons/mdi/check"; import MdiCheck from "~icons/mdi/check";
import MdiClose from "~icons/mdi/close"; import MdiClose from "~icons/mdi/close";
import MdiTableCog from "~icons/mdi/table-cog"; import MdiTableCog from "~icons/mdi/table-cog";
import { Checkbox } from "@/components/ui/checkbox";
import { Table, TableBody, TableHeader, TableCell, TableHead, TableRow } from "@/components/ui/table";
import {
Pagination,
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
} from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useDialog } from "@/components/ui/dialog-provider";
const { openDialog, closeDialog } = useDialog();
type Props = { type Props = {
items: ItemSummary[]; items: ItemSummary[];
@@ -162,14 +198,14 @@
{ text: "items.archived", value: "archived", align: "center", enabled: false, type: "boolean" }, { text: "items.archived", value: "archived", align: "center", enabled: false, type: "boolean" },
{ text: "items.created_at", value: "createdAt", align: "center", enabled: false, type: "date" }, { text: "items.created_at", value: "createdAt", align: "center", enabled: false, type: "date" },
{ text: "items.updated_at", value: "updatedAt", align: "center", enabled: false, type: "date" }, { text: "items.updated_at", value: "updatedAt", align: "center", enabled: false, type: "date" },
] satisfies TableHeader[]; ] satisfies TableHeaderType[];
const headers = ref<TableHeader[]>( const headers = ref<TableHeaderType[]>(
(preferences.value.tableHeaders ?? []) (preferences.value.tableHeaders ?? [])
.concat(defaultHeaders.filter(h => !preferences.value.tableHeaders?.find(h2 => h2.value === h.value))) .concat(defaultHeaders.filter(h => !preferences.value.tableHeaders?.find(h2 => h2.value === h.value)))
// this is a hack to make sure that any changes to the defaultHeaders are reflected in the preferences // this is a hack to make sure that any changes to the defaultHeaders are reflected in the preferences
.map(h => ({ .map(h => ({
...(defaultHeaders.find(h2 => h2.value === h.value) as TableHeader), ...(defaultHeaders.find(h2 => h2.value === h.value) as TableHeaderType),
enabled: h.enabled, enabled: h.enabled,
})) }))
); );
@@ -206,16 +242,6 @@
} }
); );
const next = () => pagination.page++;
const hasNext = computed<boolean>(() => {
return pagination.page * pagination.rowsPerPage < props.items.length;
});
const prev = () => pagination.page--;
const hasPrev = computed<boolean>(() => {
return pagination.page > 1;
});
function sortBy(property: keyof ItemSummary) { function sortBy(property: keyof ItemSummary) {
if (sortByProperty.value === property) { if (sortByProperty.value === property) {
pagination.descending = !pagination.descending; pagination.descending = !pagination.descending;
@@ -290,25 +316,7 @@
return current; return current;
} }
function cell(h: TableHeader) { function cell(h: TableHeaderType) {
return `cell-${h.value.replace(".", "_")}`; return `cell-${h.value.replace(".", "_")}`;
} }
</script> </script>
<style scoped>
:where(.table *:first-child) :where(*:first-child) :where(th, td):first-child {
border-top-left-radius: 0.5rem;
}
:where(.table *:first-child) :where(*:first-child) :where(th, td):last-child {
border-top-right-radius: 0.5rem;
}
:where(.table *:last-child) :where(*:last-child) :where(th, td):first-child {
border-bottom-left-radius: 0.5rem;
}
:where(.table *:last-child) :where(*:last-child) :where(th, td):last-child {
border-bottom-right-radius: 0.5rem;
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LabelOut, LabelSummary } from "~~/lib/api/types/data-contracts"; import type { LabelOut, LabelSummary } from "~~/lib/api/types/data-contracts";
import MdiArrowRight from "~icons/mdi/arrow-right"; import MdiArrowUp from "~icons/mdi/arrow-up";
import MdiTagOutline from "~icons/mdi/tag-outline"; import MdiTagOutline from "~icons/mdi/tag-outline";
export type sizes = "sm" | "md" | "lg" | "xl"; export type sizes = "sm" | "md" | "lg" | "xl";
@@ -14,29 +14,28 @@
default: "md", default: "md",
}, },
}); });
const badge = ref(null);
const isHover = useElementHover(badge);
const { focused } = useFocus(badge);
const isActive = computed(() => isHover.value || focused.value);
</script> </script>
<template> <template>
<NuxtLink <NuxtLink
ref="badge" class="group/label-chip flex gap-2 rounded-full bg-secondary text-secondary-foreground shadow transition duration-300 hover:bg-secondary/70"
class="badge badge-secondary text-secondary-content"
:class="{ :class="{
'badge-lg p-4': size === 'lg', 'p-4 py-1 text-base': size === 'lg',
'p-3': size !== 'sm' && size !== 'lg', 'p-3 py-1 text-sm': size !== 'sm' && size !== 'lg',
'badge-sm p-2': size === 'sm', 'p-2 py-0.5 text-xs': size === 'sm',
}" }"
:to="`/label/${label.id}`" :to="`/label/${label.id}`"
> >
<label class="swap swap-rotate" :class="isActive ? 'swap-active' : ''"> <div class="relative">
<MdiArrowRight class="swap-on mr-2" /> <MdiTagOutline class="invisible" /><!-- hack to ensure the size is correct -->
<MdiTagOutline class="swap-off mr-2" />
</label> <div
class="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-hover/label-chip:rotate-90"
>
<MdiTagOutline class="group-hover/label-chip:hidden" />
<MdiArrowUp class="hidden group-hover/label-chip:block" />
</div>
</div>
{{ label.name.length > 20 ? `${label.name.substring(0, 20)}...` : label.name }} {{ label.name.length > 20 ? `${label.name.substring(0, 20)}...` : label.name }}
</NuxtLink> </NuxtLink>
</template> </template>

View File

@@ -1,36 +1,40 @@
<template> <template>
<NuxtLink <Card>
ref="card" <NuxtLink :to="`/location/${location.id}`" class="group/location-card transition duration-300">
:to="`/location/${location.id}`" <div
class="card rounded-md bg-base-100 text-base-content shadow-md transition duration-300" class=""
> :class="{
<div 'p-4': !dense,
class="card-body" 'px-3 py-2': dense,
:class="{ }"
'p-4': !dense, >
'px-3 py-2': dense, <h2 class="flex items-center justify-between gap-2">
}" <div class="relative size-6">
> <div
<h2 class="flex items-center justify-between gap-2"> class="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-hover/location-card:-rotate-90"
<label class="swap swap-rotate" :class="isActive ? 'swap-active' : ''"> >
<MdiArrowRight class="swap-on size-6" /> <MdiMapMarkerOutline class="size-6 group-hover/location-card:hidden" />
<MdiMapMarkerOutline class="swap-off size-6" /> <MdiArrowUp class="hidden size-6 group-hover/location-card:block" />
</label> </div>
<span class="mx-auto"> </div>
{{ location.name }} <span class="mx-auto">
</span> {{ location.name }}
<span class="badge badge-primary badge-lg h-6" :class="{ 'opacity-0': !hasCount }"> </span>
{{ count }} <Badge class="" :class="{ 'opacity-0': !hasCount }">
</span> {{ count }}
</h2> </Badge>
</div> </h2>
</NuxtLink> </div>
</NuxtLink>
</Card>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { LocationOut, LocationOutCount, LocationSummary } from "~~/lib/api/types/data-contracts"; import type { LocationOut, LocationOutCount, LocationSummary } from "~~/lib/api/types/data-contracts";
import MdiArrowRight from "~icons/mdi/arrow-right"; import MdiArrowUp from "~icons/mdi/arrow-down";
import MdiMapMarkerOutline from "~icons/mdi/map-marker-outline"; import MdiMapMarkerOutline from "~icons/mdi/map-marker-outline";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
const props = defineProps({ const props = defineProps({
location: { location: {
@@ -52,10 +56,4 @@
return (props.location as LocationOutCount).itemCount; return (props.location as LocationOutCount).itemCount;
} }
}); });
const card = ref(null);
const isHover = useElementHover(card);
const { focused } = useFocus(card);
const isActive = computed(() => isHover.value || focused.value);
</script> </script>

View File

@@ -1,63 +0,0 @@
<template>
<FormAutocomplete2
v-if="locations"
v-model="value"
:items="locations"
display="name"
:label="$t('components.location.selector.parent_location')"
>
<template #display="{ item, selected, active }">
<div>
<div class="flex w-full">
{{ cast(item.value).name }}
<span
v-if="selected"
:class="['absolute inset-y-0 right-0 flex items-center pr-4', active ? 'text-white' : 'text-primary']"
>
<MdiCheck class="size-5" aria-hidden="true" />
</span>
</div>
<div v-if="cast(item.value).name != cast(item.value).treeString" class="mt-1 text-xs">
{{ cast(item.value).treeString }}
</div>
</div>
</template>
</FormAutocomplete2>
</template>
<script lang="ts" setup>
import type { FlatTreeItem } from "~~/composables/use-location-helpers";
import { useFlatLocations } from "~~/composables/use-location-helpers";
import type { LocationSummary } from "~~/lib/api/types/data-contracts";
import MdiCheck from "~icons/mdi/check";
type Props = {
modelValue?: LocationSummary | null;
};
// Cast the type of the item to a FlatTreeItem so we can get type "safety" in the template
// Note that this does not actually change the type of the item, it just tells the compiler
// that the type is FlatTreeItem. We must keep this in sync with the type of the items
function cast(value: any): FlatTreeItem {
return value as FlatTreeItem;
}
const props = defineProps<Props>();
const value = useVModel(props, "modelValue");
const locations = useFlatLocations();
const form = ref({
parent: null as LocationSummary | null,
search: "",
});
// Whenever parent goes from value to null reset search
watch(
() => value.value,
() => {
if (!value.value) {
form.value.search = "";
}
}
);
</script>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTreeState } from "./tree-state"; import { useTreeState } from "./tree-state";
import type { TreeItem } from "~~/lib/api/types/data-contracts"; import type { TreeItem } from "~~/lib/api/types/data-contracts";
import MdiChevronDown from "~icons/mdi/chevron-down";
import MdiChevronRight from "~icons/mdi/chevron-right"; import MdiChevronRight from "~icons/mdi/chevron-right";
import MdiMapMarker from "~icons/mdi/map-marker"; import MdiMapMarker from "~icons/mdi/map-marker";
import MdiPackageVariant from "~icons/mdi/package-variant"; import MdiPackageVariant from "~icons/mdi/package-variant";
@@ -53,25 +52,20 @@
}" }"
> >
<div v-if="!hasChildren" class="size-6"></div> <div v-if="!hasChildren" class="size-6"></div>
<label <div v-else class="group/node relative size-6" :data-swap="openRef">
v-else <div
class="swap swap-rotate" class="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-data-[swap=true]/node:rotate-90"
:class="{ >
'swap-active': openRef, <MdiChevronRight class="size-6" />
}" </div>
> </div>
<MdiChevronRight name="mdi-chevron-right" class="swap-off size-6" />
<MdiChevronDown name="mdi-chevron-down" class="swap-on size-6" />
</label>
</div> </div>
<MdiMapMarker v-if="item.type === 'location'" class="size-4" /> <MdiMapMarker v-if="item.type === 'location'" class="size-4" />
<MdiPackageVariant v-else class="size-4" /> <MdiPackageVariant v-else class="size-4" />
<NuxtLink class="text-lg hover:link" :to="link" @click.stop>{{ item.name }} </NuxtLink> <NuxtLink class="text-lg hover:underline" :to="link" @click.stop>{{ item.name }} </NuxtLink>
</div> </div>
<div v-if="openRef" class="ml-4"> <div v-if="openRef" class="ml-4">
<LocationTreeNode v-for="child in item.children" :key="child.id" :item="child" :tree-id="treeId" /> <LocationTreeNode v-for="child in item.children" :key="child.id" :item="child" :tree-id="treeId" />
</div> </div>
</div> </div>
</template> </template>
<style scoped></style>

View File

@@ -10,13 +10,10 @@
</script> </script>
<template> <template>
<!-- eslint-disable-next-line tailwindcss/no-custom-classname --> <div>
<div class="root border-2 p-4">
<p v-if="locs.length === 0" class="text-center text-sm"> <p v-if="locs.length === 0" class="text-center text-sm">
{{ $t("location.tree.no_locations") }} {{ $t("location.tree.no_locations") }}
</p> </p>
<LocationTreeNode v-for="item in locs" :key="item.id" :item="item" :tree-id="treeId" /> <LocationTreeNode v-for="item in locs" :key="item.id" :item="item" :tree-id="treeId" />
</div> </div>
</template> </template>
<style></style>

View File

@@ -1,24 +1,28 @@
<template> <template>
<BaseModal v-model="visible"> <Dialog dialog-id="edit-maintenance">
<template #title> <DialogContent>
{{ entry.id ? $t("maintenance.modal.edit_title") : $t("maintenance.modal.new_title") }} <DialogHeader>
</template> <DialogTitle>
<form @submit.prevent="dispatchFormSubmit"> {{ entry.id ? $t("maintenance.modal.edit_title") : $t("maintenance.modal.new_title") }}
<FormTextField v-model="entry.name" autofocus :label="$t('maintenance.modal.entry_name')" /> </DialogTitle>
<DatePicker v-model="entry.completedDate" :label="$t('maintenance.modal.completed_date')" /> </DialogHeader>
<DatePicker v-model="entry.scheduledDate" :label="$t('maintenance.modal.scheduled_date')" />
<FormTextArea v-model="entry.description" :label="$t('maintenance.modal.notes')" class="pt-2" /> <form class="flex flex-col gap-2" @submit.prevent="dispatchFormSubmit">
<FormTextField v-model="entry.cost" autofocus :label="$t('maintenance.modal.cost')" class="pt-2" /> <FormTextField v-model="entry.name" autofocus :label="$t('maintenance.modal.entry_name')" />
<div class="flex justify-end py-2"> <DatePicker v-model="entry.completedDate" :label="$t('maintenance.modal.completed_date')" />
<BaseButton type="submit" class="ml-2 mt-2"> <DatePicker v-model="entry.scheduledDate" :label="$t('maintenance.modal.scheduled_date')" />
<template #icon> <FormTextArea v-model="entry.description" :label="$t('maintenance.modal.notes')" />
<FormTextField v-model="entry.cost" autofocus :label="$t('maintenance.modal.cost')" />
<DialogFooter>
<Button type="submit">
<MdiPost /> <MdiPost />
</template> {{ entry.id ? $t("maintenance.modal.edit_action") : $t("maintenance.modal.new_action") }}
{{ entry.id ? $t("maintenance.modal.edit_action") : $t("maintenance.modal.new_action") }} </Button>
</BaseButton> </DialogFooter>
</div> </form>
</form> </DialogContent>
</BaseModal> </Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -27,13 +31,16 @@
import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts"; import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
import MdiPost from "~icons/mdi/post"; import MdiPost from "~icons/mdi/post";
import DatePicker from "~~/components/Form/DatePicker.vue"; import DatePicker from "~~/components/Form/DatePicker.vue";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useDialog } from "@/components/ui/dialog-provider";
const { openDialog, closeDialog } = useDialog();
const { t } = useI18n(); const { t } = useI18n();
const api = useUserApi(); const api = useUserApi();
const emit = defineEmits(["changed"]); const emit = defineEmits(["changed"]);
const visible = ref(false);
const entry = reactive({ const entry = reactive({
id: null as string | null, id: null as string | null,
name: "", name: "",
@@ -70,7 +77,7 @@
return; return;
} }
visible.value = false; closeDialog("edit-maintenance");
emit("changed"); emit("changed");
} }
@@ -92,7 +99,7 @@
return; return;
} }
visible.value = false; closeDialog("edit-maintenance");
emit("changed"); emit("changed");
} }
@@ -104,7 +111,7 @@
entry.description = ""; entry.description = "";
entry.cost = ""; entry.cost = "";
entry.itemId = itemId; entry.itemId = itemId;
visible.value = true; openDialog("edit-maintenance");
}; };
const openUpdateModal = (maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails) => { const openUpdateModal = (maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails) => {
@@ -115,7 +122,7 @@
entry.description = maintenanceEntry.description; entry.description = maintenanceEntry.description;
entry.cost = maintenanceEntry.cost; entry.cost = maintenanceEntry.cost;
entry.itemId = null; entry.itemId = null;
visible.value = true; openDialog("edit-maintenance");
}; };
const confirm = useConfirm(); const confirm = useConfirm();
@@ -157,7 +164,7 @@
entry.description = maintenanceEntry.description; entry.description = maintenanceEntry.description;
entry.cost = maintenanceEntry.cost; entry.cost = maintenanceEntry.cost;
entry.itemId = itemId; entry.itemId = itemId;
visible.value = true; openDialog("edit-maintenance");
} }
defineExpose({ openCreateModal, openUpdateModal, deleteEntry, complete, duplicate }); defineExpose({ openCreateModal, openUpdateModal, deleteEntry, complete, duplicate });

View File

@@ -11,6 +11,9 @@
import MdiWrenchClock from "~icons/mdi/wrench-clock"; import MdiWrenchClock from "~icons/mdi/wrench-clock";
import MdiContentDuplicate from "~icons/mdi/content-duplicate"; import MdiContentDuplicate from "~icons/mdi/content-duplicate";
import MaintenanceEditModal from "~~/components/Maintenance/EditModal.vue"; import MaintenanceEditModal from "~~/components/Maintenance/EditModal.vue";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { ButtonGroup, Button } from "@/components/ui/button";
const maintenanceFilterStatus = ref(MaintenanceFilterStatus.MaintenanceFilterStatusScheduled); const maintenanceFilterStatus = ref(MaintenanceFilterStatus.MaintenanceFilterStatusScheduled);
const maintenanceEditModal = ref<InstanceType<typeof MaintenanceEditModal>>(); const maintenanceEditModal = ref<InstanceType<typeof MaintenanceEditModal>>();
@@ -77,50 +80,47 @@
<template> <template>
<section class="space-y-6"> <section class="space-y-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-3"> <div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<StatCard <StatCard v-for="stat in stats" :key="stat.id" :title="stat.title" :value="stat.value" :type="stat.type" />
v-for="stat in stats"
:key="stat.id"
class="stats block border-l-primary shadow-xl"
:title="stat.title"
:value="stat.value"
:type="stat.type"
/>
</div> </div>
<div class="flex"> <div class="flex">
<div class="btn-group"> <ButtonGroup>
<BaseButton <Button
size="sm" size="sm"
:class="`${maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusScheduled ? 'btn-active' : ''}`" :variant="
maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusScheduled ? 'default' : 'outline'
"
@click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusScheduled" @click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusScheduled"
> >
{{ $t("maintenance.filter.scheduled") }} {{ $t("maintenance.filter.scheduled") }}
</BaseButton> </Button>
<BaseButton <Button
size="sm" size="sm"
:class="`${maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusCompleted ? 'btn-active' : ''}`" :variant="
maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusCompleted ? 'default' : 'outline'
"
@click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusCompleted" @click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusCompleted"
> >
{{ $t("maintenance.filter.completed") }} {{ $t("maintenance.filter.completed") }}
</BaseButton> </Button>
<BaseButton <Button
size="sm" size="sm"
:class="`${maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusBoth ? 'btn-active' : ''}`" :variant="
maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusBoth ? 'default' : 'outline'
"
@click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusBoth" @click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusBoth"
> >
{{ $t("maintenance.filter.both") }} {{ $t("maintenance.filter.both") }}
</BaseButton> </Button>
</div> </ButtonGroup>
<BaseButton <Button
v-if="props.currentItemId" v-if="props.currentItemId"
class="ml-auto" class="ml-auto"
size="sm" size="sm"
@click="maintenanceEditModal?.openCreateModal(props.currentItemId)" @click="maintenanceEditModal?.openCreateModal(props.currentItemId)"
> >
<template #icon> <MdiPlus />
<MdiPlus />
</template>
{{ $t("maintenance.list.new") }} {{ $t("maintenance.list.new") }}
</BaseButton> </Button>
</div> </div>
</section> </section>
<section> <section>
@@ -129,7 +129,7 @@
<div class="container space-y-6"> <div class="container space-y-6">
<BaseCard v-for="e in maintenanceDataList" :key="e.id"> <BaseCard v-for="e in maintenanceDataList" :key="e.id">
<BaseSectionHeader class="border-b border-b-gray-300 p-6"> <BaseSectionHeader class="border-b border-b-gray-300 p-6">
<span class="text-base-content"> <span class="mb-2 text-base-content">
<span v-if="!props.currentItemId"> <span v-if="!props.currentItemId">
<NuxtLink class="hover:underline" :to="`/item/${(e as MaintenanceEntryWithDetails).itemID}/maintenance`"> <NuxtLink class="hover:underline" :to="`/item/${(e as MaintenanceEntryWithDetails).itemID}/maintenance`">
{{ (e as MaintenanceEntryWithDetails).itemName }} {{ (e as MaintenanceEntryWithDetails).itemName }}
@@ -140,51 +140,53 @@
</span> </span>
<template #description> <template #description>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<div v-if="validDate(e.completedDate)" class="badge p-3"> <Badge v-if="validDate(e.completedDate)" variant="outline">
<MdiCheck class="mr-2" /> <MdiCheck class="mr-2" />
<DateTime :date="e.completedDate" format="human" datetime-type="date" /> <DateTime :date="e.completedDate" format="human" datetime-type="date" />
</div> </Badge>
<div v-else-if="validDate(e.scheduledDate)" class="badge p-3"> <Badge v-else-if="validDate(e.scheduledDate)" variant="outline">
<MdiCalendar class="mr-2" /> <MdiCalendar class="mr-2" />
<DateTime :date="e.scheduledDate" format="human" datetime-type="date" /> <DateTime :date="e.scheduledDate" format="human" datetime-type="date" />
</div> </Badge>
<div class="tooltip tooltip-primary" data-tip="Cost"> <TooltipProvider :delay-duration="0">
<div class="badge badge-primary p-3"> <Tooltip>
<Currency :amount="e.cost" /> <TooltipTrigger>
</div> <Badge>
</div> <Currency :amount="e.cost" />
</Badge>
</TooltipTrigger>
<TooltipContent> Cost </TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
</template> </template>
</BaseSectionHeader> </BaseSectionHeader>
<div class="p-6"> <div :class="{ 'p-6': e.description }">
<Markdown :source="e.description" /> <Markdown :source="e.description" />
</div> </div>
<div class="flex flex-wrap justify-end gap-1 p-4"> <ButtonGroup class="flex flex-wrap justify-end p-4">
<BaseButton size="sm" @click="maintenanceEditModal?.openUpdateModal(e)"> <Button size="sm" @click="maintenanceEditModal?.openUpdateModal(e)">
<template #icon> <MdiEdit />
<MdiEdit />
</template>
{{ $t("maintenance.list.edit") }} {{ $t("maintenance.list.edit") }}
</BaseButton> </Button>
<BaseButton v-if="!validDate(e.completedDate)" size="sm" @click="maintenanceEditModal?.complete(e)"> <Button
<template #icon> v-if="!validDate(e.completedDate)"
<MdiCheck /> size="sm"
</template> variant="outline"
@click="maintenanceEditModal?.complete(e)"
>
<MdiCheck />
{{ $t("maintenance.list.complete") }} {{ $t("maintenance.list.complete") }}
</BaseButton> </Button>
<BaseButton size="sm" @click="maintenanceEditModal?.duplicate(e, e.itemID)"> <Button size="sm" variant="outline" @click="maintenanceEditModal?.duplicate(e, e.itemID)">
<template #icon> <MdiContentDuplicate />
<MdiContentDuplicate />
</template>
{{ $t("maintenance.list.duplicate") }} {{ $t("maintenance.list.duplicate") }}
</BaseButton> </Button>
<BaseButton size="sm" class="btn-error" @click="maintenanceEditModal?.deleteEntry(e.id)"> <Button size="sm" variant="destructive" @click="maintenanceEditModal?.deleteEntry(e.id)">
<template #icon> <MdiDelete />
<MdiDelete />
</template>
{{ $t("maintenance.list.delete") }} {{ $t("maintenance.list.delete") }}
</BaseButton> </Button>
</div> </ButtonGroup>
</BaseCard> </BaseCard>
<div v-if="props.currentItemId" class="hidden first:block"> <div v-if="props.currentItemId" class="hidden first:block">
<button <button

View File

@@ -1,82 +1,73 @@
<template> <template>
<div ref="el" class="dropdown" :class="{ 'dropdown-open': dropdownOpen }"> <Popover>
<button ref="btn" tabindex="0" class="btn btn-xs" @click="toggle"> <PopoverTrigger as-child>
{{ label }} {{ len }} <MdiChevronDown class="size-4" /> <Button size="sm" variant="outline" class="group/filter">
</button> {{ label }} {{ len }}
<div tabindex="0" class="dropdown-content mt-1 w-64 rounded-md bg-base-100 shadow"> <MdiChevronDown class="transition-transform group-data-[state=open]/filter:rotate-180" />
<div class="mb-1 px-4 pt-4 shadow-sm"> </Button>
<input v-model="search" type="text" placeholder="Search…" class="input input-bordered input-sm mb-2 w-full" /> </PopoverTrigger>
<PopoverContent class="z-40 p-0">
<div class="p-4 shadow-sm">
<Input v-model="search" type="text" placeholder="Search…" />
</div> </div>
<div class="max-h-72 divide-y overflow-y-auto"> <div class="max-h-72 divide-y overflow-y-auto">
<label <Label
v-for="v in selectedView" v-for="v in selectedView"
:key="v" :key="v.id"
class="label flex cursor-pointer justify-between px-4 hover:bg-base-200" class="flex cursor-pointer justify-between px-4 py-2 text-sm hover:bg-base-200"
> >
<span class="label-text mr-2"> <div>
<slot name="display" v-bind="{ item: v }"> <span>{{ v.name }}</span>
{{ v[display] }} <span v-if="v.treeString && v.treeString !== v.name" class="ml-auto text-xs">{{ v.treeString }}</span>
</slot> </div>
</span> <Checkbox :model-value="true" @update:model-value="_ => (selected = selected.filter(s => s.id !== v.id))" />
<input v-model="selected" type="checkbox" :value="v" class="checkbox checkbox-primary checkbox-sm" /> </Label>
</label>
<hr v-if="selected.length > 0" /> <hr v-if="selected.length > 0" />
<label <Label
v-for="v in unselected" v-for="v in unselected"
:key="v" :key="v.id"
class="label flex cursor-pointer justify-between px-4 hover:bg-base-200" class="flex cursor-pointer justify-between px-4 py-2 text-sm hover:bg-base-200"
> >
<span class="label-text mr-2"> <div>
<slot name="display" v-bind="{ item: v }"> <div>{{ v.name }}</div>
{{ v[display] }} <div v-if="v.treeString && v.treeString !== v.name" class="ml-auto text-xs">{{ v.treeString }}</div>
</slot> </div>
</span> <Checkbox :model-value="false" @update:model-value="_ => (selected = [...selected, v])" />
<input v-model="selected" type="checkbox" :value="v" class="checkbox checkbox-primary checkbox-sm" /> </Label>
</label>
</div> </div>
</div> </PopoverContent>
</div> </Popover>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import MdiChevronDown from "~icons/mdi/chevron-down"; import MdiChevronDown from "~icons/mdi/chevron-down";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type Props = { type Props = {
label: string; label: string;
options: any[]; options: {
display?: string; name: string;
modelValue: any[]; id: string;
uniqueField: string; treeString?: string;
}[];
modelValue: {
name: string;
id: string;
treeString?: string;
}[];
}; };
const btn = ref<HTMLButtonElement>();
const search = ref(""); const search = ref("");
const searchFold = computed(() => search.value.toLowerCase()); const searchFold = computed(() => search.value.toLowerCase());
const dropdownOpen = ref(false);
const el = ref();
function toggle() {
dropdownOpen.value = !dropdownOpen.value;
if (!dropdownOpen.value) {
btn.value?.blur();
}
}
onClickOutside(el, () => {
dropdownOpen.value = false;
});
watch(dropdownOpen, val => {
console.log(val);
});
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
label: "", label: "",
display: "name",
modelValue: () => [], modelValue: () => [],
uniqueField: "id",
}); });
const len = computed(() => { const len = computed(() => {
@@ -86,7 +77,7 @@
const selectedView = computed(() => { const selectedView = computed(() => {
return selected.value.filter(o => { return selected.value.filter(o => {
if (searchFold.value.length > 0) { if (searchFold.value.length > 0) {
return o[props.display].toLowerCase().includes(searchFold.value); return o.name.toLowerCase().includes(searchFold.value);
} }
return true; return true;
}); });
@@ -97,14 +88,9 @@
const unselected = computed(() => { const unselected = computed(() => {
return props.options.filter(o => { return props.options.filter(o => {
if (searchFold.value.length > 0) { if (searchFold.value.length > 0) {
return ( return o.name.toLowerCase().includes(searchFold.value) && selected.value.every(s => s.id !== o.id);
o[props.display].toLowerCase().includes(searchFold.value) &&
selected.value.every(s => s[props.uniqueField] !== o[props.uniqueField])
);
} }
return selected.value.every(s => s[props.uniqueField] !== o[props.uniqueField]); return selected.value.every(s => s.id !== o.id);
}); });
}); });
</script> </script>
<style scoped></style>

View File

@@ -1,53 +1,64 @@
<template> <template>
<button @click="copyText"> <Button size="icon" variant="outline" class="relative" @click="copyText">
<label <div
class="swap swap-rotate" :data-copied="copied"
:class="{ class="group absolute inset-0 flex items-center justify-center transition-transform duration-300 data-[copied=true]:rotate-[360deg]"
'swap-active': copied,
}"
> >
<MdiContentCopy <MdiContentCopy
class="swap-off" class="group-data-[copied=true]:hidden"
:style="{ :style="{
height: `${iconSize}px`, height: `${iconSize}px`,
width: `${iconSize}px`, width: `${iconSize}px`,
}" }"
/> />
<MdiClipboard <MdiClipboard
class="swap-on" class="hidden group-data-[copied=true]:block"
:style="{ :style="{
height: `${iconSize}px`, height: `${iconSize}px`,
width: `${iconSize}px`, width: `${iconSize}px`,
}" }"
/> />
</label> </div>
<Teleport to="#app"> </Button>
<BaseModal v-model="copyError">
<div class="space-y-2"> <AlertDialog v-model:open="copyError">
<p> <AlertDialogContent>
{{ $t("components.global.copy_text.failed_to_copy") }} <AlertDialogHeader>
{{ isNotHttps ? $t("components.global.copy_text.https_required") : "" }} <AlertDialogTitle class="space-y-2">
</p> {{ $t("components.global.copy_text.failed_to_copy") }}
<p class="text-sm"> {{ isNotHttps ? $t("components.global.copy_text.https_required") : "" }}
{{ $t("components.global.copy_text.learn_more") }} </AlertDialogTitle>
<a <AlertDialogDescription class="text-sm">
href="https://homebox.software/en/user-guide/tips-tricks.html#copy-to-clipboard" {{ $t("components.global.copy_text.learn_more") }}
class="text-primary hover:underline" <a
target="_blank" href="https://homebox.software/en/user-guide/tips-tricks.html#copy-to-clipboard"
rel="noopener" class="text-primary hover:underline"
> target="_blank"
{{ $t("components.global.copy_text.documentation") }} rel="noopener"
</a> >
</p> {{ $t("components.global.copy_text.documentation") }}
</div> </a>
</BaseModal></Teleport </AlertDialogDescription>
> </AlertDialogHeader>
</button> <AlertDialogFooter>
<AlertDialogAction>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import MdiContentCopy from "~icons/mdi/content-copy"; import MdiContentCopy from "~icons/mdi/content-copy";
import MdiClipboard from "~icons/mdi/clipboard"; import MdiClipboard from "~icons/mdi/clipboard";
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
const props = defineProps({ const props = defineProps({
text: { text: {

View File

@@ -14,15 +14,23 @@
/> />
<Currency v-else-if="detail.type == 'currency'" :amount="detail.text" /> <Currency v-else-if="detail.type == 'currency'" :amount="detail.text" />
<template v-else-if="detail.type === 'link'"> <template v-else-if="detail.type === 'link'">
<div class="tooltip tooltip-top tooltip-primary" :data-tip="detail.href"> <TooltipProvider :delay-duration="0">
<a class="btn btn-primary btn-xs" :href="detail.href" target="_blank"> <Tooltip>
<MdiOpenInNew class="swap-on mr-2" /> <TooltipTrigger as-child>
{{ detail.text }} <a :href="detail.href" target="_blank" :class="badgeVariants()" class="gap-1">
</a> <MdiOpenInNew />
</div> {{ detail.text }}
</a>
</TooltipTrigger>
<TooltipContent>
{{ detail.href }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template> </template>
<template v-else-if="detail.type === 'markdown'"> <template v-else-if="detail.type === 'markdown'">
<ClientOnly> <ClientOnly>
<!-- eslint-disable-next-line tailwindcss/no-custom-classname -->
<div class="markdown-container w-full overflow-hidden break-words"> <div class="markdown-container w-full overflow-hidden break-words">
<Markdown :source="detail.text" /> <Markdown :source="detail.text" />
</div> </div>
@@ -36,12 +44,7 @@
v-if="detail.copyable" v-if="detail.copyable"
class="my-0 ml-4 shrink-0 opacity-0 transition-opacity duration-75 group-hover:opacity-100" class="my-0 ml-4 shrink-0 opacity-0 transition-opacity duration-75 group-hover:opacity-100"
> >
<CopyText <CopyText v-if="detail.text.toString()" :text="detail.text.toString()" :icon-size="16" />
v-if="detail.text.toString()"
:text="detail.text.toString()"
:icon-size="16"
class="btn btn-circle btn-ghost btn-xs"
/>
</span> </span>
</span> </span>
</template> </template>
@@ -55,6 +58,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AnyDetail, Detail } from "./types"; import type { AnyDetail, Detail } from "./types";
import MdiOpenInNew from "~icons/mdi/open-in-new"; import MdiOpenInNew from "~icons/mdi/open-in-new";
import { badgeVariants } from "~/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
defineProps({ defineProps({
details: { details: {

View File

@@ -1,9 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { route } from "../../lib/api/base"; import { route } from "../../lib/api/base";
import PageQRCode from "./PageQRCode.vue";
import { toast } from "@/components/ui/sonner"; import { toast } from "@/components/ui/sonner";
import MdiPrinterPos from "~icons/mdi/printer-pos"; import MdiPrinterPos from "~icons/mdi/printer-pos";
import MdiFileDownload from "~icons/mdi/file-download"; import MdiFileDownload from "~icons/mdi/file-download";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { useDialog } from "@/components/ui/dialog-provider";
import { Button, ButtonGroup } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
const { openDialog, closeDialog } = useDialog();
const props = defineProps<{ const props = defineProps<{
type: string; type: string;
id: string; id: string;
@@ -21,13 +36,8 @@
return data; return data;
}); });
const printModal = ref(false);
const serverPrinting = ref(false); const serverPrinting = ref(false);
function openPrint() {
printModal.value = true;
}
function browserPrint() { function browserPrint() {
const printWindow = window.open(getLabelUrl(false), "popup=true"); const printWindow = window.open(getLabelUrl(false), "popup=true");
@@ -50,7 +60,7 @@
} }
toast.success("Label printed"); toast.success("Label printed");
printModal.value = false; closeDialog("print-label");
serverPrinting.value = false; serverPrinting.value = false;
} }
@@ -80,46 +90,60 @@
<template> <template>
<div> <div>
<BaseModal v-model="printModal"> <Dialog dialog-id="print-label">
<template #title>{{ $t("components.global.label_maker.print") }}</template> <DialogContent>
<p> <DialogHeader>
{{ $t("components.global.label_maker.confirm_description") }} <DialogTitle>
</p>
<img :src="getLabelUrl(false)" />
<div class="modal-action">
<BaseButton
v-if="status?.labelPrinting || false"
type="submit"
:loading="serverPrinting"
@click="serverPrint"
>{{ $t("components.global.label_maker.server_print") }}</BaseButton
>
<BaseButton type="submit" @click="browserPrint">{{
$t("components.global.label_maker.browser_print")
}}</BaseButton>
</div>
</BaseModal>
<div class="dropdown dropdown-left">
<slot>
<label tabindex="0" class="btn btn-sm">
{{ $t("components.global.label_maker.titles") }}
</label>
</slot>
<ul class="dropdown-content menu compact rounded-box w-52 bg-base-100 shadow-lg">
<li>
<button @click="openPrint">
<MdiPrinterPos name="mdi-printer-pos" class="mr-2" />
{{ $t("components.global.label_maker.print") }} {{ $t("components.global.label_maker.print") }}
</button> </DialogTitle>
</li> <DialogDescription>
<li> {{ $t("components.global.label_maker.confirm_description") }}
<button @click="downloadLabel"> </DialogDescription>
<MdiFileDownload name="mdi-file-download" class="mr-2" /> </DialogHeader>
<img :src="getLabelUrl(false)" />
<DialogFooter>
<ButtonGroup>
<Button v-if="status?.labelPrinting || false" type="submit" :loading="serverPrinting" @click="serverPrint">
{{ $t("components.global.label_maker.server_print") }}
</Button>
<Button type="submit" @click="browserPrint">
{{ $t("components.global.label_maker.browser_print") }}
</Button>
</ButtonGroup>
</DialogFooter>
</DialogContent>
</Dialog>
<TooltipProvider :delay-duration="0">
<ButtonGroup>
<Button variant="outline" disabled class="disabled:opacity-100">
{{ $t("components.global.label_maker.titles") }}
</Button>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon" @click="downloadLabel">
<MdiFileDownload name="mdi-file-download" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ $t("components.global.label_maker.download") }} {{ $t("components.global.label_maker.download") }}
</button> </TooltipContent>
</li> </Tooltip>
</ul>
</div> <Tooltip>
<TooltipTrigger as-child>
<Button size="icon" @click="openDialog('print-label')">
<MdiPrinterPos name="mdi-printer-pos" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ $t("components.global.label_maker.browser_print") }}
</TooltipContent>
</Tooltip>
<PageQRCode />
</ButtonGroup>
</TooltipProvider>
</div> </div>
</template> </template>

View File

@@ -1,28 +1,40 @@
<template>
<div class="dropdown dropdown-left">
<slot>
<label tabindex="0" class="btn btn-circle btn-sm">
<MdiQrcode />
</label>
</slot>
<div tabindex="0" class="card dropdown-content compact rounded-box w-64 bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="text-center">{{ $t("components.global.page_qr_code.page_url") }}</h2>
<img :src="getQRCodeUrl()" />
</div>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { route } from "../../lib/api/base"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { route } from "~/lib/api/base";
import MdiQrcode from "~icons/mdi/qrcode"; import MdiQrcode from "~icons/mdi/qrcode";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDialog } from "@/components/ui/dialog-provider";
const { openDialog } = useDialog();
function getQRCodeUrl(): string { function getQRCodeUrl(): string {
const currentURL = window.location.href; const currentURL = window.location.href;
// Adjust route import as needed
return route(`/qrcode`, { data: encodeURIComponent(currentURL) }); return route(`/qrcode`, { data: encodeURIComponent(currentURL) });
} }
</script> </script>
<style lang="scss" scoped></style> <template>
<Dialog dialog-id="page-qr-code">
<DialogContent>
<DialogHeader>
<DialogTitle>
{{ $t("components.global.page_qr_code.page_url") }}
</DialogTitle>
</DialogHeader>
<img :src="getQRCodeUrl()" />
</DialogContent>
</Dialog>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon" @click="openDialog('page-qr-code')">
<MdiQrcode name="mdi-qrcode" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ $t("components.global.page_qr_code.qr_tooltip") }}
</TooltipContent>
</Tooltip>
</template>

View File

@@ -1,20 +1,13 @@
<template> <template>
<div class="py-4"> <div class="py-4">
<p class="text-sm">{{ $t("components.global.password_score.password_strength") }}: {{ message }}</p> <p class="text-sm">{{ $t("components.global.password_score.password_strength") }}: {{ message }}</p>
<progress <Progress class="w-full" :model-value="score" />
class="progress w-full"
:value="score"
max="100"
:class="{
'progress-success': score > 50,
'progress-warning': score > 25 && score < 50,
'progress-error': score < 25,
}"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Progress } from "@/components/ui/progress";
const props = defineProps({ const props = defineProps({
password: { password: {
type: String, type: String,

View File

@@ -1,18 +1,19 @@
<template> <template>
<div class="stats rounded-md bg-neutral shadow"> <Card class="flex flex-col items-center bg-neutral p-3 text-neutral-content shadow">
<div class="stat space-y-1 p-3 text-center text-neutral-content"> <CardHeader class="p-0">
<div class="stat-title text-neutral-content">{{ title }}</div> <CardTitle class="text-sm font-medium">{{ title }}</CardTitle>
<div class="stat-value text-2xl"> </CardHeader>
<Currency v-if="type === 'currency'" :amount="value" /> <CardContent class="p-0 text-2xl font-bold">
<template v-if="type === 'number'">{{ value }}</template> <Currency v-if="type === 'currency'" :amount="value" />
</div> <template v-if="type === 'number'">{{ value }}</template>
<div v-if="subtitle" class="stat-desc">{{ subtitle }}</div> </CardContent>
</div> <CardFooter v-if="subtitle">{{ subtitle }}</CardFooter>
</div> </Card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { StatsFormat } from "./types"; import type { StatsFormat } from "./types";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
type Props = { type Props = {
title: string; title: string;

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<nav aria-label="breadcrumb" :class="props.class">
<slot />
</nav>
</template>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { MoreHorizontal } from 'lucide-vue-next'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
role="presentation"
aria-hidden="true"
:class="cn('flex h-9 w-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="h-4 w-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
:class="cn('inline-flex items-center gap-1.5', props.class)"
>
<slot />
</li>
</template>

View File

@@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive, type PrimitiveProps } from 'reka-ui'
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>(), {
as: 'a',
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn('transition-colors hover:text-foreground', props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<ol
:class="cn('flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>

View File

@@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('font-normal text-foreground', props.class)"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { ChevronRight } from 'lucide-vue-next'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:w-3.5 [&>svg]:h-3.5', props.class)"
>
<slot>
<ChevronRight />
</slot>
</li>
</template>

View File

@@ -0,0 +1,7 @@
export { default as Breadcrumb } from './Breadcrumb.vue'
export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue'
export { default as BreadcrumbItem } from './BreadcrumbItem.vue'
export { default as BreadcrumbLink } from './BreadcrumbLink.vue'
export { default as BreadcrumbList } from './BreadcrumbList.vue'
export { default as BreadcrumbPage } from './BreadcrumbPage.vue'
export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue'

View File

@@ -16,7 +16,12 @@
</script> </script>
<template> <template>
<Primitive :as="as" :as-child="asChild" :class="cn(buttonVariants({ variant, size }), props.class)"> <Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
:data-button="true"
>
<slot /> <slot />
</Primitive> </Primitive>
</template> </template>

View File

@@ -10,9 +10,12 @@
:class=" :class="
cn( cn(
'inline-flex rounded-lg', 'inline-flex rounded-lg',
'[&>*]:rounded-none', '[&>[data-button=true]]:rounded-none',
'[&>*:first-child]:rounded-l-lg', '[&>[data-button=true]:first-child]:rounded-l-lg',
'[&>*:last-child]:rounded-r-lg', '[&>[data-button=true]:last-child]:rounded-r-lg',
'[&_[data-button=true]]:rounded-none',
'[&_[data-button=true][data-pos=start]]:rounded-l-lg',
'[&_[data-button=true][data-pos=end]]:rounded-r-lg',
props.class props.class
) )
" "

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'rounded-lg bg-card text-card-foreground shadow',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<h3
:class="
cn('text-2xl font-semibold leading-none tracking-tight', props.class)
"
>
<slot />
</h3>
</template>

View File

@@ -0,0 +1,6 @@
export { default as Card } from './Card.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { Check } from 'lucide-vue-next'
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-bind="forwarded"
:class="
cn('peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
props.class)"
>
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
<slot>
<Check class="h-4 w-4" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Checkbox } from './Checkbox.vue'

View File

@@ -12,7 +12,8 @@ const forwarded = useForwardPropsEmits(props, emits)
<template> <template>
<Dialog v-bind="forwarded"> <Dialog v-bind="forwarded">
<DialogContent class="overflow-hidden p-0 shadow-lg"> <!-- https://github.com/unovue/reka-ui/issues/1743 -->
<DialogContent class="overflow-hidden p-0 shadow-lg" disable-portal>
<Command class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> <Command class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<slot /> <slot />
</Command> </Command>

View File

@@ -22,7 +22,7 @@ const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<div class="flex items-center border-b px-3" cmdk-input-wrapper> <div class="flex items-center px-3" cmdk-input-wrapper>
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" /> <Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
<ComboboxInput <ComboboxInput
v-bind="{ ...forwardedProps, ...$attrs }" v-bind="{ ...forwardedProps, ...$attrs }"

View File

@@ -1,50 +1,78 @@
<script setup lang="ts"> <script setup lang="ts">
import { cn } from '@/lib/utils' import { X } from "lucide-vue-next";
import { X } from 'lucide-vue-next' import {
import { DialogClose,
DialogClose, DialogContent,
DialogContent, type DialogContentEmits,
type DialogContentEmits, type DialogContentProps,
type DialogContentProps, DialogOverlay,
DialogOverlay, DialogPortal,
DialogPortal, useForwardPropsEmits,
useForwardPropsEmits, } from "reka-ui";
} from 'reka-ui' import { computed, type HTMLAttributes } from "vue";
import { computed, type HTMLAttributes } from 'vue' import { cn } from "@/lib/utils";
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>() const props = defineProps<
const emits = defineEmits<DialogContentEmits>() DialogContentProps & { class?: HTMLAttributes["class"]; disableClose?: boolean; disablePortal?: boolean }
>();
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = computed(() => { const delegatedProps = computed(() => {
const { class: _, ...delegated } = props const { class: _, disableClose, disablePortal, ...delegated } = props;
return delegated return delegated;
}) });
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal v-if="!props.disablePortal">
<DialogOverlay <DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
/> />
<DialogContent <DialogContent
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', 'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class, props.class
)" )
"
> >
<slot /> <slot />
<DialogClose <DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground" v-if="!props.disableClose"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
> >
<X class="w-4 h-4" /> <X class="size-4" />
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
<template v-else>
<DialogOverlay
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
/>
<DialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class
)
"
>
<slot />
<DialogClose
v-if="!props.disableClose"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</template>
</template> </template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { MoreHorizontal } from 'lucide-vue-next'
import { PaginationEllipsis, type PaginationEllipsisProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<PaginationEllipsis v-bind="delegatedProps" :class="cn('w-9 h-9 flex items-center justify-center', props.class)">
<slot>
<MoreHorizontal />
</slot>
</PaginationEllipsis>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import {
Button,
} from '@/components/ui/button'
import { ChevronsLeft } from 'lucide-vue-next'
import { PaginationFirst, type PaginationFirstProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = withDefaults(defineProps<PaginationFirstProps & { class?: HTMLAttributes['class'] }>(), {
asChild: true,
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<PaginationFirst v-bind="delegatedProps">
<Button :class="cn('w-10 h-10 p-0', props.class)" variant="outline">
<slot>
<ChevronsLeft class="h-4 w-4" />
</slot>
</Button>
</PaginationFirst>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import {
Button,
} from '@/components/ui/button'
import { ChevronsRight } from 'lucide-vue-next'
import { PaginationLast, type PaginationLastProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = withDefaults(defineProps<PaginationLastProps & { class?: HTMLAttributes['class'] }>(), {
asChild: true,
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<PaginationLast v-bind="delegatedProps">
<Button :class="cn('w-10 h-10 p-0', props.class)" variant="outline">
<slot>
<ChevronsRight class="h-4 w-4" />
</slot>
</Button>
</PaginationLast>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import {
Button,
} from '@/components/ui/button'
import { ChevronRight } from 'lucide-vue-next'
import { PaginationNext, type PaginationNextProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = withDefaults(defineProps<PaginationNextProps & { class?: HTMLAttributes['class'] }>(), {
asChild: true,
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<PaginationNext v-bind="delegatedProps">
<Button :class="cn('w-10 h-10 p-0', props.class)" variant="outline">
<slot>
<ChevronRight class="h-4 w-4" />
</slot>
</Button>
</PaginationNext>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import {
Button,
} from '@/components/ui/button'
import { ChevronLeft } from 'lucide-vue-next'
import { PaginationPrev, type PaginationPrevProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = withDefaults(defineProps<PaginationPrevProps & { class?: HTMLAttributes['class'] }>(), {
asChild: true,
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<PaginationPrev v-bind="delegatedProps">
<Button :class="cn('w-10 h-10 p-0', props.class)" variant="outline">
<slot>
<ChevronLeft class="h-4 w-4" />
</slot>
</Button>
</PaginationPrev>
</template>

View File

@@ -0,0 +1,10 @@
export { default as PaginationEllipsis } from './PaginationEllipsis.vue'
export { default as PaginationFirst } from './PaginationFirst.vue'
export { default as PaginationLast } from './PaginationLast.vue'
export { default as PaginationNext } from './PaginationNext.vue'
export { default as PaginationPrev } from './PaginationPrev.vue'
export {
PaginationRoot as Pagination,
PaginationList,
PaginationListItem,
} from 'reka-ui'

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { ProgressIndicator, ProgressRoot, type ProgressRootProps } from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = withDefaults(defineProps<ProgressRootProps & { class?: HTMLAttributes["class"] }>(), {
modelValue: 0,
});
const value = computed(() => {
return props.modelValue ?? 0;
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<ProgressRoot
v-bind="delegatedProps"
:class="cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', props.class)"
>
<ProgressIndicator
class="size-full flex-1 transition-all"
:style="`transform: translateX(-${100 - value}%);`"
:class="{
'bg-green-500': value > 50,
'bg-yellow-500': value > 25 && value < 50,
'bg-red-500': value < 25,
}"
/>
</ProgressRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Progress } from './Progress.vue'

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot v-bind="forwarded">
<slot />
</SelectRoot>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import {
SelectContent,
type SelectContentEmits,
type SelectContentProps,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
{
position: 'popper',
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
v-bind="{ ...forwarded, ...$attrs }" :class="cn(
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[--reka-select-trigger-height] w-full min-w-[--reka-select-trigger-width]')">
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { SelectGroup, type SelectGroupProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<SelectGroup :class="cn('p-1 w-full', props.class)" v-bind="delegatedProps">
<slot />
</SelectGroup>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { Check } from 'lucide-vue-next'
import {
SelectItem,
SelectItemIndicator,
type SelectItemProps,
SelectItemText,
useForwardProps,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
v-bind="forwardedProps"
:class="
cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class,
)
"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectItemIndicator>
<Check class="h-4 w-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { SelectItemText, type SelectItemTextProps } from 'reka-ui'
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText v-bind="props">
<slot />
</SelectItemText>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { SelectLabel, type SelectLabelProps } from 'reka-ui'
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<SelectLabel :class="cn('py-1.5 pl-8 pr-2 text-sm font-semibold', props.class)">
<slot />
</SelectLabel>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { ChevronDown } from 'lucide-vue-next'
import { SelectScrollDownButton, type SelectScrollDownButtonProps, useForwardProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollDownButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)">
<slot>
<ChevronDown class="h-4 w-4" />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { ChevronUp } from 'lucide-vue-next'
import { SelectScrollUpButton, type SelectScrollUpButtonProps, useForwardProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollUpButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)">
<slot>
<ChevronUp class="h-4 w-4" />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { SelectSeparator, type SelectSeparatorProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<SelectSeparator v-bind="delegatedProps" :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" />
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { ChevronDown } from 'lucide-vue-next'
import { SelectIcon, SelectTrigger, type SelectTriggerProps, useForwardProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectTrigger
v-bind="forwardedProps"
:class="cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
props.class,
)"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="w-4 h-4 opacity-50 shrink-0" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { SelectValue, type SelectValueProps } from 'reka-ui'
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue v-bind="props">
<slot />
</SelectValue>
</template>

View File

@@ -0,0 +1,11 @@
export { default as Select } from './Select.vue'
export { default as SelectContent } from './SelectContent.vue'
export { default as SelectGroup } from './SelectGroup.vue'
export { default as SelectItem } from './SelectItem.vue'
export { default as SelectItemText } from './SelectItemText.vue'
export { default as SelectLabel } from './SelectLabel.vue'
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
export { default as SelectSeparator } from './SelectSeparator.vue'
export { default as SelectTrigger } from './SelectTrigger.vue'
export { default as SelectValue } from './SelectValue.vue'

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import {
SwitchRoot,
type SwitchRootEmits,
type SwitchRootProps,
SwitchThumb,
useForwardPropsEmits,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SwitchRoot
v-bind="forwarded"
:class="cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
props.class,
)"
>
<SwitchThumb
:class="cn('pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5')"
>
<slot name="thumb" />
</SwitchThumb>
</SwitchRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Switch } from './Switch.vue'

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div class="relative w-full overflow-auto">
<table :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<caption :class="cn('mt-4 text-sm text-muted-foreground', props.class)">
<slot />
</caption>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<td
:class="
cn(
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
props.class,
)
"
>
<slot />
</td>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed, type HTMLAttributes } from 'vue'
import TableCell from './TableCell.vue'
import TableRow from './TableRow.vue'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
colspan?: number
}>(), {
colspan: 1,
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tfoot :class="cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', props.class)">
<slot />
</tfoot>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<th :class="cn('h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', props.class)">
<slot />
</th>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<thead :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tr :class="cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', props.class)">
<slot />
</tr>
</template>

View File

@@ -0,0 +1,9 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'

View File

@@ -1,5 +1,5 @@
import type { Ref } from "vue"; import type { Ref } from "vue";
import type { TableHeader } from "~/components/Item/View/Table.types"; import type { TableHeaderType } from "~/components/Item/View/Table.types";
import type { DaisyTheme } from "~~/lib/data/themes"; import type { DaisyTheme } from "~~/lib/data/themes";
export type ViewType = "table" | "card" | "tree"; export type ViewType = "table" | "card" | "tree";
@@ -11,7 +11,7 @@ export type LocationViewPreferences = {
itemDisplayView: ViewType; itemDisplayView: ViewType;
theme: DaisyTheme; theme: DaisyTheme;
itemsPerTablePage: number; itemsPerTablePage: number;
tableHeaders?: TableHeader[]; tableHeaders?: TableHeaderType[];
displayHeaderDecor: boolean; displayHeaderDecor: boolean;
language?: string; language?: string;
}; };

View File

@@ -6,7 +6,7 @@ describe("maybeURL works as expected", () => {
const result = maybeUrl("https://example.com"); const result = maybeUrl("https://example.com");
expect(result.isUrl).toBe(true); expect(result.isUrl).toBe(true);
expect(result.url).toBe("https://example.com"); expect(result.url).toBe("https://example.com");
expect(result.text).toBe("Link"); expect(result.text).toBe("https://example.com");
}); });
test("special URL syntax", () => { test("special URL syntax", () => {

View File

@@ -64,7 +64,7 @@ export function maybeUrl(str: string): MaybeUrlResult {
} }
} else { } else {
result.url = str; result.url = str;
result.text = "Link"; result.text = str;
} }
return result; return result;

View File

@@ -15,8 +15,8 @@
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader class="items-center bg-base-200"> <SidebarHeader class="items-center bg-base-200">
<SidebarGroupLabel class="text-base">{{ $t("global.welcome", { username: username }) }}</SidebarGroupLabel> <SidebarGroupLabel class="text-base">{{ $t("global.welcome", { username: username }) }}</SidebarGroupLabel>
<NuxtLink class="avatar placeholder group-data-[collapsible=icon]:hidden" to="/home"> <NuxtLink class="group-data-[collapsible=icon]:hidden" to="/home">
<div class="w-24 rounded-full bg-base-300 p-4 text-neutral-content"> <div class="flex size-24 items-center justify-center rounded-full bg-base-300 p-4 text-neutral-content">
<AppLogo /> <AppLogo />
</div> </div>
</NuxtLink> </NuxtLink>
@@ -33,7 +33,7 @@
</span> </span>
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[var(--reka-dropdown-menu-trigger-width)]"> <DropdownMenuContent class="z-40 min-w-[var(--reka-dropdown-menu-trigger-width)]">
<DropdownMenuItem <DropdownMenuItem
v-for="btn in dropdown" v-for="btn in dropdown"
:key="btn.id" :key="btn.id"

View File

@@ -4,8 +4,7 @@
"import_dialog": { "import_dialog": {
"change_warning": "Behavior for imports with existing import_refs has changed. If an import_ref is present in the CSV file, the \nitem will be updated with the values in the CSV file.", "change_warning": "Behavior for imports with existing import_refs has changed. If an import_ref is present in the CSV file, the \nitem will be updated with the values in the CSV file.",
"description": "Import a CSV file containing your items, labels, and locations. See documentation for more information on the \nrequired format.", "description": "Import a CSV file containing your items, labels, and locations. See documentation for more information on the \nrequired format.",
"title": "Import CSV File", "title": "Import CSV File"
"upload": "Upload"
}, },
"outdated": { "outdated": {
"current_version": "Current Version", "current_version": "Current Version",
@@ -55,7 +54,8 @@
"titles": "Labels" "titles": "Labels"
}, },
"page_qr_code": { "page_qr_code": {
"page_url": "Page URL" "page_url": "Page URL",
"qr_tooltip": "Show QR Code"
}, },
"password_score": { "password_score": {
"password_strength": "Password Strength" "password_strength": "Password Strength"
@@ -78,7 +78,9 @@
}, },
"table": { "table": {
"page": "Page", "page": "Page",
"rows_per_page": "Rows per page" "rows_per_page": "Rows per page",
"table_settings": "Table Settings",
"headers": "Headers"
} }
} }
}, },
@@ -148,7 +150,10 @@
"update": "Update", "update": "Update",
"value": "Value", "value": "Value",
"version": "Version: { version }", "version": "Version: { version }",
"welcome": "Welcome, { username }" "welcome": "Welcome, { username }",
"insured": "Insured",
"archived": "Archived",
"quantity": "Quantity"
}, },
"home": { "home": {
"labels": "Labels", "labels": "Labels",
@@ -279,6 +284,7 @@
"locations": { "locations": {
"child_locations": "Child Locations", "child_locations": "Child Locations",
"collapse_tree": "Collapse Tree", "collapse_tree": "Collapse Tree",
"expand_tree": "Expand Tree",
"no_results": "No Locations Found", "no_results": "No Locations Found",
"update_location": "Update Location" "update_location": "Update Location"
}, },
@@ -359,6 +365,7 @@
"user_profile_sub": "Invite users, and manage your account." "user_profile_sub": "Invite users, and manage your account."
}, },
"scanner": { "scanner": {
"title": "Scanner",
"error": "An error occurred while scanning", "error": "An error occurred while scanning",
"invalid_url": "Invalid barcode URL", "invalid_url": "Invalid barcode URL",
"no_sources": "No video sources available", "no_sources": "No video sources available",

Some files were not shown because too many files have changed in this diff Show More