modifications in ui for audio recording display and some backend fixes
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
.vs
|
||||
32
management-ui/package-lock.json
generated
32
management-ui/package-lock.json
generated
@@ -10,12 +10,12 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.525.0",
|
||||
"reka-ui": "^2.4.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
@@ -1495,14 +1495,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "13.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.5.0.tgz",
|
||||
"integrity": "sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g==",
|
||||
"version": "13.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz",
|
||||
"integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "13.5.0",
|
||||
"@vueuse/shared": "13.5.0"
|
||||
"@vueuse/metadata": "13.9.0",
|
||||
"@vueuse/shared": "13.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
@@ -1512,18 +1512,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "13.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.5.0.tgz",
|
||||
"integrity": "sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw==",
|
||||
"version": "13.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz",
|
||||
"integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "13.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.5.0.tgz",
|
||||
"integrity": "sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g==",
|
||||
"version": "13.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz",
|
||||
"integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
@@ -2413,9 +2413,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reka-ui": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.4.0.tgz",
|
||||
"integrity": "sha512-5WHLEquWI5W67NnjH9F+RhpzRAcwjAEQHtHZMa5wYdhClcYDr59q0RAAcymcnfndFqv0MiBxyFfzbGSRyIZG5g==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz",
|
||||
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.525.0",
|
||||
"reka-ui": "^2.4.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { Primitive, type PrimitiveProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type ButtonVariants, buttonVariants } from '.'
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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(
|
||||
'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: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
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:
|
||||
'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:
|
||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
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',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
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",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "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">
|
||||
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>
|
||||
<template>
|
||||
<Tabs default-value="records">
|
||||
<TabsList class="space-x-8">
|
||||
<TabsTrigger value="dashboard">
|
||||
Dashboard
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="records">
|
||||
Records
|
||||
</TabsTrigger>
|
||||
@@ -12,11 +23,19 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
Livestream
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="dashboard">
|
||||
<DeviceDashboard/>
|
||||
</TabsContent>
|
||||
<TabsContent value="records">
|
||||
|
||||
<DeviceRecordings :guid="guid"/>
|
||||
</TabsContent>
|
||||
<TabsContent value="livestream">
|
||||
|
||||
<Button>
|
||||
Start streaming
|
||||
</Button>
|
||||
<Button>
|
||||
Stop streaming
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</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 Navbar from '@/customcompometns/Navbar.vue';
|
||||
import DeviceComponent from '@/customcompometns/DeviceComponent.vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { computed } from 'vue';
|
||||
const mode = useColorMode()
|
||||
|
||||
const route = useRoute()
|
||||
const guid = computed(() => String(route.params.guid ?? route.query.guid ?? ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Navbar>
|
||||
<DeviceComponent></DeviceComponent>
|
||||
<DeviceComponent :guid="guid"></DeviceComponent>
|
||||
</Navbar>
|
||||
</template>
|
||||
@@ -1,8 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"path"
|
||||
@@ -111,13 +111,35 @@ func (h *RecordsHandler) File(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var rec models.Record
|
||||
if err := h.db.First(&rec, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
c.Status(http.StatusNotFound)
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "presign failed"})
|
||||
c.Status(http.StatusInternalServerError)
|
||||
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/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/:id/file", authMW, recH.File)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user