doocus first ver
This commit is contained in:
12
ui/meetus-app/index.html
Normal file
12
ui/meetus-app/index.html
Normal 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/meetus-app/package-lock.json
generated
Normal file
1618
ui/meetus-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
ui/meetus-app/package.json
Normal file
23
ui/meetus-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
391
ui/meetus-app/server/meetusApi.ts
Normal file
391
ui/meetus-app/server/meetusApi.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* 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
|
||||
/** Optional directory to look for source videos by name when the absolute
|
||||
* path recorded in the manifest is not reachable on this machine. */
|
||||
videoDir?: 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)
|
||||
const videoDir = opts.videoDir ? path.resolve(opts.videoDir) : null
|
||||
|
||||
/** Locate a run's source video. The manifest records an absolute path from
|
||||
* the machine that processed it, which may not exist here — so fall back to
|
||||
* finding the file by name in MEETUS_VIDEO_DIR, the run dir, its parent, and
|
||||
* the output root (videos are sometimes stored alongside the output). */
|
||||
function resolveVideoPath(dir: string, manifest: Manifest): string | null {
|
||||
const recorded = manifest.video?.path
|
||||
if (recorded && isFile(recorded)) return recorded
|
||||
const name = manifest.video?.name
|
||||
if (name) {
|
||||
const candidates: string[] = []
|
||||
if (videoDir) candidates.push(path.join(videoDir, name))
|
||||
candidates.push(
|
||||
path.join(dir, name), // inside the run dir
|
||||
path.join(path.dirname(dir), name), // sibling of the run dir
|
||||
path.join(outputDir, name), // output root
|
||||
)
|
||||
for (const c of candidates) if (isFile(c)) return c
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Resolve a run id (possibly nested, e.g. "batch/2026/team a/<run>") to its
|
||||
* directory, rejecting traversal outside the output root. */
|
||||
function runDir(id: string): string | null {
|
||||
const dir = path.resolve(outputDir, id)
|
||||
if (dir !== outputDir && !dir.startsWith(outputDir + path.sep)) return null
|
||||
try {
|
||||
if (fs.statSync(dir).isDirectory()) return dir
|
||||
} catch { /* missing */ }
|
||||
return null
|
||||
}
|
||||
|
||||
const toPosix = (p: string) => p.split(path.sep).join('/')
|
||||
|
||||
/** Find every run directory (one containing manifest.json) under `base`,
|
||||
* at any depth — batch.sh mirrors the input tree, so runs are nested. */
|
||||
async function findRunDirs(base: string, maxDepth = 8): Promise<string[]> {
|
||||
const found: string[] = []
|
||||
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||
if (depth > maxDepth) return
|
||||
let ents
|
||||
try {
|
||||
ents = await fsp.readdir(dir, { withFileTypes: true })
|
||||
} catch { return }
|
||||
if (ents.some((e) => e.isFile() && e.name === 'manifest.json')) {
|
||||
found.push(dir)
|
||||
return // a run dir; don't descend into it (frames/ etc.)
|
||||
}
|
||||
for (const e of ents) {
|
||||
if (!e.isDirectory()) continue
|
||||
if (e.name === 'frames' || e.name === 'node_modules' || e.name.startsWith('.')) continue
|
||||
await walk(path.join(dir, e.name), depth + 1)
|
||||
}
|
||||
}
|
||||
await walk(base, 0)
|
||||
return found
|
||||
}
|
||||
|
||||
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.config.logger.info(`[meetus-api] serving meeting runs from: ${outputDir}`)
|
||||
if (videoDir) server.config.logger.info(`[meetus-api] video fallback dir: ${videoDir}`)
|
||||
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> {
|
||||
const dirs = await findRunDirs(outputDir)
|
||||
const runs = []
|
||||
for (const dir of dirs) {
|
||||
const manifest = readManifest(dir)
|
||||
if (!manifest) continue
|
||||
const id = toPosix(path.relative(outputDir, dir))
|
||||
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: manifest.video?.name ?? id,
|
||||
processedAt: manifest.processed_at ?? null,
|
||||
frameCount,
|
||||
hasVideo: !!resolveVideoPath(dir, manifest),
|
||||
})
|
||||
}
|
||||
// Most recent first (by processed time, then id).
|
||||
runs.sort((a, b) =>
|
||||
(b.processedAt ?? '').localeCompare(a.processedAt ?? '') || b.id.localeCompare(a.id))
|
||||
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: !!resolveVideoPath(dir, manifest),
|
||||
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 ? resolveVideoPath(dir, manifest) : null
|
||||
if (!videoPath) {
|
||||
logger.warn(`[meetus-api] source video not found for run: ${dir} (recorded: ${manifest?.video?.path ?? '?'})`)
|
||||
sendJson(res, 404, { error: 'source video not found (recorded path unreachable and no match by name)' })
|
||||
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 isFile(p: string): boolean {
|
||||
try { return fs.statSync(p).isFile() } catch { return false }
|
||||
}
|
||||
|
||||
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/meetus-app/src/App.vue
Normal file
89
ui/meetus-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/meetus-app/src/components/ExportBar.vue
Normal file
88
ui/meetus-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>
|
||||
153
ui/meetus-app/src/components/FrameSelector.vue
Normal file
153
ui/meetus-app/src/components/FrameSelector.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed } 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>>({})
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
<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 }"
|
||||
:style="{ width: cardW + 'px' }"
|
||||
>
|
||||
<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>
|
||||
<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 {
|
||||
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: 100%;
|
||||
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);
|
||||
}
|
||||
.sizer {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.sizer button {
|
||||
width: 24px;
|
||||
padding: var(--space-1) 0;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
55
ui/meetus-app/src/components/RunPicker.vue
Normal file
55
ui/meetus-app/src/components/RunPicker.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<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>
|
||||
<span v-else-if="!state.runs.length" class="hint" :title="state.outputDir ?? ''">
|
||||
no runs found in {{ state.outputDir ?? '(unknown)' }}
|
||||
</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>
|
||||
296
ui/meetus-app/src/components/TranscriptEditor.vue
Normal file
296
ui/meetus-app/src/components/TranscriptEditor.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed, onMounted } from 'vue'
|
||||
import Panel from '@framework/components/Panel.vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
const { state, activeSegmentIndex, seek } = useStore()
|
||||
|
||||
// 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)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 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 setArea(i: number, el: Element | null) {
|
||||
if (!el) return
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
const tag = document.activeElement?.tagName
|
||||
if (tag === 'TEXTAREA' || tag === 'INPUT') return
|
||||
await nextTick()
|
||||
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>
|
||||
|
||||
<!-- 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) => setSeg(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) => 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;
|
||||
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);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 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>
|
||||
7
ui/meetus-app/src/env.d.ts
vendored
Normal file
7
ui/meetus-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/meetus-app/src/main.ts
Normal file
6
ui/meetus-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/meetus-app/src/store.ts
Normal file
BIN
ui/meetus-app/src/store.ts
Normal file
Binary file not shown.
61
ui/meetus-app/src/styles.css
Normal file
61
ui/meetus-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;
|
||||
}
|
||||
22
ui/meetus-app/tsconfig.json
Normal file
22
ui/meetus-app/tsconfig.json
Normal 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"]
|
||||
}
|
||||
33
ui/meetus-app/vite.config.ts
Normal file
33
ui/meetus-app/vite.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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))
|
||||
|
||||
// Optional: where source videos live, if the absolute paths recorded in the
|
||||
// manifests are not reachable on this machine. Videos are matched by name.
|
||||
const videoDir = process.env.MEETUS_VIDEO_DIR || undefined
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), meetusApi({ outputDir, videoDir })],
|
||||
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,
|
||||
...(videoDir ? [videoDir] : []),
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user