ui
This commit is contained in:
88
ui/app/src/components/ExportBar.vue
Normal file
88
ui/app/src/components/ExportBar.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
const { state, packageEstimate, selectedFrames, buildTranscript } = useStore()
|
||||
|
||||
const busy = ref(false)
|
||||
const copied = ref(false)
|
||||
|
||||
const saveLabel = computed(() => {
|
||||
switch (state.saveStatus) {
|
||||
case 'saving': return 'saving…'
|
||||
case 'saved': return 'saved'
|
||||
case 'error': return 'save failed'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
async function copyTranscript() {
|
||||
await navigator.clipboard.writeText(buildTranscript())
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 1500)
|
||||
}
|
||||
|
||||
async function downloadPackage() {
|
||||
if (!state.currentRunId) return
|
||||
busy.value = true
|
||||
try {
|
||||
const { default: JSZip } = await import('jszip')
|
||||
const zip = new JSZip()
|
||||
zip.file('transcript.txt', buildTranscript())
|
||||
const framesFolder = zip.folder('frames')!
|
||||
for (const f of selectedFrames.value) {
|
||||
const res = await fetch(f.url)
|
||||
framesFolder.file(f.file, await res.blob())
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = `${state.currentRunId}-package.zip`
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="export-bar">
|
||||
<span v-if="saveLabel" class="save" :class="state.saveStatus">{{ saveLabel }}</span>
|
||||
<span class="estimate" v-if="state.currentRunId">
|
||||
{{ packageEstimate.frames }}/{{ packageEstimate.totalFrames }} frames ·
|
||||
{{ formatBytes(packageEstimate.bytes) }}
|
||||
</span>
|
||||
<button :disabled="!state.currentRunId" @click="copyTranscript">
|
||||
{{ copied ? 'copied!' : 'Copy transcript' }}
|
||||
</button>
|
||||
<button :disabled="!state.currentRunId || busy" @click="downloadPackage">
|
||||
{{ busy ? 'building…' : 'Download package' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.export-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.estimate {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.save {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.save.saved { color: var(--status-live); }
|
||||
.save.saving { color: var(--status-processing); }
|
||||
.save.error { color: var(--status-error); }
|
||||
</style>
|
||||
121
ui/app/src/components/FrameSelector.vue
Normal file
121
ui/app/src/components/FrameSelector.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import Panel from '@framework/components/Panel.vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
const { state, activeFrameIndex, selectedFrames, seek, setSelectAll } = useStore()
|
||||
|
||||
const cardEls = ref<Record<number, HTMLElement>>({})
|
||||
|
||||
function setCard(i: number, el: Element | null) {
|
||||
if (el) cardEls.value[i] = el as HTMLElement
|
||||
}
|
||||
|
||||
function fmt(seconds: number | null): string {
|
||||
if (seconds == null) return '—'
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
watch(activeFrameIndex, async (i) => {
|
||||
if (i < 0) return
|
||||
await nextTick()
|
||||
cardEls.value[i]?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Panel title="Frames">
|
||||
<template #actions>
|
||||
<span class="count">{{ selectedFrames.length }}/{{ state.frames.length }} selected</span>
|
||||
<button @click="setSelectAll(true)">All</button>
|
||||
<button @click="setSelectAll(false)">None</button>
|
||||
</template>
|
||||
<div class="strip">
|
||||
<p v-if="!state.frames.length" class="empty">No frames for this run.</p>
|
||||
<div
|
||||
v-for="(f, i) in state.frames"
|
||||
:key="f.file"
|
||||
:ref="(el) => setCard(i, el as Element | null)"
|
||||
class="card"
|
||||
:class="{ active: i === activeFrameIndex, off: !f.selected }"
|
||||
>
|
||||
<div class="thumb" @click="f.time != null && seek(f.time)" :title="f.time != null ? 'Seek here' : 'No timestamp'">
|
||||
<img :src="f.url" loading="lazy" :alt="f.file" />
|
||||
<span class="time">{{ fmt(f.time) }}</span>
|
||||
</div>
|
||||
<label class="pick">
|
||||
<input type="checkbox" v-model="f.selected" />
|
||||
include
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.strip {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-content: flex-start;
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.card {
|
||||
width: 150px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--panel-radius);
|
||||
overflow: hidden;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
.card.active {
|
||||
border-color: var(--status-processing);
|
||||
box-shadow: 0 0 0 1px var(--status-processing);
|
||||
}
|
||||
.card.off {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
}
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 84px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
.time {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: var(--text-primary);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.count {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
52
ui/app/src/components/RunPicker.vue
Normal file
52
ui/app/src/components/RunPicker.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
const { state, loadRuns, openRun } = useStore()
|
||||
|
||||
onMounted(loadRuns)
|
||||
|
||||
function onChange(e: Event) {
|
||||
const id = (e.target as HTMLSelectElement).value
|
||||
if (id) openRun(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="run-picker">
|
||||
<label class="lbl">Meeting</label>
|
||||
<select :value="state.currentRunId ?? ''" @change="onChange">
|
||||
<option value="" disabled>Select a run…</option>
|
||||
<option v-for="r in state.runs" :key="r.id" :value="r.id">
|
||||
{{ r.name }} · {{ r.frameCount }} frames{{ r.hasVideo ? '' : ' · no video' }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="state.loading" class="hint">loading…</span>
|
||||
<span v-else-if="state.error" class="hint err">{{ state.error }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.run-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.lbl {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
select {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
min-width: 280px;
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.hint.err {
|
||||
color: var(--status-error);
|
||||
}
|
||||
</style>
|
||||
128
ui/app/src/components/TranscriptEditor.vue
Normal file
128
ui/app/src/components/TranscriptEditor.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import Panel from '@framework/components/Panel.vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
const { state, activeSegmentIndex, seek } = useStore()
|
||||
|
||||
const listEl = ref<HTMLElement | null>(null)
|
||||
const rowEls = ref<Record<number, HTMLElement>>({})
|
||||
|
||||
function fmt(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function setRow(i: number, el: Element | null) {
|
||||
if (el) rowEls.value[i] = el as HTMLElement
|
||||
}
|
||||
|
||||
function autoResize(e: Event) {
|
||||
const el = e.target as HTMLTextAreaElement
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}
|
||||
|
||||
function onAreaMounted(el: HTMLTextAreaElement | null) {
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}
|
||||
|
||||
// Keep the active segment in view while the video plays (not while editing).
|
||||
watch(activeSegmentIndex, async (i) => {
|
||||
if (i < 0) return
|
||||
if (document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.tagName === 'INPUT') return
|
||||
await nextTick()
|
||||
rowEls.value[i]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Panel title="Transcript">
|
||||
<template #actions>
|
||||
<span class="count">{{ state.segments.length }} segments</span>
|
||||
</template>
|
||||
<div ref="listEl" class="seg-list">
|
||||
<p v-if="!state.segments.length" class="empty">
|
||||
No transcript for this run.
|
||||
</p>
|
||||
<div
|
||||
v-for="(seg, i) in state.segments"
|
||||
:key="i"
|
||||
:ref="(el) => setRow(i, el as Element | null)"
|
||||
class="seg"
|
||||
:class="{ active: i === activeSegmentIndex }"
|
||||
>
|
||||
<div class="seg-head">
|
||||
<button class="ts" title="Seek here" @click="seek(seg.start)">{{ fmt(seg.start) }}</button>
|
||||
<input v-model="seg.speaker" class="speaker" spellcheck="false" placeholder="SPEAKER" />
|
||||
</div>
|
||||
<textarea
|
||||
v-model="seg.text"
|
||||
class="text"
|
||||
rows="1"
|
||||
spellcheck="false"
|
||||
:ref="(el) => onAreaMounted(el as HTMLTextAreaElement | null)"
|
||||
@input="autoResize"
|
||||
@focus="seek(seg.start)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.seg-list {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.seg {
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--panel-radius);
|
||||
border: 1px solid transparent;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.seg.active {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--status-processing);
|
||||
}
|
||||
.seg-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.ts {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--status-processing);
|
||||
background: var(--surface-0);
|
||||
padding: 1px var(--space-2);
|
||||
}
|
||||
.speaker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
padding: 1px var(--space-2);
|
||||
width: 130px;
|
||||
}
|
||||
.text {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
line-height: 1.45;
|
||||
padding: var(--space-2);
|
||||
background: var(--surface-0);
|
||||
}
|
||||
.count {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user