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

12
ui/app/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meetus — Review</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1618
ui/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
ui/app/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "meetus-review-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"jszip": "^3.10",
"vue": "^3.5"
},
"devDependencies": {
"@types/node": "^22",
"@vitejs/plugin-vue": "^5",
"typescript": "^5.6",
"vite": "^6",
"vue-tsc": "^2"
}
}

340
ui/app/server/meetusApi.ts Normal file
View File

@@ -0,0 +1,340 @@
/**
* Vite dev middleware exposing the local meetus output tree to the app.
*
* Runs in the dev server process (Node), so it can reach the source video by
* the absolute path stored in each run's manifest.json — something a browser
* cannot do. All reads are local; nothing leaves the machine.
*
* Routes:
* GET /api/runs list runs
* GET /api/runs/:id manifest + segments + frames + review
* GET /api/runs/:id/frames/:file stream a frame JPEG
* GET /api/runs/:id/video stream the source video (Range support)
* PUT /api/runs/:id/review write sidecar review.json (atomic)
*/
import type { Plugin } from 'vite'
import type { IncomingMessage, ServerResponse } from 'node:http'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
interface Options {
outputDir: string
}
interface Manifest {
video?: { name?: string; path?: string }
processed_at?: string
outputs?: {
frames?: string
enhanced_transcript?: string
whisper_transcript?: string | null
}
}
const VIDEO_MIME: Record<string, string> = {
'.mp4': 'video/mp4',
'.m4v': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.mkv': 'video/x-matroska',
'.avi': 'video/x-msvideo',
}
export function meetusApi(opts: Options): Plugin {
const outputDir = path.resolve(opts.outputDir)
/** Resolve a run id to its directory, rejecting traversal. */
function runDir(id: string): string | null {
const safe = path.basename(id)
if (safe !== id || !safe || safe.startsWith('.')) return null
const dir = path.join(outputDir, safe)
if (!dir.startsWith(outputDir + path.sep)) return null
try {
if (fs.statSync(dir).isDirectory()) return dir
} catch { /* missing */ }
return null
}
function readManifest(dir: string): Manifest | null {
try {
return JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf-8'))
} catch {
return null
}
}
return {
name: 'meetus-api',
configureServer(server) {
server.middlewares.use('/api', (req, res, next) => {
const url = new URL(req.url ?? '/', 'http://localhost')
const parts = url.pathname.split('/').filter(Boolean) // after /api strip
// parts: ['runs', ...] (the '/api' prefix is stripped by `use('/api', ...)`)
handle(req, res, parts, server.config.logger).catch((err) => {
server.config.logger.error(`[meetus-api] ${String(err)}`)
sendJson(res, 500, { error: String(err) })
}).then((handled) => {
if (!handled) next()
})
})
},
}
async function handle(
req: IncomingMessage,
res: ServerResponse,
parts: string[],
logger: { warn: (m: string) => void },
): Promise<boolean> {
if (parts[0] !== 'runs') return false
// GET /api/runs
if (parts.length === 1 && req.method === 'GET') {
await listRuns(res)
return true
}
const id = parts[1] ? decodeURIComponent(parts[1]) : ''
const dir = id ? runDir(id) : null
if (!dir) { sendJson(res, 404, { error: 'run not found' }); return true }
// GET /api/runs/:id
if (parts.length === 2 && req.method === 'GET') {
await getRun(res, id, dir)
return true
}
// GET /api/runs/:id/frames/:file
if (parts.length === 4 && parts[2] === 'frames' && req.method === 'GET') {
serveFrame(req, res, dir, decodeURIComponent(parts[3]))
return true
}
// GET /api/runs/:id/video
if (parts.length === 3 && parts[2] === 'video' && req.method === 'GET') {
serveVideo(req, res, dir, logger)
return true
}
// PUT /api/runs/:id/review
if (parts.length === 3 && parts[2] === 'review' && req.method === 'PUT') {
await saveReview(req, res, dir)
return true
}
sendJson(res, 404, { error: 'not found' })
return true
}
async function listRuns(res: ServerResponse): Promise<void> {
let entries: string[] = []
try {
entries = (await fsp.readdir(outputDir, { withFileTypes: true }))
.filter((e) => e.isDirectory())
.map((e) => e.name)
} catch {
sendJson(res, 200, { runs: [], outputDir })
return
}
const runs = []
for (const name of entries.sort().reverse()) {
const dir = path.join(outputDir, name)
const manifest = readManifest(dir)
if (!manifest) continue
const framesRel = manifest.outputs?.frames ?? 'frames'
let frameCount = 0
try {
frameCount = (await fsp.readdir(path.join(dir, framesRel)))
.filter((f) => f.toLowerCase().endsWith('.jpg')).length
} catch { /* no frames */ }
runs.push({
id: name,
name: manifest.video?.name ?? name,
processedAt: manifest.processed_at ?? null,
frameCount,
hasVideo: !!manifest.video?.path && fs.existsSync(manifest.video.path),
})
}
sendJson(res, 200, { runs, outputDir })
}
async function getRun(res: ServerResponse, id: string, dir: string): Promise<void> {
const manifest = readManifest(dir)
if (!manifest) { sendJson(res, 404, { error: 'manifest missing' }); return }
// Segments from the Whisper JSON. The manifest sometimes records
// `whisper_transcript: null` even though `{stem}.json` exists on disk
// (whisper run out-of-band), so fall back to probing for it by stem.
const segments: Array<{ start: number; end: number; text: string; speaker: string | null }> = []
let whisperName = manifest.outputs?.whisper_transcript ?? null
if (!whisperName) {
const stem = manifest.video?.name ? path.parse(manifest.video.name).name : null
if (stem && fs.existsSync(path.join(dir, `${stem}.json`))) whisperName = `${stem}.json`
}
if (whisperName && fs.existsSync(path.join(dir, whisperName))) {
try {
const data = JSON.parse(await fsp.readFile(path.join(dir, whisperName), 'utf-8'))
const segs = Array.isArray(data) ? data : data.segments ?? []
for (const s of segs) {
segments.push({
start: Number(s.start ?? s.timestamp ?? 0),
end: Number(s.end ?? s.start ?? 0),
text: String(s.text ?? '').trim(),
speaker: s.speaker ?? null,
})
}
} catch { /* no/invalid transcript */ }
}
// Frame → time map from enhanced.txt (universal, MM:SS granularity).
const framesRel = manifest.outputs?.frames ?? 'frames'
const enhancedName = manifest.outputs?.enhanced_transcript
const enhancedTimes = new Map<string, number>()
let enhancedAvailable = false
if (enhancedName) {
try {
const text = await fsp.readFile(path.join(dir, enhancedName), 'utf-8')
enhancedAvailable = true
let lastTs: number | null = null
for (const line of text.split('\n')) {
const ts = line.match(/^\[(\d+):(\d+)\]/)
if (ts) lastTs = Number(ts[1]) * 60 + Number(ts[2])
const fm = line.match(/Frame:\s*\S*?([^/\\\s]+\.jpg)/i)
if (fm && lastTs != null) enhancedTimes.set(fm[1], lastTs)
}
} catch { /* none */ }
}
// Frame list with resolved times and byte sizes.
const frames: Array<{ file: string; url: string; time: number | null; size: number }> = []
try {
const framesAbs = path.join(dir, framesRel)
const files = (await fsp.readdir(framesAbs))
.filter((f) => f.toLowerCase().endsWith('.jpg'))
.sort()
for (const file of files) {
// Interval-mode filenames encode an exact float: `..._123.45s.jpg`.
const fny = file.match(/_(\d+(?:\.\d+)?)s\.jpg$/i)
const time = fny ? Number(fny[1])
: enhancedTimes.has(file) ? enhancedTimes.get(file)!
: null
let size = 0
try { size = (await fsp.stat(path.join(framesAbs, file))).size } catch { /* ignore */ }
frames.push({
file,
url: `/api/runs/${encodeURIComponent(id)}/frames/${encodeURIComponent(file)}`,
time,
size,
})
}
} catch { /* no frames dir */ }
// Existing review sidecar, if any.
let review: unknown = null
try {
review = JSON.parse(await fsp.readFile(path.join(dir, 'review.json'), 'utf-8'))
} catch { /* none */ }
sendJson(res, 200, {
id,
manifest,
segments,
frames,
enhancedAvailable,
hasVideo: !!manifest.video?.path && fs.existsSync(manifest.video.path),
videoUrl: `/api/runs/${encodeURIComponent(id)}/video`,
review,
})
}
function serveFrame(req: IncomingMessage, res: ServerResponse, dir: string, file: string): void {
const manifest = readManifest(dir)
const framesRel = manifest?.outputs?.frames ?? 'frames'
const safe = path.basename(file)
const full = path.join(dir, framesRel, safe)
if (safe !== file || !fs.existsSync(full)) { sendJson(res, 404, { error: 'frame not found' }); return }
res.setHeader('Content-Type', 'image/jpeg')
res.setHeader('Cache-Control', 'no-cache')
fs.createReadStream(full).pipe(res)
}
function serveVideo(
req: IncomingMessage,
res: ServerResponse,
dir: string,
logger: { warn: (m: string) => void },
): void {
const manifest = readManifest(dir)
const videoPath = manifest?.video?.path
if (!videoPath || !fs.existsSync(videoPath)) {
logger.warn(`[meetus-api] source video missing for run: ${dir}`)
sendJson(res, 404, { error: 'source video not available at manifest path' })
return
}
const stat = fs.statSync(videoPath)
const mime = VIDEO_MIME[path.extname(videoPath).toLowerCase()] ?? 'application/octet-stream'
res.setHeader('Accept-Ranges', 'bytes')
res.setHeader('Content-Type', mime)
const range = req.headers.range
if (range) {
const m = range.match(/bytes=(\d*)-(\d*)/)
if (m) {
const start = m[1] ? parseInt(m[1], 10) : 0
const end = m[2] ? parseInt(m[2], 10) : stat.size - 1
if (start >= stat.size || end >= stat.size || start > end) {
res.statusCode = 416
res.setHeader('Content-Range', `bytes */${stat.size}`)
res.end()
return
}
res.statusCode = 206
res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`)
res.setHeader('Content-Length', String(end - start + 1))
fs.createReadStream(videoPath, { start, end }).pipe(res)
return
}
}
res.statusCode = 200
res.setHeader('Content-Length', String(stat.size))
fs.createReadStream(videoPath).pipe(res)
}
async function saveReview(req: IncomingMessage, res: ServerResponse, dir: string): Promise<void> {
const body = await readBody(req)
let parsed: unknown
try {
parsed = JSON.parse(body || '{}')
} catch {
sendJson(res, 400, { error: 'invalid JSON' })
return
}
if (typeof parsed !== 'object' || parsed === null) {
sendJson(res, 400, { error: 'review must be an object' })
return
}
const target = path.join(dir, 'review.json')
const tmp = path.join(dir, `.review.${process.pid}.tmp`)
await fsp.writeFile(tmp, JSON.stringify(parsed, null, 2), 'utf-8')
await fsp.rename(tmp, target) // atomic; never touches source artifacts
sendJson(res, 200, { ok: true })
}
}
function sendJson(res: ServerResponse, status: number, obj: unknown): void {
const body = JSON.stringify(obj)
res.statusCode = status
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Length', Buffer.byteLength(body))
res.end(body)
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = ''
req.on('data', (c) => { data += c; if (data.length > 5_000_000) reject(new Error('body too large')) })
req.on('end', () => resolve(data))
req.on('error', reject)
})
}

89
ui/app/src/App.vue Normal file
View 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>

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>

7
ui/app/src/env.d.ts vendored Normal file
View 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
View 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

Binary file not shown.

61
ui/app/src/styles.css Normal file
View 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;
}

22
ui/app/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"@framework/*": ["../framework/src/*"],
"@/*": ["src/*"],
"vue": ["node_modules/vue"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue", "server/**/*.ts", "vite.config.ts"]
}

28
ui/app/vite.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
import { meetusApi } from './server/meetusApi'
// Output directory produced by the meetus pipeline. Override with MEETUS_OUTPUT.
const outputDir = process.env.MEETUS_OUTPUT
?? fileURLToPath(new URL('../../output', import.meta.url))
export default defineConfig({
plugins: [vue(), meetusApi({ outputDir })],
resolve: {
alias: {
'@framework': fileURLToPath(new URL('../framework/src', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
fs: {
// Allow the dev server to read the framework source and the output tree.
allow: [
fileURLToPath(new URL('.', import.meta.url)),
fileURLToPath(new URL('../framework', import.meta.url)),
outputDir,
],
},
},
})