some small chenges in device ui

This commit is contained in:
tdv
2025-09-04 12:10:04 +03:00
parent 247a2ed6b2
commit c38dd658f5
2 changed files with 225 additions and 139 deletions

View File

@@ -26,45 +26,65 @@ const usrIDs = selectedUserIds.value
</script> </script>
<template> <template>
<Card> <Card class="flex w-full max-w-4xl mx-auto">
<CardHeader> <CardHeader class="pb-4">
<CardTitle> <CardTitle>
Dashboard Dashboard
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent class="flex-1 space-y-6">
<Label for="users">Allowed users</Label> <div class="grid gap-5">
<AssignDevice v-model="usrIDs"></AssignDevice> <div class="grid space-y-2 grid-cols-4 items-center">
<Button>Start recording</Button> <Label for="users">Allowed users</Label>
<Button>Stop recording</Button> <AssignDevice v-model="usrIDs" class="col-span-3 w-full"></AssignDevice>
<NumberField id="duration" :default-value="120" :min="30"> </div>
<Label for="duration"> Record duration in seconds</Label> </div>
<NumberFieldContent>
<NumberFieldDecrement></NumberFieldDecrement> <div class="flex space-x-4 pt-2 gap-4">
<NumberFieldInput></NumberFieldInput> <Button>Start recording</Button>
<NumberFieldIncrement></NumberFieldIncrement> <Button>Stop recording</Button>
</NumberFieldContent> </div>
</NumberField> <div class="space-y-2 gap-4">
<Separator>Communication settings</Separator> <NumberField id="duration" :default-value="120" :min="30">
<NumberField id="polling" :default-value="60" :min="30"> <Label for="duration"> Record duration in seconds</Label>
<Label for="polling"> Timeout interwal in seconds </Label> <NumberFieldContent>
<NumberFieldContent> <NumberFieldDecrement></NumberFieldDecrement>
<NumberFieldDecrement></NumberFieldDecrement> <NumberFieldInput></NumberFieldInput>
<NumberFieldInput></NumberFieldInput> <NumberFieldIncrement></NumberFieldIncrement>
<NumberFieldIncrement></NumberFieldIncrement> </NumberFieldContent>
</NumberFieldContent> </NumberField>
</NumberField> </div>
<NumberField id="jitter" :default-value="10" :min="5">
<Label for="jitter"> Jitter in seconds </Label> <Separator class="my-6">Communication settings</Separator>
<NumberFieldContent> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<NumberFieldDecrement></NumberFieldDecrement> <div class="space-y-2">
<NumberFieldInput></NumberFieldInput> <NumberField id="polling" :default-value="60" :min="30">
<NumberFieldIncrement></NumberFieldIncrement> <Label for="polling"> Timeout interwal in seconds </Label>
</NumberFieldContent> <NumberFieldContent>
</NumberField> <NumberFieldDecrement></NumberFieldDecrement>
<NumberFieldInput></NumberFieldInput>
<NumberFieldIncrement></NumberFieldIncrement>
</NumberFieldContent>
</NumberField>
</div>
<div class="space-y-2">
<NumberField id="jitter" :default-value="10" :min="5">
<Label for="jitter"> Jitter in seconds </Label>
<NumberFieldContent>
<NumberFieldDecrement></NumberFieldDecrement>
<NumberFieldInput></NumberFieldInput>
<NumberFieldIncrement></NumberFieldIncrement>
</NumberFieldContent>
</NumberField>
</div>
</div>
<Separator></Separator> <Separator></Separator>
<Label for="sleepdate">Date/time to sleep</Label> <div class="space-y-2 gap-4">
<DataRangePicker id="sleepdate"></DataRangePicker> <Label for="sleepdate">Date/time to sleep</Label>
<DataRangePicker id="sleepdate"></DataRangePicker>
</div>
</CardContent> </CardContent>
</Card> </Card>
</template> </template>

View File

