migrate pages to shadcn (#628)

* feat: migrate tools page and label generator to shadcn

* chore: lint issues

* feat: also do profile page

* feat: shadcn 404 page

* feat: login page shadcn

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

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

* feat: use nuxtlink

* feat: card and table made more shadcn

* feat: shadcn statscard

* chore: lint

* feat: shadcn labelchip and locationcard

* feat: shadcn locations page

* refactor: remove unused new item page

* chore: lint

* feat: shadcn item card

* fix: wrapping of location and lint

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

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

* chore: lint + remove unused code

* fix: remove uneeded margin

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

* feat: shadcn scanner

* chore: lint

* feat: begin shadcning item pages

* feat: shadcn maintenance page

* feat: begin shadcn search page

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

* feat: finish shadcn search page

* chore: lint

* feat: shadcn edit item page

* fix: quickmenumodal bug

* feat: shadcn item details page

* feat: remove all non-color related daisyui classes

* fix: type error

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

View File

@@ -29,7 +29,7 @@ module.exports = {
"vue/no-v-html": 0,
"@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",
{

View File

@@ -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;

View File

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

View File

@@ -1,11 +1,12 @@
<template>
<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.");

View File

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

View File

@@ -7,7 +7,12 @@
<p>{{ $t("components.app.outdated.current_version") }}: {{ current }}</p>
<p>{{ $t("components.app.outdated.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: {

View File

@@ -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);
}
"

View File

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

View File

@@ -1,13 +1,12 @@
<template>
<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;

View File

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

View File

@@ -1,16 +1,16 @@
<template>
<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,

View File

@@ -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>

View File

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

View File

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

View File

@@ -1,21 +1,22 @@
<template>
<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>

View File

@@ -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({

View File

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

View File

@@ -1,19 +1,26 @@
<template>
<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;

View File

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

View File

@@ -3,37 +3,39 @@
<Label :for="id" class="flex w-full px-1">
<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>

View File

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

View File

@@ -9,13 +9,29 @@
<MdiPaperclip class="size-5 shrink-0 text-gray-400" aria-hidden="true" />
<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: {

View File

@@ -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();

View File

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

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
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>

View File

@@ -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>

View File

@@ -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 });

View File

@@ -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

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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>

View File

@@ -1,28 +1,40 @@
<template>
<div class="dropdown dropdown-left">
<slot>
<label tabindex="0" class="btn btn-circle btn-sm">
<MdiQrcode />
</label>
</slot>
<div tabindex="0" class="card dropdown-content compact rounded-box w-64 bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="text-center">{{ $t("components.global.page_qr_code.page_url") }}</h2>
<img :src="getQRCodeUrl()" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
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>

View File

@@ -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,

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,12 @@
</script>
<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>

View File

@@ -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
)
"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,8 @@ const forwarded = useForwardPropsEmits(props, emits)
<template>
<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>

View File

@@ -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 }"

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import type { Ref } from "vue";
import type { 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;
};

View File

@@ -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", () => {

View File

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

View File

@@ -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"

View File

@@ -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