mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 21:33:02 +01:00
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:
@@ -29,7 +29,7 @@ module.exports = {
|
||||
"vue/no-v-html": 0,
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
"@typescript-eslint/ban-ts-comment": 0,
|
||||
"tailwindcss/no-custom-classname": 0,
|
||||
"tailwindcss/no-custom-classname": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -777,14 +777,6 @@
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.btn {
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* transparent subtle scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 0.2em;
|
||||
|
||||
@@ -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>
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<BaseModal v-model="dialog">
|
||||
<template #title> {{ $t("components.app.import_dialog.title") }} </template>
|
||||
<p>
|
||||
{{ $t("components.app.import_dialog.description") }}
|
||||
</p>
|
||||
<div class="alert alert-warning mt-4 shadow-lg">
|
||||
<div>
|
||||
<Dialog dialog-id="import">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t("components.app.import_dialog.title") }}</DialogTitle>
|
||||
<DialogDescription> {{ $t("components.app.import_dialog.description") }} </DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex gap-2 rounded bg-destructive p-2 text-destructive-foreground shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mb-auto size-6 shrink-0 stroke-current"
|
||||
@@ -23,31 +24,30 @@
|
||||
{{ $t("components.app.import_dialog.change_warning") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitCsvFile">
|
||||
<div class="flex flex-col gap-2 py-6">
|
||||
<input ref="importRef" type="file" class="hidden" accept=".csv,.tsv" @change="setFile" />
|
||||
<form class="flex flex-col gap-4" @submit.prevent="submitCsvFile">
|
||||
<Input ref="importRef" type="file" accept=".csv,.tsv" @change="setFile" />
|
||||
|
||||
<BaseButton type="button" @click="uploadCsv">
|
||||
<MdiUpload class="mr-2 size-5" />
|
||||
{{ $t("components.app.import_dialog.upload") }}
|
||||
</BaseButton>
|
||||
<p class="-mb-5 pt-4 text-center">
|
||||
{{ importCsv?.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<BaseButton type="submit" :disabled="!importCsv"> {{ $t("global.submit") }} </BaseButton>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="!importCsv"> {{ $t("global.submit") }} </Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 = {
|
||||
modelValue: boolean;
|
||||
};
|
||||
@@ -81,10 +81,6 @@
|
||||
importCsv.value = result.files[0];
|
||||
}
|
||||
|
||||
function uploadCsv() {
|
||||
importRef.value?.click();
|
||||
}
|
||||
|
||||
async function submitCsvFile() {
|
||||
if (!importCsv.value) {
|
||||
toast.error("Please select a file to import.");
|
||||
|
||||
51
frontend/components/App/LanguageSelector.vue
Normal file
51
frontend/components/App/LanguageSelector.vue
Normal 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>
|
||||
@@ -7,7 +7,12 @@
|
||||
<p>{{ $t("components.app.outdated.current_version") }}: {{ current }}</p>
|
||||
<p>{{ $t("components.app.outdated.latest_version") }}: {{ latest }}</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") }}
|
||||
</a>
|
||||
</p>
|
||||
@@ -30,8 +35,8 @@
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { useDialog } from "~/components/ui/dialog-provider";
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
|
||||
const props = defineProps<{
|
||||
status: {
|
||||
|
||||
@@ -38,12 +38,19 @@
|
||||
(e: KeyboardEvent) => {
|
||||
const item = props.actions.filter(item => 'shortcut' in item).find(item => item.shortcut === e.key);
|
||||
if (item) {
|
||||
e.preventDefault();
|
||||
openDialog(item.dialogId);
|
||||
}
|
||||
// if esc is pressed, close the dialog
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeDialog('quick-menu');
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandSeparator />
|
||||
<CommandEmpty>{{ t("components.quick_menu.no_results") }}</CommandEmpty>
|
||||
<CommandGroup :heading="t('global.create')">
|
||||
<CommandItem
|
||||
@@ -51,7 +58,8 @@
|
||||
:key="`$global.create_${i + 1}`"
|
||||
:value="create.text"
|
||||
@select="
|
||||
() => {
|
||||
e => {
|
||||
e.preventDefault();
|
||||
openDialog(create.dialogId);
|
||||
}
|
||||
"
|
||||
|
||||
@@ -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>
|
||||
@@ -1,13 +1,12 @@
|
||||
<template>
|
||||
<div class="card rounded-lg bg-base-100 shadow-xl">
|
||||
<div v-if="$slots.title" class="px-4 py-5 sm:px-6">
|
||||
<Card class="overflow-hidden shadow-xl">
|
||||
<CardHeader v-if="$slots.title" class="px-4 py-5 sm:px-6">
|
||||
<component :is="collapsable ? 'button' : 'div'" v-on="collapsable ? { click: toggle } : {}">
|
||||
<h3 class="flex items-center text-lg font-medium leading-6">
|
||||
<slot name="title"></slot>
|
||||
<template v-if="collapsable">
|
||||
<span class="swap swap-rotate ml-2" :class="`${collapsed ? 'swap-active' : ''}`">
|
||||
<MdiChevronRight class="swap-on size-6" />
|
||||
<MdiChevronDown class="swap-off size-6" />
|
||||
<span class="ml-2 transition-transform" :class="{ 'rotate-180': collapsed }">
|
||||
<MdiChevronDown class="size-6" />
|
||||
</span>
|
||||
</template>
|
||||
</h3>
|
||||
@@ -20,22 +19,22 @@
|
||||
<slot name="title-actions"></slot>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
:class="{
|
||||
'max-h-[9000px]': collapsable && !collapsed,
|
||||
'max-h-0 overflow-hidden': collapsed,
|
||||
}"
|
||||
class="transition-[max-height] duration-200"
|
||||
class="p-0 transition-[max-height] duration-200"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MdiChevronDown from "~icons/mdi/chevron-down";
|
||||
import MdiChevronRight from "~icons/mdi/chevron-right";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
defineProps<{
|
||||
collapsable?: boolean;
|
||||
|
||||
@@ -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>
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div class="pb-3">
|
||||
<h3
|
||||
class="flex items-center text-3xl font-bold tracking-tight"
|
||||
<CardTitle
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'text-neutral-content': dark,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
<p v-if="$slots.description" class="mt-2 max-w-4xl text-sm text-base-content">
|
||||
</CardTitle>
|
||||
<CardDescription v-if="$slots.description">
|
||||
<slot name="description" />
|
||||
</p>
|
||||
</CardDescription>
|
||||
<div v-if="$slots.after">
|
||||
<slot name="after" />
|
||||
</div>
|
||||
@@ -18,6 +18,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { CardDescription, CardTitle } from "@/components/ui/card";
|
||||
|
||||
defineProps({
|
||||
dark: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -8,10 +8,30 @@
|
||||
<slot></slot>
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton class="btn-primary mt-auto" @click="$emit('action')">
|
||||
<template v-if="to">
|
||||
<NuxtLink class="mt-auto" :to="to" :class="buttonVariants({ size: 'lg' })">
|
||||
<slot name="button">
|
||||
<slot name="title"></slot>
|
||||
</slot>
|
||||
</BaseButton>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from "vue";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
defineProps<{
|
||||
to?: string;
|
||||
}>();
|
||||
|
||||
defineEmits(["action"]);
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,21 +1,22 @@
|
||||
<template>
|
||||
<div v-if="!inline" class="form-control w-full">
|
||||
<label class="label cursor-pointer">
|
||||
<input v-model="value" type="checkbox" class="checkbox checkbox-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">
|
||||
<div v-if="!inline" class="flex w-full items-center gap-1.5">
|
||||
<Checkbox :id="id" v-model="value" class="size-6" />
|
||||
<Label :for="id" class="cursor-pointer">
|
||||
{{ label }}
|
||||
</span>
|
||||
</label>
|
||||
<input v-model="value" type="checkbox" class="checkbox checkbox-primary" />
|
||||
</Label>
|
||||
</div>
|
||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||
<Label :for="id" class="flex w-full cursor-pointer px-1 py-2">
|
||||
{{ label }}
|
||||
</Label>
|
||||
<Checkbox :id="id" v-model="value" class="size-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
@@ -32,4 +33,6 @@
|
||||
});
|
||||
|
||||
const value = useVModel(props, "modelValue");
|
||||
|
||||
const id = useId();
|
||||
</script>
|
||||
|
||||
@@ -1,29 +1,11 @@
|
||||
<template>
|
||||
<div v-if="!inline" class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text"> {{ label }} </span>
|
||||
</label>
|
||||
<VueDatePicker
|
||||
v-model="selected"
|
||||
:enable-time-picker="false"
|
||||
clearable
|
||||
:dark="isDark"
|
||||
:teleport="true"
|
||||
:format="formatDate"
|
||||
/>
|
||||
<div v-if="!inline" class="flex w-full flex-col">
|
||||
<Label class="cursor-pointer"> {{ label }} </Label>
|
||||
<VueDatePicker v-model="selected" :enable-time-picker="false" clearable :dark="isDark" :format="formatDate" />
|
||||
</div>
|
||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||
<label class="label">
|
||||
<span class="label-text"> {{ label }} </span>
|
||||
</label>
|
||||
<VueDatePicker
|
||||
v-model="selected"
|
||||
:enable-time-picker="false"
|
||||
clearable
|
||||
:dark="isDark"
|
||||
:teleport="true"
|
||||
:format="formatDate"
|
||||
/>
|
||||
<div v-else class="sm:flex sm:items-start sm:gap-4">
|
||||
<Label class="flex w-full cursor-pointer px-1 py-2"> {{ label }} </Label>
|
||||
<VueDatePicker v-model="selected" :enable-time-picker="false" clearable :dark="isDark" :format="formatDate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,6 +14,8 @@
|
||||
import VueDatePicker from "@vuepic/vue-datepicker";
|
||||
import "@vuepic/vue-datepicker/dist/main.css";
|
||||
import * as datelib from "~/lib/datelib/datelib";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:text"]);
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -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>
|
||||
@@ -1,19 +1,26 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<FormTextField v-model="value" placeholder="Password" :label="label" :type="inputType"> </FormTextField>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="tooltip absolute right-3 top-6 mb-3 ml-1 mt-auto inline-flex justify-center p-1"
|
||||
data-tip="Toggle Password Show"
|
||||
class="absolute right-3 top-6 mb-3 ml-1 mt-auto inline-flex justify-center p-1"
|
||||
@click="toggle()"
|
||||
>
|
||||
<MdiEye name="mdi-eye" class="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle Password Show</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MdiEye from "~icons/mdi/eye";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
|
||||
@@ -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>
|
||||
@@ -3,37 +3,39 @@
|
||||
<Label :for="id" class="flex w-full px-1">
|
||||
<span>{{ label }}</span>
|
||||
<span class="grow"></span>
|
||||
<span
|
||||
:class="{
|
||||
'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 :class="{ 'text-red-600': isLengthInvalid }">
|
||||
{{ lengthIndicator }}
|
||||
</span>
|
||||
</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 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">
|
||||
<span>{{ label }}</span>
|
||||
<span class="grow"></span>
|
||||
<span
|
||||
:class="{
|
||||
'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 :class="{ 'text-red-600': isLengthInvalid }">
|
||||
{{ lengthIndicator }}
|
||||
</span>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
@@ -68,4 +70,35 @@
|
||||
|
||||
const id = useId();
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -9,13 +9,29 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="ml-4 shrink-0">
|
||||
<a class="tooltip mr-2" data-tip="Download" :href="attachmentURL(attachment.id)" target="_blank">
|
||||
<MdiDownload class="size-5" />
|
||||
<div class="ml-4 flex shrink-0 gap-2">
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<a
|
||||
:class="buttonVariants({ size: 'icon' })"
|
||||
:href="attachmentURL(attachment.id)"
|
||||
:download="attachment.document.title"
|
||||
>
|
||||
<MdiDownload />
|
||||
</a>
|
||||
<a class="tooltip" data-tip="Open" :href="attachmentURL(attachment.id)" target="_blank">
|
||||
<MdiOpenInNew class="size-5" />
|
||||
</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>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -26,6 +42,8 @@
|
||||
import MdiPaperclip from "~icons/mdi/paperclip";
|
||||
import MdiDownload from "~icons/mdi/download";
|
||||
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({
|
||||
attachments: {
|
||||
|
||||
@@ -1,52 +1,67 @@
|
||||
<template>
|
||||
<NuxtLink class="group card rounded-md border border-gray-300" :to="`/item/${item.id}`">
|
||||
<Card class="overflow-hidden">
|
||||
<NuxtLink :to="`/item/${item.id}`">
|
||||
<div class="relative h-[200px]">
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
class="h-[200px] w-full rounded-t border-gray-300 object-cover shadow-sm"
|
||||
loading="lazy"
|
||||
:src="imageUrl"
|
||||
alt=""
|
||||
/>
|
||||
<div class="absolute inset-x-1 bottom-1 text-wrap">
|
||||
<NuxtLink
|
||||
v-if="item.location"
|
||||
class="badge h-auto rounded-md text-sm shadow-md hover:link"
|
||||
:to="`/location/${item.location.id}`"
|
||||
>
|
||||
<img v-if="imageUrl" class="h-[200px] w-full object-cover shadow-md" loading="lazy" :src="imageUrl" alt="" />
|
||||
<div class="absolute inset-x-1 bottom-1">
|
||||
<Badge class="text-wrap bg-neutral text-neutral-content hover:bg-neutral/90 hover:underline">
|
||||
<NuxtLink v-if="item.location" :to="`/location/${item.location.id}`">
|
||||
{{ locationString }}
|
||||
</NuxtLink>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-4 flex grow flex-col gap-y-1 rounded-b bg-base-100 p-4 pt-2">
|
||||
<div class="col-span-4 flex grow flex-col gap-y-1 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">
|
||||
<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" />
|
||||
</div>
|
||||
<div v-if="item.archived" class="tooltip z-10" data-tip="Archived">
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{{ $t("global.insured") }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="item.archived">
|
||||
<TooltipTrigger>
|
||||
<MdiArchive class="size-5 text-red-700" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{{ $t("global.archived") }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div class="grow"></div>
|
||||
<div class="tooltip" data-tip="Quantity">
|
||||
<span class="badge badge-primary badge-sm h-5 text-xs">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge>
|
||||
{{ item.quantity }}
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
</NuxtLink>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ItemOut, ItemSummary } from "~~/lib/api/types/data-contracts";
|
||||
import MdiShieldCheck from "~icons/mdi/shield-check";
|
||||
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();
|
||||
|
||||
|
||||
139
frontend/components/Item/Selector.vue
Normal file
139
frontend/components/Item/Selector.vue
Normal 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>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { ViewType } from "~~/composables/use-preferences";
|
||||
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 MdiTable from "~icons/mdi/table";
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
|
||||
type Props = {
|
||||
view?: ViewType;
|
||||
@@ -28,27 +28,24 @@
|
||||
|
||||
<template>
|
||||
<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") }}
|
||||
<template #description>
|
||||
<div v-if="!viewSet" class="dropdown dropdown-left dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-ghost m-1">
|
||||
<MdiDotsVertical class="size-7" />
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu rounded-box w-32 bg-base-100 p-2 shadow">
|
||||
<li>
|
||||
<button @click="setViewPreference('card')">
|
||||
<div v-if="!viewSet">
|
||||
<ButtonGroup>
|
||||
<Button size="sm" :variant="itemView === 'card' ? 'default' : 'outline'" @click="setViewPreference('card')">
|
||||
<MdiCardTextOutline class="size-5" />
|
||||
{{ $t("components.item.view.selectable.card") }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="setViewPreference('table')">
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="itemView === 'table' ? 'default' : 'outline'"
|
||||
@click="setViewPreference('table')"
|
||||
>
|
||||
<MdiTable class="size-5" />
|
||||
{{ $t("components.item.view.selectable.table") }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</template>
|
||||
</BaseSectionHeader>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ItemSummary } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
export type TableHeader = {
|
||||
export type TableHeaderType = {
|
||||
text: string;
|
||||
value: keyof ItemSummary;
|
||||
sortable?: boolean;
|
||||
|
||||
@@ -1,12 +1,58 @@
|
||||
<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>
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
<Table class="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
v-for="h in headers.filter(h => h.enabled)"
|
||||
: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)"
|
||||
>
|
||||
<div
|
||||
@@ -20,24 +66,21 @@
|
||||
<template v-if="typeof h === 'string'">{{ h }}</template>
|
||||
<template v-else>{{ $t(h.text) }}</template>
|
||||
<div
|
||||
v-if="sortByProperty === h.value"
|
||||
:class="`inline-flex ${sortByProperty === h.value ? '' : 'opacity-0'}`"
|
||||
:data-swap="pagination.descending"
|
||||
: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 }">
|
||||
<MdiArrowDown class="swap-on size-5" />
|
||||
<MdiArrowUp class="swap-off size-5" />
|
||||
</span>
|
||||
<MdiArrowUp class="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(d, i) in data" :key="d.id" class="hover cursor-pointer" @click="navigateTo(`/item/${d.id}`)">
|
||||
<td
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="(d, i) in data" :key="d.id" class="cursor-pointer" @click="navigateTo(`/item/${d.id}`)">
|
||||
<TableCell
|
||||
v-for="h in headers.filter(h => h.enabled)"
|
||||
:key="`${h.value}-${i}`"
|
||||
class="bg-base-100"
|
||||
:class="{
|
||||
'text-center': h.align === 'center',
|
||||
'text-right': h.align === 'right',
|
||||
@@ -45,7 +88,7 @@
|
||||
}"
|
||||
>
|
||||
<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 }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -57,7 +100,7 @@
|
||||
<MdiClose v-else class="inline size-5 text-red-500" />
|
||||
</template>
|
||||
<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 }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -67,76 +110,69 @@
|
||||
<slot v-else :name="cell(h)" v-bind="{ item: d }">
|
||||
{{ extractValue(d, h.value) }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<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="{
|
||||
hidden: disableControls,
|
||||
}"
|
||||
>
|
||||
<div class="dropdown dropdown-top dropdown-hover">
|
||||
<label tabindex="0" class="btn btn-square btn-outline btn-sm m-1">
|
||||
<Button class="size-10 p-0" variant="outline" @click="openDialog('item-table-settings')">
|
||||
<MdiTableCog />
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content rounded-box flex w-64 flex-col gap-2 bg-base-100 p-2 pl-3 shadow">
|
||||
<li>Headers:</li>
|
||||
<li v-for="(h, i) in headers" :key="h.value" class="flex flex-row items-center gap-1">
|
||||
<button
|
||||
class="btn btn-square btn-ghost btn-xs"
|
||||
:class="{
|
||||
'btn-disabled': i === 0,
|
||||
}"
|
||||
@click="moveHeader(i, i - 1)"
|
||||
</Button>
|
||||
<Pagination
|
||||
v-slot="{ page }"
|
||||
:items-per-page="pagination.rowsPerPage"
|
||||
:total="props.items.length"
|
||||
:sibling-count="2"
|
||||
@update:page="pagination.page = $event"
|
||||
>
|
||||
<MdiArrowUp />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-square btn-ghost btn-xs"
|
||||
:class="{
|
||||
'btn-disabled': i === headers.length - 1,
|
||||
}"
|
||||
@click="moveHeader(i, i + 1)"
|
||||
>
|
||||
<MdiArrowDown />
|
||||
</button>
|
||||
<input
|
||||
: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>
|
||||
<PaginationList v-slot="{ pageItems }" class="flex items-center gap-1">
|
||||
<PaginationFirst />
|
||||
<template v-for="(item, index) in pageItems">
|
||||
<PaginationListItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
|
||||
<Button class="size-10 p-0" :variant="item.value === page ? 'default' : 'outline'">
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :key="item.type" :index="index" />
|
||||
</template>
|
||||
<PaginationLast />
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
<Button class="invisible hidden size-10 p-0 md:block">
|
||||
<!-- properly centre the pagination buttons -->
|
||||
</Button>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
<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 MdiArrowDown from "~icons/mdi/arrow-down";
|
||||
import MdiArrowUp from "~icons/mdi/arrow-up";
|
||||
import MdiCheck from "~icons/mdi/check";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
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 = {
|
||||
items: ItemSummary[];
|
||||
@@ -162,14 +198,14 @@
|
||||
{ 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.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 ?? [])
|
||||
.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
|
||||
.map(h => ({
|
||||
...(defaultHeaders.find(h2 => h2.value === h.value) as TableHeader),
|
||||
...(defaultHeaders.find(h2 => h2.value === h.value) as TableHeaderType),
|
||||
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) {
|
||||
if (sortByProperty.value === property) {
|
||||
pagination.descending = !pagination.descending;
|
||||
@@ -290,25 +316,7 @@
|
||||
return current;
|
||||
}
|
||||
|
||||
function cell(h: TableHeader) {
|
||||
function cell(h: TableHeaderType) {
|
||||
return `cell-${h.value.replace(".", "_")}`;
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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";
|
||||
|
||||
export type sizes = "sm" | "md" | "lg" | "xl";
|
||||
@@ -14,29 +14,28 @@
|
||||
default: "md",
|
||||
},
|
||||
});
|
||||
|
||||
const badge = ref(null);
|
||||
const isHover = useElementHover(badge);
|
||||
const { focused } = useFocus(badge);
|
||||
|
||||
const isActive = computed(() => isHover.value || focused.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
ref="badge"
|
||||
class="badge badge-secondary text-secondary-content"
|
||||
class="group/label-chip flex gap-2 rounded-full bg-secondary text-secondary-foreground shadow transition duration-300 hover:bg-secondary/70"
|
||||
:class="{
|
||||
'badge-lg p-4': size === 'lg',
|
||||
'p-3': size !== 'sm' && size !== 'lg',
|
||||
'badge-sm p-2': size === 'sm',
|
||||
'p-4 py-1 text-base': size === 'lg',
|
||||
'p-3 py-1 text-sm': size !== 'sm' && size !== 'lg',
|
||||
'p-2 py-0.5 text-xs': size === 'sm',
|
||||
}"
|
||||
:to="`/label/${label.id}`"
|
||||
>
|
||||
<label class="swap swap-rotate" :class="isActive ? 'swap-active' : ''">
|
||||
<MdiArrowRight class="swap-on mr-2" />
|
||||
<MdiTagOutline class="swap-off mr-2" />
|
||||
</label>
|
||||
<div class="relative">
|
||||
<MdiTagOutline class="invisible" /><!-- hack to ensure the size is correct -->
|
||||
|
||||
<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 }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
ref="card"
|
||||
:to="`/location/${location.id}`"
|
||||
class="card rounded-md bg-base-100 text-base-content shadow-md transition duration-300"
|
||||
>
|
||||
<Card>
|
||||
<NuxtLink :to="`/location/${location.id}`" class="group/location-card transition duration-300">
|
||||
<div
|
||||
class="card-body"
|
||||
class=""
|
||||
:class="{
|
||||
'p-4': !dense,
|
||||
'px-3 py-2': dense,
|
||||
}"
|
||||
>
|
||||
<h2 class="flex items-center justify-between gap-2">
|
||||
<label class="swap swap-rotate" :class="isActive ? 'swap-active' : ''">
|
||||
<MdiArrowRight class="swap-on size-6" />
|
||||
<MdiMapMarkerOutline class="swap-off size-6" />
|
||||
</label>
|
||||
<div class="relative size-6">
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-hover/location-card:-rotate-90"
|
||||
>
|
||||
<MdiMapMarkerOutline class="size-6 group-hover/location-card:hidden" />
|
||||
<MdiArrowUp class="hidden size-6 group-hover/location-card:block" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="mx-auto">
|
||||
{{ location.name }}
|
||||
</span>
|
||||
<span class="badge badge-primary badge-lg h-6" :class="{ 'opacity-0': !hasCount }">
|
||||
<Badge class="" :class="{ 'opacity-0': !hasCount }">
|
||||
{{ count }}
|
||||
</span>
|
||||
</Badge>
|
||||
</h2>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
location: {
|
||||
@@ -52,10 +56,4 @@
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useTreeState } from "./tree-state";
|
||||
import type { TreeItem } from "~~/lib/api/types/data-contracts";
|
||||
import MdiChevronDown from "~icons/mdi/chevron-down";
|
||||
import MdiChevronRight from "~icons/mdi/chevron-right";
|
||||
import MdiMapMarker from "~icons/mdi/map-marker";
|
||||
import MdiPackageVariant from "~icons/mdi/package-variant";
|
||||
@@ -53,25 +52,20 @@
|
||||
}"
|
||||
>
|
||||
<div v-if="!hasChildren" class="size-6"></div>
|
||||
<label
|
||||
v-else
|
||||
class="swap swap-rotate"
|
||||
:class="{
|
||||
'swap-active': openRef,
|
||||
}"
|
||||
<div v-else class="group/node relative size-6" :data-swap="openRef">
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-data-[swap=true]/node:rotate-90"
|
||||
>
|
||||
<MdiChevronRight name="mdi-chevron-right" class="swap-off size-6" />
|
||||
<MdiChevronDown name="mdi-chevron-down" class="swap-on size-6" />
|
||||
</label>
|
||||
<MdiChevronRight class="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MdiMapMarker v-if="item.type === 'location'" 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 v-if="openRef" class="ml-4">
|
||||
<LocationTreeNode v-for="child in item.children" :key="child.id" :item="child" :tree-id="treeId" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable-next-line tailwindcss/no-custom-classname -->
|
||||
<div class="root border-2 p-4">
|
||||
<div>
|
||||
<p v-if="locs.length === 0" class="text-center text-sm">
|
||||
{{ $t("location.tree.no_locations") }}
|
||||
</p>
|
||||
<LocationTreeNode v-for="item in locs" :key="item.id" :item="item" :tree-id="treeId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
<template>
|
||||
<BaseModal v-model="visible">
|
||||
<template #title>
|
||||
<Dialog dialog-id="edit-maintenance">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{ entry.id ? $t("maintenance.modal.edit_title") : $t("maintenance.modal.new_title") }}
|
||||
</template>
|
||||
<form @submit.prevent="dispatchFormSubmit">
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form class="flex flex-col gap-2" @submit.prevent="dispatchFormSubmit">
|
||||
<FormTextField v-model="entry.name" autofocus :label="$t('maintenance.modal.entry_name')" />
|
||||
<DatePicker v-model="entry.completedDate" :label="$t('maintenance.modal.completed_date')" />
|
||||
<DatePicker v-model="entry.scheduledDate" :label="$t('maintenance.modal.scheduled_date')" />
|
||||
<FormTextArea v-model="entry.description" :label="$t('maintenance.modal.notes')" class="pt-2" />
|
||||
<FormTextField v-model="entry.cost" autofocus :label="$t('maintenance.modal.cost')" class="pt-2" />
|
||||
<div class="flex justify-end py-2">
|
||||
<BaseButton type="submit" class="ml-2 mt-2">
|
||||
<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 />
|
||||
</template>
|
||||
{{ entry.id ? $t("maintenance.modal.edit_action") : $t("maintenance.modal.new_action") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -27,13 +31,16 @@
|
||||
import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import MdiPost from "~icons/mdi/post";
|
||||
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 api = useUserApi();
|
||||
|
||||
const emit = defineEmits(["changed"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const entry = reactive({
|
||||
id: null as string | null,
|
||||
name: "",
|
||||
@@ -70,7 +77,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
visible.value = false;
|
||||
closeDialog("edit-maintenance");
|
||||
emit("changed");
|
||||
}
|
||||
|
||||
@@ -92,7 +99,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
visible.value = false;
|
||||
closeDialog("edit-maintenance");
|
||||
emit("changed");
|
||||
}
|
||||
|
||||
@@ -104,7 +111,7 @@
|
||||
entry.description = "";
|
||||
entry.cost = "";
|
||||
entry.itemId = itemId;
|
||||
visible.value = true;
|
||||
openDialog("edit-maintenance");
|
||||
};
|
||||
|
||||
const openUpdateModal = (maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails) => {
|
||||
@@ -115,7 +122,7 @@
|
||||
entry.description = maintenanceEntry.description;
|
||||
entry.cost = maintenanceEntry.cost;
|
||||
entry.itemId = null;
|
||||
visible.value = true;
|
||||
openDialog("edit-maintenance");
|
||||
};
|
||||
|
||||
const confirm = useConfirm();
|
||||
@@ -157,7 +164,7 @@
|
||||
entry.description = maintenanceEntry.description;
|
||||
entry.cost = maintenanceEntry.cost;
|
||||
entry.itemId = itemId;
|
||||
visible.value = true;
|
||||
openDialog("edit-maintenance");
|
||||
}
|
||||
|
||||
defineExpose({ openCreateModal, openUpdateModal, deleteEntry, complete, duplicate });
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
import MdiWrenchClock from "~icons/mdi/wrench-clock";
|
||||
import MdiContentDuplicate from "~icons/mdi/content-duplicate";
|
||||
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 maintenanceEditModal = ref<InstanceType<typeof MaintenanceEditModal>>();
|
||||
@@ -77,50 +80,47 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<StatCard
|
||||
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"
|
||||
/>
|
||||
<StatCard v-for="stat in stats" :key="stat.id" :title="stat.title" :value="stat.value" :type="stat.type" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="btn-group">
|
||||
<BaseButton
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
size="sm"
|
||||
:class="`${maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusScheduled ? 'btn-active' : ''}`"
|
||||
:variant="
|
||||
maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusScheduled ? 'default' : 'outline'
|
||||
"
|
||||
@click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusScheduled"
|
||||
>
|
||||
{{ $t("maintenance.filter.scheduled") }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:class="`${maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusCompleted ? 'btn-active' : ''}`"
|
||||
:variant="
|
||||
maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusCompleted ? 'default' : 'outline'
|
||||
"
|
||||
@click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusCompleted"
|
||||
>
|
||||
{{ $t("maintenance.filter.completed") }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:class="`${maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusBoth ? 'btn-active' : ''}`"
|
||||
:variant="
|
||||
maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusBoth ? 'default' : 'outline'
|
||||
"
|
||||
@click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusBoth"
|
||||
>
|
||||
{{ $t("maintenance.filter.both") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<BaseButton
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Button
|
||||
v-if="props.currentItemId"
|
||||
class="ml-auto"
|
||||
size="sm"
|
||||
@click="maintenanceEditModal?.openCreateModal(props.currentItemId)"
|
||||
>
|
||||
<template #icon>
|
||||
<MdiPlus />
|
||||
</template>
|
||||
{{ $t("maintenance.list.new") }}
|
||||
</BaseButton>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
@@ -129,7 +129,7 @@
|
||||
<div class="container space-y-6">
|
||||
<BaseCard v-for="e in maintenanceDataList" :key="e.id">
|
||||
<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">
|
||||
<NuxtLink class="hover:underline" :to="`/item/${(e as MaintenanceEntryWithDetails).itemID}/maintenance`">
|
||||
{{ (e as MaintenanceEntryWithDetails).itemName }}
|
||||
@@ -140,51 +140,53 @@
|
||||
</span>
|
||||
<template #description>
|
||||
<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" />
|
||||
<DateTime :date="e.completedDate" format="human" datetime-type="date" />
|
||||
</div>
|
||||
<div v-else-if="validDate(e.scheduledDate)" class="badge p-3">
|
||||
</Badge>
|
||||
<Badge v-else-if="validDate(e.scheduledDate)" variant="outline">
|
||||
<MdiCalendar class="mr-2" />
|
||||
<DateTime :date="e.scheduledDate" format="human" datetime-type="date" />
|
||||
</div>
|
||||
<div class="tooltip tooltip-primary" data-tip="Cost">
|
||||
<div class="badge badge-primary p-3">
|
||||
</Badge>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge>
|
||||
<Currency :amount="e.cost" />
|
||||
</div>
|
||||
</div>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent> Cost </TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</template>
|
||||
</BaseSectionHeader>
|
||||
<div class="p-6">
|
||||
<div :class="{ 'p-6': e.description }">
|
||||
<Markdown :source="e.description" />
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-end gap-1 p-4">
|
||||
<BaseButton size="sm" @click="maintenanceEditModal?.openUpdateModal(e)">
|
||||
<template #icon>
|
||||
<ButtonGroup class="flex flex-wrap justify-end p-4">
|
||||
<Button size="sm" @click="maintenanceEditModal?.openUpdateModal(e)">
|
||||
<MdiEdit />
|
||||
</template>
|
||||
{{ $t("maintenance.list.edit") }}
|
||||
</BaseButton>
|
||||
<BaseButton v-if="!validDate(e.completedDate)" size="sm" @click="maintenanceEditModal?.complete(e)">
|
||||
<template #icon>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!validDate(e.completedDate)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="maintenanceEditModal?.complete(e)"
|
||||
>
|
||||
<MdiCheck />
|
||||
</template>
|
||||
{{ $t("maintenance.list.complete") }}
|
||||
</BaseButton>
|
||||
<BaseButton size="sm" @click="maintenanceEditModal?.duplicate(e, e.itemID)">
|
||||
<template #icon>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" @click="maintenanceEditModal?.duplicate(e, e.itemID)">
|
||||
<MdiContentDuplicate />
|
||||
</template>
|
||||
{{ $t("maintenance.list.duplicate") }}
|
||||
</BaseButton>
|
||||
<BaseButton size="sm" class="btn-error" @click="maintenanceEditModal?.deleteEntry(e.id)">
|
||||
<template #icon>
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" @click="maintenanceEditModal?.deleteEntry(e.id)">
|
||||
<MdiDelete />
|
||||
</template>
|
||||
{{ $t("maintenance.list.delete") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</BaseCard>
|
||||
<div v-if="props.currentItemId" class="hidden first:block">
|
||||
<button
|
||||
|
||||
@@ -1,82 +1,73 @@
|
||||
<template>
|
||||
<div ref="el" class="dropdown" :class="{ 'dropdown-open': dropdownOpen }">
|
||||
<button ref="btn" tabindex="0" class="btn btn-xs" @click="toggle">
|
||||
{{ label }} {{ len }} <MdiChevronDown class="size-4" />
|
||||
</button>
|
||||
<div tabindex="0" class="dropdown-content mt-1 w-64 rounded-md bg-base-100 shadow">
|
||||
<div class="mb-1 px-4 pt-4 shadow-sm">
|
||||
<input v-model="search" type="text" placeholder="Search…" class="input input-bordered input-sm mb-2 w-full" />
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button size="sm" variant="outline" class="group/filter">
|
||||
{{ label }} {{ len }}
|
||||
<MdiChevronDown class="transition-transform group-data-[state=open]/filter:rotate-180" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="z-40 p-0">
|
||||
<div class="p-4 shadow-sm">
|
||||
<Input v-model="search" type="text" placeholder="Search…" />
|
||||
</div>
|
||||
<div class="max-h-72 divide-y overflow-y-auto">
|
||||
<label
|
||||
<Label
|
||||
v-for="v in selectedView"
|
||||
:key="v"
|
||||
class="label flex cursor-pointer justify-between px-4 hover:bg-base-200"
|
||||
:key="v.id"
|
||||
class="flex cursor-pointer justify-between px-4 py-2 text-sm hover:bg-base-200"
|
||||
>
|
||||
<span class="label-text mr-2">
|
||||
<slot name="display" v-bind="{ item: v }">
|
||||
{{ v[display] }}
|
||||
</slot>
|
||||
</span>
|
||||
<input v-model="selected" type="checkbox" :value="v" class="checkbox checkbox-primary checkbox-sm" />
|
||||
</label>
|
||||
<div>
|
||||
<span>{{ v.name }}</span>
|
||||
<span v-if="v.treeString && v.treeString !== v.name" class="ml-auto text-xs">{{ v.treeString }}</span>
|
||||
</div>
|
||||
<Checkbox :model-value="true" @update:model-value="_ => (selected = selected.filter(s => s.id !== v.id))" />
|
||||
</Label>
|
||||
<hr v-if="selected.length > 0" />
|
||||
<label
|
||||
<Label
|
||||
v-for="v in unselected"
|
||||
:key="v"
|
||||
class="label flex cursor-pointer justify-between px-4 hover:bg-base-200"
|
||||
:key="v.id"
|
||||
class="flex cursor-pointer justify-between px-4 py-2 text-sm hover:bg-base-200"
|
||||
>
|
||||
<span class="label-text mr-2">
|
||||
<slot name="display" v-bind="{ item: v }">
|
||||
{{ v[display] }}
|
||||
</slot>
|
||||
</span>
|
||||
<input v-model="selected" type="checkbox" :value="v" class="checkbox checkbox-primary checkbox-sm" />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div>{{ v.name }}</div>
|
||||
<div v-if="v.treeString && v.treeString !== v.name" class="ml-auto text-xs">{{ v.treeString }}</div>
|
||||
</div>
|
||||
<Checkbox :model-value="false" @update:model-value="_ => (selected = [...selected, v])" />
|
||||
</Label>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 = {
|
||||
label: string;
|
||||
options: any[];
|
||||
display?: string;
|
||||
modelValue: any[];
|
||||
uniqueField: string;
|
||||
options: {
|
||||
name: string;
|
||||
id: string;
|
||||
treeString?: string;
|
||||
}[];
|
||||
modelValue: {
|
||||
name: string;
|
||||
id: string;
|
||||
treeString?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
const btn = ref<HTMLButtonElement>();
|
||||
|
||||
const search = ref("");
|
||||
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 props = withDefaults(defineProps<Props>(), {
|
||||
label: "",
|
||||
display: "name",
|
||||
modelValue: () => [],
|
||||
uniqueField: "id",
|
||||
});
|
||||
|
||||
const len = computed(() => {
|
||||
@@ -86,7 +77,7 @@
|
||||
const selectedView = computed(() => {
|
||||
return selected.value.filter(o => {
|
||||
if (searchFold.value.length > 0) {
|
||||
return o[props.display].toLowerCase().includes(searchFold.value);
|
||||
return o.name.toLowerCase().includes(searchFold.value);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -97,14 +88,9 @@
|
||||
const unselected = computed(() => {
|
||||
return props.options.filter(o => {
|
||||
if (searchFold.value.length > 0) {
|
||||
return (
|
||||
o[props.display].toLowerCase().includes(searchFold.value) &&
|
||||
selected.value.every(s => s[props.uniqueField] !== o[props.uniqueField])
|
||||
);
|
||||
return o.name.toLowerCase().includes(searchFold.value) && selected.value.every(s => s.id !== o.id);
|
||||
}
|
||||
return selected.value.every(s => s[props.uniqueField] !== o[props.uniqueField]);
|
||||
return selected.value.every(s => s.id !== o.id);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
<template>
|
||||
<button @click="copyText">
|
||||
<label
|
||||
class="swap swap-rotate"
|
||||
:class="{
|
||||
'swap-active': copied,
|
||||
}"
|
||||
<Button size="icon" variant="outline" class="relative" @click="copyText">
|
||||
<div
|
||||
:data-copied="copied"
|
||||
class="group absolute inset-0 flex items-center justify-center transition-transform duration-300 data-[copied=true]:rotate-[360deg]"
|
||||
>
|
||||
<MdiContentCopy
|
||||
class="swap-off"
|
||||
class="group-data-[copied=true]:hidden"
|
||||
:style="{
|
||||
height: `${iconSize}px`,
|
||||
width: `${iconSize}px`,
|
||||
}"
|
||||
/>
|
||||
<MdiClipboard
|
||||
class="swap-on"
|
||||
class="hidden group-data-[copied=true]:block"
|
||||
:style="{
|
||||
height: `${iconSize}px`,
|
||||
width: `${iconSize}px`,
|
||||
}"
|
||||
/>
|
||||
</label>
|
||||
<Teleport to="#app">
|
||||
<BaseModal v-model="copyError">
|
||||
<div class="space-y-2">
|
||||
<p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<AlertDialog v-model:open="copyError">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle class="space-y-2">
|
||||
{{ $t("components.global.copy_text.failed_to_copy") }}
|
||||
{{ isNotHttps ? $t("components.global.copy_text.https_required") : "" }}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="text-sm">
|
||||
{{ $t("components.global.copy_text.learn_more") }}
|
||||
<a
|
||||
href="https://homebox.software/en/user-guide/tips-tricks.html#copy-to-clipboard"
|
||||
@@ -38,16 +38,27 @@
|
||||
>
|
||||
{{ $t("components.global.copy_text.documentation") }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</BaseModal></Teleport
|
||||
>
|
||||
</button>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction>Continue</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MdiContentCopy from "~icons/mdi/content-copy";
|
||||
import MdiClipboard from "~icons/mdi/clipboard";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const props = defineProps({
|
||||
text: {
|
||||
|
||||
@@ -14,15 +14,23 @@
|
||||
/>
|
||||
<Currency v-else-if="detail.type == 'currency'" :amount="detail.text" />
|
||||
<template v-else-if="detail.type === 'link'">
|
||||
<div class="tooltip tooltip-top tooltip-primary" :data-tip="detail.href">
|
||||
<a class="btn btn-primary btn-xs" :href="detail.href" target="_blank">
|
||||
<MdiOpenInNew class="swap-on mr-2" />
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<a :href="detail.href" target="_blank" :class="badgeVariants()" class="gap-1">
|
||||
<MdiOpenInNew />
|
||||
{{ detail.text }}
|
||||
</a>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{{ detail.href }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
<template v-else-if="detail.type === 'markdown'">
|
||||
<ClientOnly>
|
||||
<!-- eslint-disable-next-line tailwindcss/no-custom-classname -->
|
||||
<div class="markdown-container w-full overflow-hidden break-words">
|
||||
<Markdown :source="detail.text" />
|
||||
</div>
|
||||
@@ -36,12 +44,7 @@
|
||||
v-if="detail.copyable"
|
||||
class="my-0 ml-4 shrink-0 opacity-0 transition-opacity duration-75 group-hover:opacity-100"
|
||||
>
|
||||
<CopyText
|
||||
v-if="detail.text.toString()"
|
||||
:text="detail.text.toString()"
|
||||
:icon-size="16"
|
||||
class="btn btn-circle btn-ghost btn-xs"
|
||||
/>
|
||||
<CopyText v-if="detail.text.toString()" :text="detail.text.toString()" :icon-size="16" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -55,6 +58,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { AnyDetail, Detail } from "./types";
|
||||
import MdiOpenInNew from "~icons/mdi/open-in-new";
|
||||
import { badgeVariants } from "~/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
defineProps({
|
||||
details: {
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { route } from "../../lib/api/base";
|
||||
import PageQRCode from "./PageQRCode.vue";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import MdiPrinterPos from "~icons/mdi/printer-pos";
|
||||
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<{
|
||||
type: string;
|
||||
id: string;
|
||||
@@ -21,13 +36,8 @@
|
||||
return data;
|
||||
});
|
||||
|
||||
const printModal = ref(false);
|
||||
const serverPrinting = ref(false);
|
||||
|
||||
function openPrint() {
|
||||
printModal.value = true;
|
||||
}
|
||||
|
||||
function browserPrint() {
|
||||
const printWindow = window.open(getLabelUrl(false), "popup=true");
|
||||
|
||||
@@ -50,7 +60,7 @@
|
||||
}
|
||||
|
||||
toast.success("Label printed");
|
||||
printModal.value = false;
|
||||
closeDialog("print-label");
|
||||
serverPrinting.value = false;
|
||||
}
|
||||
|
||||
@@ -80,46 +90,60 @@
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseModal v-model="printModal">
|
||||
<template #title>{{ $t("components.global.label_maker.print") }}</template>
|
||||
<p>
|
||||
{{ $t("components.global.label_maker.confirm_description") }}
|
||||
</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" />
|
||||
<Dialog dialog-id="print-label">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{ $t("components.global.label_maker.print") }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="downloadLabel">
|
||||
<MdiFileDownload name="mdi-file-download" class="mr-2" />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t("components.global.label_maker.confirm_description") }}
|
||||
</DialogDescription>
|
||||
</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") }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -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">
|
||||
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 { 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 {
|
||||
const currentURL = window.location.href;
|
||||
|
||||
// Adjust route import as needed
|
||||
return route(`/qrcode`, { data: encodeURIComponent(currentURL) });
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
<template>
|
||||
<div class="py-4">
|
||||
<p class="text-sm">{{ $t("components.global.password_score.password_strength") }}: {{ message }}</p>
|
||||
<progress
|
||||
class="progress w-full"
|
||||
:value="score"
|
||||
max="100"
|
||||
:class="{
|
||||
'progress-success': score > 50,
|
||||
'progress-warning': score > 25 && score < 50,
|
||||
'progress-error': score < 25,
|
||||
}"
|
||||
/>
|
||||
<Progress class="w-full" :model-value="score" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
const props = defineProps({
|
||||
password: {
|
||||
type: String,
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<template>
|
||||
<div class="stats rounded-md bg-neutral shadow">
|
||||
<div class="stat space-y-1 p-3 text-center text-neutral-content">
|
||||
<div class="stat-title text-neutral-content">{{ title }}</div>
|
||||
<div class="stat-value text-2xl">
|
||||
<Card class="flex flex-col items-center bg-neutral p-3 text-neutral-content shadow">
|
||||
<CardHeader class="p-0">
|
||||
<CardTitle class="text-sm font-medium">{{ title }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0 text-2xl font-bold">
|
||||
<Currency v-if="type === 'currency'" :amount="value" />
|
||||
<template v-if="type === 'number'">{{ value }}</template>
|
||||
</div>
|
||||
<div v-if="subtitle" class="stat-desc">{{ subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter v-if="subtitle">{{ subtitle }}</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { StatsFormat } from "./types";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
|
||||
13
frontend/components/ui/breadcrumb/Breadcrumb.vue
Normal file
13
frontend/components/ui/breadcrumb/Breadcrumb.vue
Normal 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>
|
||||
22
frontend/components/ui/breadcrumb/BreadcrumbEllipsis.vue
Normal file
22
frontend/components/ui/breadcrumb/BreadcrumbEllipsis.vue
Normal 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>
|
||||
16
frontend/components/ui/breadcrumb/BreadcrumbItem.vue
Normal file
16
frontend/components/ui/breadcrumb/BreadcrumbItem.vue
Normal 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>
|
||||
19
frontend/components/ui/breadcrumb/BreadcrumbLink.vue
Normal file
19
frontend/components/ui/breadcrumb/BreadcrumbLink.vue
Normal 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>
|
||||
16
frontend/components/ui/breadcrumb/BreadcrumbList.vue
Normal file
16
frontend/components/ui/breadcrumb/BreadcrumbList.vue
Normal 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>
|
||||
19
frontend/components/ui/breadcrumb/BreadcrumbPage.vue
Normal file
19
frontend/components/ui/breadcrumb/BreadcrumbPage.vue
Normal 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>
|
||||
21
frontend/components/ui/breadcrumb/BreadcrumbSeparator.vue
Normal file
21
frontend/components/ui/breadcrumb/BreadcrumbSeparator.vue
Normal 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>
|
||||
7
frontend/components/ui/breadcrumb/index.ts
Normal file
7
frontend/components/ui/breadcrumb/index.ts
Normal 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'
|
||||
@@ -16,7 +16,12 @@
|
||||
</script>
|
||||
|
||||
<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 />
|
||||
</Primitive>
|
||||
</template>
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex rounded-lg',
|
||||
'[&>*]:rounded-none',
|
||||
'[&>*:first-child]:rounded-l-lg',
|
||||
'[&>*:last-child]:rounded-r-lg',
|
||||
'[&>[data-button=true]]:rounded-none',
|
||||
'[&>[data-button=true]:first-child]:rounded-l-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
|
||||
)
|
||||
"
|
||||
|
||||
21
frontend/components/ui/card/Card.vue
Normal file
21
frontend/components/ui/card/Card.vue
Normal 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>
|
||||
14
frontend/components/ui/card/CardContent.vue
Normal file
14
frontend/components/ui/card/CardContent.vue
Normal 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>
|
||||
14
frontend/components/ui/card/CardDescription.vue
Normal file
14
frontend/components/ui/card/CardDescription.vue
Normal 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>
|
||||
14
frontend/components/ui/card/CardFooter.vue
Normal file
14
frontend/components/ui/card/CardFooter.vue
Normal 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>
|
||||
14
frontend/components/ui/card/CardHeader.vue
Normal file
14
frontend/components/ui/card/CardHeader.vue
Normal 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>
|
||||
18
frontend/components/ui/card/CardTitle.vue
Normal file
18
frontend/components/ui/card/CardTitle.vue
Normal 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>
|
||||
6
frontend/components/ui/card/index.ts
Normal file
6
frontend/components/ui/card/index.ts
Normal 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'
|
||||
33
frontend/components/ui/checkbox/Checkbox.vue
Normal file
33
frontend/components/ui/checkbox/Checkbox.vue
Normal 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>
|
||||
1
frontend/components/ui/checkbox/index.ts
Normal file
1
frontend/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from './Checkbox.vue'
|
||||
@@ -12,7 +12,8 @@ const forwarded = useForwardPropsEmits(props, emits)
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<slot />
|
||||
</Command>
|
||||
|
||||
@@ -22,7 +22,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
<ComboboxInput
|
||||
v-bind="{ ...forwardedProps, ...$attrs }"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { X } from "lucide-vue-next";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
@@ -9,42 +8,71 @@ import {
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
} from "reka-ui";
|
||||
import { computed, type HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
const props = defineProps<
|
||||
DialogContentProps & { class?: HTMLAttributes["class"]; disableClose?: boolean; disablePortal?: boolean }
|
||||
>();
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogPortal v-if="!props.disablePortal">
|
||||
<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
|
||||
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,
|
||||
)"
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<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>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</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>
|
||||
|
||||
22
frontend/components/ui/pagination/PaginationEllipsis.vue
Normal file
22
frontend/components/ui/pagination/PaginationEllipsis.vue
Normal 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>
|
||||
29
frontend/components/ui/pagination/PaginationFirst.vue
Normal file
29
frontend/components/ui/pagination/PaginationFirst.vue
Normal 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>
|
||||
29
frontend/components/ui/pagination/PaginationLast.vue
Normal file
29
frontend/components/ui/pagination/PaginationLast.vue
Normal 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>
|
||||
29
frontend/components/ui/pagination/PaginationNext.vue
Normal file
29
frontend/components/ui/pagination/PaginationNext.vue
Normal 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>
|
||||
29
frontend/components/ui/pagination/PaginationPrev.vue
Normal file
29
frontend/components/ui/pagination/PaginationPrev.vue
Normal 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>
|
||||
10
frontend/components/ui/pagination/index.ts
Normal file
10
frontend/components/ui/pagination/index.ts
Normal 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'
|
||||
36
frontend/components/ui/progress/Progress.vue
Normal file
36
frontend/components/ui/progress/Progress.vue
Normal 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>
|
||||
1
frontend/components/ui/progress/index.ts
Normal file
1
frontend/components/ui/progress/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Progress } from './Progress.vue'
|
||||
15
frontend/components/ui/select/Select.vue
Normal file
15
frontend/components/ui/select/Select.vue
Normal 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>
|
||||
53
frontend/components/ui/select/SelectContent.vue
Normal file
53
frontend/components/ui/select/SelectContent.vue
Normal 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>
|
||||
19
frontend/components/ui/select/SelectGroup.vue
Normal file
19
frontend/components/ui/select/SelectGroup.vue
Normal 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>
|
||||
44
frontend/components/ui/select/SelectItem.vue
Normal file
44
frontend/components/ui/select/SelectItem.vue
Normal 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>
|
||||
11
frontend/components/ui/select/SelectItemText.vue
Normal file
11
frontend/components/ui/select/SelectItemText.vue
Normal 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>
|
||||
13
frontend/components/ui/select/SelectLabel.vue
Normal file
13
frontend/components/ui/select/SelectLabel.vue
Normal 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>
|
||||
24
frontend/components/ui/select/SelectScrollDownButton.vue
Normal file
24
frontend/components/ui/select/SelectScrollDownButton.vue
Normal 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>
|
||||
24
frontend/components/ui/select/SelectScrollUpButton.vue
Normal file
24
frontend/components/ui/select/SelectScrollUpButton.vue
Normal 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>
|
||||
17
frontend/components/ui/select/SelectSeparator.vue
Normal file
17
frontend/components/ui/select/SelectSeparator.vue
Normal 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>
|
||||
31
frontend/components/ui/select/SelectTrigger.vue
Normal file
31
frontend/components/ui/select/SelectTrigger.vue
Normal 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>
|
||||
11
frontend/components/ui/select/SelectValue.vue
Normal file
11
frontend/components/ui/select/SelectValue.vue
Normal 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>
|
||||
11
frontend/components/ui/select/index.ts
Normal file
11
frontend/components/ui/select/index.ts
Normal 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'
|
||||
39
frontend/components/ui/switch/Switch.vue
Normal file
39
frontend/components/ui/switch/Switch.vue
Normal 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>
|
||||
1
frontend/components/ui/switch/index.ts
Normal file
1
frontend/components/ui/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Switch } from './Switch.vue'
|
||||
16
frontend/components/ui/table/Table.vue
Normal file
16
frontend/components/ui/table/Table.vue
Normal 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>
|
||||
14
frontend/components/ui/table/TableBody.vue
Normal file
14
frontend/components/ui/table/TableBody.vue
Normal 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>
|
||||
14
frontend/components/ui/table/TableCaption.vue
Normal file
14
frontend/components/ui/table/TableCaption.vue
Normal 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>
|
||||
21
frontend/components/ui/table/TableCell.vue
Normal file
21
frontend/components/ui/table/TableCell.vue
Normal 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>
|
||||
37
frontend/components/ui/table/TableEmpty.vue
Normal file
37
frontend/components/ui/table/TableEmpty.vue
Normal 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>
|
||||
14
frontend/components/ui/table/TableFooter.vue
Normal file
14
frontend/components/ui/table/TableFooter.vue
Normal 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>
|
||||
14
frontend/components/ui/table/TableHead.vue
Normal file
14
frontend/components/ui/table/TableHead.vue
Normal 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>
|
||||
14
frontend/components/ui/table/TableHeader.vue
Normal file
14
frontend/components/ui/table/TableHeader.vue
Normal 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>
|
||||
14
frontend/components/ui/table/TableRow.vue
Normal file
14
frontend/components/ui/table/TableRow.vue
Normal 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>
|
||||
9
frontend/components/ui/table/index.ts
Normal file
9
frontend/components/ui/table/index.ts
Normal 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'
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
export type ViewType = "table" | "card" | "tree";
|
||||
@@ -11,7 +11,7 @@ export type LocationViewPreferences = {
|
||||
itemDisplayView: ViewType;
|
||||
theme: DaisyTheme;
|
||||
itemsPerTablePage: number;
|
||||
tableHeaders?: TableHeader[];
|
||||
tableHeaders?: TableHeaderType[];
|
||||
displayHeaderDecor: boolean;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("maybeURL works as expected", () => {
|
||||
const result = maybeUrl("https://example.com");
|
||||
expect(result.isUrl).toBe(true);
|
||||
expect(result.url).toBe("https://example.com");
|
||||
expect(result.text).toBe("Link");
|
||||
expect(result.text).toBe("https://example.com");
|
||||
});
|
||||
|
||||
test("special URL syntax", () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function maybeUrl(str: string): MaybeUrlResult {
|
||||
}
|
||||
} else {
|
||||
result.url = str;
|
||||
result.text = "Link";
|
||||
result.text = str;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader class="items-center bg-base-200">
|
||||
<SidebarGroupLabel class="text-base">{{ $t("global.welcome", { username: username }) }}</SidebarGroupLabel>
|
||||
<NuxtLink class="avatar placeholder group-data-[collapsible=icon]:hidden" to="/home">
|
||||
<div class="w-24 rounded-full bg-base-300 p-4 text-neutral-content">
|
||||
<NuxtLink class="group-data-[collapsible=icon]:hidden" to="/home">
|
||||
<div class="flex size-24 items-center justify-center rounded-full bg-base-300 p-4 text-neutral-content">
|
||||
<AppLogo />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
@@ -33,7 +33,7 @@
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-[var(--reka-dropdown-menu-trigger-width)]">
|
||||
<DropdownMenuContent class="z-40 min-w-[var(--reka-dropdown-menu-trigger-width)]">
|
||||
<DropdownMenuItem
|
||||
v-for="btn in dropdown"
|
||||
:key="btn.id"
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"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.",
|
||||
"description": "Import a CSV file containing your items, labels, and locations. See documentation for more information on the \nrequired format.",
|
||||
"title": "Import CSV File",
|
||||
"upload": "Upload"
|
||||
"title": "Import CSV File"
|
||||
},
|
||||
"outdated": {
|
||||
"current_version": "Current Version",
|
||||
@@ -55,7 +54,8 @@
|
||||
"titles": "Labels"
|
||||
},
|
||||
"page_qr_code": {
|
||||
"page_url": "Page URL"
|
||||
"page_url": "Page URL",
|
||||
"qr_tooltip": "Show QR Code"
|
||||
},
|
||||
"password_score": {
|
||||
"password_strength": "Password Strength"
|
||||
@@ -78,7 +78,9 @@
|
||||
},
|
||||
"table": {
|
||||
"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",
|
||||
"value": "Value",
|
||||
"version": "Version: { version }",
|
||||
"welcome": "Welcome, { username }"
|
||||
"welcome": "Welcome, { username }",
|
||||
"insured": "Insured",
|
||||
"archived": "Archived",
|
||||
"quantity": "Quantity"
|
||||
},
|
||||
"home": {
|
||||
"labels": "Labels",
|
||||
@@ -279,6 +284,7 @@
|
||||
"locations": {
|
||||
"child_locations": "Child Locations",
|
||||
"collapse_tree": "Collapse Tree",
|
||||
"expand_tree": "Expand Tree",
|
||||
"no_results": "No Locations Found",
|
||||
"update_location": "Update Location"
|
||||
},
|
||||
@@ -359,6 +365,7 @@
|
||||
"user_profile_sub": "Invite users, and manage your account."
|
||||
},
|
||||
"scanner": {
|
||||
"title": "Scanner",
|
||||
"error": "An error occurred while scanning",
|
||||
"invalid_url": "Invalid barcode URL",
|
||||
"no_sources": "No video sources available",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user