Switch layouts to use shadcn (#507)

* feat: begin switching sonner, currently this breaks all alerts

* feat: switch to using new sonner and fix class names

* feat: add Shortcut component for improved keyboard shortcuts display in default layout

* feat: rewrite quick menu modal in shadcn

* feat: update QuickMenu modal placeholders and localize no results message in default layout

* feat: begin switching modals in layout to use shadcn dialog, needs bug fixing

* feat: implement DialogProvider for consistent dialog management across components

* fix: types

* feat: begin adding shadcn label selector (wip)

* feat: shadcnify textarea

* feat: begin adding location selector

* feat: add hotkey support for opening create modals in dialog provider components

* fix: update click event on NuxtLink and reorder sidebar menu item IDs for consistency

* feat: unify shortcut text across create modals and sort issue with text centring

* feat: prevent dialog from opening when a dialog alert is open

* fix: prevent potential out of bounds error

* feat: enhance button group UI in create modals for better layout and introduce new item photo label in the form

* fix: search on label selector

* chore: lint

* fix: oops

* feat: make selector usable

* feat: add actual data to label selector

* feat: label selector kinda works

* fix: add legacy selector for edit page

* fix: enable camera capture in image upload for CreateModal component

* fix: z levels for sidebar mobile

* fix: gap between inputs

* feat: update radix-vue, custom search function for location selector

* feat: add fuzzysort (can always remove it and go to lunr if we want to)

* feat: limit label name to 50 characters in create modal and selector, helps with issues with ui not working with larger labels, as it is only enforced on the frontend could be easily bypassed but thats a them problem

* feat: add colours to toast

* chore: lint

* feat: abstract the dialog for creation modals

* feat: add drawer component and responsive dialog for create modals

* feat: enhance photo preview in CreateModal

* fix: remember state of sidebar

* feat: add ui functionality for changing primary image

* feat: use button for file upload

* style: lint

* fix: dont clone asset id

* fix: using create and add label breaks selector

* chore: oops remove logging

* chore: lint

* fix: cut length of label dramatically to ensure maximal compatibility, not sure if too much

* fix: more limiting of label length

* feat: update reka-ui (prev radix-vue)

* chore: cleanup dialog provider and siebar provider a bit

* fix: improve accessibility

* fix: docs for shadcn error

* fix: hack to prevent issues with lots of toasts in quick succession

* feat: cleanup toast file and lint

* feat: improvements to dialog scroll and disable the ability to set default photo for now

* feat: add tooltips for photo buttons

* chore: substring to length check

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Tonya
2025-04-11 11:02:49 +01:00
committed by GitHub
parent 964d3264cc
commit 9ff4b32db0
144 changed files with 2375 additions and 883 deletions

View File

@@ -26,6 +26,7 @@ export default [
text: 'Contributing',
items: [
{text: 'Get Started', link: '/en/contribute/get-started'},
{text: 'Switching to Shadcn-vue', link: '/en/contribute/shadcn'},
{text: 'Bounty Program', link: '/en/contribute/bounty'}
]
},

View File

@@ -52,9 +52,9 @@ When modifying components, follow these best practices:
During the migration process, you can test without DaisyUI using these commands:
```bash
DISABLE_DAISYUI=true; task ui:dev
export DISABLE_DAISYUI=true; task ui:dev
```
or
```bash
DISABLE_DAISYUI=true; task ui:fix
export DISABLE_DAISYUI=true; task ui:fix
```

View File

@@ -1,17 +1,25 @@
<template>
<NuxtLayout>
<Html :lang="locale" :data-theme="theme || 'homebox'" />
<Link rel="icon" type="image/svg" href="/favicon.svg"></Link>
<Link rel="apple-touch-icon" href="/apple-touch-icon.png" size="180x180" />
<Link rel="mask-icon" href="/mask-icon.svg" color="#5b7f67" />
<Meta name="theme-color" content="#5b7f67" />
<Link rel="manifest" href="/manifest.webmanifest" />
<NuxtPage />
</NuxtLayout>
<DialogProvider>
<ClientOnly>
<Toaster class="pointer-events-auto" />
</ClientOnly>
<NuxtLayout>
<Html :lang="locale" :data-theme="theme || 'homebox'" />
<Link rel="icon" type="image/svg" href="/favicon.svg"></Link>
<Link rel="apple-touch-icon" href="/apple-touch-icon.png" size="180x180" />
<Link rel="mask-icon" href="/mask-icon.svg" color="#5b7f67" />
<Meta name="theme-color" content="#5b7f67" />
<Link rel="manifest" href="/manifest.webmanifest" />
<NuxtPage />
</NuxtLayout>
</DialogProvider>
</template>
<script lang="ts" setup>
import { useI18n } from "vue-i18n";
import { DialogProvider } from "@/components/ui/dialog-provider";
import { Toaster } from "@/components/ui/sonner";
const { theme } = useTheme();

View File

@@ -2,7 +2,6 @@
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"typescript": true,
"tsConfigPath": "tsconfig.json",
"tailwind": {
"config": "tailwind.config.js",
"css": "assets/css/main.css",
@@ -10,7 +9,6 @@
"cssVariables": true,
"prefix": ""
},
"framework": "nuxt",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"

View File

@@ -0,0 +1,43 @@
<template>
<Dialog v-if="isDesktop" :dialog-id="dialogId">
<DialogScrollContent>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
</DialogHeader>
<slot />
<DialogFooter>
<span class="flex items-center gap-1 text-sm">
Use <Shortcut size="sm" :keys="['Shift']" /> + <Shortcut size="sm" :keys="['Enter']" /> to create and add
another.
</span>
</DialogFooter>
</DialogScrollContent>
</Dialog>
<Drawer v-else :dialog-id="dialogId">
<DrawerContent class="max-h-[80%]">
<DrawerHeader>
<DrawerTitle>{{ title }}</DrawerTitle>
</DrawerHeader>
<div class="m-2 overflow-y-auto">
<slot />
</div>
</DrawerContent>
</Drawer>
</template>
<script setup lang="ts">
import { useMediaQuery } from "@vueuse/core";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { Dialog, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
const isDesktop = useMediaQuery("(min-width: 768px)");
defineProps<{
dialogId: string;
title: string;
}>();
</script>

View File

@@ -46,6 +46,7 @@
</template>
<script setup lang="ts">
import { toast } from "@/components/ui/sonner";
import MdiUpload from "~icons/mdi/upload";
type Props = {
modelValue: boolean;
@@ -60,7 +61,6 @@
const dialog = useVModel(props, "modelValue", emit);
const api = useUserApi();
const toast = useNotifier();
const importCsv = ref<File | null>(null);
const importLoading = ref(false);

View File

@@ -1,41 +1,86 @@
<template>
<BaseModal v-model="modal">
<template #title>🎉 {{ $t("components.app.outdated.new_version_available") }} 🎉</template>
<div class="p-4">
<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">
{{ $t("components.app.outdated.new_version_available_link") }}
</a>
</p>
</div>
<button class="btn btn-warning" @click="hide">
{{ $t("components.app.outdated.dismiss") }}
</button>
</BaseModal>
<AlertDialog v-model:open="open">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>🎉 {{ $t("components.app.outdated.new_version_available") }} 🎉</AlertDialogTitle>
<AlertDialogDescription>
<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">
{{ $t("components.app.outdated.new_version_available_link") }}
</a>
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction @click="hide">{{ $t("components.app.outdated.dismiss") }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
current: {
type: String,
required: true,
},
latest: {
type: String,
required: true,
},
});
import { lt } from "semver";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
} from "~/components/ui/alert-dialog";
import { useDialog } from "~/components/ui/dialog-provider";
const modal = useVModel(props, "modelValue");
const props = defineProps<{
status: {
build: {
version: string;
};
latest: {
version: string;
};
};
}>();
const latest = computed(() => props.status.latest.version);
const current = computed(() => props.status.build.version);
const isDev = computed(() => import.meta.dev || !current.value?.includes("."));
const isOutdated = computed(() => current.value && latest.value && lt(current.value, latest.value));
const hasHiddenLatest = computed(() => localStorage.getItem("latestVersion") === latest.value);
const displayOutdatedWarning = computed(() => Boolean(!isDev.value && !hasHiddenLatest.value && isOutdated.value));
const open = ref(false);
watch(
displayOutdatedWarning,
displayOutdatedWarning => {
if (displayOutdatedWarning) {
open.value = true;
}
},
{ immediate: true }
);
const hide = () => {
modal.value = false;
localStorage.setItem("latestVersion", props.latest);
open.value = false;
localStorage.setItem("latestVersion", latest.value);
};
const { addAlert, removeAlert } = useDialog();
watch(
open,
val => {
if (val) {
addAlert("new-version-modal");
} else {
removeAlert("new-version-modal");
}
},
{ immediate: true }
);
</script>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
} from "~/components/ui/command";
import { Shortcut } from "~/components/ui/shortcut";
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
export type QuickMenuAction =
| { text: string; href: string; type: "navigate" }
| { text: string; dialogId: string; shortcut: string; type: "create" };
const props = defineProps({
actions: {
type: Array as PropType<QuickMenuAction[]>,
required: false,
default: () => [],
},
});
const { t } = useI18n();
const { closeDialog, openDialog } = useDialog();
useDialogHotkey("quick-menu", { code: "Backquote", ctrl: true });
</script>
<template>
<CommandDialog dialog-id="quick-menu">
<CommandInput
:placeholder="t('components.quick_menu.shortcut_hint')"
@keydown="
(e: KeyboardEvent) => {
const item = props.actions.filter(item => 'shortcut' in item).find(item => item.shortcut === e.key);
if (item) {
openDialog(item.dialogId);
}
}
"
/>
<CommandList>
<CommandEmpty>{{ t("components.quick_menu.no_results") }}</CommandEmpty>
<CommandGroup :heading="t('global.create')">
<CommandItem
v-for="(create, i) in props.actions.filter(item => item.type === 'create')"
:key="`$global.create_${i + 1}`"
:value="create.text"
@select="
() => {
openDialog(create.dialogId);
}
"
>
{{ create.text }}
<Shortcut v-if="'shortcut' in create" class="ml-auto" size="sm" :keys="[create.shortcut]" />
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup :heading="t('global.navigate')">
<CommandItem
v-for="(navigate, i) in props.actions.filter(item => item.type === 'navigate')"
:key="navigate.text"
:value="`global.navigate_${i + 1}`"
@select="
() => {
closeDialog('quick-menu');
navigateTo(navigate.href);
}
"
>
{{ navigate.text }}
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</template>

