ui
This commit is contained in:
89
ui/app/src/App.vue
Normal file
89
ui/app/src/App.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import SplitPane from '@framework/components/SplitPane.vue'
|
||||
import Panel from '@framework/components/Panel.vue'
|
||||
import VideoPlayer from '@framework/components/VideoPlayer.vue'
|
||||
import RunPicker from '@/components/RunPicker.vue'
|
||||
import TranscriptEditor from '@/components/TranscriptEditor.vue'
|
||||
import FrameSelector from '@/components/FrameSelector.vue'
|
||||
import ExportBar from '@/components/ExportBar.vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
const { state } = useStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<RunPicker />
|
||||
<div class="spacer" />
|
||||
<ExportBar />
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<SplitPane direction="horizontal" :initialSize="1.4" sizeMode="ratio" :min="0.4" :max="4">
|
||||
<template #first>
|
||||
<SplitPane direction="vertical" :initialSize="1.3" sizeMode="ratio" :min="0.3" :max="4">
|
||||
<template #first>
|
||||
<Panel title="Video" :status="state.playing ? 'live' : 'idle'">
|
||||
<VideoPlayer
|
||||
v-if="state.videoUrl"
|
||||
:src="state.videoUrl"
|
||||
v-model:currentTime="state.currentTime"
|
||||
v-model:playing="state.playing"
|
||||
@duration="state.duration = $event"
|
||||
/>
|
||||
<div v-else class="placeholder">
|
||||
{{ state.currentRunId
|
||||
? 'Source video not available at the path recorded in manifest.json.'
|
||||
: 'Select a meeting to begin.' }}
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
<template #second>
|
||||
<FrameSelector />
|
||||
</template>
|
||||
</SplitPane>
|
||||
</template>
|
||||
<template #second>
|
||||
<TranscriptEditor />
|
||||
</template>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--surface-1);
|
||||
border-bottom: var(--panel-border);
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
7
ui/app/src/env.d.ts
vendored
Normal file
7
ui/app/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
6
ui/app/src/main.ts
Normal file
6
ui/app/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import '@framework/tokens.css'
|
||||
import './styles.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
BIN
ui/app/src/store.ts
Normal file
BIN
ui/app/src/store.ts
Normal file
Binary file not shown.
61
ui/app/src/styles.css
Normal file
61
ui/app/src/styles.css
Normal file
@@ -0,0 +1,61 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-2);
|
||||
border: var(--panel-border);
|
||||
border-radius: var(--panel-radius);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-0);
|
||||
border: var(--panel-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--surface-3);
|
||||
border-radius: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
Reference in New Issue
Block a user