Compare commits
2 commits
1727a4cbf0
...
012f364a7a
| Author | SHA1 | Date | |
|---|---|---|---|
| 012f364a7a | |||
| 93c05104df |
18 changed files with 690 additions and 89 deletions
75
package-lock.json
generated
75
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "aio-shadcn-vite",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.1",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/components": "^12.5.0",
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-vue": "^1.9.13",
|
||||
"reka-ui": "^2.6.0",
|
||||
"reka-ui": "^2.9.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
|
|
@ -3948,9 +3949,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz",
|
||||
"integrity": "sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==",
|
||||
"version": "3.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz",
|
||||
"integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
|
|
@ -7054,9 +7055,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
|
|
@ -12225,9 +12226,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/reka-ui": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz",
|
||||
"integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==",
|
||||
"version": "2.9.7",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.7.tgz",
|
||||
"integrity": "sha512-aX7foYYR20v4+majO58OJJdBNfLMm0eJb448l9N4JVy8JB7GXOr4H/S4a+J1pkcoxZH8Cb7YHpJ855+miAm7sA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
|
@ -12235,26 +12236,62 @@
|
|||
"@internationalized/date": "^3.5.0",
|
||||
"@internationalized/number": "^3.5.0",
|
||||
"@tanstack/vue-virtual": "^3.12.0",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/shared": "^12.5.0",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@vueuse/shared": "^14.1.0",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"defu": "^6.1.4",
|
||||
"defu": "^6.1.5",
|
||||
"ohash": "^2.0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/zernonia"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">= 3.2.0"
|
||||
"vue": ">= 3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui/node_modules/@vueuse/shared": {
|
||||
"version": "12.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz",
|
||||
"integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==",
|
||||
"node_modules/reka-ui/node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reka-ui/node_modules/@vueuse/core": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
|
||||
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.3.0",
|
||||
"@vueuse/shared": "14.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui/node_modules/@vueuse/metadata": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
|
||||
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui/node_modules/@vueuse/shared": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
|
||||
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"make": "electron-forge make"
|
||||
},
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.1",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/components": "^12.5.0",
|
||||
|
|
@ -63,7 +64,7 @@
|
|||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-vue": "^1.9.13",
|
||||
"reka-ui": "^2.6.0",
|
||||
"reka-ui": "^2.9.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
|
|
|
|||
58
src/components/ui/calendar/Calendar.vue
Normal file
58
src/components/ui/calendar/Calendar.vue
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarRootEmits, CalendarRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from "."
|
||||
|
||||
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<CalendarRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarRoot
|
||||
v-slot="{ grid, weekDays }"
|
||||
:class="cn('p-3', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<CalendarHeader>
|
||||
<CalendarPrevButton />
|
||||
<CalendarHeading />
|
||||
<CalendarNextButton />
|
||||
</CalendarHeader>
|
||||
|
||||
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
|
||||
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||
<CalendarGridHead>
|
||||
<CalendarGridRow>
|
||||
<CalendarHeadCell
|
||||
v-for="day in weekDays" :key="day"
|
||||
>
|
||||
{{ day }}
|
||||
</CalendarHeadCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridHead>
|
||||
<CalendarGridBody>
|
||||
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
|
||||
<CalendarCell
|
||||
v-for="weekDate in weekDates"
|
||||
:key="weekDate.toString()"
|
||||
:date="weekDate"
|
||||
>
|
||||
<CalendarCellTrigger
|
||||
:day="weekDate"
|
||||
:month="month.value"
|
||||
/>
|
||||
</CalendarCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridBody>
|
||||
</CalendarGrid>
|
||||
</div>
|
||||
</CalendarRoot>
|
||||
</template>
|
||||
22
src/components/ui/calendar/CalendarCell.vue
Normal file
22
src/components/ui/calendar/CalendarCell.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarCellProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarCell, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCell
|
||||
:class="cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCell>
|
||||
</template>
|
||||
36
src/components/ui/calendar/CalendarCellTrigger.vue
Normal file
36
src/components/ui/calendar/CalendarCellTrigger.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarCellTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarCellTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCellTrigger
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-8 w-8 p-0 font-normal',
|
||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||
// Selected
|
||||
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
|
||||
// Disabled
|
||||
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||
// Unavailable
|
||||
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||
// Outside months
|
||||
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCellTrigger>
|
||||
</template>
|
||||
22
src/components/ui/calendar/CalendarGrid.vue
Normal file
22
src/components/ui/calendar/CalendarGrid.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarGrid, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGrid
|
||||
:class="cn('w-full border-collapse space-y-1', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarGrid>
|
||||
</template>
|
||||
12
src/components/ui/calendar/CalendarGridBody.vue
Normal file
12
src/components/ui/calendar/CalendarGridBody.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridBodyProps } from "reka-ui"
|
||||
import { CalendarGridBody } from "reka-ui"
|
||||
|
||||
const props = defineProps<CalendarGridBodyProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridBody v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridBody>
|
||||
</template>
|
||||
13
src/components/ui/calendar/CalendarGridHead.vue
Normal file
13
src/components/ui/calendar/CalendarGridHead.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridHeadProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { CalendarGridHead } from "reka-ui"
|
||||
|
||||
const props = defineProps<CalendarGridHeadProps & { class?: HTMLAttributes["class"] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridHead v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridHead>
|
||||
</template>
|
||||
19
src/components/ui/calendar/CalendarGridRow.vue
Normal file
19
src/components/ui/calendar/CalendarGridRow.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridRowProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarGridRow, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarGridRow>
|
||||
</template>
|
||||
19
src/components/ui/calendar/CalendarHeadCell.vue
Normal file
19
src/components/ui/calendar/CalendarHeadCell.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarHeadCellProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarHeadCell, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeadCell :class="cn('w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarHeadCell>
|
||||
</template>
|
||||
19
src/components/ui/calendar/CalendarHeader.vue
Normal file
19
src/components/ui/calendar/CalendarHeader.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarHeaderProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarHeader, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarHeader>
|
||||
</template>
|
||||
29
src/components/ui/calendar/CalendarHeading.vue
Normal file
29
src/components/ui/calendar/CalendarHeading.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarHeadingProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarHeading, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
defineSlots<{
|
||||
default: (props: { headingValue: string }) => any
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeading
|
||||
v-slot="{ headingValue }"
|
||||
:class="cn('text-sm font-medium', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot :heading-value>
|
||||
{{ headingValue }}
|
||||
</slot>
|
||||
</CalendarHeading>
|
||||
</template>
|
||||
30
src/components/ui/calendar/CalendarNextButton.vue
Normal file
30
src/components/ui/calendar/CalendarNextButton.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarNextProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronRight } from "lucide-vue-next"
|
||||
import { CalendarNext, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarNext
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarNext>
|
||||
</template>
|
||||
30
src/components/ui/calendar/CalendarPrevButton.vue
Normal file
30
src/components/ui/calendar/CalendarPrevButton.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarPrevProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronLeft } from "lucide-vue-next"
|
||||
import { CalendarPrev, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarPrev
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarPrev>
|
||||
</template>
|
||||
12
src/components/ui/calendar/index.ts
Normal file
12
src/components/ui/calendar/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { default as Calendar } from "./Calendar.vue"
|
||||
export { default as CalendarCell } from "./CalendarCell.vue"
|
||||
export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue"
|
||||
export { default as CalendarGrid } from "./CalendarGrid.vue"
|
||||
export { default as CalendarGridBody } from "./CalendarGridBody.vue"
|
||||
export { default as CalendarGridHead } from "./CalendarGridHead.vue"
|
||||
export { default as CalendarGridRow } from "./CalendarGridRow.vue"
|
||||
export { default as CalendarHeadCell } from "./CalendarHeadCell.vue"
|
||||
export { default as CalendarHeader } from "./CalendarHeader.vue"
|
||||
export { default as CalendarHeading } from "./CalendarHeading.vue"
|
||||
export { default as CalendarNextButton } from "./CalendarNextButton.vue"
|
||||
export { default as CalendarPrevButton } from "./CalendarPrevButton.vue"
|
||||
|
|
@ -24,7 +24,6 @@ import { Input } from '@/components/ui/input'
|
|||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -33,15 +32,12 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Calendar, Loader2, ChevronDown, MapPin } from 'lucide-vue-next'
|
||||
import { Calendar, Loader2, MapPin } from 'lucide-vue-next'
|
||||
import { toastService } from '@/core/services/ToastService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
||||
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
||||
import TimePicker from '@/modules/base/components/TimePicker.vue'
|
||||
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import type { CreateEventRequest } from '../types/ticket'
|
||||
|
|
@ -60,7 +56,19 @@ const emit = defineEmits<{
|
|||
|
||||
const { t } = useI18n()
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
// Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM")
|
||||
// into the events-extension wire format: date-only when no time given,
|
||||
// ISO 8601 datetime otherwise. The publisher switches NIP-52 kinds on
|
||||
// the "T" delimiter. Hoisted above the schema so the validation refine
|
||||
// can reuse it.
|
||||
function foldDateTime(date: string, time: string): string {
|
||||
if (!date) return ''
|
||||
return time ? `${date}T${time}` : date
|
||||
}
|
||||
|
||||
const formSchema = toTypedSchema(
|
||||
z
|
||||
.object({
|
||||
name: z.string().min(1, "Title is required").max(200, "Title too long"),
|
||||
info: z.string().max(2000, "Description too long").optional().default(''),
|
||||
event_start_date: z.string().min(1, "Start date is required"),
|
||||
|
|
@ -71,7 +79,22 @@ const formSchema = toTypedSchema(z.object({
|
|||
currency: z.string().default("sat"),
|
||||
amount_tickets: z.number().min(0).max(100000).default(0),
|
||||
price_per_ticket: z.number().min(0).default(0),
|
||||
}))
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
// End must not precede start. Compare on the folded date+time
|
||||
// string so equal-date / later-time is enforced too.
|
||||
if (!v.event_end_date) return
|
||||
const start = foldDateTime(v.event_start_date, v.event_start_time)
|
||||
const end = foldDateTime(v.event_end_date, v.event_end_time)
|
||||
if (start && end && end < start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['event_end_date'],
|
||||
message: 'End must be on or after start',
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
|
|
@ -94,14 +117,21 @@ interface BannerImage extends UploadedImage {
|
|||
}
|
||||
const bannerImages = ref<BannerImage[]>([])
|
||||
|
||||
// Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM")
|
||||
// into the events-extension wire format: date-only when no time given,
|
||||
// ISO 8601 datetime otherwise. The publisher switches NIP-52 kinds on
|
||||
// the "T" delimiter.
|
||||
function foldDateTime(date: string, time: string): string {
|
||||
if (!date) return ''
|
||||
return time ? `${date}T${time}` : date
|
||||
// Auto-mirror end date to start: when the user picks a start date,
|
||||
// surface that same date in the end-date picker so a one-day event
|
||||
// requires no extra clicks. Don't overwrite an end date the user
|
||||
// already set *after* the start — only fill when empty or when the
|
||||
// existing end has fallen behind the new start.
|
||||
watch(
|
||||
() => form.values.event_start_date,
|
||||
(start, prev) => {
|
||||
if (!start) return
|
||||
const end = form.values.event_end_date
|
||||
if (!end || end < start || end === prev) {
|
||||
form.setFieldValue('event_end_date', start)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE)
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null
|
||||
|
|
@ -110,7 +140,6 @@ const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLO
|
|||
const availableCurrencies = ref<string[]>(['sat'])
|
||||
const loadingCurrencies = ref(false)
|
||||
const selectedCategories = ref<string[]>([])
|
||||
const showMoreOptions = ref(false)
|
||||
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen && ticketApi && !loadingCurrencies.value) {
|
||||
|
|
@ -125,7 +154,6 @@ watch(() => props.open, async (isOpen) => {
|
|||
}
|
||||
if (!isOpen) {
|
||||
selectedCategories.value = []
|
||||
showMoreOptions.value = false
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -240,21 +268,67 @@ const handleOpenChange = (open: boolean) => {
|
|||
|
||||
<!-- Start date (required) + optional time -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<FormField v-slot="{ componentField }" name="event_start_date">
|
||||
<FormField v-slot="{ value, handleChange }" name="event_start_date">
|
||||
<FormItem class="sm:col-span-2 min-w-0">
|
||||
<FormLabel>Start date *</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" :min="today" class="w-full" v-bind="componentField" />
|
||||
<DatePicker
|
||||
:model-value="value as string ?? ''"
|
||||
:min="today"
|
||||
placeholder="Pick a date"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="event_start_time">
|
||||
<FormField v-slot="{ value, handleChange }" name="event_start_time">
|
||||
<FormItem class="min-w-0">
|
||||
<FormLabel>Start time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="time" class="w-full" v-bind="componentField" />
|
||||
<TimePicker
|
||||
:model-value="value as string ?? ''"
|
||||
clearable
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- End date + optional time. Auto-mirrors start date until
|
||||
the user moves it forward; cross-field rule enforces
|
||||
end >= start in the Zod schema. -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<FormField v-slot="{ value, handleChange }" name="event_end_date">
|
||||
<FormItem class="sm:col-span-2 min-w-0">
|
||||
<FormLabel>End date</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
:model-value="value as string ?? ''"
|
||||
:min="(form.values.event_start_date as string) || today"
|
||||
placeholder="Pick a date"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="event_end_time">
|
||||
<FormItem class="min-w-0">
|
||||
<FormLabel>
|
||||
End time
|
||||
<span class="text-muted-foreground font-normal">(optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TimePicker
|
||||
:model-value="value as string ?? ''"
|
||||
clearable
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -371,42 +445,6 @@ const handleOpenChange = (open: boolean) => {
|
|||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- More options (collapsible) -->
|
||||
<Collapsible v-model:open="showMoreOptions">
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button type="button" variant="ghost" size="sm" class="w-full justify-between text-muted-foreground">
|
||||
More options
|
||||
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': showMoreOptions }" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="space-y-4 pt-2">
|
||||
<Separator />
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<FormField v-slot="{ componentField }" name="event_end_date">
|
||||
<FormItem class="sm:col-span-2 min-w-0">
|
||||
<FormLabel>End date</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" :min="today" class="w-full" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription class="text-xs">Defaults to start date</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="event_end_time">
|
||||
<FormItem class="min-w-0">
|
||||
<FormLabel>End time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="time" class="w-full" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
|
||||
|
|
|
|||
98
src/modules/base/components/DatePicker.vue
Normal file
98
src/modules/base/components/DatePicker.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { CalendarDate, parseDate, type DateValue } from '@internationalized/date'
|
||||
import { format } from 'date-fns'
|
||||
import { Calendar as CalendarIcon, X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
/** Minimum selectable date as YYYY-MM-DD. */
|
||||
min?: string
|
||||
disabled?: boolean
|
||||
clearable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
// Bridge between the wire format (YYYY-MM-DD string) and reka-ui's
|
||||
// CalendarDate. Empty string ↔ undefined keeps the field optional.
|
||||
// Closes the popover on user pick to match the shadcn-vue docs example
|
||||
// — the Calendar component doesn't auto-close by itself.
|
||||
const dateValue = computed<DateValue | undefined>({
|
||||
get() {
|
||||
if (!props.modelValue) return undefined
|
||||
try {
|
||||
return parseDate(props.modelValue)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
set(v) {
|
||||
if (!v) {
|
||||
emit('update:modelValue', '')
|
||||
return
|
||||
}
|
||||
const d = new CalendarDate(v.year, v.month, v.day)
|
||||
emit('update:modelValue', d.toString())
|
||||
open.value = false
|
||||
},
|
||||
})
|
||||
|
||||
const minDate = computed<DateValue | undefined>(() => {
|
||||
if (!props.min) return undefined
|
||||
try {
|
||||
return parseDate(props.min)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
const display = computed(() => {
|
||||
if (!props.modelValue) return ''
|
||||
const d = new Date(props.modelValue + 'T00:00:00')
|
||||
if (isNaN(d.getTime())) return ''
|
||||
return format(d, 'PPP')
|
||||
})
|
||||
|
||||
function clear(e: Event) {
|
||||
e.stopPropagation()
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="disabled"
|
||||
:class="cn('w-full justify-start text-left font-normal', !modelValue && 'text-muted-foreground')"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4 shrink-0" />
|
||||
<span class="truncate">{{ display || placeholder || 'Pick a date' }}</span>
|
||||
<button
|
||||
v-if="clearable && modelValue && !disabled"
|
||||
type="button"
|
||||
class="ml-auto -mr-1 inline-flex h-5 w-5 items-center justify-center rounded-sm opacity-60 hover:opacity-100"
|
||||
@click="clear"
|
||||
aria-label="Clear date"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" align="start">
|
||||
<Calendar v-model="dateValue" :min-value="minDate" initial-focus />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
106
src/modules/base/components/TimePicker.vue
Normal file
106
src/modules/base/components/TimePicker.vue
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Clock, X } from 'lucide-vue-next'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* HH:MM time picker — two shadcn <Select> dropdowns. Mobile-first:
|
||||
* tap to pick from the native sheet / wheel. Clear button when set.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
/** Minute granularity for the dropdown list. Default 15. */
|
||||
minuteStep?: number
|
||||
disabled?: boolean
|
||||
clearable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const step = computed(() => props.minuteStep ?? 15)
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'))
|
||||
const minutes = computed(() =>
|
||||
Array.from({ length: Math.floor(60 / step.value) }, (_, i) =>
|
||||
(i * step.value).toString().padStart(2, '0')
|
||||
)
|
||||
)
|
||||
|
||||
const hour = computed<string>({
|
||||
get() {
|
||||
return props.modelValue.split(':')[0] ?? ''
|
||||
},
|
||||
set(h) {
|
||||
const m = props.modelValue.split(':')[1] || '00'
|
||||
emit('update:modelValue', `${h}:${m}`)
|
||||
},
|
||||
})
|
||||
|
||||
const minute = computed<string>({
|
||||
get() {
|
||||
return props.modelValue.split(':')[1] ?? ''
|
||||
},
|
||||
set(m) {
|
||||
const h = props.modelValue.split(':')[0] || '00'
|
||||
emit('update:modelValue', `${h}:${m}`)
|
||||
},
|
||||
})
|
||||
|
||||
const hasValue = computed(() => Boolean(props.modelValue))
|
||||
|
||||
function clear() {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1.5', disabled && 'opacity-50')">
|
||||
<Clock class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
|
||||
<Select v-model="hour" :disabled="disabled">
|
||||
<SelectTrigger class="flex-1 min-w-0 px-2 tabular-nums" aria-label="Hours">
|
||||
<SelectValue placeholder="HH" />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-h-56">
|
||||
<SelectItem v-for="h in hours" :key="h" :value="h" class="tabular-nums">
|
||||
{{ h }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<span class="text-muted-foreground select-none">:</span>
|
||||
|
||||
<Select v-model="minute" :disabled="disabled">
|
||||
<SelectTrigger class="flex-1 min-w-0 px-2 tabular-nums" aria-label="Minutes">
|
||||
<SelectValue placeholder="MM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-h-56">
|
||||
<SelectItem v-for="m in minutes" :key="m" :value="m" class="tabular-nums">
|
||||
{{ m }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
v-if="clearable && hasValue && !disabled"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
@click="clear"
|
||||
aria-label="Clear time"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue