edit and compact mode, wip

This commit is contained in:
Mariano Gabriel
2026-06-30 22:16:08 -03:00
parent 221ed53696
commit e214b17c55
2 changed files with 226 additions and 26 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ref, watch, nextTick, computed } from 'vue'
import Panel from '@framework/components/Panel.vue'
import { useStore } from '@/store'
@@ -7,6 +7,19 @@ const { state, activeFrameIndex, selectedFrames, seek, setSelectAll } = useStore
const cardEls = ref<Record<number, HTMLElement>>({})
// Thumbnail width (px), persisted. Height derives from a ~16:9 ratio.
const MIN_W = 90
const MAX_W = 360
const STEP = 30
const cardW = ref(clampW(Number(localStorage.getItem('meetus.frameW')) || 150))
watch(cardW, (w) => localStorage.setItem('meetus.frameW', String(w)))
const thumbH = computed(() => Math.round(cardW.value * 0.56))
function clampW(w: number) { return Math.max(MIN_W, Math.min(MAX_W, w)) }
function grow() { cardW.value = clampW(cardW.value + STEP) }
function shrink() { cardW.value = clampW(cardW.value - STEP) }
function setCard(i: number, el: Element | null) {
if (el) cardEls.value[i] = el as HTMLElement
}
@@ -29,6 +42,10 @@ watch(activeFrameIndex, async (i) => {
<Panel title="Frames">
<template #actions>
<span class="count">{{ selectedFrames.length }}/{{ state.frames.length }} selected</span>
<div class="sizer">
<button title="Smaller" :disabled="cardW <= MIN_W" @click="shrink"></button>
<button title="Larger" :disabled="cardW >= MAX_W" @click="grow">+</button>
</div>
<button @click="setSelectAll(true)">All</button>
<button @click="setSelectAll(false)">None</button>
</template>
@@ -40,8 +57,14 @@ watch(activeFrameIndex, async (i) => {
:ref="(el) => setCard(i, el as Element | null)"
class="card"
:class="{ active: i === activeFrameIndex, off: !f.selected }"
:style="{ width: cardW + 'px' }"
>
<div class="thumb" @click="f.time != null && seek(f.time)" :title="f.time != null ? 'Seek here' : 'No timestamp'">
<div
class="thumb"
:style="{ height: thumbH + 'px' }"
@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>
@@ -69,7 +92,6 @@ watch(activeFrameIndex, async (i) => {
padding: var(--space-4);
}
.card {
width: 150px;
border: 1px solid var(--border);
border-radius: var(--panel-radius);
overflow: hidden;
@@ -89,7 +111,7 @@ watch(activeFrameIndex, async (i) => {
}
.thumb img {
width: 100%;
height: 84px;
height: 100%;
object-fit: cover;
display: block;
background: #000;
@@ -118,4 +140,14 @@ watch(activeFrameIndex, async (i) => {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.sizer {
display: flex;
gap: 2px;
}
.sizer button {
width: 24px;
padding: var(--space-1) 0;
font-weight: 600;
line-height: 1;
}
</style>

View File

@@ -1,12 +1,24 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ref, watch, nextTick, computed, onMounted } 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>>({})
// View preferences (persisted).
type ViewMode = 'edit' | 'read'
const viewMode = ref<ViewMode>((localStorage.getItem('meetus.viewMode') as ViewMode) || 'edit')
const compact = ref(localStorage.getItem('meetus.compact') === '1')
const showTimes = ref(localStorage.getItem('meetus.readTimes') !== '0')
watch(viewMode, (v) => localStorage.setItem('meetus.viewMode', v))
watch(compact, (v) => localStorage.setItem('meetus.compact', v ? '1' : '0'))
watch(showTimes, (v) => localStorage.setItem('meetus.readTimes', v ? '1' : '0'))
// One element per segment index (whichever mode is rendered), for auto-scroll.
const segEls = ref<Record<number, HTMLElement>>({})
function setSeg(i: number, el: Element | null) {
if (el) segEls.value[i] = el as HTMLElement
}
function fmt(seconds: number): string {
const m = Math.floor(seconds / 60)
@@ -14,44 +26,79 @@ function fmt(seconds: number): string {
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
// Auto-grow textareas. They must be re-fit after the transcript loads (initial
// mount can measure before layout/fonts settle), and after mode/density changes.
const areaEls = ref<Record<number, HTMLTextAreaElement>>({})
function fit(el: HTMLTextAreaElement) {
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}
function onAreaMounted(el: HTMLTextAreaElement | null) {
function setArea(i: number, el: Element | null) {
if (!el) return
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
areaEls.value[i] = el as HTMLTextAreaElement
fit(el as HTMLTextAreaElement)
}
function autoResize(e: Event) { fit(e.target as HTMLTextAreaElement) }
function resizeAll() {
for (const el of Object.values(areaEls.value)) {
if (el.isConnected) fit(el)
}
}
// Keep the active segment in view while the video plays (not while editing).
// Re-fit whenever the segment set is (re)loaded or the layout mode changes.
watch(() => [state.segments, viewMode.value, compact.value], () => {
nextTick(resizeAll)
})
onMounted(() => {
nextTick(resizeAll)
// Fonts can load after first paint and change wrapping/height.
;(document as Document & { fonts?: FontFaceSet }).fonts?.ready.then(resizeAll)
})
// Read view: merge consecutive same-speaker segments into flowing turns.
const readingTurns = computed(() => {
const turns: Array<{ speaker: string; start: number; items: Array<{ i: number; start: number; text: string }> }> = []
let cur: (typeof turns)[number] | null = null
state.segments.forEach((s, i) => {
const sp = s.speaker || 'SPEAKER'
if (!cur || cur.speaker !== sp) {
cur = { speaker: sp, start: s.start, items: [] }
turns.push(cur)
}
cur.items.push({ i, start: s.start, text: s.text })
})
return turns
})
// Keep the active segment in view during playback (not while editing).
watch(activeSegmentIndex, async (i) => {
if (i < 0) return
if (document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.tagName === 'INPUT') return
const tag = document.activeElement?.tagName
if (tag === 'TEXTAREA' || tag === 'INPUT') return
await nextTick()
rowEls.value[i]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
segEls.value[i]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
})
</script>
<template>
<Panel title="Transcript">
<template #actions>
<div class="seg-control">
<button :class="{ on: viewMode === 'edit' }" @click="viewMode = 'edit'">Edit</button>
<button :class="{ on: viewMode === 'read' }" @click="viewMode = 'read'">Read</button>
</div>
<button v-if="viewMode === 'edit'" :class="{ on: compact }" @click="compact = !compact">Compact</button>
<button v-else :class="{ on: showTimes }" @click="showTimes = !showTimes">Times</button>
<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>
<!-- EDIT: per-segment blocks (comfortable or compact density) -->
<div v-if="viewMode === 'edit'" class="seg-list" :class="{ compact }">
<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)"
:ref="(el) => setSeg(i, el as Element | null)"
class="seg"
:class="{ active: i === activeSegmentIndex }"
>
@@ -64,16 +111,44 @@ watch(activeSegmentIndex, async (i) => {
class="text"
rows="1"
spellcheck="false"
:ref="(el) => onAreaMounted(el as HTMLTextAreaElement | null)"
:ref="(el) => setArea(i, el as Element | null)"
@input="autoResize"
@focus="seek(seg.start)"
/>
</div>
</div>
<!-- READ: continuous, speaker-grouped flowing text (view only) -->
<div v-else class="read">
<p v-if="!state.segments.length" class="empty">No transcript for this run.</p>
<div v-for="(turn, ti) in readingTurns" :key="ti" class="turn">
<div class="turn-head">
<button class="ts" title="Seek here" @click="seek(turn.start)">{{ fmt(turn.start) }}</button>
<span class="speaker-lbl">{{ turn.speaker }}</span>
</div>
<p class="turn-body">
<template v-for="it in turn.items" :key="it.i">
<button
v-if="showTimes"
class="inline-ts"
title="Seek here"
@click="seek(it.start)"
>{{ fmt(it.start) }}</button
><span
:ref="(el) => setSeg(it.i, el as Element | null)"
class="rseg"
:class="{ active: it.i === activeSegmentIndex }"
@click="seek(it.start)"
>{{ it.text }} </span>
</template>
</p>
</div>
</div>
</Panel>
</template>
<style scoped>
/* ---- Edit view ---- */
.seg-list {
height: 100%;
overflow-y: auto;
@@ -105,6 +180,8 @@ watch(activeSegmentIndex, async (i) => {
color: var(--status-processing);
background: var(--surface-0);
padding: 1px var(--space-2);
user-select: none;
-webkit-user-select: none;
}
.speaker {
font-family: var(--font-mono);
@@ -125,4 +202,95 @@ watch(activeSegmentIndex, async (i) => {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* Compact density: time + speaker inline on the same row as the text. */
.seg-list.compact .seg {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: 1px var(--space-1);
margin-bottom: 0;
border-radius: 0;
}
.seg-list.compact .seg-head {
margin-bottom: 0;
flex-shrink: 0;
padding-top: 2px;
}
.seg-list.compact .speaker {
width: 96px;
}
.seg-list.compact .text {
flex: 1;
min-width: 0;
padding: 1px var(--space-2);
background: transparent;
border-color: transparent;
line-height: 1.35;
}
.seg-list.compact .seg.active {
background: var(--surface-2);
}
.seg-list.compact .seg.active .text {
background: transparent;
}
/* ---- Read view ---- */
.read {
height: 100%;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
line-height: 1.55;
}
.turn {
margin-bottom: var(--space-3);
}
.turn-head {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: 2px;
}
.speaker-lbl {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.turn-body {
margin: 0;
}
.rseg {
cursor: pointer;
border-radius: 3px;
}
.rseg:hover {
background: var(--surface-2);
}
.rseg.active {
background: var(--surface-2);
box-shadow: 0 0 0 1px var(--status-processing);
}
.inline-ts {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
background: transparent;
border: none;
padding: 0 3px 0 0;
vertical-align: baseline;
user-select: none;
-webkit-user-select: none;
}
.inline-ts:hover {
color: var(--status-processing);
}
.seg-control {
display: flex;
gap: 2px;
}
.seg-control button.on,
button.on {
background: var(--surface-3);
border-color: var(--status-processing);
}
</style>