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,17 +26,25 @@ 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">
<div class="grid gap-5">
<div class="grid space-y-2 grid-cols-4 items-center">
<Label for="users">Allowed users</Label> <Label for="users">Allowed users</Label>
<AssignDevice v-model="usrIDs"></AssignDevice> <AssignDevice v-model="usrIDs" class="col-span-3 w-full"></AssignDevice>
</div>
</div>
<div class="flex space-x-4 pt-2 gap-4">
<Button>Start recording</Button> <Button>Start recording</Button>
<Button>Stop recording</Button> <Button>Stop recording</Button>
</div>
<div class="space-y-2 gap-4">
<NumberField id="duration" :default-value="120" :min="30"> <NumberField id="duration" :default-value="120" :min="30">
<Label for="duration"> Record duration in seconds</Label> <Label for="duration"> Record duration in seconds</Label>
<NumberFieldContent> <NumberFieldContent>
@@ -45,7 +53,11 @@ const usrIDs = selectedUserIds.value
<NumberFieldIncrement></NumberFieldIncrement> <NumberFieldIncrement></NumberFieldIncrement>
</NumberFieldContent> </NumberFieldContent>
</NumberField> </NumberField>
<Separator>Communication settings</Separator> </div>
<Separator class="my-6">Communication settings</Separator>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<NumberField id="polling" :default-value="60" :min="30"> <NumberField id="polling" :default-value="60" :min="30">
<Label for="polling"> Timeout interwal in seconds </Label> <Label for="polling"> Timeout interwal in seconds </Label>
<NumberFieldContent> <NumberFieldContent>
@@ -54,6 +66,9 @@ const usrIDs = selectedUserIds.value
<NumberFieldIncrement></NumberFieldIncrement> <NumberFieldIncrement></NumberFieldIncrement>
</NumberFieldContent> </NumberFieldContent>
</NumberField> </NumberField>
</div>
<div class="space-y-2">
<NumberField id="jitter" :default-value="10" :min="5"> <NumberField id="jitter" :default-value="10" :min="5">
<Label for="jitter"> Jitter in seconds </Label> <Label for="jitter"> Jitter in seconds </Label>
<NumberFieldContent> <NumberFieldContent>
@@ -62,9 +77,14 @@ const usrIDs = selectedUserIds.value
<NumberFieldIncrement></NumberFieldIncrement> <NumberFieldIncrement></NumberFieldIncrement>
</NumberFieldContent> </NumberFieldContent>
</NumberField> </NumberField>
</div>
</div>
<Separator></Separator> <Separator></Separator>
<div class="space-y-2 gap-4">
<Label for="sleepdate">Date/time to sleep</Label> <Label for="sleepdate">Date/time to sleep</Label>
<DataRangePicker id="sleepdate"></DataRangePicker> <DataRangePicker id="sleepdate"></DataRangePicker>
</div>
</CardContent> </CardContent>
</Card> </Card>
</template> </template>

View File

@@ -1,7 +1,7 @@
<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 = {
@@ -18,17 +18,21 @@ type ListResp = {
} }
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,
},
pageSize: {
type: Number as PropType<number>,
default: 50,
},
apiBase: { apiBase: {
type: String as PropType<string>, type: String as PropType<string>,
default: (import.meta as any)?.env?.VITE_API_URL || '' default: (import.meta as any)?.env?.VITE_API_URL || '',
}, },
// Provide a custom builder if your record stream URL differs.
recordSrcBuilder: { recordSrcBuilder: {
type: Function as PropType<(id: number) => string>, type: Function as PropType<(id: number) => string>,
default: undefined default: undefined,
} }
}) })
@@ -37,8 +41,8 @@ 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)
@@ -46,7 +50,7 @@ const sortedRecords = computed(() =>
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) {
@@ -55,19 +59,60 @@ function formatDate(unix: number) {
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 loading.value = true
error.value = null error.value = null
@@ -75,6 +120,9 @@ async function fetchRecords(reset = true) {
if (reset) { if (reset) {
offset.value = 0 offset.value = 0
records.value = [] records.value = []
// Clean up old blob URLs
audioBlobUrls.value.forEach(url => URL.revokeObjectURL(url))
audioBlobUrls.value.clear()
} }
const { data } = await api.get<ListResp>('/records', { const { data } = await api.get<ListResp>('/records', {
@@ -88,10 +136,14 @@ async function fetchRecords(reset = true) {
records.value = reset ? data.records : [...records.value, ...data.records] records.value = reset ? data.records : [...records.value, ...data.records]
total.value = data.total ?? records.value.length total.value = data.total ?? records.value.length
offset.value = (data.offset ?? offset.value) + (data.limit ?? props.pageSize) 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) { } catch (err: any) {
// Axios-style error extraction error.value = (err?.response?.data?.error as string) ??
error.value =
(err?.response?.data?.error as string) ??
(err?.message as string) ?? (err?.message as string) ??
'Failed to load recordings' 'Failed to load recordings'
} finally { } finally {
@@ -102,19 +154,20 @@ async function fetchRecords(reset = true) {
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">
@@ -129,7 +182,7 @@ watch(
<ul v-else class="divide-y divide-border rounded-lg border"> <ul v-else class="divide-y divide-border rounded-lg border">
<li v-for="rec in sortedRecords" :key="rec.id" <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"> class="p-3 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="space-y-1"> <div class="space-y-1 flex-1">
<div class="font-medium">Record #{{ rec.id }}</div> <div class="font-medium">Record #{{ rec.id }}</div>
<div class="text-sm text-muted-foreground"> <div class="text-sm text-muted-foreground">
{{ formatDate(rec.startedAt) }} {{ formatDate(rec.stoppedAt) }} {{ formatDate(rec.startedAt) }} {{ formatDate(rec.stoppedAt) }}
@@ -139,15 +192,28 @@ watch(
</div> </div>
</div> </div>
<!-- Optional playback; update getRecordSrc via prop if your URL differs --> <!-- Audio player with loading state -->
<audio class="w-full md:w-auto" :src="getRecordSrc(rec.id)" controls preload="none"> <div class="w-full md:w-80 flex items-center">
<audio
class="w-full"
:src="audioBlobUrls.get(rec.id) || ''"
controls
preload="none"
@play="() => preloadAudio(rec.id)"
>
Your browser does not support the audio element. Your browser does not support the audio element.
</audio> </audio>
<div v-if="!audioBlobUrls.has(rec.id)" class="text-xs text-muted-foreground ml-2">
Click play to load
</div>
</div>
</li> </li>
</ul> </ul>
<div class="flex items-center gap-3"> <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" <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"> :disabled="loading">
{{ loading ? 'Loading…' : 'Load more' }} {{ loading ? 'Loading…' : 'Load more' }}
</button> </button>