This commit is contained in:
Mariano Gabriel
2026-06-30 20:31:27 -03:00
parent cc64544d50
commit 221ed53696
43 changed files with 6669 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>