View File

@@ -1,58 +0,0 @@
<template>
<div class="fixed right-2 top-2 z-[9999] w-[300px]">
<TransitionGroup name="notify" tag="div">
<div
v-for="(notify, index) in notifications.slice(0, 4)"
:key="notify.id"
class="my-2 w-[300px] rounded-md p-3 text-sm text-white"
:class="{
'bg-primary': notify.type === 'info',
'bg-red-600': notify.type === 'error',
'bg-green-600': notify.type === 'success',
}"
@click="dropNotification(index)"
>
<div class="flex gap-1">
<template v-if="notify.type == 'success'">
<MdiCheckboxMarkedCircle class="size-5" />
</template>
<template v-if="notify.type == 'info'">
<MdiInformationSlabCircle class="size-5" />
</template>
<template v-if="notify.type == 'error'">
<MdiAlert class="size-5" />
</template>
{{ notify.message }}
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import MdiCheckboxMarkedCircle from "~icons/mdi/checkbox-marked-circle";
import MdiInformationSlabCircle from "~icons/mdi/information-slab-circle";
import MdiAlert from "~icons/mdi/alert";
import { useNotifications } from "@/composables/use-notifier";
const { notifications, dropNotification } = useNotifications();
</script>
<style scoped>
.notify-move,
.notify-enter-active,
.notify-leave-active {
transition: all 0.5s ease;
}
.notify-enter-from,
.notify-leave-to {
opacity: 0;
transform: translateY(-30px);
}
.notify-leave-active {
position: absolute;
transform: translateY(30px);
}
</style>

View File

