mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 13:23:14 +01:00
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:
@@ -26,6 +26,7 @@ export default [
|
|||||||
text: 'Contributing',
|
text: 'Contributing',
|
||||||
items: [
|
items: [
|
||||||
{text: 'Get Started', link: '/en/contribute/get-started'},
|
{text: 'Get Started', link: '/en/contribute/get-started'},
|
||||||
|
{text: 'Switching to Shadcn-vue', link: '/en/contribute/shadcn'},
|
||||||
{text: 'Bounty Program', link: '/en/contribute/bounty'}
|
{text: 'Bounty Program', link: '/en/contribute/bounty'}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ When modifying components, follow these best practices:
|
|||||||
During the migration process, you can test without DaisyUI using these commands:
|
During the migration process, you can test without DaisyUI using these commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DISABLE_DAISYUI=true; task ui:dev
|
export DISABLE_DAISYUI=true; task ui:dev
|
||||||
```
|
```
|
||||||
or
|
or
|
||||||
```bash
|
```bash
|
||||||
DISABLE_DAISYUI=true; task ui:fix
|
export DISABLE_DAISYUI=true; task ui:fix
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<DialogProvider>
|
||||||
|
<ClientOnly>
|
||||||
|
<Toaster class="pointer-events-auto" />
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<Html :lang="locale" :data-theme="theme || 'homebox'" />
|
<Html :lang="locale" :data-theme="theme || 'homebox'" />
|
||||||
<Link rel="icon" type="image/svg" href="/favicon.svg"></Link>
|
<Link rel="icon" type="image/svg" href="/favicon.svg"></Link>
|
||||||
@@ -8,10 +13,13 @@
|
|||||||
<Link rel="manifest" href="/manifest.webmanifest" />
|
<Link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
|
</DialogProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { DialogProvider } from "@/components/ui/dialog-provider";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"$schema": "https://shadcn-vue.com/schema.json",
|
"$schema": "https://shadcn-vue.com/schema.json",
|
||||||
"style": "default",
|
"style": "default",
|
||||||
"typescript": true,
|
"typescript": true,
|
||||||
"tsConfigPath": "tsconfig.json",
|
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
"config": "tailwind.config.js",
|
||||||
"css": "assets/css/main.css",
|
"css": "assets/css/main.css",
|
||||||
@@ -10,7 +9,6 @@
|
|||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"framework": "nuxt",
|
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils"
|
"utils": "@/lib/utils"
|
||||||
|
|||||||
43
frontend/components/App/CreateModal.vue
Normal file
43
frontend/components/App/CreateModal.vue
Normal 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>
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { toast } from "@/components/ui/sonner";
|
||||||
import MdiUpload from "~icons/mdi/upload";
|
import MdiUpload from "~icons/mdi/upload";
|
||||||
type Props = {
|
type Props = {
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
@@ -60,7 +61,6 @@
|
|||||||
const dialog = useVModel(props, "modelValue", emit);
|
const dialog = useVModel(props, "modelValue", emit);
|
||||||
|
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const toast = useNotifier();
|
|
||||||
|
|
||||||
const importCsv = ref<File | null>(null);
|
const importCsv = ref<File | null>(null);
|
||||||
const importLoading = ref(false);
|
const importLoading = ref(false);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal v-model="modal">
|
<AlertDialog v-model:open="open">
|
||||||
<template #title>🎉 {{ $t("components.app.outdated.new_version_available") }} 🎉</template>
|
<AlertDialogContent>
|
||||||
<div class="p-4">
|
<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.current_version") }}: {{ current }}</p>
|
||||||
<p>{{ $t("components.app.outdated.latest_version") }}: {{ latest }}</p>
|
<p>{{ $t("components.app.outdated.latest_version") }}: {{ latest }}</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -9,33 +11,76 @@
|
|||||||
{{ $t("components.app.outdated.new_version_available_link") }}
|
{{ $t("components.app.outdated.new_version_available_link") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</AlertDialogDescription>
|
||||||
<button class="btn btn-warning" @click="hide">
|
</AlertDialogHeader>
|
||||||
{{ $t("components.app.outdated.dismiss") }}
|
<AlertDialogFooter>
|
||||||
</button>
|
<AlertDialogAction @click="hide">{{ $t("components.app.outdated.dismiss") }}</AlertDialogAction>
|
||||||
</BaseModal>
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps({
|
import { lt } from "semver";
|
||||||
modelValue: {
|
import {
|
||||||
type: Boolean,
|
AlertDialog,
|
||||||
required: true,
|
AlertDialogContent,
|
||||||
},
|
AlertDialogHeader,
|
||||||
current: {
|
AlertDialogTitle,
|
||||||
type: String,
|
AlertDialogDescription,
|
||||||
required: true,
|
AlertDialogFooter,
|
||||||
},
|
AlertDialogAction,
|
||||||
latest: {
|
} from "~/components/ui/alert-dialog";
|
||||||
type: String,
|
import { useDialog } from "~/components/ui/dialog-provider";
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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 = () => {
|
const hide = () => {
|
||||||
modal.value = false;
|
open.value = false;
|
||||||
localStorage.setItem("latestVersion", props.latest);
|
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>
|
</script>
|
||||||
|
|||||||
81
frontend/components/App/QuickMenuModal.vue
Normal file
81
frontend/components/App/QuickMenuModal.vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { toast } from "@/components/ui/sonner";
|
||||||
import MdiClose from "~icons/mdi/close";
|
import MdiClose from "~icons/mdi/close";
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
@@ -101,7 +102,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const toast = useNotifier();
|
|
||||||
|
|
||||||
async function createAndAdd(name: string) {
|
async function createAndAdd(name: string) {
|
||||||
const { error, data } = await api.labels.create({
|
const { error, data } = await api.labels.create({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<FormTextField v-model="value" placeholder="Password" :label="label" :type="inputType"> </FormTextField>
|
<FormTextField v-model="value" placeholder="Password" :label="label" :type="inputType"> </FormTextField>
|
||||||
<button
|
<button
|
||||||
type="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"
|
data-tip="Toggle Password Show"
|
||||||
@click="toggle()"
|
@click="toggle()"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!inline" class="form-control w-full">
|
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
|
||||||
<label class="label">
|
<Label :for="id" class="flex w-full px-1">
|
||||||
<span class="label-text">{{ label }}</span>
|
<span>{{ label }}</span>
|
||||||
|
<span class="grow"></span>
|
||||||
<span
|
<span
|
||||||
:class="{
|
:class="{
|
||||||
'text-red-600':
|
'text-red-600':
|
||||||
@@ -11,12 +12,13 @@
|
|||||||
>
|
>
|
||||||
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</Label>
|
||||||
<textarea ref="el" v-model="value" class="textarea textarea-bordered h-28 w-full" :placeholder="placeholder" />
|
<Textarea :id="id" v-model="value" :placeholder="placeholder" class="min-h-[112px] w-full resize-none" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||||
<label class="label">
|
<Label :for="id" class="flex w-full px-1 py-2">
|
||||||
<span class="label-text">{{ label }}</span>
|
<span>{{ label }}</span>
|
||||||
|
<span class="grow"></span>
|
||||||
<span
|
<span
|
||||||
:class="{
|
:class="{
|
||||||
'text-red-600':
|
'text-red-600':
|
||||||
@@ -26,32 +28,23 @@
|
|||||||
>
|
>
|
||||||
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</Label>
|
||||||
<textarea
|
<Textarea :id="id" v-model="value" autosize :placeholder="placeholder" class="col-span-3 mt-2 w-full resize-none" />
|
||||||
ref="el"
|
|
||||||
v-model="value"
|
|
||||||
class="textarea textarea-bordered col-span-3 mt-3 h-28 w-full"
|
|
||||||
auto-grow
|
|
||||||
:placeholder="placeholder"
|
|
||||||
auto-height
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
|
||||||
type: [String],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
type: {
|
modelValue: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "text",
|
required: true,
|
||||||
},
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -73,17 +66,6 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const el = ref();
|
const id = useId();
|
||||||
function setHeight() {
|
const value = useVModel(props, "modelValue");
|
||||||
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);
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!inline" class="form-control w-full">
|
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
|
||||||
<label class="label">
|
<Label :for="id" class="flex w-full px-1">
|
||||||
<span class="label-text"> {{ label }} </span>
|
<span> {{ label }} </span>
|
||||||
|
<span class="grow"></span>
|
||||||
<span
|
<span
|
||||||
:class="{
|
:class="{
|
||||||
'text-red-600':
|
'text-red-600':
|
||||||
@@ -11,19 +12,21 @@
|
|||||||
>
|
>
|
||||||
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</Label>
|
||||||
<input
|
<Input
|
||||||
|
:id="id"
|
||||||
ref="input"
|
ref="input"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:type="type"
|
:type="type"
|
||||||
:required="required"
|
:required="required"
|
||||||
class="input input-bordered w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||||
<label class="label">
|
<Label class="flex w-full px-1 py-2" :for="id">
|
||||||
<span class="label-text"> {{ label }} </span>
|
<span> {{ label }} </span>
|
||||||
|
<span class="grow"></span>
|
||||||
<span
|
<span
|
||||||
:class="{
|
:class="{
|
||||||
'text-red-600':
|
'text-red-600':
|
||||||
@@ -33,18 +36,21 @@
|
|||||||
>
|
>
|
||||||
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</Label>
|
||||||
<input
|
<Input
|
||||||
|
:id="id"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:type="type"
|
:type="type"
|
||||||
:required="required"
|
:required="required"
|
||||||
class="input input-bordered col-span-3 mt-2 w-full"
|
class="col-span-3 mt-2 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -86,6 +92,8 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
const input = ref<HTMLElement | null>(null);
|
const input = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal v-model="modal">
|
<BaseModal dialog-id="create-item" :title="$t('components.item.create_modal.title')">
|
||||||
<template #title> {{ $t("components.item.create_modal.title") }} </template>
|
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||||
<form @submit.prevent="create()">
|
|
||||||
<LocationSelector v-model="form.location" />
|
<LocationSelector v-model="form.location" />
|
||||||
<FormTextField
|
<FormTextField
|
||||||
ref="nameInput"
|
ref="nameInput"
|
||||||
@@ -17,93 +16,125 @@
|
|||||||
:label="$t('components.item.create_modal.item_description')"
|
:label="$t('components.item.create_modal.item_description')"
|
||||||
:max-length="1000"
|
:max-length="1000"
|
||||||
/>
|
/>
|
||||||
<FormMultiselect v-model="form.labels" :label="$t('global.labels')" :items="labels ?? []" />
|
<LabelSelector v-model="form.labels" :labels="labels ?? []" />
|
||||||
|
<div class="flex w-full flex-col gap-1.5">
|
||||||
<div class="modal-action mb-6">
|
<Label for="image-create-photo" class="flex w-full px-1">
|
||||||
<div>
|
{{ $t("components.item.create_modal.item_photo") }}
|
||||||
<label for="photo" class="btn">{{ $t("components.item.create_modal.photo_button") }}</label>
|
</Label>
|
||||||
<input
|
<div class="relative inline-block">
|
||||||
id="photo"
|
<Button type="button" variant="outline" class="w-full" aria-hidden="true" @click.prevent="">
|
||||||
class="hidden"
|
{{ $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"
|
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
|
multiple
|
||||||
@change="previewImage"
|
@change="previewImage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow"></div>
|
</div>
|
||||||
<div>
|
<div class="mt-4 flex flex-row-reverse">
|
||||||
<BaseButton class="rounded-r-none" :loading="loading" type="submit">
|
<ButtonGroup>
|
||||||
<template #icon>
|
<Button :disabled="loading" type="submit" class="group">
|
||||||
<MdiPackageVariant class="swap-off size-5" />
|
<div class="relative mx-2">
|
||||||
<MdiPackageVariantClosed class="swap-on size-5" />
|
<div
|
||||||
</template>
|
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") }}
|
{{ $t("global.create") }}
|
||||||
</BaseButton>
|
</Button>
|
||||||
<div class="dropdown dropdown-top">
|
<Button variant="outline" :disabled="loading" type="button" @click="create(false)">
|
||||||
<label tabindex="0" class="btn rounded-l-none rounded-r-xl">
|
{{ $t("global.create_and_add") }}
|
||||||
<MdiChevronDown class="size-5" name="mdi-chevron-down" />
|
</Button>
|
||||||
</label>
|
</ButtonGroup>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- photo preview area is AFTER the create button, to avoid pushing the button below the screen on small displays -->
|
<!-- 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 v-for="(photo, index) in form.photos" :key="index">
|
||||||
<div class="indicator mt-8 w-auto">
|
<div class="mt-8 w-full">
|
||||||
<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>
|
|
||||||
|
|
||||||
<img
|
<img
|
||||||
:src="photo.fileBase64"
|
: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"
|
alt="Uploaded Photo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</BaseModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useLabelStore } from "~~/stores/labels";
|
||||||
import { useLocationStore } from "~~/stores/locations";
|
import { useLocationStore } from "~~/stores/locations";
|
||||||
import MdiPackageVariant from "~icons/mdi/package-variant";
|
import MdiPackageVariant from "~icons/mdi/package-variant";
|
||||||
import MdiPackageVariantClosed from "~icons/mdi/package-variant-closed";
|
import MdiPackageVariantClosed from "~icons/mdi/package-variant-closed";
|
||||||
import MdiChevronDown from "~icons/mdi/chevron-down";
|
|
||||||
import MdiDelete from "~icons/mdi/delete";
|
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 { AttachmentTypes } from "~~/lib/api/types/non-generated";
|
||||||
|
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||||
|
import LabelSelector from "~/components/Label/Selector.vue";
|
||||||
|
|
||||||
interface PhotoPreview {
|
interface PhotoPreview {
|
||||||
photoName: string;
|
photoName: string;
|
||||||
file: File;
|
file: File;
|
||||||
fileBase64: string;
|
fileBase64: string;
|
||||||
|
primary: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const { activeDialog, closeDialog } = useDialog();
|
||||||
modelValue: {
|
|
||||||
type: Boolean,
|
useDialogHotkey("create-item", { code: "Digit1", shift: true });
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const toast = useNotifier();
|
|
||||||
|
|
||||||
const locationsStore = useLocationStore();
|
const locationsStore = useLocationStore();
|
||||||
const locations = computed(() => locationsStore.allLocations);
|
const locations = computed(() => locationsStore.allLocations);
|
||||||
@@ -129,15 +160,14 @@
|
|||||||
|
|
||||||
const nameInput = ref<HTMLInputElement | null>(null);
|
const nameInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const modal = useVModel(props, "modelValue");
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const focused = ref(false);
|
const focused = ref(false);
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
location: locations.value && locations.value.length > 0 ? locations.value[0] : ({} as LocationOut),
|
location: locations.value && locations.value.length > 0 ? locations.value[0] : ({} as LocationOut),
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
color: "", // Future!
|
color: "",
|
||||||
labels: [] as LabelOut[],
|
labels: [] as string[],
|
||||||
photos: [] as PhotoPreview[],
|
photos: [] as PhotoPreview[],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,48 +177,56 @@
|
|||||||
form.photos.splice(index, 1);
|
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) {
|
function previewImage(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
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) {
|
if (input.files && input.files.length > 0) {
|
||||||
for (const file of input.files) {
|
for (const file of input.files) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = e => {
|
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);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
|
input.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => modal.value,
|
() => activeDialog.value,
|
||||||
open => {
|
active => {
|
||||||
if (open) {
|
if (active === "create-item") {
|
||||||
useTimeoutFn(() => {
|
|
||||||
focused.value = true;
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
if (locationId.value) {
|
if (locationId.value) {
|
||||||
const found = locations.value.find(l => l.id === locationId.value);
|
const found = locations.value.find(l => l.id === locationId.value);
|
||||||
if (found) {
|
if (found) {
|
||||||
form.location = found;
|
form.location = found;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labelId.value) {
|
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) {
|
async function create(close = true) {
|
||||||
if (!form.location) {
|
if (!form.location?.id) {
|
||||||
|
toast.error("Please select a location.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,20 +237,18 @@
|
|||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
if (shift.value) {
|
if (shift.value) close = false;
|
||||||
close = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const out: ItemCreate = {
|
const out: ItemCreate = {
|
||||||
parentId: null,
|
parentId: null,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
locationId: form.location.id as string,
|
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);
|
const { error, data } = await api.items.create(out);
|
||||||
loading.value = false;
|
|
||||||
if (error) {
|
if (error) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
toast.error("Couldn't create item");
|
toast.error("Couldn't create item");
|
||||||
@@ -221,30 +257,40 @@
|
|||||||
|
|
||||||
toast.success("Item created");
|
toast.success("Item created");
|
||||||
|
|
||||||
// If the photo was provided, upload it
|
if (form.photos.length > 0) {
|
||||||
// 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
|
toast.info(`Uploading ${form.photos.length} photo(s)...`);
|
||||||
|
let uploadError = false;
|
||||||
for (const photo of form.photos) {
|
for (const photo of form.photos) {
|
||||||
const { error } = await api.items.attachments.add(data.id, photo.file, photo.photoName, AttachmentTypes.Photo);
|
const { error: attachError } = await api.items.attachments.add(
|
||||||
|
data.id,
|
||||||
|
photo.file,
|
||||||
|
photo.photoName,
|
||||||
|
AttachmentTypes.Photo
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (attachError) {
|
||||||
loading.value = false;
|
uploadError = true;
|
||||||
toast.error("Failed to upload Photo " + photo.photoName);
|
toast.error(`Failed to upload Photo: ${photo.photoName}`);
|
||||||
return;
|
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.name = "";
|
||||||
form.description = "";
|
form.description = "";
|
||||||
form.color = "";
|
form.color = "";
|
||||||
form.photos = [];
|
form.photos = [];
|
||||||
|
form.labels = [];
|
||||||
focused.value = false;
|
focused.value = false;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
||||||
if (close) {
|
if (close) {
|
||||||
modal.value = false;
|
closeDialog("create-item");
|
||||||
navigateTo(`/item/${data.id}`);
|
navigateTo(`/item/${data.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,6 @@
|
|||||||
<MdiArrowRight class="swap-on mr-2" />
|
<MdiArrowRight class="swap-on mr-2" />
|
||||||
<MdiTagOutline class="swap-off mr-2" />
|
<MdiTagOutline class="swap-off mr-2" />
|
||||||
</label>
|
</label>
|
||||||
{{ label.name }}
|
{{ label.name.length > 20 ? `${label.name.substring(0, 20)}...` : label.name }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal v-model="modal">
|
<BaseModal dialog-id="create-label" :title="$t('components.label.create_modal.title')">
|
||||||
<template #title>{{ $t("components.label.create_modal.title") }}</template>
|
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||||
<form @submit.prevent="create()">
|
|
||||||
<FormTextField
|
<FormTextField
|
||||||
ref="locationNameRef"
|
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
:trigger-focus="focused"
|
:trigger-focus="focused"
|
||||||
:autofocus="true"
|
:autofocus="true"
|
||||||
:label="$t('components.label.create_modal.label_name')"
|
:label="$t('components.label.create_modal.label_name')"
|
||||||
:max-length="255"
|
:max-length="50"
|
||||||
:min-length="1"
|
:min-length="1"
|
||||||
/>
|
/>
|
||||||
<FormTextArea
|
<FormTextArea
|
||||||
@@ -16,38 +14,27 @@
|
|||||||
:label="$t('components.label.create_modal.label_description')"
|
:label="$t('components.label.create_modal.label_description')"
|
||||||
:max-length="255"
|
:max-length="255"
|
||||||
/>
|
/>
|
||||||
<div class="modal-action">
|
<div class="mt-4 flex flex-row-reverse">
|
||||||
<div class="flex justify-center">
|
<ButtonGroup>
|
||||||
<BaseButton class="rounded-r-none" :loading="loading" type="submit"> {{ $t("global.create") }} </BaseButton>
|
<Button :disabled="loading" type="submit">{{ $t("global.create") }}</Button>
|
||||||
<div class="dropdown dropdown-top">
|
<Button variant="outline" :disabled="loading" type="button" @click="create(false)">
|
||||||
<label tabindex="0" class="btn rounded-l-none rounded-r-xl">
|
{{ $t("global.create_and_add") }}
|
||||||
<MdiChevronDown class="size-5" />
|
</Button>
|
||||||
</label>
|
</ButtonGroup>
|
||||||
<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>
|
</div>
|
||||||
</form>
|
</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>
|
</BaseModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MdiChevronDown from "~icons/mdi/chevron-down";
|
import { toast } from "@/components/ui/sonner";
|
||||||
const props = defineProps({
|
import BaseModal from "@/components/App/CreateModal.vue";
|
||||||
modelValue: {
|
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
const { closeDialog } = useDialog();
|
||||||
},
|
|
||||||
});
|
useDialogHotkey("create-label", { code: "Digit2", shift: true });
|
||||||
|
|
||||||
const modal = useVModel(props, "modelValue");
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const focused = ref(false);
|
const focused = ref(false);
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -64,20 +51,7 @@
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
|
||||||
() => modal.value,
|
|
||||||
open => {
|
|
||||||
if (open)
|
|
||||||
useTimeoutFn(() => {
|
|
||||||
focused.value = true;
|
|
||||||
}, 50);
|
|
||||||
else focused.value = false;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const toast = useNotifier();
|
|
||||||
|
|
||||||
const { shift } = useMagicKeys();
|
const { shift } = useMagicKeys();
|
||||||
|
|
||||||
async function create(close = true) {
|
async function create(close = true) {
|
||||||
@@ -85,13 +59,17 @@
|
|||||||
toast.error("Already creating a label");
|
toast.error("Already creating a label");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
if (form.name.length > 50) {
|
||||||
|
toast.error("Label name must not be longer than 50 characters");
|
||||||
if (shift.value) {
|
return;
|
||||||
close = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
if (shift.value) close = false;
|
||||||
|
|
||||||
const { error, data } = await api.labels.create(form);
|
const { error, data } = await api.labels.create(form);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error("Couldn't create label");
|
toast.error("Couldn't create label");
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -102,7 +80,7 @@
|
|||||||
reset();
|
reset();
|
||||||
|
|
||||||
if (close) {
|
if (close) {
|
||||||
modal.value = false;
|
closeDialog("create-label");
|
||||||
navigateTo(`/label/${data.id}`);
|
navigateTo(`/label/${data.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
149
frontend/components/Label/Selector.vue
Normal file
149
frontend/components/Label/Selector.vue
Normal 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>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal v-model="modal">
|
<BaseModal dialog-id="create-location" :title="$t('components.location.create_modal.title')">
|
||||||
<template #title>{{ $t("components.location.create_modal.title") }}</template>
|
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||||
<form @submit.prevent="create()">
|
|
||||||
<LocationSelector v-model="form.parent" />
|
<LocationSelector v-model="form.parent" />
|
||||||
<FormTextField
|
<FormTextField
|
||||||
ref="locationNameRef"
|
ref="locationNameRef"
|
||||||
@@ -18,39 +17,29 @@
|
|||||||
:label="$t('components.location.create_modal.location_description')"
|
:label="$t('components.location.create_modal.location_description')"
|
||||||
:max-length="1000"
|
:max-length="1000"
|
||||||
/>
|
/>
|
||||||
<div class="modal-action">
|
<div class="mt-4 flex flex-row-reverse">
|
||||||
<div class="flex justify-center">
|
<ButtonGroup>
|
||||||
<BaseButton class="rounded-r-none" type="submit" :loading="loading">{{ $t("global.create") }}</BaseButton>
|
<Button :disabled="loading" type="submit">{{ $t("global.create") }}</Button>
|
||||||
<div class="dropdown dropdown-top">
|
<Button variant="outline" :disabled="loading" type="button" @click="create(false)">{{
|
||||||
<label tabindex="0" class="btn rounded-l-none rounded-r-xl">
|
$t("global.create_and_add")
|
||||||
<MdiChevronDown class="size-5" />
|
}}</Button>
|
||||||
</label>
|
</ButtonGroup>
|
||||||
<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>
|
</div>
|
||||||
</form>
|
</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>
|
</BaseModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 type { LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||||
import MdiChevronDown from "~icons/mdi/chevron-down";
|
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
const { activeDialog, closeDialog } = useDialog();
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
useDialogHotkey("create-location", { code: "Digit3", shift: true });
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const modal = useVModel(props, "modelValue");
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const focused = ref(false);
|
const focused = ref(false);
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -60,12 +49,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => modal.value,
|
() => activeDialog.value,
|
||||||
open => {
|
active => {
|
||||||
if (open) {
|
if (active === "create-location") {
|
||||||
useTimeoutFn(() => {
|
// useTimeoutFn(() => {
|
||||||
focused.value = true;
|
// focused.value = true;
|
||||||
}, 50);
|
// }, 50);
|
||||||
|
|
||||||
if (locationId.value) {
|
if (locationId.value) {
|
||||||
const found = locations.value.find(l => l.id === locationId.value);
|
const found = locations.value.find(l => l.id === locationId.value);
|
||||||
@@ -74,7 +63,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
focused.value = false;
|
// focused.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -88,7 +77,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const toast = useNotifier();
|
|
||||||
|
|
||||||
const locationsStore = useLocationStore();
|
const locationsStore = useLocationStore();
|
||||||
const locations = computed(() => locationsStore.allLocations);
|
const locations = computed(() => locationsStore.allLocations);
|
||||||
@@ -111,9 +99,7 @@
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
if (shift.value) {
|
if (shift.value) close = false;
|
||||||
close = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await api.locations.create({
|
const { data, error } = await api.locations.create({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
@@ -132,7 +118,7 @@
|
|||||||
reset();
|
reset();
|
||||||
|
|
||||||
if (close) {
|
if (close) {
|
||||||
modal.value = false;
|
closeDialog("create-location");
|
||||||
navigateTo(`/location/${data.id}`);
|
navigateTo(`/location/${data.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
frontend/components/Location/LegacySelector.vue
Normal file
63
frontend/components/Location/LegacySelector.vue
Normal 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>
|
||||||
@@ -1,62 +1,95 @@
|
|||||||
<template>
|
<template>
|
||||||
<FormAutocomplete2
|
<div class="flex flex-col gap-1">
|
||||||
v-if="locations"
|
<Label :for="id" class="px-1">
|
||||||
v-model="value"
|
{{ $t("components.location.selector.parent_location") }}
|
||||||
:items="locations"
|
</Label>
|
||||||
display="name"
|
|
||||||
:label="$t('components.location.selector.parent_location')"
|
<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)"
|
||||||
>
|
>
|
||||||
<template #display="{ item, selected, active }">
|
<Check :class="cn('mr-2 h-4 w-4', value?.id === location.id ? 'opacity-100' : 'opacity-0')" />
|
||||||
<div>
|
<div>
|
||||||
<div class="flex w-full">
|
<div class="flex w-full">
|
||||||
{{ cast(item.value).name }}
|
{{ location.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>
|
||||||
<div v-if="cast(item.value).name != cast(item.value).treeString" class="mt-1 text-xs">
|
<div v-if="location.name !== location.treeString" class="mt-1 text-xs text-muted-foreground">
|
||||||
{{ cast(item.value).treeString }}
|
{{ location.treeString }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</CommandItem>
|
||||||
</FormAutocomplete2>
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script setup lang="ts">
|
||||||
import type { FlatTreeItem } from "~~/composables/use-location-helpers";
|
import { Check, ChevronsUpDown } from "lucide-vue-next";
|
||||||
import { useFlatLocations } from "~~/composables/use-location-helpers";
|
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 type { LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||||
import MdiCheck from "~icons/mdi/check";
|
import { useFlatLocations } from "~~/composables/use-location-helpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
modelValue?: LocationSummary | null;
|
modelValue?: LocationSummary | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cast the type of the item to a FlatTreeItem so we can get type "safety" in the template
|
const props = defineProps<Props>();
|
||||||
// Note that this does not actually change the type of the item, it just tells the compiler
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
// that the type is FlatTreeItem. We must keep this in sync with the type of the items
|
|
||||||
function cast(value: any): FlatTreeItem {
|
const open = ref(false);
|
||||||
return value as FlatTreeItem;
|
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 filteredLocations = computed(() => {
|
||||||
const value = useVModel(props, "modelValue");
|
const filtered = fuzzysort.go(search.value, locations.value, { key: "name", all: true }).map(i => i.obj);
|
||||||
|
|
||||||
const locations = useFlatLocations();
|
return filtered;
|
||||||
const form = ref({
|
|
||||||
parent: null as LocationSummary | null,
|
|
||||||
search: "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Whenever parent goes from value to null reset search
|
// Reset search when value is cleared
|
||||||
watch(
|
watch(
|
||||||
() => value.value,
|
() => value.value,
|
||||||
() => {
|
() => {
|
||||||
if (!value.value) {
|
if (!value.value) {
|
||||||
form.value.search = "";
|
search.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
<FormTextField v-model="entry.name" autofocus :label="$t('maintenance.modal.entry_name')" />
|
<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.completedDate" :label="$t('maintenance.modal.completed_date')" />
|
||||||
<DatePicker v-model="entry.scheduledDate" :label="$t('maintenance.modal.scheduled_date')" />
|
<DatePicker v-model="entry.scheduledDate" :label="$t('maintenance.modal.scheduled_date')" />
|
||||||
<FormTextArea v-model="entry.description" :label="$t('maintenance.modal.notes')" />
|
<FormTextArea v-model="entry.description" :label="$t('maintenance.modal.notes')" class="pt-2" />
|
||||||
<FormTextField v-model="entry.cost" autofocus :label="$t('maintenance.modal.cost')" />
|
<FormTextField v-model="entry.cost" autofocus :label="$t('maintenance.modal.cost')" class="pt-2" />
|
||||||
<div class="flex justify-end py-2">
|
<div class="flex justify-end py-2">
|
||||||
<BaseButton type="submit" class="ml-2 mt-2">
|
<BaseButton type="submit" class="ml-2 mt-2">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -23,13 +23,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { toast } from "@/components/ui/sonner";
|
||||||
import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||||
import MdiPost from "~icons/mdi/post";
|
import MdiPost from "~icons/mdi/post";
|
||||||
import DatePicker from "~~/components/Form/DatePicker.vue";
|
import DatePicker from "~~/components/Form/DatePicker.vue";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const toast = useNotifier();
|
|
||||||
|
|
||||||
const emit = defineEmits(["changed"]);
|
const emit = defineEmits(["changed"]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal v-model="isRevealed" readonly @cancel="cancel(false)">
|
<AlertDialog :open="isRevealed">
|
||||||
<template #title> {{ $t("global.confirm") }} </template>
|
<AlertDialogContent>
|
||||||
<div>
|
<AlertDialogHeader>
|
||||||
<p>{{ text }}</p>
|
<AlertDialogTitle>{{ $t("global.confirm") }}</AlertDialogTitle>
|
||||||
</div>
|
<AlertDialogDescription> {{ text }} </AlertDialogDescription>
|
||||||
<div class="modal-action">
|
</AlertDialogHeader>
|
||||||
<BaseButton type="submit" @click="confirm(true)"> {{ $t("global.confirm") }} </BaseButton>
|
<AlertDialogFooter>
|
||||||
</div>
|
<AlertDialogCancel @click="cancel(false)">
|
||||||
</BaseModal>
|
{{ $t("global.cancel") }}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction @click="confirm(true)">
|
||||||
|
{{ $t("global.confirm") }}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useDialog } from "./ui/dialog-provider";
|
||||||
|
|
||||||
const { text, isRevealed, confirm, cancel } = useConfirm();
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { route } from "../../lib/api/base";
|
import { route } from "../../lib/api/base";
|
||||||
|
import { toast } from "@/components/ui/sonner";
|
||||||
import MdiPrinterPos from "~icons/mdi/printer-pos";
|
import MdiPrinterPos from "~icons/mdi/printer-pos";
|
||||||
import MdiFileDownload from "~icons/mdi/file-download";
|
import MdiFileDownload from "~icons/mdi/file-download";
|
||||||
|
|
||||||
@@ -9,7 +10,6 @@
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const pubApi = usePublicApi();
|
const pubApi = usePublicApi();
|
||||||
const toast = useNotifier();
|
|
||||||
|
|
||||||
const { data: status } = useAsyncData(async () => {
|
const { data: status } = useAsyncData(async () => {
|
||||||
const { data, error } = await pubApi.status();
|
const { data, error } = await pubApi.status();
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
14
frontend/components/ui/alert-dialog/AlertDialog.vue
Normal file
14
frontend/components/ui/alert-dialog/AlertDialog.vue
Normal 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>
|
||||||
20
frontend/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
20
frontend/components/ui/alert-dialog/AlertDialogAction.vue
Normal 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>
|
||||||
27
frontend/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
27
frontend/components/ui/alert-dialog/AlertDialogCancel.vue
Normal 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>
|
||||||
42
frontend/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
42
frontend/components/ui/alert-dialog/AlertDialogContent.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
21
frontend/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
21
frontend/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
frontend/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
16
frontend/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
22
frontend/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
22
frontend/components/ui/alert-dialog/AlertDialogTitle.vue
Normal 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>
|
||||||
11
frontend/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
11
frontend/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal 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>
|
||||||
9
frontend/components/ui/alert-dialog/index.ts
Normal file
9
frontend/components/ui/alert-dialog/index.ts
Normal 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'
|
||||||
16
frontend/components/ui/badge/Badge.vue
Normal file
16
frontend/components/ui/badge/Badge.vue
Normal 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>
|
||||||
25
frontend/components/ui/badge/index.ts
Normal file
25
frontend/components/ui/badge/index.ts
Normal 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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue";
|
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 { type ButtonVariants, buttonVariants } from ".";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
22
frontend/components/ui/button/ButtonGroup.vue
Normal file
22
frontend/components/ui/button/ButtonGroup.vue
Normal 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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
export { default as Button } from "./Button.vue";
|
export { default as Button } from "./Button.vue";
|
||||||
|
export { default as ButtonGroup } from "./ButtonGroup.vue";
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
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",
|
"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",
|
||||||
|
|||||||
30
frontend/components/ui/command/Command.vue
Normal file
30
frontend/components/ui/command/Command.vue
Normal 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>
|
||||||
21
frontend/components/ui/command/CommandDialog.vue
Normal file
21
frontend/components/ui/command/CommandDialog.vue
Normal 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>
|
||||||
20
frontend/components/ui/command/CommandEmpty.vue
Normal file
20
frontend/components/ui/command/CommandEmpty.vue
Normal 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>
|
||||||
29
frontend/components/ui/command/CommandGroup.vue
Normal file
29
frontend/components/ui/command/CommandGroup.vue
Normal 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>
|
||||||
33
frontend/components/ui/command/CommandInput.vue
Normal file
33
frontend/components/ui/command/CommandInput.vue
Normal 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>
|
||||||
26
frontend/components/ui/command/CommandItem.vue
Normal file
26
frontend/components/ui/command/CommandItem.vue
Normal 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>
|
||||||
25
frontend/components/ui/command/CommandList.vue
Normal file
25
frontend/components/ui/command/CommandList.vue
Normal 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>
|
||||||
23
frontend/components/ui/command/CommandSeparator.vue
Normal file
23
frontend/components/ui/command/CommandSeparator.vue
Normal 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>
|
||||||
14
frontend/components/ui/command/CommandShortcut.vue
Normal file
14
frontend/components/ui/command/CommandShortcut.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
9
frontend/components/ui/command/index.ts
Normal file
9
frontend/components/ui/command/index.ts
Normal 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'
|
||||||
48
frontend/components/ui/dialog-provider/DialogProvider.vue
Normal file
48
frontend/components/ui/dialog-provider/DialogProvider.vue
Normal 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>
|
||||||
2
frontend/components/ui/dialog-provider/index.ts
Normal file
2
frontend/components/ui/dialog-provider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { useDialog, useDialogHotkey } from "./utils";
|
||||||
|
export { default as DialogProvider } from "./DialogProvider.vue";
|
||||||
56
frontend/components/ui/dialog-provider/utils.ts
Normal file
56
frontend/components/ui/dialog-provider/utils.ts
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
22
frontend/components/ui/dialog/Dialog.vue
Normal file
22
frontend/components/ui/dialog/Dialog.vue
Normal 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>
|
||||||
11
frontend/components/ui/dialog/DialogClose.vue
Normal file
11
frontend/components/ui/dialog/DialogClose.vue
Normal 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>
|
||||||
50
frontend/components/ui/dialog/DialogContent.vue
Normal file
50
frontend/components/ui/dialog/DialogContent.vue
Normal 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>
|
||||||
24
frontend/components/ui/dialog/DialogDescription.vue
Normal file
24
frontend/components/ui/dialog/DialogDescription.vue
Normal 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>
|
||||||
19
frontend/components/ui/dialog/DialogFooter.vue
Normal file
19
frontend/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||||
16
frontend/components/ui/dialog/DialogHeader.vue
Normal file
16
frontend/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
59
frontend/components/ui/dialog/DialogScrollContent.vue
Normal file
59
frontend/components/ui/dialog/DialogScrollContent.vue
Normal 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>
|
||||||
29
frontend/components/ui/dialog/DialogTitle.vue
Normal file
29
frontend/components/ui/dialog/DialogTitle.vue
Normal 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>
|
||||||
11
frontend/components/ui/dialog/DialogTrigger.vue
Normal file
11
frontend/components/ui/dialog/DialogTrigger.vue
Normal 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>
|
||||||
9
frontend/components/ui/dialog/index.ts
Normal file
9
frontend/components/ui/dialog/index.ts
Normal 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'
|
||||||
27
frontend/components/ui/drawer/Drawer.vue
Normal file
27
frontend/components/ui/drawer/Drawer.vue
Normal 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>
|
||||||
28
frontend/components/ui/drawer/DrawerContent.vue
Normal file
28
frontend/components/ui/drawer/DrawerContent.vue
Normal 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>
|
||||||
20
frontend/components/ui/drawer/DrawerDescription.vue
Normal file
20
frontend/components/ui/drawer/DrawerDescription.vue
Normal 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>
|
||||||
14
frontend/components/ui/drawer/DrawerFooter.vue
Normal file
14
frontend/components/ui/drawer/DrawerFooter.vue
Normal 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>
|
||||||
14
frontend/components/ui/drawer/DrawerHeader.vue
Normal file
14
frontend/components/ui/drawer/DrawerHeader.vue
Normal 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>
|
||||||
18
frontend/components/ui/drawer/DrawerOverlay.vue
Normal file
18
frontend/components/ui/drawer/DrawerOverlay.vue
Normal 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>
|
||||||
20
frontend/components/ui/drawer/DrawerTitle.vue
Normal file
20
frontend/components/ui/drawer/DrawerTitle.vue
Normal 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>
|
||||||
8
frontend/components/ui/drawer/index.ts
Normal file
8
frontend/components/ui/drawer/index.ts
Normal 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'
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
type DropdownMenuRootEmits,
|
type DropdownMenuRootEmits,
|
||||||
type DropdownMenuRootProps,
|
type DropdownMenuRootProps,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "radix-vue";
|
} from "reka-ui";
|
||||||
|
|
||||||
const props = defineProps<DropdownMenuRootProps>();
|
const props = defineProps<DropdownMenuRootProps>();
|
||||||
const emits = defineEmits<DropdownMenuRootEmits>();
|
const emits = defineEmits<DropdownMenuRootEmits>();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
type DropdownMenuCheckboxItemProps,
|
type DropdownMenuCheckboxItemProps,
|
||||||
DropdownMenuItemIndicator,
|
DropdownMenuItemIndicator,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "radix-vue";
|
} from "reka-ui";
|
||||||
import { computed, type HTMLAttributes } from "vue";
|
import { computed, type HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
type DropdownMenuContentProps,
|
type DropdownMenuContentProps,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "radix-vue";
|
} from "reka-ui";
|
||||||
import { computed, type HTMLAttributes } from "vue";
|
import { computed, type HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DropdownMenuGroup, type DropdownMenuGroupProps } from "radix-vue";
|
import { DropdownMenuGroup, type DropdownMenuGroupProps } from "reka-ui";
|
||||||
|
|
||||||
const props = defineProps<DropdownMenuGroupProps>();
|
const props = defineProps<DropdownMenuGroupProps>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { computed, type HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { computed, type HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
type DropdownMenuRadioGroupEmits,
|
type DropdownMenuRadioGroupEmits,
|
||||||
type DropdownMenuRadioGroupProps,
|
type DropdownMenuRadioGroupProps,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "radix-vue";
|
} from "reka-ui";
|
||||||
|
|
||||||
const props = defineProps<DropdownMenuRadioGroupProps>();
|
const props = defineProps<DropdownMenuRadioGroupProps>();
|
||||||
const emits = defineEmits<DropdownMenuRadioGroupEmits>();
|
const emits = defineEmits<DropdownMenuRadioGroupEmits>();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
type DropdownMenuRadioItemEmits,
|
type DropdownMenuRadioItemEmits,
|
||||||
type DropdownMenuRadioItemProps,
|
type DropdownMenuRadioItemProps,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "radix-vue";
|
} from "reka-ui";
|
||||||
import { computed, type HTMLAttributes } from "vue";
|
import { computed, type HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { computed, type HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
type DropdownMenuSubEmits,
|
type DropdownMenuSubEmits,
|
||||||
type DropdownMenuSubProps,
|
type DropdownMenuSubProps,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "radix-vue";
|
} from "reka-ui";
|
||||||
|
|
||||||
const props = defineProps<DropdownMenuSubProps>();
|
const props = defineProps<DropdownMenuSubProps>();
|
||||||
const emits = defineEmits<DropdownMenuSubEmits>();
|
const emits = defineEmits<DropdownMenuSubEmits>();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
type DropdownMenuSubContentEmits,
|
type DropdownMenuSubContentEmits,
|
||||||
type DropdownMenuSubContentProps,
|
type DropdownMenuSubContentProps,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "radix-vue";
|
} from "reka-ui";
|
||||||
import { computed, type HTMLAttributes } from "vue";
|
import { computed, type HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronRight } from "lucide-vue-next";
|
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 { computed, type HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DropdownMenuTrigger, type DropdownMenuTriggerProps, useForwardProps } from "radix-vue";
|
import { DropdownMenuTrigger, type DropdownMenuTriggerProps, useForwardProps } from "reka-ui";
|
||||||
|
|
||||||
const props = defineProps<DropdownMenuTriggerProps>();
|
const props = defineProps<DropdownMenuTriggerProps>();
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ export { default as DropdownMenuSub } from "./DropdownMenuSub.vue";
|
|||||||
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue";
|
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue";
|
||||||
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue";
|
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue";
|
||||||
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue";
|
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue";
|
||||||
export { DropdownMenuPortal } from "radix-vue";
|
export { DropdownMenuPortal } from "reka-ui";
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
27
frontend/components/ui/label/Label.vue
Normal file
27
frontend/components/ui/label/Label.vue
Normal 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>
|
||||||
1
frontend/components/ui/label/index.ts
Normal file
1
frontend/components/ui/label/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Label } from './Label.vue'
|
||||||
15
frontend/components/ui/popover/Popover.vue
Normal file
15
frontend/components/ui/popover/Popover.vue
Normal 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>
|
||||||
48
frontend/components/ui/popover/PopoverContent.vue
Normal file
48
frontend/components/ui/popover/PopoverContent.vue
Normal 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>
|
||||||
11
frontend/components/ui/popover/PopoverTrigger.vue
Normal file
11
frontend/components/ui/popover/PopoverTrigger.vue
Normal 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>
|
||||||
3
frontend/components/ui/popover/index.ts
Normal file
3
frontend/components/ui/popover/index.ts
Normal 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'
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { computed, type HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 props = defineProps<DialogRootProps>();
|
||||||
const emits = defineEmits<DialogRootEmits>();
|
const emits = defineEmits<DialogRootEmits>();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DialogClose, type DialogCloseProps } from "radix-vue";
|
import { DialogClose, type DialogCloseProps } from "reka-ui";
|
||||||
|
|
||||||
const props = defineProps<DialogCloseProps>();
|
const props = defineProps<DialogCloseProps>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "radix-vue";
|
} from "reka-ui";
|
||||||
import { computed, type HTMLAttributes } from "vue";
|
import { computed, type HTMLAttributes } from "vue";
|
||||||
import { type SheetVariants, sheetVariants } from ".";
|
import { type SheetVariants, sheetVariants } from ".";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay
|
<DialogOverlay
|
||||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
class="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 }">
|
<DialogContent :class="cn(sheetVariants({ side }), props.class)" v-bind="{ ...forwarded, ...$attrs }">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { computed, type HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { computed, type HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DialogTrigger, type DialogTriggerProps } from "radix-vue";
|
import { DialogTrigger, type DialogTriggerProps } from "reka-ui";
|
||||||
|
|
||||||
const props = defineProps<DialogTriggerProps>();
|
const props = defineProps<DialogTriggerProps>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
35
frontend/components/ui/shortcut/Shortcut.vue
Normal file
35
frontend/components/ui/shortcut/Shortcut.vue
Normal 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
Reference in New Issue
Block a user