modifications in ui for audio recording display and some backend fixes
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,4 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.env
|
.env
|
||||||
|
.vs
|
||||||
32
management-ui/package-lock.json
generated
32
management-ui/package-lock.json
generated
@@ -10,12 +10,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^13.5.0",
|
"@vueuse/core": "^13.9.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.525.0",
|
"lucide-vue-next": "^0.525.0",
|
||||||
"reka-ui": "^2.4.0",
|
"reka-ui": "^2.5.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
@@ -1495,14 +1495,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vueuse/core": {
|
"node_modules/@vueuse/core": {
|
||||||
"version": "13.5.0",
|
"version": "13.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz",
|
||||||
"integrity": "sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g==",
|
"integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/web-bluetooth": "^0.0.21",
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
"@vueuse/metadata": "13.5.0",
|
"@vueuse/metadata": "13.9.0",
|
||||||
"@vueuse/shared": "13.5.0"
|
"@vueuse/shared": "13.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
@@ -1512,18 +1512,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vueuse/metadata": {
|
"node_modules/@vueuse/metadata": {
|
||||||
"version": "13.5.0",
|
"version": "13.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz",
|
||||||
"integrity": "sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw==",
|
"integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vueuse/shared": {
|
"node_modules/@vueuse/shared": {
|
||||||
"version": "13.5.0",
|
"version": "13.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz",
|
||||||
"integrity": "sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g==",
|
"integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
@@ -2413,9 +2413,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/reka-ui": {
|
"node_modules/reka-ui": {
|
||||||
"version": "2.4.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz",
|
||||||
"integrity": "sha512-5WHLEquWI5W67NnjH9F+RhpzRAcwjAEQHtHZMa5wYdhClcYDr59q0RAAcymcnfndFqv0MiBxyFfzbGSRyIZG5g==",
|
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -11,12 +11,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^13.5.0",
|
"@vueuse/core": "^13.9.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.525.0",
|
"lucide-vue-next": "^0.525.0",
|
||||||
"reka-ui": "^2.4.0",
|
"reka-ui": "^2.5.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from 'vue'
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
import { Primitive, type PrimitiveProps } from 'reka-ui'
|
import type { HTMLAttributes } from "vue"
|
||||||
import { cn } from '@/lib/utils'
|
import type { ButtonVariants } from "."
|
||||||
import { type ButtonVariants, buttonVariants } from '.'
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "."
|
||||||
|
|
||||||
interface Props extends PrimitiveProps {
|
interface Props extends PrimitiveProps {
|
||||||
variant?: ButtonVariants['variant']
|
variant?: ButtonVariants["variant"]
|
||||||
size?: ButtonVariants['size']
|
size?: ButtonVariants["size"]
|
||||||
class?: HTMLAttributes['class']
|
class?: HTMLAttributes["class"]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
as: 'button',
|
as: "button",
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Primitive
|
<Primitive
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
:as="as"
|
:as="as"
|
||||||
:as-child="asChild"
|
:as-child="asChild"
|
||||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</Primitive>
|
</Primitive>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
export { default as Button } from './Button.vue'
|
export { default as Button } from "./Button.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 transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: 'size-9',
|
icon: "size-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
20
management-ui/src/components/ui/number-field/NumberField.vue
Normal file
20
management-ui/src/components/ui/number-field/NumberField.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NumberFieldRootEmits, NumberFieldRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { NumberFieldRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<NumberFieldRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<NumberFieldRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NumberFieldRoot v-bind="forwarded" :class="cn('grid gap-1.5', props.class)">
|
||||||
|
<slot />
|
||||||
|
</NumberFieldRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('relative [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-5 [&>[data-slot=input]]:has-[[data-slot=decrement]]:pl-5', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NumberFieldDecrementProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Minus } from "lucide-vue-next"
|
||||||
|
import { NumberFieldDecrement, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<NumberFieldDecrementProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NumberFieldDecrement data-slot="decrement" v-bind="forwarded" :class="cn('absolute top-1/2 -translate-y-1/2 left-0 p-3 disabled:cursor-not-allowed disabled:opacity-20', props.class)">
|
||||||
|
<slot>
|
||||||
|
<Minus class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</NumberFieldDecrement>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NumberFieldIncrementProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Plus } from "lucide-vue-next"
|
||||||
|
import { NumberFieldIncrement, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<NumberFieldIncrementProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NumberFieldIncrement data-slot="increment" v-bind="forwarded" :class="cn('absolute top-1/2 -translate-y-1/2 right-0 disabled:cursor-not-allowed disabled:opacity-20 p-3', props.class)">
|
||||||
|
<slot>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</NumberFieldIncrement>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { NumberFieldInput } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NumberFieldInput
|
||||||
|
data-slot="input"
|
||||||
|
:class="cn('flex h-9 w-full rounded-md border border-input bg-transparent py-1 text-sm text-center shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
5
management-ui/src/components/ui/number-field/index.ts
Normal file
5
management-ui/src/components/ui/number-field/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as NumberField } from "./NumberField.vue"
|
||||||
|
export { default as NumberFieldContent } from "./NumberFieldContent.vue"
|
||||||
|
export { default as NumberFieldDecrement } from "./NumberFieldDecrement.vue"
|
||||||
|
export { default as NumberFieldIncrement } from "./NumberFieldIncrement.vue"
|
||||||
|
export { default as NumberFieldInput } from "./NumberFieldInput.vue"
|
||||||
18
management-ui/src/components/ui/popover/Popover.vue
Normal file
18
management-ui/src/components/ui/popover/Popover.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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
|
||||||
|
data-slot="popover"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</PopoverRoot>
|
||||||
|
</template>
|
||||||
15
management-ui/src/components/ui/popover/PopoverAnchor.vue
Normal file
15
management-ui/src/components/ui/popover/PopoverAnchor.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PopoverAnchorProps } from "reka-ui"
|
||||||
|
import { PopoverAnchor } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<PopoverAnchorProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PopoverAnchor
|
||||||
|
data-slot="popover-anchor"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</PopoverAnchor>
|
||||||
|
</template>
|
||||||
46
management-ui/src/components/ui/popover/PopoverContent.vue
Normal file
46
management-ui/src/components/ui/popover/PopoverContent.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
PopoverContent,
|
||||||
|
|
||||||
|
PopoverPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<PopoverContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
|
{
|
||||||
|
align: "center",
|
||||||
|
sideOffset: 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const emits = defineEmits<PopoverContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PopoverPortal>
|
||||||
|
<PopoverContent
|
||||||
|
data-slot="popover-content"
|
||||||
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-popover text-popover-foreground 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 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</PopoverContent>
|
||||||
|
</PopoverPortal>
|
||||||
|
</template>
|
||||||
15
management-ui/src/components/ui/popover/PopoverTrigger.vue
Normal file
15
management-ui/src/components/ui/popover/PopoverTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PopoverTriggerProps } from "reka-ui"
|
||||||
|
import { PopoverTrigger } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<PopoverTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PopoverTrigger
|
||||||
|
data-slot="popover-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</PopoverTrigger>
|
||||||
|
</template>
|
||||||
4
management-ui/src/components/ui/popover/index.ts
Normal file
4
management-ui/src/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as Popover } from "./Popover.vue"
|
||||||
|
export { default as PopoverAnchor } from "./PopoverAnchor.vue"
|
||||||
|
export { default as PopoverContent } from "./PopoverContent.vue"
|
||||||
|
export { default as PopoverTrigger } from "./PopoverTrigger.vue"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarRootEmits, RangeCalendarRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { RangeCalendarCell, RangeCalendarCellTrigger, RangeCalendarGrid, RangeCalendarGridBody, RangeCalendarGridHead, RangeCalendarGridRow, RangeCalendarHeadCell, RangeCalendarHeader, RangeCalendarHeading, RangeCalendarNextButton, RangeCalendarPrevButton } from "."
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const emits = defineEmits<RangeCalendarRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarRoot
|
||||||
|
v-slot="{ grid, weekDays }"
|
||||||
|
data-slot="range-calendar"
|
||||||
|
:class="cn('p-3', props.class)"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<RangeCalendarHeader>
|
||||||
|
<RangeCalendarHeading />
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<RangeCalendarPrevButton />
|
||||||
|
<RangeCalendarNextButton />
|
||||||
|
</div>
|
||||||
|
</RangeCalendarHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
|
||||||
|
<RangeCalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||||
|
<RangeCalendarGridHead>
|
||||||
|
<RangeCalendarGridRow>
|
||||||
|
<RangeCalendarHeadCell
|
||||||
|
v-for="day in weekDays" :key="day"
|
||||||
|
>
|
||||||
|
{{ day }}
|
||||||
|
</RangeCalendarHeadCell>
|
||||||
|
</RangeCalendarGridRow>
|
||||||
|
</RangeCalendarGridHead>
|
||||||
|
<RangeCalendarGridBody>
|
||||||
|
<RangeCalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
|
||||||
|
<RangeCalendarCell
|
||||||
|
v-for="weekDate in weekDates"
|
||||||
|
:key="weekDate.toString()"
|
||||||
|
:date="weekDate"
|
||||||
|
>
|
||||||
|
<RangeCalendarCellTrigger
|
||||||
|
:day="weekDate"
|
||||||
|
:month="month.value"
|
||||||
|
/>
|
||||||
|
</RangeCalendarCell>
|
||||||
|
</RangeCalendarGridRow>
|
||||||
|
</RangeCalendarGridBody>
|
||||||
|
</RangeCalendarGrid>
|
||||||
|
</div>
|
||||||
|
</RangeCalendarRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarCellProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarCell, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarCellProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarCell
|
||||||
|
data-slot="range-calendar-cell"
|
||||||
|
:class="cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:bg-accent first:[&:has([data-selected])]:rounded-l-md last:[&:has([data-selected])]:rounded-r-md [&:has([data-selected][data-selection-end])]:rounded-r-md [&:has([data-selected][data-selection-start])]:rounded-l-md', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarCell>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarCellTriggerProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarCellTrigger, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<RangeCalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>(), {
|
||||||
|
as: "button",
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarCellTrigger
|
||||||
|
data-slot="range-calendar-trigger"
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'ghost' }),
|
||||||
|
'h-8 w-8 p-0 font-normal data-[selected]:opacity-100',
|
||||||
|
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||||
|
// Selection Start
|
||||||
|
'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-primary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground',
|
||||||
|
// Selection End
|
||||||
|
'data-[selection-end]:bg-primary data-[selection-end]:text-primary-foreground data-[selection-end]:hover:bg-primary data-[selection-end]:hover:text-primary-foreground data-[selection-end]:focus:bg-primary data-[selection-end]:focus:text-primary-foreground',
|
||||||
|
// Outside months
|
||||||
|
'data-[outside-view]:text-muted-foreground',
|
||||||
|
// Disabled
|
||||||
|
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||||
|
// Unavailable
|
||||||
|
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarCellTrigger>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarGridProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarGrid, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarGridProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarGrid
|
||||||
|
data-slot="range-calendar-grid"
|
||||||
|
:class="cn('w-full border-collapse space-x-1', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarGrid>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarGridBodyProps } from "reka-ui"
|
||||||
|
import { RangeCalendarGridBody } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarGridBodyProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarGridBody
|
||||||
|
data-slot="range-calendar-grid-body"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarGridBody>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarGridHeadProps } from "reka-ui"
|
||||||
|
import { RangeCalendarGridHead } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarGridHeadProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarGridHead
|
||||||
|
data-slot="range-calendar-grid-head"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarGridHead>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarGridRowProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarGridRow, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarGridRowProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarGridRow
|
||||||
|
data-slot="range-calendar-grid-row"
|
||||||
|
:class="cn('flex', props.class)" v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarGridRow>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarHeadCellProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarHeadCell, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarHeadCellProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarHeadCell
|
||||||
|
data-slot="range-calendar-head-cell"
|
||||||
|
:class="cn('w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarHeadCell>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarHeaderProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarHeader, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarHeaderProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarHeader
|
||||||
|
data-slot="range-calendar-header"
|
||||||
|
:class="cn('flex justify-center pt-1 relative items-center w-full', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarHeader>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarHeadingProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarHeading, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default: (props: { headingValue: string }) => any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarHeading
|
||||||
|
v-slot="{ headingValue }"
|
||||||
|
data-slot="range-calendar-heading"
|
||||||
|
:class="cn('text-sm font-medium', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot :heading-value>
|
||||||
|
{{ headingValue }}
|
||||||
|
</slot>
|
||||||
|
</RangeCalendarHeading>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarNextProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ChevronRight } from "lucide-vue-next"
|
||||||
|
import { RangeCalendarNext, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarNextProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarNext
|
||||||
|
data-slot="range-calendar-next-button"
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'absolute right-1',
|
||||||
|
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronRight class="size-4" />
|
||||||
|
</slot>
|
||||||
|
</RangeCalendarNext>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarPrevProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ChevronLeft } from "lucide-vue-next"
|
||||||
|
import { RangeCalendarPrev, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarPrevProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarPrev
|
||||||
|
data-slot="range-calendar-prev-button"
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'absolute left-1',
|
||||||
|
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronLeft class="size-4" />
|
||||||
|
</slot>
|
||||||
|
</RangeCalendarPrev>
|
||||||
|
</template>
|
||||||
12
management-ui/src/components/ui/range-calendar/index.ts
Normal file
12
management-ui/src/components/ui/range-calendar/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export { default as RangeCalendar } from "./RangeCalendar.vue"
|
||||||
|
export { default as RangeCalendarCell } from "./RangeCalendarCell.vue"
|
||||||
|
export { default as RangeCalendarCellTrigger } from "./RangeCalendarCellTrigger.vue"
|
||||||
|
export { default as RangeCalendarGrid } from "./RangeCalendarGrid.vue"
|
||||||
|
export { default as RangeCalendarGridBody } from "./RangeCalendarGridBody.vue"
|
||||||
|
export { default as RangeCalendarGridHead } from "./RangeCalendarGridHead.vue"
|
||||||
|
export { default as RangeCalendarGridRow } from "./RangeCalendarGridRow.vue"
|
||||||
|
export { default as RangeCalendarHeadCell } from "./RangeCalendarHeadCell.vue"
|
||||||
|
export { default as RangeCalendarHeader } from "./RangeCalendarHeader.vue"
|
||||||
|
export { default as RangeCalendarHeading } from "./RangeCalendarHeading.vue"
|
||||||
|
export { default as RangeCalendarNextButton } from "./RangeCalendarNextButton.vue"
|
||||||
|
export { default as RangeCalendarPrevButton } from "./RangeCalendarPrevButton.vue"
|
||||||
54
management-ui/src/customcompometns/DataRangePicker.vue
Normal file
54
management-ui/src/customcompometns/DataRangePicker.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DateRange } from 'reka-ui'
|
||||||
|
import {
|
||||||
|
DateFormatter,
|
||||||
|
getLocalTimeZone,
|
||||||
|
} from '@internationalized/date'
|
||||||
|
|
||||||
|
import { CalendarIcon } from 'lucide-vue-next'
|
||||||
|
import { type Ref, ref } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { RangeCalendar } from '@/components/ui/range-calendar'
|
||||||
|
|
||||||
|
const df = new DateFormatter('en-US', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
})
|
||||||
|
|
||||||
|
const value = ref({
|
||||||
|
start: undefined,
|
||||||
|
end: undefined,
|
||||||
|
}) as Ref<DateRange>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:class="cn(
|
||||||
|
'w-[280px] justify-start text-left font-normal',
|
||||||
|
!value.start && 'text-muted-foreground',
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||||
|
<template v-if="value.start">
|
||||||
|
<template v-if="value.end">
|
||||||
|
{{ df.format(value.start.toDate(getLocalTimeZone())) }} - {{ df.format(value.end.toDate(getLocalTimeZone())) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
{{ df.format(value.start.toDate(getLocalTimeZone())) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Pick a date
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-auto p-0">
|
||||||
|
<RangeCalendar v-model="value" initial-focus :number-of-months="2" @update:start-value="(startDate) => value.start = startDate" />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import DeviceDashboard from './DeviceDashboard.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import DeviceRecordings from './DeviceRecordings.vue';
|
||||||
|
import { type PropType } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
guid: { type: String as PropType<string>, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Tabs default-value="records">
|
<Tabs default-value="records">
|
||||||
<TabsList class="space-x-8">
|
<TabsList class="space-x-8">
|
||||||
|
<TabsTrigger value="dashboard">
|
||||||
|
Dashboard
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="records">
|
<TabsTrigger value="records">
|
||||||
Records
|
Records
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -12,11 +23,19 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||||||
Livestream
|
Livestream
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
<TabsContent value="dashboard">
|
||||||
|
<DeviceDashboard/>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="records">
|
<TabsContent value="records">
|
||||||
|
<DeviceRecordings :guid="guid"/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="livestream">
|
<TabsContent value="livestream">
|
||||||
|
<Button>
|
||||||
|
Start streaming
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
Stop streaming
|
||||||
|
</Button>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</template>
|
</template>
|
||||||
70
management-ui/src/customcompometns/DeviceDashboard.vue
Normal file
70
management-ui/src/customcompometns/DeviceDashboard.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
NumberField,
|
||||||
|
NumberFieldContent,
|
||||||
|
NumberFieldDecrement,
|
||||||
|
NumberFieldIncrement,
|
||||||
|
NumberFieldInput,
|
||||||
|
} from '@/components/ui/number-field'
|
||||||
|
import AssignDevice from "./AssignDevice.vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import Separator from "@/components/ui/separator/Separator.vue";
|
||||||
|
import DataRangePicker from "./DataRangePicker.vue";
|
||||||
|
const selectedUserIds = ref<string[]>([])
|
||||||
|
const usrIDs = selectedUserIds.value
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Dashboard
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Label for="users">Allowed users</Label>
|
||||||
|
<AssignDevice v-model="usrIDs"></AssignDevice>
|
||||||
|
<Button>Start recording</Button>
|
||||||
|
<Button>Stop recording</Button>
|
||||||
|
<NumberField id="duration" :default-value="120" :min="30">
|
||||||
|
<Label for="duration"> Record duration in seconds</Label>
|
||||||
|
<NumberFieldContent>
|
||||||
|
<NumberFieldDecrement></NumberFieldDecrement>
|
||||||
|
<NumberFieldInput></NumberFieldInput>
|
||||||
|
<NumberFieldIncrement></NumberFieldIncrement>
|
||||||
|
</NumberFieldContent>
|
||||||
|
</NumberField>
|
||||||
|
<Separator>Communication settings</Separator>
|
||||||
|
<NumberField id="polling" :default-value="60" :min="30">
|
||||||
|
<Label for="polling"> Timeout interwal in seconds </Label>
|
||||||
|
<NumberFieldContent>
|
||||||
|
<NumberFieldDecrement></NumberFieldDecrement>
|
||||||
|
<NumberFieldInput></NumberFieldInput>
|
||||||
|
<NumberFieldIncrement></NumberFieldIncrement>
|
||||||
|
</NumberFieldContent>
|
||||||
|
</NumberField>
|
||||||
|
<NumberField id="jitter" :default-value="10" :min="5">
|
||||||
|
<Label for="jitter"> Jitter in seconds </Label>
|
||||||
|
<NumberFieldContent>
|
||||||
|
<NumberFieldDecrement></NumberFieldDecrement>
|
||||||
|
<NumberFieldInput></NumberFieldInput>
|
||||||
|
<NumberFieldIncrement></NumberFieldIncrement>
|
||||||
|
</NumberFieldContent>
|
||||||
|
</NumberField>
|
||||||
|
<Separator></Separator>
|
||||||
|
<Label for="sleepdate">Date/time to sleep</Label>
|
||||||
|
<DataRangePicker id="sleepdate"></DataRangePicker>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
158
management-ui/src/customcompometns/DeviceRecordings.vue
Normal file
158
management-ui/src/customcompometns/DeviceRecordings.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
|
type DeviceRecord = {
|
||||||
|
id: number
|
||||||
|
startedAt: number // unix seconds
|
||||||
|
stoppedAt: number // unix seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListResp = {
|
||||||
|
records: DeviceRecord[]
|
||||||
|
offset: number
|
||||||
|
limit: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
guid: { type: String as PropType<string>, required: true },
|
||||||
|
pageSize: { type: Number as PropType<number>, default: 50 },
|
||||||
|
// If your API base is set via Vite (e.g. "/api"), it will be used automatically.
|
||||||
|
apiBase: {
|
||||||
|
type: String as PropType<string>,
|
||||||
|
default: (import.meta as any)?.env?.VITE_API_URL || ''
|
||||||
|
},
|
||||||
|
// Provide a custom builder if your record stream URL differs.
|
||||||
|
recordSrcBuilder: {
|
||||||
|
type: Function as PropType<(id: number) => string>,
|
||||||
|
default: undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const records = ref<DeviceRecord[]>([])
|
||||||
|
const offset = ref(0)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const baseUrl = computed(() => (props.apiBase || '').replace(/\/+$/, ''))
|
||||||
|
|
||||||
|
const sortedRecords = computed(() =>
|
||||||
|
[...records.value].sort((a, b) => b.startedAt - a.startedAt)
|
||||||
|
)
|
||||||
|
|
||||||
|
const dtf = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDate(unix: number) {
|
||||||
|
return dtf.format(new Date(unix * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationMinutes(rec: DeviceRecord) {
|
||||||
|
const seconds = Math.max(0, (rec.stoppedAt ?? 0) - (rec.startedAt ?? 0))
|
||||||
|
return (seconds / 60).toFixed(2) // minutes with 2 decimals
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordSrc(id: number) {
|
||||||
|
if (props.recordSrcBuilder) return props.recordSrcBuilder(id)
|
||||||
|
|
||||||
|
// Build from Axios baseURL (works for '/api' or 'https://host/api')
|
||||||
|
const base = (api.defaults.baseURL as string | undefined)?.replace(/\/+$/, '') ?? ''
|
||||||
|
return `${base}/records/${id}/file`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecords(reset = true) {
|
||||||
|
if (!props.guid) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (reset) {
|
||||||
|
offset.value = 0
|
||||||
|
records.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.get<ListResp>('/records', {
|
||||||
|
params: {
|
||||||
|
guid: props.guid,
|
||||||
|
offset: offset.value,
|
||||||
|
limit: props.pageSize,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
records.value = reset ? data.records : [...records.value, ...data.records]
|
||||||
|
total.value = data.total ?? records.value.length
|
||||||
|
offset.value = (data.offset ?? offset.value) + (data.limit ?? props.pageSize)
|
||||||
|
} catch (err: any) {
|
||||||
|
// Axios-style error extraction
|
||||||
|
error.value =
|
||||||
|
(err?.response?.data?.error as string) ??
|
||||||
|
(err?.message as string) ??
|
||||||
|
'Failed to load recordings'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = computed(() => offset.value < total.value)
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
if (!loading.value && hasMore.value) fetchRecords(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRecords(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.guid,
|
||||||
|
() => fetchRecords(true),
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ScrollArea class="w-full h-full">
|
||||||
|
<div class="p-3 space-y-3">
|
||||||
|
<div v-if="error" class="text-sm text-red-500 border border-red-200 rounded-md p-2">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && records.length === 0 && !error" class="text-sm text-muted-foreground">
|
||||||
|
No recordings found.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="divide-y divide-border rounded-lg border">
|
||||||
|
<li v-for="rec in sortedRecords" :key="rec.id"
|
||||||
|
class="p-3 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">Record #{{ rec.id }}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{{ formatDate(rec.startedAt) }} → {{ formatDate(rec.stoppedAt) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
Duration: <span class="font-semibold">{{ durationMinutes(rec) }}</span> min
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Optional playback; update getRecordSrc via prop if your URL differs -->
|
||||||
|
<audio class="w-full md:w-auto" :src="getRecordSrc(rec.id)" controls preload="none">
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button v-if="hasMore" class="px-3 py-2 rounded-md border text-sm hover:bg-accent" @click="loadMore"
|
||||||
|
:disabled="loading">
|
||||||
|
{{ loading ? 'Loading…' : 'Load more' }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="text-xs text-muted-foreground">All loaded</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</template>
|
||||||
@@ -2,12 +2,15 @@
|
|||||||
import { useColorMode } from '@vueuse/core'
|
import { useColorMode } from '@vueuse/core'
|
||||||
import Navbar from '@/customcompometns/Navbar.vue';
|
import Navbar from '@/customcompometns/Navbar.vue';
|
||||||
import DeviceComponent from '@/customcompometns/DeviceComponent.vue';
|
import DeviceComponent from '@/customcompometns/DeviceComponent.vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { computed } from 'vue';
|
||||||
const mode = useColorMode()
|
const mode = useColorMode()
|
||||||
|
const route = useRoute()
|
||||||
|
const guid = computed(() => String(route.params.guid ?? route.query.guid ?? ''))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Navbar>
|
<Navbar>
|
||||||
<DeviceComponent></DeviceComponent>
|
<DeviceComponent :guid="guid"></DeviceComponent>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
@@ -111,13 +111,35 @@ func (h *RecordsHandler) File(c *gin.Context) {
|
|||||||
id, _ := strconv.Atoi(c.Param("id"))
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
var rec models.Record
|
var rec models.Record
|
||||||
if err := h.db.First(&rec, id).Error; err != nil {
|
if err := h.db.First(&rec, id).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.Status(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u, err := h.minio.PresignedGetObject(context.Background(), h.recordsBucket, rec.ObjectKey, h.presignTTL, nil)
|
// Stream from MinIO to the client to avoid redirecting to an internal host like "minio".
|
||||||
|
obj, err := h.minio.GetObject(c.Request.Context(), h.recordsBucket, rec.ObjectKey, minio.GetObjectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "presign failed"})
|
c.Status(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, u.String())
|
defer obj.Close()
|
||||||
|
|
||||||
|
info, err := h.minio.StatObject(c.Request.Context(), h.recordsBucket, rec.ObjectKey, minio.StatObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ct := info.ContentType; ct != "" {
|
||||||
|
c.Header("Content-Type", ct)
|
||||||
|
} else {
|
||||||
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
|
}
|
||||||
|
if info.Size >= 0 {
|
||||||
|
c.Header("Content-Length", strconv.FormatInt(info.Size, 10))
|
||||||
|
}
|
||||||
|
if filename := path.Base(rec.ObjectKey); filename != "" {
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
_, _ = io.Copy(c.Writer, obj)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
|
|||||||
r.POST("/devices/:guid/set_users", authMW, adminOnly, devH.SetUsers)
|
r.POST("/devices/:guid/set_users", authMW, adminOnly, devH.SetUsers)
|
||||||
r.POST("/devices/:guid/remove_from_user", authMW, devH.RemoveFromUser)
|
r.POST("/devices/:guid/remove_from_user", authMW, devH.RemoveFromUser)
|
||||||
|
|
||||||
r.POST("/records/upload", authMW, recH.Upload)
|
r.POST("/records/upload", recH.Upload)
|
||||||
r.GET("/records", authMW, recH.List)
|
r.GET("/records", authMW, recH.List)
|
||||||
r.GET("/records/:id/file", authMW, recH.File)
|
r.GET("/records/:id/file", authMW, recH.File)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user