@@ -47,6 +47,7 @@
</template>
<script lang="ts" setup>
import { toast } from "@/components/ui/sonner";
import MdiClose from "~icons/mdi/close";
const emit = defineEmits(["update:modelValue"]);
@@ -101,7 +102,6 @@
}
const api = useUserApi();
const toast = useNotifier();
async function createAndAdd(name: string) {
const { error, data } = await api.labels.create({

View File

@@ -3,7 +3,7 @@
<FormTextField v-model="value" placeholder="Password" :label="label" :type="inputType"> </FormTextField>
<button
type="button"
class="tooltip absolute right-3 top-11 mb-3 ml-1 mt-auto inline-flex justify-center p-1"
class="tooltip absolute right-3 top-6 mb-3 ml-1 mt-auto inline-flex justify-center p-1"
data-tip="Toggle Password Show"
@click="toggle()"
>

View File

@@ -1,7 +1,8 @@
<template>
<div v-if="!inline" class="form-control w-full">
<label class="label">
<span class="label-text">{{ label }}</span>
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
<Label :for="id" class="flex w-full px-1">
<span>{{ label }}</span>
<span class="grow"></span>
<span
:class="{
'text-red-600':
@@ -11,12 +12,13 @@
>
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
</span>
</label>
<textarea ref="el" v-model="value" class="textarea textarea-bordered h-28 w-full" :placeholder="placeholder" />
</Label>
<Textarea :id="id" v-model="value" :placeholder="placeholder" class="min-h-[112px] w-full resize-none" />
</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 :for="id" class="flex w-full px-1 py-2">
<span>{{ label }}</span>
<span class="grow"></span>
<span
:class="{
'text-red-600':
@@ -26,32 +28,23 @@
>
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
</span>
</label>
<textarea
ref="el"
v-model="value"
class="textarea textarea-bordered col-span-3 mt-3 h-28 w-full"
auto-grow
:placeholder="placeholder"
auto-height
/>
</Label>
<Textarea :id="id" v-model="value" autosize :placeholder="placeholder" class="col-span-3 mt-2 w-full resize-none" />
</div>
</template>
<script lang="ts" setup>
const emit = defineEmits(["update:modelValue"]);
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
const props = defineProps({
modelValue: {
type: [String],
required: true,
},
label: {
type: String,
required: true,
},
type: {
modelValue: {
type: String,
default: "text",
required: true,
},
placeholder: {
type: String,
@@ -73,17 +66,6 @@
},
});
const el = ref();
function setHeight() {
el.value.style.height = "auto";
el.value.style.height = el.value.scrollHeight + 5 + "px";
}
onUpdated(() => {
if (props.inline) {
setHeight();
}
});
const value = useVModel(props, "modelValue", emit);
const id = useId();
const value = useVModel(props, "modelValue");
</script>

View File

@@ -1,7 +1,8 @@
<template>
<div v-if="!inline" class="form-control w-full">
<label class="label">
<span class="label-text"> {{ label }} </span>
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
<Label :for="id" class="flex w-full px-1">
<span> {{ label }} </span>
<span class="grow"></span>
<span
:class="{
'text-red-600':
@@ -11,19 +12,21 @@
>
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
</span>
</label>
<input
</Label>
<Input
:id="id"
ref="input"
v-model="value"
:placeholder="placeholder"
:type="type"
:required="required"
class="input input-bordered w-full"
class="w-full"
/>
</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 class="flex w-full px-1 py-2" :for="id">
<span> {{ label }} </span>
<span class="grow"></span>
<span
:class="{
'text-red-600':
@@ -33,18 +36,21 @@
>
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
</span>
</label>
<input
</Label>
<Input
:id="id"
v-model="value"
:placeholder="placeholder"
:type="type"
:required="required"
class="input input-bordered col-span-3 mt-2 w-full"
class="col-span-3 mt-2 w-full"
/>
</div>
</template>
<script lang="ts" setup>
import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input";
const props = defineProps({
label: {
type: String,
@@ -86,6 +92,8 @@
},
});
const id = useId();
const input = ref<HTMLElement | null>(null);
whenever(

View File

@@ -1,7 +1,6 @@
<template>
<BaseModal v-model="modal">
<template #title> {{ $t("components.item.create_modal.title") }} </template>
<form @submit.prevent="create()">
<BaseModal dialog-id="create-item" :title="$t('components.item.create_modal.title')">
<form class="flex flex-col gap-2" @submit.prevent="create()">
<LocationSelector v-model="form.location" />
<FormTextField
ref="nameInput"
@@ -17,93 +16,125 @@
:label="$t('components.item.create_modal.item_description')"
:max-length="1000"
/>
<FormMultiselect v-model="form.labels" :label="$t('global.labels')" :items="labels ?? []" />
<div class="modal-action mb-6">
<div>
<label for="photo" class="btn">{{ $t("components.item.create_modal.photo_button") }}</label>
<input
id="photo"
class="hidden"
<LabelSelector v-model="form.labels" :labels="labels ?? []" />
<div class="flex w-full flex-col gap-1.5">
<Label for="image-create-photo" class="flex w-full px-1">
{{ $t("components.item.create_modal.item_photo") }}
</Label>
<div class="relative inline-block">
<Button type="button" variant="outline" class="w-full" aria-hidden="true" @click.prevent="">
{{ $t("components.item.create_modal.upload_photos") }}
</Button>
<Input
id="image-create-photo"
ref="fileInput"
class="absolute left-0 top-0 size-full cursor-pointer opacity-0"
type="file"
accept="image/png,image/jpeg,image/gif,image/avif,image/webp"
accept="image/png,image/jpeg,image/gif,image/avif,image/webp;capture=camera"
multiple
@change="previewImage"
/>
</div>
<div class="grow"></div>
<div>
<BaseButton class="rounded-r-none" :loading="loading" type="submit">
<template #icon>
<MdiPackageVariant class="swap-off size-5" />
<MdiPackageVariantClosed class="swap-on size-5" />
</template>
</div>
<div class="mt-4 flex flex-row-reverse">
<ButtonGroup>
<Button :disabled="loading" type="submit" class="group">
<div class="relative mx-2">
<div
class="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-hover:rotate-[360deg]"
>
<MdiPackageVariant class="size-5 group-hover:hidden" />
<MdiPackageVariantClosed class="hidden size-5 group-hover:block" />
</div>
</div>
{{ $t("global.create") }}
</BaseButton>
<div class="dropdown dropdown-top">
<label tabindex="0" class="btn rounded-l-none rounded-r-xl">
<MdiChevronDown class="size-5" name="mdi-chevron-down" />
</label>
<ul tabindex="0" class="dropdown-content menu rounded-box right-0 w-64 bg-base-100 p-2 shadow">
<li>
<button type="button" @click="create(false)">{{ $t("global.create_and_add") }}</button>
</li>
</ul>
</div>
</div>
</Button>
<Button variant="outline" :disabled="loading" type="button" @click="create(false)">
{{ $t("global.create_and_add") }}
</Button>
</ButtonGroup>
</div>
<!-- photo preview area is AFTER the create button, to avoid pushing the button below the screen on small displays -->
<div class="border-t border-gray-300 px-4 pb-4">
<div v-if="form.photos.length > 0" class="mt-4 border-t border-gray-300 px-4 pb-4">
<div v-for="(photo, index) in form.photos" :key="index">
<div class="indicator mt-8 w-auto">
<div class="indicator-item right-2 top-2">
<button type="button" class="btn btn-circle btn-primary btn-md" @click="deleteImage(index)">
<MdiDelete class="size-5" />
</button>
</div>
<div class="mt-8 w-full">
<img
:src="photo.fileBase64"
class="w-full rounded-t border-gray-300 object-fill shadow-sm"
class="w-full rounded border-gray-300 object-fill shadow-sm"
alt="Uploaded Photo"
/>
</div>
<p class="mt-1 text-sm" style="overflow-wrap: anywhere">File name: {{ photo.photoName }}</p>
<div class="mt-2 flex items-center gap-2">
<TooltipProvider class="flex gap-2" :delay-duration="0">
<Tooltip>
<TooltipTrigger>
<Button size="icon" type="button" variant="destructive" @click.prevent="deleteImage(index)">
<MdiDelete />
<div class="sr-only">Delete photo</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete photo</p>
</TooltipContent>
</Tooltip>
<!-- TODO: re-enable when we have a way to set primary photos -->
<!-- <Tooltip>
<TooltipTrigger>
<Button
size="icon"
type="button"
:variant="photo.primary ? 'default' : 'outline'"
@click.prevent="setPrimary(index)"
>
<MdiStar v-if="photo.primary" />
<MdiStarOutline v-else />
<div class="sr-only">Set as {{ photo.primary ? "non" : "" }} primary photo</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Set as {{ photo.primary ? "non" : "" }} primary photo</p>
</TooltipContent>
</Tooltip> -->
</TooltipProvider>
<p class="mt-1 text-sm" style="overflow-wrap: anywhere">{{ photo.photoName }}</p>
</div>
</div>
</div>
</form>
<p class="mt-4 text-center text-sm">
use <kbd class="kbd kbd-xs">Shift</kbd> + <kbd class="kbd kbd-xs"> Enter </kbd> to create and add another
</p>
</BaseModal>
</template>
<script setup lang="ts">
import type { ItemCreate, LabelOut, LocationOut } from "~~/lib/api/types/data-contracts";
import { toast } from "@/components/ui/sonner";
import { Button, ButtonGroup } from "~/components/ui/button";
import BaseModal from "@/components/App/CreateModal.vue";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import type { ItemCreate, LocationOut } from "~~/lib/api/types/data-contracts";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
import MdiPackageVariant from "~icons/mdi/package-variant";
import MdiPackageVariantClosed from "~icons/mdi/package-variant-closed";
import MdiChevronDown from "~icons/mdi/chevron-down";
import MdiDelete from "~icons/mdi/delete";
// import MdiStarOutline from "~icons/mdi/star-outline";
// import MdiStar from "~icons/mdi/star";
import { AttachmentTypes } from "~~/lib/api/types/non-generated";
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
import LabelSelector from "~/components/Label/Selector.vue";
interface PhotoPreview {
photoName: string;
file: File;
fileBase64: string;
primary: boolean;
}
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
});
const { activeDialog, closeDialog } = useDialog();
useDialogHotkey("create-item", { code: "Digit1", shift: true });
const api = useUserApi();
const toast = useNotifier();
const locationsStore = useLocationStore();
const locations = computed(() => locationsStore.allLocations);
@@ -129,15 +160,14 @@
const nameInput = ref<HTMLInputElement | null>(null);
const modal = useVModel(props, "modelValue");
const loading = ref(false);
const focused = ref(false);
const form = reactive({
location: locations.value && locations.value.length > 0 ? locations.value[0] : ({} as LocationOut),
name: "",
description: "",
color: "", // Future!
labels: [] as LabelOut[],
color: "",
labels: [] as string[],
photos: [] as PhotoPreview[],
});
@@ -147,48 +177,56 @@
form.photos.splice(index, 1);
}
// TODO: actually set the primary when adding item
// function setPrimary(index: number) {
// const primary = form.photos.findIndex(p => p.primary);
// if (primary !== -1) form.photos[primary].primary = false;
// if (primary !== index) form.photos[index].primary = true;
// toast.error("Currently this does not do anything, the first photo will always be primary");
// }
function previewImage(event: Event) {
const input = event.target as HTMLInputElement;
// We support uploading multiple files at once, so build up the list of files to preview and upload
if (input.files && input.files.length > 0) {
for (const file of input.files) {
const reader = new FileReader();
reader.onload = e => {
form.photos.push({ photoName: file.name, fileBase64: e.target?.result as string, file });
form.photos.push({
photoName: file.name,
fileBase64: e.target?.result as string,
file,
primary: form.photos.length === 0,
});
};
reader.readAsDataURL(file);
}
input.value = "";
}
}
watch(
() => modal.value,
open => {
if (open) {
useTimeoutFn(() => {
focused.value = true;
}, 50);
() => activeDialog.value,
active => {
if (active === "create-item") {
if (locationId.value) {
const found = locations.value.find(l => l.id === locationId.value);
if (found) {
form.location = found;
}
}
if (labelId.value) {
form.labels = labels.value.filter(l => l.id === labelId.value);
form.labels = labels.value.filter(l => l.id === labelId.value).map(l => l.id);
}
} else {
focused.value = false;
}
}
);
async function create(close = true) {
if (!form.location) {
if (!form.location?.id) {
toast.error("Please select a location.");
return;
}
@@ -199,20 +237,18 @@
loading.value = true;
if (shift.value) {
close = false;
}
if (shift.value) close = false;
const out: ItemCreate = {
parentId: null,
name: form.name,
description: form.description,
locationId: form.location.id as string,
labelIds: form.labels.map(l => l.id) as string[],
labelIds: form.labels,
};
const { error, data } = await api.items.create(out);
loading.value = false;
if (error) {
loading.value = false;
toast.error("Couldn't create item");
@@ -221,30 +257,40 @@
toast.success("Item created");
// If the photo was provided, upload it
// NOTE: This is not transactional. It's entirely possible for some of the photos to successfully upload and the rest to fail, which will result in missing photos
for (const photo of form.photos) {
const { error } = await api.items.attachments.add(data.id, photo.file, photo.photoName, AttachmentTypes.Photo);
if (form.photos.length > 0) {
toast.info(`Uploading ${form.photos.length} photo(s)...`);
let uploadError = false;
for (const photo of form.photos) {
const { error: attachError } = await api.items.attachments.add(
data.id,
photo.file,
photo.photoName,
AttachmentTypes.Photo
);
if (error) {
loading.value = false;
toast.error("Failed to upload Photo " + photo.photoName);
return;
if (attachError) {
uploadError = true;
toast.error(`Failed to upload Photo: ${photo.photoName}`);
console.error(attachError);
}
}
if (uploadError) {
toast.warning("Some photos failed to upload.");
} else {
toast.success("All photos uploaded successfully.");
}
toast.success("Photo uploaded");
}
// Reset
form.name = "";
form.description = "";
form.color = "";
form.photos = [];
form.labels = [];
focused.value = false;
loading.value = false;
if (close) {
modal.value = false;
closeDialog("create-item");
navigateTo(`/item/${data.id}`);
}
}

View File

@@ -37,6 +37,6 @@
<MdiArrowRight class="swap-on mr-2" />
<MdiTagOutline class="swap-off mr-2" />
</label>
{{ label.name }}
{{ label.name.length > 20 ? `${label.name.substring(0, 20)}...` : label.name }}
</NuxtLink>
</template>

View File

@@ -1,14 +1,12 @@
<template>
<BaseModal v-model="modal">
<template #title>{{ $t("components.label.create_modal.title") }}</template>
<form @submit.prevent="create()">
<BaseModal dialog-id="create-label" :title="$t('components.label.create_modal.title')">
<form class="flex flex-col gap-2" @submit.prevent="create()">
<FormTextField
ref="locationNameRef"
v-model="form.name"
:trigger-focus="focused"
:autofocus="true"
:label="$t('components.label.create_modal.label_name')"
:max-length="255"
:max-length="50"
:min-length="1"
/>
<FormTextArea
@@ -16,38 +14,27 @@
:label="$t('components.label.create_modal.label_description')"
:max-length="255"
/>
<div class="modal-action">
<div class="flex justify-center">
<BaseButton class="rounded-r-none" :loading="loading" type="submit"> {{ $t("global.create") }} </BaseButton>
<div class="dropdown dropdown-top">
<label tabindex="0" class="btn rounded-l-none rounded-r-xl">
<MdiChevronDown class="size-5" />
</label>
<ul tabindex="0" class="dropdown-content menu rounded-box right-0 w-64 bg-base-100 p-2 shadow">
<li>
<button type="button" @click="create(false)">{{ $t("global.create_and_add") }}</button>
</li>
</ul>
</div>
</div>
<div class="mt-4 flex flex-row-reverse">
<ButtonGroup>
<Button :disabled="loading" type="submit">{{ $t("global.create") }}</Button>
<Button variant="outline" :disabled="loading" type="button" @click="create(false)">
{{ $t("global.create_and_add") }}
</Button>
</ButtonGroup>
</div>
</form>
<p class="mt-4 text-center text-sm">
use <kbd class="kbd kbd-xs">Shift</kbd> + <kbd class="kbd kbd-xs"> Enter </kbd> to create and add another
</p>
</BaseModal>
</template>
<script setup lang="ts">
import MdiChevronDown from "~icons/mdi/chevron-down";
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
});
import { toast } from "@/components/ui/sonner";
import BaseModal from "@/components/App/CreateModal.vue";
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
const { closeDialog } = useDialog();
useDialogHotkey("create-label", { code: "Digit2", shift: true });
const modal = useVModel(props, "modelValue");
const loading = ref(false);
const focused = ref(false);
const form = reactive({
@@ -64,20 +51,7 @@
loading.value = false;
}
watch(
() => modal.value,
open => {
if (open)
useTimeoutFn(() => {
focused.value = true;
}, 50);
else focused.value = false;
}
);
const api = useUserApi();
const toast = useNotifier();
const { shift } = useMagicKeys();
async function create(close = true) {
@@ -85,13 +59,17 @@
toast.error("Already creating a label");
return;
}
loading.value = true;
if (shift.value) {
close = false;
if (form.name.length > 50) {
toast.error("Label name must not be longer than 50 characters");
return;
}
loading.value = true;
if (shift.value) close = false;
const { error, data } = await api.labels.create(form);
if (error) {
toast.error("Couldn't create label");
loading.value = false;
@@ -102,7 +80,7 @@
reset();
if (close) {
modal.value = false;
closeDialog("create-label");
navigateTo(`/label/${data.id}`);
}
}

View File

@@ -0,0 +1,149 @@
<template>
<div class="flex flex-col gap-1">
<Label :for="id" class="px-1">
{{ $t("global.labels") }}
</Label>
<TagsInput
v-model="modelValue"
class="w-full gap-0 px-0"
:display-value="v => shortenedLabels.find(l => l.id === v)?.name ?? 'Loading...'"
>
<div class="flex flex-wrap items-center gap-2 px-3">
<TagsInputItem v-for="item in modelValue" :key="item" :value="item">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
</div>
<ComboboxRoot v-model="modelValue" v-model:open="open" class="w-full" :ignore-filter="true">
<ComboboxAnchor as-child>
<ComboboxInput v-model="searchTerm" :placeholder="$t('components.label.selector.select_labels')" as-child>
<TagsInputInput
:id="id"
class="w-full px-3"
:class="modelValue.length > 0 ? 'mt-2' : ''"
@focus="open = true"
/>
</ComboboxInput>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent :side-offset="4" position="popper" class="z-50">
<CommandList
position="popper"
class="mt-2 w-[--reka-popper-anchor-width] rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<CommandEmpty />
<CommandGroup>
<CommandItem
v-for="label in filteredLabels"
:key="label.value"
:value="label.value"
@select.prevent="
ev => {
if (typeof ev.detail.value === 'string') {
if (ev.detail.value === 'create-item') {
void createAndAdd(searchTerm);
} else {
if (!modelValue.includes(ev.detail.value)) {
modelValue = [...modelValue, ev.detail.value];
}
}
searchTerm = '';
}
}
"
>
{{ label.label }}
</CommandItem>
</CommandGroup>
</CommandList>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</TagsInput>
</div>
</template>
<script setup lang="ts">
import { ComboboxAnchor, ComboboxContent, ComboboxInput, ComboboxPortal, ComboboxRoot } from "reka-ui";
import { computed, ref } from "vue";
import fuzzysort from "fuzzysort";
import { toast } from "@/components/ui/sonner";
import { CommandEmpty, CommandGroup, CommandItem, CommandList } from "~/components/ui/command";
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText,
} from "@/components/ui/tags-input";
import type { LabelOut } from "~/lib/api/types/data-contracts";
const id = useId();
const api = useUserApi();
const emit = defineEmits(["update:modelValue"]);
const props = defineProps({
modelValue: {
type: Array as () => string[],
default: null,
},
labels: {
type: Array as () => LabelOut[],
required: true,
},
});
const modelValue = useVModel(props, "modelValue", emit);
const open = ref(false);
const searchTerm = ref("");
const shortenedLabels = computed(() => {
return props.labels.map(l => ({
...l,
name: l.name.length > 20 ? `${l.name.substring(0, 20)}...` : l.name,
}));
});
const filteredLabels = computed(() => {
const filtered = fuzzysort
.go(searchTerm.value, shortenedLabels.value, { key: "name", all: true })
.map(l => ({
value: l.obj.id,
label: l.obj.name,
}))
.filter(i => !modelValue.value.includes(i.value));
if (searchTerm.value.trim() !== "") {
filtered.push({ value: "create-item", label: `Create ${searchTerm.value}` });
}
return filtered;
});
const createAndAdd = async (name: string) => {
if (name.length > 50) {
toast.error("Label name must not be longer than 50 characters");
return;
}
const { error, data } = await api.labels.create({
name,
color: "", // Future!
description: "",
});
if (error) {
toast.error("Couldn't create label");
return;
}
toast.success("Label created");
modelValue.value = [...modelValue.value, data.id];
};
// TODO: when reka-ui 2 is release use hook to set cursor to end when label is added with click
</script>

View File

@@ -1,7 +1,6 @@
<template>
<BaseModal v-model="modal">
<template #title>{{ $t("components.location.create_modal.title") }}</template>
<form @submit.prevent="create()">
<BaseModal dialog-id="create-location" :title="$t('components.location.create_modal.title')">
<form class="flex flex-col gap-2" @submit.prevent="create()">
<LocationSelector v-model="form.parent" />
<FormTextField
ref="locationNameRef"
@@ -18,39 +17,29 @@
:label="$t('components.location.create_modal.location_description')"
:max-length="1000"
/>
<div class="modal-action">
<div class="flex justify-center">
<BaseButton class="rounded-r-none" type="submit" :loading="loading">{{ $t("global.create") }}</BaseButton>
<div class="dropdown dropdown-top">
<label tabindex="0" class="btn rounded-l-none rounded-r-xl">
<MdiChevronDown class="size-5" />
</label>
<ul tabindex="0" class="dropdown-content menu rounded-box right-0 w-64 bg-base-100 p-2 shadow">
<li>
<button type="button" @click="create(false)">{{ $t("global.create_and_add") }}</button>
</li>
</ul>
</div>
</div>
<div class="mt-4 flex flex-row-reverse">
<ButtonGroup>
<Button :disabled="loading" type="submit">{{ $t("global.create") }}</Button>
<Button variant="outline" :disabled="loading" type="button" @click="create(false)">{{
$t("global.create_and_add")
}}</Button>
</ButtonGroup>
</div>
</form>
<p class="mt-4 text-center text-sm">
use <kbd class="kbd kbd-xs">Shift</kbd> + <kbd class="kbd kbd-xs"> Enter </kbd> to create and add another
</p>
</BaseModal>
</template>
<script setup lang="ts">
import { toast } from "@/components/ui/sonner";
import { Button, ButtonGroup } from "~/components/ui/button";
import BaseModal from "@/components/App/CreateModal.vue";
import type { LocationSummary } from "~~/lib/api/types/data-contracts";
import MdiChevronDown from "~icons/mdi/chevron-down";
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
});
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
const { activeDialog, closeDialog } = useDialog();
useDialogHotkey("create-location", { code: "Digit3", shift: true });
const modal = useVModel(props, "modelValue");
const loading = ref(false);
const focused = ref(false);
const form = reactive({
@@ -60,12 +49,12 @@
});
watch(
() => modal.value,
open => {
if (open) {
useTimeoutFn(() => {
focused.value = true;
}, 50);
() => activeDialog.value,
active => {
if (active === "create-location") {
// useTimeoutFn(() => {
// focused.value = true;
// }, 50);
if (locationId.value) {
const found = locations.value.find(l => l.id === locationId.value);
@@ -74,7 +63,7 @@
}
}
} else {
focused.value = false;
// focused.value = false;
}
}
);
@@ -88,7 +77,6 @@
}
const api = useUserApi();
const toast = useNotifier();
const locationsStore = useLocationStore();
const locations = computed(() => locationsStore.allLocations);
@@ -111,9 +99,7 @@
}
loading.value = true;
if (shift.value) {
close = false;
}
if (shift.value) close = false;
const { data, error } = await api.locations.create({
name: form.name,
@@ -132,7 +118,7 @@
reset();
if (close) {
modal.value = false;
closeDialog("create-location");
navigateTo(`/location/${data.id}`);
}
}

View File

@@ -0,0 +1,63 @@
<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,62 +1,95 @@
<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>
<div class="flex flex-col gap-1">
<Label :for="id" class="px-1">
{{ $t("components.location.selector.parent_location") }}
</Label>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
{{ value && value.name ? value.name : $t("components.location.selector.select_location") }}
<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="$t('components.location.selector.search_location')"
:display-value="_ => ''"
/>
<CommandEmpty>{{ $t("components.location.selector.no_location_found") }}</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem
v-for="location in filteredLocations"
:key="location.id"
:value="location.id"
@select="selectLocation(location as unknown as LocationSummary)"
>
<Check :class="cn('mr-2 h-4 w-4', value?.id === location.id ? 'opacity-100' : 'opacity-0')" />
<div>
<div class="flex w-full">
{{ location.name }}
</div>
<div v-if="location.name !== location.treeString" class="mt-1 text-xs text-muted-foreground">
{{ location.treeString }}
</div>
</div>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</template>
<script lang="ts" setup>
import type { FlatTreeItem } from "~~/composables/use-location-helpers";
import { useFlatLocations } from "~~/composables/use-location-helpers";
<script setup lang="ts">
import { Check, ChevronsUpDown } from "lucide-vue-next";
import fuzzysort from "fuzzysort";
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 type { LocationSummary } from "~~/lib/api/types/data-contracts";
import MdiCheck from "~icons/mdi/check";
import { useFlatLocations } from "~~/composables/use-location-helpers";
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 emit = defineEmits(["update:modelValue"]);
const open = ref(false);
const search = ref("");
const id = useId();
const locations = useFlatLocations();
const value = useVModel(props, "modelValue", emit);
function selectLocation(location: LocationSummary) {
if (value.value?.id !== location.id) {
value.value = location;
} else {
value.value = null;
}
open.value = false;
}
const props = defineProps<Props>();
const value = useVModel(props, "modelValue");
const filteredLocations = computed(() => {
const filtered = fuzzysort.go(search.value, locations.value, { key: "name", all: true }).map(i => i.obj);
const locations = useFlatLocations();
const form = ref({
parent: null as LocationSummary | null,
search: "",
return filtered;
});
// Whenever parent goes from value to null reset search
// Reset search when value is cleared
watch(
() => value.value,
() => {
if (!value.value) {
form.value.search = "";
search.value = "";
}
}
);

View File

@@ -7,8 +7,8 @@
<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')" />
<FormTextField v-model="entry.cost" autofocus :label="$t('maintenance.modal.cost')" />
<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>
@@ -23,13 +23,13 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
import MdiPost from "~icons/mdi/post";
import DatePicker from "~~/components/Form/DatePicker.vue";
const { t } = useI18n();
const api = useUserApi();
const toast = useNotifier();
const emit = defineEmits(["changed"]);

View File

@@ -1,15 +1,37 @@
<template>
<BaseModal v-model="isRevealed" readonly @cancel="cancel(false)">
<template #title> {{ $t("global.confirm") }} </template>
<div>
<p>{{ text }}</p>
</div>
<div class="modal-action">
<BaseButton type="submit" @click="confirm(true)"> {{ $t("global.confirm") }} </BaseButton>
</div>
</BaseModal>
<AlertDialog :open="isRevealed">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ $t("global.confirm") }}</AlertDialogTitle>
<AlertDialogDescription> {{ text }} </AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="cancel(false)">
{{ $t("global.cancel") }}
</AlertDialogCancel>
<AlertDialogAction @click="confirm(true)">
{{ $t("global.confirm") }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<script setup lang="ts">
import { useDialog } from "./ui/dialog-provider";
const { text, isRevealed, confirm, cancel } = useConfirm();
const { addAlert, removeAlert } = useDialog();
watch(
isRevealed,
val => {
if (val) {
addAlert("confirm-modal");
} else {
removeAlert("confirm-modal");
}
},
{ immediate: true }
);
</script>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { route } from "../../lib/api/base";
import { toast } from "@/components/ui/sonner";
import MdiPrinterPos from "~icons/mdi/printer-pos";
import MdiFileDownload from "~icons/mdi/file-download";
@@ -9,7 +10,6 @@
}>();
const pubApi = usePublicApi();
const toast = useNotifier();
const { data: status } = useAsyncData(async () => {
const { data, error } = await pubApi.status();

View File

@@ -1,116 +0,0 @@
<template>
<Combobox v-model="selectedAction" :nullable="true">
<ComboboxInput
ref="inputBox"
class="input input-bordered mt-2 w-full"
@input="inputValue = $event.target.value"
></ComboboxInput>
<ComboboxOptions
class="card dropdown-content absolute max-h-48 w-full overflow-y-scroll rounded-lg border border-base-300 bg-base-100"
:unmount="false"
>
<ComboboxOption
v-for="(action, idx) in filteredActions"
:key="idx"
v-slot="{ active }"
:value="action"
as="template"
>
<button
class="flex w-full rounded-lg px-3 py-1.5 text-left transition-colors"
:class="{ 'bg-primary text-primary-content': active }"
>
{{ action.text }}
<kbd
v-if="action.shortcut"
class="kbd kbd-sm ml-auto"
:class="{ 'border-primary-content bg-primary': active }"
>
{{ action.shortcut }}
</kbd>
</button>
</ComboboxOption>
<div
v-if="filteredActions.length == 0"
class="w-full rounded-lg p-3 text-left transition-colors hover:bg-base-300"
>
No actions found.
</div>
</ComboboxOptions>
<ComboboxButton ref="inputBoxButton"></ComboboxButton>
</Combobox>
</template>
<script setup lang="ts">
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption, ComboboxButton } from "@headlessui/vue";
type ExposedProps = {
focused: boolean;
revealActions: () => void;
};
type QuickMenuAction = {
text: string;
action: () => void;
// A character that invokes this action instantly if pressed
shortcut?: string;
};
const props = defineProps({
modelValue: {
type: Object as PropType<QuickMenuAction>,
required: false,
default: undefined,
},
actions: {
type: Array as PropType<QuickMenuAction[]>,
required: true,
},
});
const emit = defineEmits(["update:modelValue", "actionSelected"]);
const selectedAction = ref(null);
const inputValue = ref("");
const inputBox = ref();
const inputBoxButton = ref();
const { focused: inputBoxFocused } = useFocus(inputBox);
const revealActions = () => {
unrefElement(inputBoxButton).click();
};
watch(inputBoxFocused, val => {
if (val) revealActions();
else inputValue.value = "";
});
watch(inputValue, (val, oldVal) => {
if (!oldVal) {
const action = props.actions?.find(v => v.shortcut === val);
if (action) {
emit("actionSelected", action);
inputBoxFocused.value = false;
}
}
});
watch(selectedAction, val => {
if (val) {
emit("actionSelected", val);
selectedAction.value = null;
}
});
const filteredActions = computed(() => {
const searchTerm = inputValue.value.toLowerCase();
return (props.actions || []).filter(action => {
return action.text.toLowerCase().includes(searchTerm) || action.shortcut?.includes(searchTerm);
});
});
defineExpose({ focused: inputBoxFocused, revealActions });
export type { QuickMenuAction, ExposedProps };
</script>

View File

@@ -1,53 +0,0 @@
<template>
<BaseModal
v-model="modal"
:show-close-button="false"
:click-outside-to-close="true"
:modal-top="true"
:class="{ 'self-start': true }"
>
<div class="relative">
<span class="text-neutral-400">{{ $t("components.quick_menu.shortcut_hint") }}</span>
<QuickMenuInput ref="inputBox" :actions="props.actions || []" @action-selected="invokeAction"></QuickMenuInput>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import type { ExposedProps as QuickMenuInputData, QuickMenuAction } from "./Input.vue";
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
actions: {
type: Array as PropType<QuickMenuAction[]>,
required: false,
default: () => [],
},
});
const modal = useVModel(props, "modelValue");
const inputBox = ref<QuickMenuInputData>({ focused: false, revealActions: () => {} });
const onModalOpen = useTimeoutFn(() => {
inputBox.value.focused = true;
}, 50).start;
const onModalClose = () => {
inputBox.value.focused = false;
};
watch(modal, () => (modal.value ? onModalOpen : onModalClose)());
onStartTyping(() => {
inputBox.value.focused = true;
});
function invokeAction(action: QuickMenuAction) {
modal.value = false;
useTimeoutFn(action.action, 100).start();
}
</script>

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)"
>
<slot />
</AlertDialogCancel>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import {
AlertDialogContent,
type AlertDialogContentEmits,
type AlertDialogContentProps,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
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"
/>
<AlertDialogContent
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 />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import {
AlertDialogDescription,
type AlertDialogDescriptionProps,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</AlertDialogDescription>
</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>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

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="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { AlertDialogTitle, type AlertDialogTitleProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>

View File

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

View File

@@ -0,0 +1,9 @@
export { default as AlertDialog } from './AlertDialog.vue'
export { default as AlertDialogAction } from './AlertDialogAction.vue'
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'
export { default as AlertDialogContent } from './AlertDialogContent.vue'
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue'

View File

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

View File

@@ -0,0 +1,25 @@
import { cva, type VariantProps } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { Primitive, type PrimitiveProps } from "radix-vue";
import { Primitive, type PrimitiveProps } from "reka-ui";
import { type ButtonVariants, buttonVariants } from ".";
import { cn } from "@/lib/utils";

View File

@@ -0,0 +1,22 @@
<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(
'inline-flex rounded-lg',
'[&>*]:rounded-none',
'[&>*:first-child]:rounded-l-lg',
'[&>*:last-child]:rounded-r-lg',
props.class
)
"
>
<slot />
</div>
</template>

View File

@@ -1,6 +1,7 @@
import { cva, type VariantProps } from "class-variance-authority";
export { default as Button } from "./Button.vue";
export { default as ButtonGroup } from "./ButtonGroup.vue";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { ComboboxRootEmits, ComboboxRootProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { ComboboxRoot, useForwardPropsEmits } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = withDefaults(defineProps<ComboboxRootProps & { class?: HTMLAttributes['class'] }>(), {
open: true,
modelValue: '',
})
const emits = defineEmits<ComboboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ComboboxRoot
v-bind="forwarded"
:class="cn('flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', props.class)"
>
<slot />
</ComboboxRoot>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { useForwardPropsEmits } from 'reka-ui'
import Command from './Command.vue'
const props = defineProps<DialogRootProps & { dialogId: string }>();
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<Dialog v-bind="forwarded">
<DialogContent class="overflow-hidden p-0 shadow-lg">
<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>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { ComboboxEmptyProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { ComboboxEmpty } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<ComboboxEmptyProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ComboboxEmpty v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)">
<slot />
</ComboboxEmpty>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { ComboboxGroupProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { ComboboxGroup, ComboboxLabel } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<ComboboxGroupProps & {
class?: HTMLAttributes['class']
heading?: string
}>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ComboboxGroup
v-bind="delegatedProps"
:class="cn('overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', props.class)"
>
<ComboboxLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ heading }}
</ComboboxLabel>
<slot />
</ComboboxGroup>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { Search } from 'lucide-vue-next'
import { ComboboxInput, type ComboboxInputProps, useForwardProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ComboboxInputProps & {
class?: HTMLAttributes['class']
}>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<div class="flex items-center border-b px-3" cmdk-input-wrapper>
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
<ComboboxInput
v-bind="{ ...forwardedProps, ...$attrs }"
auto-focus
:class="cn('flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', props.class)"
/>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { ComboboxItemEmits, ComboboxItemProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { ComboboxItem, useForwardPropsEmits } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ComboboxItemEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ComboboxItem
v-bind="forwarded"
:class="cn('relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', props.class)"
>
<slot />
</ComboboxItem>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { ComboboxContentEmits, ComboboxContentProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { ComboboxContent, useForwardPropsEmits } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<ComboboxContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ComboboxContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ComboboxContent v-bind="forwarded" :class="cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)">
<div role="presentation">
<slot />
</div>
</ComboboxContent>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { ComboboxSeparatorProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { ComboboxSeparator } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<ComboboxSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ComboboxSeparator
v-bind="delegatedProps"
:class="cn('-mx-1 h-px bg-border', props.class)"
>
<slot />
</ComboboxSeparator>
</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>
<span :class="cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)">
<slot />
</span>
</template>

View File

@@ -0,0 +1,9 @@
export { default as Command } from './Command.vue'
export { default as CommandDialog } from './CommandDialog.vue'
export { default as CommandEmpty } from './CommandEmpty.vue'
export { default as CommandGroup } from './CommandGroup.vue'
export { default as CommandInput } from './CommandInput.vue'
export { default as CommandItem } from './CommandItem.vue'
export { default as CommandList } from './CommandList.vue'
export { default as CommandSeparator } from './CommandSeparator.vue'
export { default as CommandShortcut } from './CommandShortcut.vue'

View File

@@ -0,0 +1,48 @@
<!-- DialogProvider.vue -->
<script setup lang="ts">
import { ref, reactive, computed } from "vue";
import { provideDialogContext } from "./utils";
const activeDialog = ref<string | null>(null);
const activeAlerts = reactive<string[]>([]);
const openDialog = (dialogId: string) => {
if (activeAlerts.length > 0) return;
activeDialog.value = dialogId;
};
const closeDialog = (dialogId?: string) => {
if (dialogId) {
if (activeDialog.value === dialogId) {
activeDialog.value = null;
}
} else {
activeDialog.value = null;
}
};
const addAlert = (alertId: string) => {
activeAlerts.push(alertId);
};
const removeAlert = (alertId: string) => {
const index = activeAlerts.indexOf(alertId);
if (index !== -1) {
activeAlerts.splice(index, 1);
}
};
// Provide context to child components
provideDialogContext({
activeDialog: computed(() => activeDialog.value),
openDialog,
closeDialog,
activeAlerts: computed(() => activeAlerts),
addAlert,
removeAlert,
});
</script>
<template>
<slot />
</template>

View File

@@ -0,0 +1,2 @@
export { useDialog, useDialogHotkey } from "./utils";
export { default as DialogProvider } from "./DialogProvider.vue";

View File

@@ -0,0 +1,56 @@
import type { ComputedRef } from "vue";
import { createContext } from "reka-ui";
import { useMagicKeys, useActiveElement } from "@vueuse/core";
export const [useDialog, provideDialogContext] = createContext<{
activeDialog: ComputedRef<string | null>;
activeAlerts: ComputedRef<string[]>;
openDialog: (dialogId: string) => void;
closeDialog: (dialogId?: string) => void;
addAlert: (alertId: string) => void;
removeAlert: (alertId: string) => void;
}>("DialogProvider");
export const useDialogHotkey = (
dialogId: string,
key: {
shift?: boolean;
ctrl?: boolean;
code: string;
}
) => {
const { openDialog } = useDialog();
const activeElement = useActiveElement();
const notUsingInput = computed(
() => activeElement.value?.tagName !== "INPUT" && activeElement.value?.tagName !== "TEXTAREA"
);
useMagicKeys({
passive: false,
onEventFired: event => {
// console.log({
// event,
// notUsingInput: notUsingInput.value,
// eventType: event.type,
// keyCode: event.code,
// matchingKeyCode: key.code === event.code,
// shift: event.shiftKey,
// matchingShift: key.shift === undefined || event.shiftKey === key.shift,
// ctrl: event.ctrlKey,
// matchingCtrl: key.ctrl === undefined || event.ctrlKey === key.ctrl,
// });
if (
notUsingInput.value &&
event.type === "keydown" &&
event.code === key.code &&
(key.shift === undefined || event.shiftKey === key.shift) &&
(key.ctrl === undefined || event.ctrlKey === key.ctrl)
) {
openDialog(dialogId);
event.preventDefault();
}
},
});
};

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from "reka-ui";
import { useDialog } from "../dialog-provider/utils";
const props = defineProps<DialogRootProps & { dialogId: string }>();
const emits = defineEmits<DialogRootEmits>();
const { closeDialog, activeDialog } = useDialog();
const isOpen = computed(() => activeDialog.value === props.dialogId);
const onOpenChange = (open: boolean) => {
if (!open) closeDialog(props.dialogId);
};
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DialogRoot v-bind="forwarded" :open="isOpen" @update:open="onOpenChange">
<slot />
</DialogRoot>
</template>

View File

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

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<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"
/>
<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
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"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { DialogDescription, type DialogDescriptionProps, useForwardProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
v-bind="forwardedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,19 @@
<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-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

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="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto 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"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="forwarded"
@pointer-down-outside="(event: CustomEvent<{ originalEvent: PointerEvent }>) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-3 right-3 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { DialogTitle, type DialogTitleProps, useForwardProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
v-bind="forwardedProps"
:class="
cn(
'text-lg font-semibold leading-none tracking-tight',
props.class,
)
"
>
<slot />
</DialogTitle>
</template>

View File

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

View File

@@ -0,0 +1,9 @@
export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from './DialogClose.vue'
export { default as DialogContent } from './DialogContent.vue'
export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogFooter } from './DialogFooter.vue'
export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogScrollContent } from './DialogScrollContent.vue'
export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogTrigger } from './DialogTrigger.vue'

View File

@@ -0,0 +1,27 @@
<script lang="ts" setup>
import type { DrawerRootEmits, DrawerRootProps } from "vaul-vue";
import { useForwardPropsEmits } from "reka-ui";
import { DrawerRoot } from "vaul-vue";
import { useDialog } from "../dialog-provider/utils";
const props = withDefaults(defineProps<DrawerRootProps & { dialogId: string }>(), {
shouldScaleBackground: true,
}) as DrawerRootProps & { dialogId: string };
const emits = defineEmits<DrawerRootEmits>();
const { closeDialog, activeDialog } = useDialog();
const isOpen = computed(() => activeDialog.value === props.dialogId);
const onOpenChange = (open: boolean) => {
if (!open) closeDialog(props.dialogId);
};
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DrawerRoot v-bind="forwarded" :open="isOpen" @update:open="onOpenChange">
<slot />
</DrawerRoot>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HtmlHTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { useForwardPropsEmits } from 'reka-ui'
import { DrawerContent, DrawerPortal } from 'vaul-vue'
import DrawerOverlay from './DrawerOverlay.vue'
const props = defineProps<DialogContentProps & { class?: HtmlHTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DrawerPortal>
<DrawerOverlay />
<DrawerContent
v-bind="forwarded" :class="cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
props.class,
)"
>
<div class="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
<slot />
</DrawerContent>
</DrawerPortal>
</template>

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { DrawerDescriptionProps } from 'vaul-vue'
import { cn } from '@/lib/utils'
import { DrawerDescription } from 'vaul-vue'
import { computed, type HtmlHTMLAttributes } from 'vue'
const props = defineProps<DrawerDescriptionProps & { class?: HtmlHTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<DrawerDescription v-bind="delegatedProps" :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</DrawerDescription>
</template>

View File

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

View File

@@ -0,0 +1,14 @@
<script lang="ts" setup>
import type { HtmlHTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HtmlHTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('grid gap-1.5 p-4 text-center sm:text-left', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
import type { DialogOverlayProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { DrawerOverlay } from 'vaul-vue'
import { computed, type HtmlHTMLAttributes } from 'vue'
const props = defineProps<DialogOverlayProps & { class?: HtmlHTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<DrawerOverlay v-bind="delegatedProps" :class="cn('fixed inset-0 z-50 bg-black/80', props.class)" />
</template>

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { DrawerTitleProps } from 'vaul-vue'
import { cn } from '@/lib/utils'
import { DrawerTitle } from 'vaul-vue'
import { computed, type HtmlHTMLAttributes } from 'vue'
const props = defineProps<DrawerTitleProps & { class?: HtmlHTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<DrawerTitle v-bind="delegatedProps" :class="cn('text-lg font-semibold leading-none tracking-tight', props.class)">
<slot />
</DrawerTitle>
</template>

View File

@@ -0,0 +1,8 @@
export { default as Drawer } from './Drawer.vue'
export { default as DrawerContent } from './DrawerContent.vue'
export { default as DrawerDescription } from './DrawerDescription.vue'
export { default as DrawerFooter } from './DrawerFooter.vue'
export { default as DrawerHeader } from './DrawerHeader.vue'
export { default as DrawerOverlay } from './DrawerOverlay.vue'
export { default as DrawerTitle } from './DrawerTitle.vue'
export { DrawerClose, DrawerPortal, DrawerTrigger } from 'vaul-vue'

View File

@@ -4,7 +4,7 @@
type DropdownMenuRootEmits,
type DropdownMenuRootProps,
useForwardPropsEmits,
} from "radix-vue";
} from "reka-ui";
const props = defineProps<DropdownMenuRootProps>();
const emits = defineEmits<DropdownMenuRootEmits>();

View File

@@ -6,7 +6,7 @@
type DropdownMenuCheckboxItemProps,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from "radix-vue";
} from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

View File

@@ -5,7 +5,7 @@
type DropdownMenuContentProps,
DropdownMenuPortal,
useForwardPropsEmits,
} from "radix-vue";
} from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DropdownMenuGroup, type DropdownMenuGroupProps } from "radix-vue";
import { DropdownMenuGroup, type DropdownMenuGroupProps } from "reka-ui";
const props = defineProps<DropdownMenuGroupProps>();
</script>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DropdownMenuItem, type DropdownMenuItemProps, useForwardProps } from "radix-vue";
import { DropdownMenuItem, type DropdownMenuItemProps, useForwardProps } from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DropdownMenuLabel, type DropdownMenuLabelProps, useForwardProps } from "radix-vue";
import { DropdownMenuLabel, type DropdownMenuLabelProps, useForwardProps } from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

View File

@@ -4,7 +4,7 @@
type DropdownMenuRadioGroupEmits,
type DropdownMenuRadioGroupProps,
useForwardPropsEmits,
} from "radix-vue";
} from "reka-ui";
const props = defineProps<DropdownMenuRadioGroupProps>();
const emits = defineEmits<DropdownMenuRadioGroupEmits>();

View File

@@ -6,7 +6,7 @@
type DropdownMenuRadioItemEmits,
type DropdownMenuRadioItemProps,
useForwardPropsEmits,
} from "radix-vue";
} from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DropdownMenuSeparator, type DropdownMenuSeparatorProps } from "radix-vue";
import { DropdownMenuSeparator, type DropdownMenuSeparatorProps } from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