@@ -1,35 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
type DeviceRecord = { type DeviceRecord = {
id: number id: number
startedAt: number // unix seconds startedAt: number // unix seconds
stoppedAt: number // unix seconds stoppedAt: number // unix seconds
} }
type ListResp = { type ListResp = {
records: DeviceRecord[] records: DeviceRecord[]
offset: number offset: number
limit: number limit: number
total: number total: number
} }
const props = defineProps({ const props = defineProps({
guid: { type: String as PropType<string>, required: true }, guid: {
pageSize: { type: Number as PropType<number>, default: 50 }, type: String as PropType<string>,
// If your API base is set via Vite (e.g. "/api"), it will be used automatically. required: true,
apiBase: { },
type: String as PropType<string>, pageSize: {
default: (import.meta as any)?.env?.VITE_API_URL || '' type: Number as PropType<number>,
}, default: 50,
// Provide a custom builder if your record stream URL differs. },
recordSrcBuilder: { apiBase: {
type: Function as PropType<(id: number) => string>, type: String as PropType<string>,
default: undefined default: (import.meta as any)?.env?.VITE_API_URL || '',
} },
recordSrcBuilder: {
type: Function as PropType<(id: number) => string>,
default: undefined,
}
}) })
const loading = ref(false) const loading = ref(false)
@@ -37,122 +41,184 @@ const error = ref<string | null>(null)
const records = ref<DeviceRecord[]>([]) const records = ref<DeviceRecord[]>([])
const offset = ref(0) const offset = ref(0)
const total = ref(0) const total = ref(0)
const baseUrl = computed(() => (props.apiBase || '').replace(/\/+$/, '')) const baseUrl = computed(() => (props.apiBase || '').replace(/\/+$/, ''))
const audioBlobUrls = ref<Map<number, string>>(new Map())
const sortedRecords = computed(() => const sortedRecords = computed(() =>
[...records.value].sort((a, b) => b.startedAt - a.startedAt) [...records.value].sort((a, b) => b.startedAt - a.startedAt)
) )
const dtf = new Intl.DateTimeFormat(undefined, { const dtf = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short' timeStyle: 'short',
}) })
function formatDate(unix: number) { function formatDate(unix: number) {
return dtf.format(new Date(unix * 1000)) return dtf.format(new Date(unix * 1000))
} }
function durationMinutes(rec: DeviceRecord) { function durationMinutes(rec: DeviceRecord) {
const seconds = Math.max(0, (rec.stoppedAt ?? 0) - (rec.startedAt ?? 0)) const seconds = Math.max(0, (rec.stoppedAt ?? 0) - (rec.startedAt ?? 0))
return (seconds / 60).toFixed(2) // minutes with 2 decimals return (seconds / 60).toFixed(2)
} }
function getRecordSrc(id: number) { // Get audio URL using blob approach to preserve authentication
if (props.recordSrcBuilder) return props.recordSrcBuilder(id) async function getAudioUrl(id: number): Promise<string> {
// Return existing blob URL if available
if (audioBlobUrls.value.has(id)) {
return audioBlobUrls.value.get(id)!
}
// Build from Axios baseURL (works for '/api' or 'https://host/api') try {
const base = (api.defaults.baseURL as string | undefined)?.replace(/\/+$/, '') ?? '' const response = await api.get(`/records/${id}/file`, {
return `${base}/records/${id}/file` responseType: 'blob',
headers: {
'Accept': 'audio/*'
}
})
// Create blob URL from the response
const blob = new Blob([response.data], {
type: response.headers['content-type'] || 'audio/mpeg'
})
const blobUrl = URL.createObjectURL(blob)
// Store for future use
audioBlobUrls.value.set(id, blobUrl)
return blobUrl
} catch (err) {
console.error('Failed to load audio:', err)
throw new Error('Failed to load audio file')
}
}
// Clean up blob URLs when component is destroyed
onUnmounted(() => {
audioBlobUrls.value.forEach(url => {
URL.revokeObjectURL(url)
})
audioBlobUrls.value.clear()
})
// Optional: Preload audio for visible items
const preloadAudio = async (recordId: number) => {
try {
await getAudioUrl(recordId)
} catch (error) {
console.warn(`Failed to preload audio for record ${recordId}:`, error)
}
} }
async function fetchRecords(reset = true) { async function fetchRecords(reset = true) {
if (!props.guid) return if (!props.guid) return
loading.value = true
error.value = null loading.value = true
error.value = null
try {
if (reset) { try {
offset.value = 0 if (reset) {
records.value = [] offset.value = 0
} records.value = []
// Clean up old blob URLs
const { data } = await api.get<ListResp>('/records', { audioBlobUrls.value.forEach(url => URL.revokeObjectURL(url))
params: { audioBlobUrls.value.clear()
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 { 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)
// Preload first few audio files for better UX
if (reset && data.records.length > 0) {
const recordsToPreload = data.records.slice(0, 3)
recordsToPreload.forEach(rec => preloadAudio(rec.id))
}
} catch (err: any) {
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) const hasMore = computed(() => offset.value < total.value)
function loadMore() { function loadMore() {
if (!loading.value && hasMore.value) fetchRecords(false) if (!loading.value && hasMore.value) {
fetchRecords(false)
}
} }
onMounted(() => { onMounted(() => {
fetchRecords(true) fetchRecords(true)
}) })
watch( watch(() => props.guid, () => {
() => props.guid, fetchRecords(true)
() => fetchRecords(true), }, { immediate: false })
{ immediate: false }
)
</script> </script>
<template> <template>
<ScrollArea class="w-full h-full"> <ScrollArea class="w-full h-full">
<div class="p-3 space-y-3"> <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"> <div v-if="error" class="text-sm text-red-500 border border-red-200 rounded-md p-2">
{{ error }} {{ 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 flex-1">
<div class="font-medium">Record #{{ rec.id }}</div>
<div class="text-sm text-muted-foreground">
{{ formatDate(rec.startedAt) }} {{ formatDate(rec.stoppedAt) }}
</div> </div>
<div class="text-xs">
<div v-if="!loading && records.length === 0 && !error" class="text-sm text-muted-foreground"> Duration: <span class="font-semibold">{{ durationMinutes(rec) }}</span> min
No recordings found.
</div> </div>
</div>
<ul v-else class="divide-y divide-border rounded-lg border">
<li v-for="rec in sortedRecords" :key="rec.id" <!-- Audio player with loading state -->
class="p-3 flex flex-col md:flex-row md:items-center md:justify-between gap-3"> <div class="w-full md:w-80 flex items-center">
<div class="space-y-1"> <audio
<div class="font-medium">Record #{{ rec.id }}</div> class="w-full"
<div class="text-sm text-muted-foreground"> :src="audioBlobUrls.get(rec.id) || ''"
{{ formatDate(rec.startedAt) }} {{ formatDate(rec.stoppedAt) }} controls
</div> preload="none"
<div class="text-xs"> @play="() => preloadAudio(rec.id)"
Duration: <span class="font-semibold">{{ durationMinutes(rec) }}</span> min >
</div> Your browser does not support the audio element.
</div> </audio>
<div v-if="!audioBlobUrls.has(rec.id)" class="text-xs text-muted-foreground ml-2">
<!-- Optional playback; update getRecordSrc via prop if your URL differs --> Click play to load
<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>
</div> </div>
</ScrollArea> </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 transition-colors disabled:opacity-50"
@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> </template>