View File

@@ -4,7 +4,7 @@
type DropdownMenuSubEmits,
type DropdownMenuSubProps,
useForwardPropsEmits,
} from "radix-vue";
} from "reka-ui";
const props = defineProps<DropdownMenuSubProps>();
const emits = defineEmits<DropdownMenuSubEmits>();

View File

@@ -4,7 +4,7 @@
type DropdownMenuSubContentEmits,
type DropdownMenuSubContentProps,
useForwardPropsEmits,
} from "radix-vue";
} from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ChevronRight } from "lucide-vue-next";
import { DropdownMenuSubTrigger, type DropdownMenuSubTriggerProps, useForwardProps } from "radix-vue";
import { DropdownMenuSubTrigger, type DropdownMenuSubTriggerProps, useForwardProps } from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DropdownMenuTrigger, type DropdownMenuTriggerProps, useForwardProps } from "radix-vue";
import { DropdownMenuTrigger, type DropdownMenuTriggerProps, useForwardProps } from "reka-ui";
const props = defineProps<DropdownMenuTriggerProps>();

View File

@@ -13,4 +13,4 @@ export { default as DropdownMenuSub } from "./DropdownMenuSub.vue";
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue";
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue";
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue";
export { DropdownMenuPortal } from "radix-vue";
export { DropdownMenuPortal } from "reka-ui";

View File

@@ -24,7 +24,7 @@
v-model="modelValue"
:class="
cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-muted-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { Label, type LabelProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<Label
v-bind="delegatedProps"
:class="
cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import {
PopoverContent,
type PopoverContentEmits,
type PopoverContentProps,
PopoverPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(),
{
align: 'center',
sideOffset: 4,
},
)
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class,
)
"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>

View File

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

View File

@@ -0,0 +1,3 @@
export { default as Popover } from './Popover.vue'
export { default as PopoverContent } from './PopoverContent.vue'
export { default as PopoverTrigger } from './PopoverTrigger.vue'

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Separator, type SeparatorProps } from "radix-vue";
import { Separator, type SeparatorProps } from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from "radix-vue";
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from "reka-ui";
const props = defineProps<DialogRootProps>();
const emits = defineEmits<DialogRootEmits>();

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DialogClose, type DialogCloseProps } from "radix-vue";
import { DialogClose, type DialogCloseProps } from "reka-ui";
const props = defineProps<DialogCloseProps>();
</script>

View File

@@ -8,7 +8,7 @@
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "radix-vue";
} from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { type SheetVariants, sheetVariants } from ".";
import { cn } from "@/lib/utils";
@@ -38,7 +38,7 @@
<template>
<DialogPortal>
<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="fixed inset-0 z-40 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"
/>
<DialogContent :class="cn(sheetVariants({ side }), props.class)" v-bind="{ ...forwarded, ...$attrs }">
<slot />

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DialogDescription, type DialogDescriptionProps } from "radix-vue";
import { DialogDescription, type DialogDescriptionProps } from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DialogTitle, type DialogTitleProps } from "radix-vue";
import { DialogTitle, type DialogTitleProps } from "reka-ui";
import { computed, type HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DialogTrigger, type DialogTriggerProps } from "radix-vue";
import { DialogTrigger, type DialogTriggerProps } from "reka-ui";
const props = defineProps<DialogTriggerProps>();
</script>

View File

@@ -0,0 +1,35 @@
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps<{
keys: string[];
className?: string;
size?: 'sm' | 'md' | 'lg';
}>();
const sizeClasses = computed(() => {
return {
sm: 'px-1 py-0.5 text-xs',
md: 'px-1.5 py-0.5 text-sm',
lg: 'px-2 py-1 text-base',
}[props.size ?? 'md'];
});
</script>
<template>
<kbd
:class="[
'inline-flex items-center gap-0.5 rounded-md bg-background shadow-sm ring-1 ring-gray-200',
sizeClasses,
className,
]"
>
<span
v-for="(key, index) in keys"
:key="index"
class="font-medium text-muted-foreground font-sans"
>
{{ key }}
</span>
</kbd>
</template>

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