doocus first ver
This commit is contained in:
12
ui/doocus-app/index.html
Normal file
12
ui/doocus-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>Doocus — Browse</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1618
ui/doocus-app/package-lock.json
generated
Normal file
1618
ui/doocus-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
ui/doocus-app/package.json
Normal file
23
ui/doocus-app/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "doocus-browser-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"
|
||||
}
|
||||
}
|
||||
228
ui/doocus-app/server/doocusApi.ts
Normal file
228
ui/doocus-app/server/doocusApi.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Vite dev middleware exposing the local doocus docs-output tree to the app.
|
||||
*
|
||||
* Runs in the dev server process (Node), so it can reach the original source
|
||||
* file by the absolute path stored in each doc's manifest.json — needed for the
|
||||
* package builder, which bundles the original alongside content.md + meta.json.
|
||||
* All reads are local; nothing leaves the machine.
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/docs list extracted docs
|
||||
* GET /api/docs/:id meta.json + content.md + thumb/original flags
|
||||
* GET /api/docs/:id/thumb stream thumb.jpg
|
||||
* GET /api/docs/:id/original stream the original source file
|
||||
*/
|
||||
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 {
|
||||
source?: { name?: string; path?: string; sha256?: string }
|
||||
processed_at?: string
|
||||
outputs?: { content?: string; meta?: string; thumb?: string | null }
|
||||
}
|
||||
|
||||
const ORIGINAL_MIME: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.csv': 'text/csv',
|
||||
'.html': 'text/html',
|
||||
'.htm': 'text/html',
|
||||
'.json': 'application/json',
|
||||
'.md': 'text/markdown',
|
||||
'.txt': 'text/plain',
|
||||
'.yaml': 'text/yaml',
|
||||
'.yml': 'text/yaml',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.mp4': 'video/mp4',
|
||||
}
|
||||
|
||||
export function doocusApi(opts: Options): Plugin {
|
||||
const outputDir = path.resolve(opts.outputDir)
|
||||
const toPosix = (p: string) => p.split(path.sep).join('/')
|
||||
|
||||
/** Resolve a doc id (possibly nested) to its directory, rejecting traversal. */
|
||||
function docDir(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
|
||||
}
|
||||
|
||||
/** Find every doc run directory (one containing meta.json) under `base`. */
|
||||
async function findDocDirs(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 === 'meta.json')) {
|
||||
found.push(dir)
|
||||
return // a doc dir; don't descend into assets/
|
||||
}
|
||||
for (const e of ents) {
|
||||
if (!e.isDirectory()) continue
|
||||
if (e.name === 'assets' || e.name === 'node_modules' || e.name.startsWith('.')) continue
|
||||
await walk(path.join(dir, e.name), depth + 1)
|
||||
}
|
||||
}
|
||||
await walk(base, 0)
|
||||
return found
|
||||
}
|
||||
|
||||
function readJson<T>(file: string): T | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8')) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'doocus-api',
|
||||
configureServer(server) {
|
||||
server.config.logger.info(`[doocus-api] serving docs from: ${outputDir}`)
|
||||
server.middlewares.use('/api', (req, res, next) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost')
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
handle(req, res, parts).catch((err) => {
|
||||
server.config.logger.error(`[doocus-api] ${String(err)}`)
|
||||
sendJson(res, 500, { error: String(err) })
|
||||
}).then((handled) => {
|
||||
if (!handled) next()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
async function handle(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<boolean> {
|
||||
if (parts[0] !== 'docs') return false
|
||||
|
||||
// GET /api/docs
|
||||
if (parts.length === 1 && req.method === 'GET') {
|
||||
await listDocs(res)
|
||||
return true
|
||||
}
|
||||
|
||||
const id = parts[1] ? decodeURIComponent(parts[1]) : ''
|
||||
const dir = id ? docDir(id) : null
|
||||
if (!dir) { sendJson(res, 404, { error: 'doc not found' }); return true }
|
||||
|
||||
// GET /api/docs/:id
|
||||
if (parts.length === 2 && req.method === 'GET') {
|
||||
await getDoc(res, id, dir)
|
||||
return true
|
||||
}
|
||||
// GET /api/docs/:id/thumb
|
||||
if (parts.length === 3 && parts[2] === 'thumb' && req.method === 'GET') {
|
||||
serveThumb(res, dir)
|
||||
return true
|
||||
}
|
||||
// GET /api/docs/:id/original
|
||||
if (parts.length === 3 && parts[2] === 'original' && req.method === 'GET') {
|
||||
serveOriginal(res, dir)
|
||||
return true
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: 'not found' })
|
||||
return true
|
||||
}
|
||||
|
||||
async function listDocs(res: ServerResponse): Promise<void> {
|
||||
const dirs = await findDocDirs(outputDir)
|
||||
const docs = []
|
||||
for (const dir of dirs) {
|
||||
const meta = readJson<Record<string, unknown>>(path.join(dir, 'meta.json'))
|
||||
if (!meta) continue
|
||||
const id = toPosix(path.relative(outputDir, dir))
|
||||
const source = (meta.source ?? {}) as { name?: string; bytes?: number }
|
||||
docs.push({
|
||||
id,
|
||||
name: source.name ?? id,
|
||||
type: meta.type ?? null,
|
||||
family: meta.family ?? null,
|
||||
title: meta.title ?? null,
|
||||
extractedAt: meta.extracted_at ?? null,
|
||||
wordCount: meta.word_count ?? 0,
|
||||
bytes: source.bytes ?? 0,
|
||||
hasThumb: fs.existsSync(path.join(dir, 'thumb.jpg')),
|
||||
warnings: Array.isArray(meta.warnings) ? meta.warnings.length : 0,
|
||||
})
|
||||
}
|
||||
docs.sort((a, b) =>
|
||||
String(b.extractedAt ?? '').localeCompare(String(a.extractedAt ?? '')) || a.id.localeCompare(b.id))
|
||||
sendJson(res, 200, { docs, outputDir })
|
||||
}
|
||||
|
||||
async function getDoc(res: ServerResponse, id: string, dir: string): Promise<void> {
|
||||
const meta = readJson<Record<string, unknown>>(path.join(dir, 'meta.json'))
|
||||
if (!meta) { sendJson(res, 404, { error: 'meta missing' }); return }
|
||||
const manifest = readJson<Manifest>(path.join(dir, 'manifest.json'))
|
||||
|
||||
let content = ''
|
||||
try {
|
||||
content = await fsp.readFile(path.join(dir, 'content.md'), 'utf-8')
|
||||
} catch { /* none */ }
|
||||
|
||||
const hasThumb = fs.existsSync(path.join(dir, 'thumb.jpg'))
|
||||
const originalPath = manifest?.source?.path
|
||||
const hasOriginal = !!originalPath && isFile(originalPath)
|
||||
|
||||
sendJson(res, 200, {
|
||||
id,
|
||||
meta,
|
||||
content,
|
||||
hasThumb,
|
||||
thumbUrl: hasThumb ? `/api/docs/${encodeURIComponent(id)}/thumb` : null,
|
||||
hasOriginal,
|
||||
originalUrl: hasOriginal ? `/api/docs/${encodeURIComponent(id)}/original` : null,
|
||||
})
|
||||
}
|
||||
|
||||
function serveThumb(res: ServerResponse, dir: string): void {
|
||||
const full = path.join(dir, 'thumb.jpg')
|
||||
if (!fs.existsSync(full)) { sendJson(res, 404, { error: 'no thumb' }); return }
|
||||
res.setHeader('Content-Type', 'image/jpeg')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
fs.createReadStream(full).pipe(res)
|
||||
}
|
||||
|
||||
function serveOriginal(res: ServerResponse, dir: string): void {
|
||||
const manifest = readJson<Manifest>(path.join(dir, 'manifest.json'))
|
||||
const original = manifest?.source?.path
|
||||
if (!original || !isFile(original)) {
|
||||
sendJson(res, 404, { error: 'original source not reachable on this machine' })
|
||||
return
|
||||
}
|
||||
const mime = ORIGINAL_MIME[path.extname(original).toLowerCase()] ?? 'application/octet-stream'
|
||||
res.setHeader('Content-Type', mime)
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(original)}"`)
|
||||
fs.createReadStream(original).pipe(res)
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
71
ui/doocus-app/src/App.vue
Normal file
71
ui/doocus-app/src/App.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import Panel from '@framework/components/Panel.vue'
|
||||
import SplitPane from '@framework/components/SplitPane.vue'
|
||||
import DocPicker from '@/components/DocPicker.vue'
|
||||
import DocViewer from '@/components/DocViewer.vue'
|
||||
import MetadataPanel from '@/components/MetadataPanel.vue'
|
||||
import ExportBar from '@/components/ExportBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<span class="brand">doocus</span>
|
||||
<span class="spacer" />
|
||||
<ExportBar />
|
||||
</header>
|
||||
|
||||
<main class="body">
|
||||
<SplitPane :initial-size="0.9" :min="0.5" :max="2">
|
||||
<template #first>
|
||||
<Panel title="Documents">
|
||||
<DocPicker />
|
||||
</Panel>
|
||||
</template>
|
||||
<template #second>
|
||||
<SplitPane :initial-size="2.2" :min="1" :max="5">
|
||||
<template #first>
|
||||
<Panel title="Content">
|
||||
<DocViewer />
|
||||
</Panel>
|
||||
</template>
|
||||
<template #second>
|
||||
<Panel title="Metadata">
|
||||
<MetadataPanel />
|
||||
</Panel>
|
||||
</template>
|
||||
</SplitPane>
|
||||
</template>
|
||||
</SplitPane>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--surface-1);
|
||||
border-bottom: var(--panel-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
85
ui/doocus-app/src/components/DocPicker.vue
Normal file
85
ui/doocus-app/src/components/DocPicker.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { store, loadDocs, openDoc, toggleSelect, isSelected } from '@/store'
|
||||
|
||||
onMounted(loadDocs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="doc-picker">
|
||||
<div class="picker-head">
|
||||
<strong>{{ store.docs.length }}</strong> document{{ store.docs.length === 1 ? '' : 's' }}
|
||||
</div>
|
||||
<ul class="doc-list">
|
||||
<li
|
||||
v-for="d in store.docs"
|
||||
:key="d.id"
|
||||
:class="{ active: d.id === store.currentId }"
|
||||
@click="openDoc(d.id)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(d.id)"
|
||||
title="Add to package"
|
||||
@click.stop="toggleSelect(d.id)"
|
||||
/>
|
||||
<span class="type-badge" :data-type="d.type ?? '?'">{{ d.type ?? '?' }}</span>
|
||||
<span class="doc-name">{{ d.title || d.name }}</span>
|
||||
<span v-if="d.warnings" class="warn" :title="`${d.warnings} warning(s)`">⚠</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doc-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.picker-head {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
border-bottom: var(--panel-border);
|
||||
}
|
||||
.doc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.doc-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--surface-2);
|
||||
}
|
||||
.doc-list li:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.doc-list li.active {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.type-badge {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.doc-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.warn {
|
||||
margin-left: auto;
|
||||
color: var(--status-error, #e0a030);
|
||||
}
|
||||
</style>
|
||||
76
ui/doocus-app/src/components/DocViewer.vue
Normal file
76
ui/doocus-app/src/components/DocViewer.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import VideoPlayer from '@framework/components/VideoPlayer.vue'
|
||||
import { store } from '@/store'
|
||||
|
||||
// Choose a viewer by file type. Images and video render the original directly;
|
||||
// everything else renders the textified content.md (monospace for structured
|
||||
// text, prose for documents).
|
||||
const kind = computed<'image' | 'video' | 'mono' | 'prose'>(() => {
|
||||
const t = (store.current?.meta as any)?.type as string | undefined
|
||||
if (t === 'jpg' || t === 'jpeg' || t === 'png') return 'image'
|
||||
if (t === 'mp4') return 'video'
|
||||
if (t === 'json' || t === 'yaml' || t === 'yml' || t === 'csv') return 'mono'
|
||||
return 'prose'
|
||||
})
|
||||
|
||||
const content = computed(() => store.current?.content ?? '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="viewer">
|
||||
<div v-if="!store.current" class="empty">Select a document to view its extracted content.</div>
|
||||
|
||||
<div v-else-if="kind === 'image'" class="image-wrap">
|
||||
<img :src="store.current.originalUrl ?? store.current.thumbUrl ?? ''" alt="document" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="kind === 'video'" class="video-wrap">
|
||||
<VideoPlayer v-if="store.current.originalUrl" :src="store.current.originalUrl" />
|
||||
<p class="note">
|
||||
Video preview. The transcript is produced by the meetus pipeline, not doocus.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<pre v-else-if="kind === 'mono'" class="mono">{{ content }}</pre>
|
||||
|
||||
<div v-else class="prose">{{ content }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.viewer {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.image-wrap img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--panel-radius);
|
||||
border: var(--panel-border);
|
||||
}
|
||||
.video-wrap {
|
||||
max-width: 900px;
|
||||
}
|
||||
.note {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.mono {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 820px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
110
ui/doocus-app/src/components/ExportBar.vue
Normal file
110
ui/doocus-app/src/components/ExportBar.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import JSZip from 'jszip'
|
||||
import {
|
||||
store, TARGETS, target, packageEstimate, humanBytes,
|
||||
} from '@/store'
|
||||
|
||||
const building = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Build a zip: per selected doc, a folder holding the original source file plus
|
||||
// the textified content.md and meta.json. Originals are included because the
|
||||
// permitted services (Gemini web / NotebookLM) receive them directly; content.md
|
||||
// keeps each doc greppable and small.
|
||||
async function buildPackage(): Promise<void> {
|
||||
if (!store.selected.size) return
|
||||
building.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
for (const id of store.selected) {
|
||||
const summary = store.docs.find((d) => d.id === id)
|
||||
const res = await fetch(`/api/docs/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) throw new Error(`fetch ${id} failed`)
|
||||
const detail = await res.json()
|
||||
const folder = zip.folder(id.replace(/[\\/]+/g, '__')) ?? zip
|
||||
folder.file('content.md', detail.content ?? '')
|
||||
folder.file('meta.json', JSON.stringify(detail.meta ?? {}, null, 2))
|
||||
if (detail.originalUrl) {
|
||||
const bin = await fetch(detail.originalUrl)
|
||||
if (bin.ok) {
|
||||
const name = (summary?.name) || 'original'
|
||||
folder.file(name, await bin.blob())
|
||||
}
|
||||
}
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `doocus-package-${target.value.key}.zip`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
error.value = String(e)
|
||||
} finally {
|
||||
building.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection(): void {
|
||||
store.selected.clear()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="export-bar">
|
||||
<label class="target">
|
||||
Target
|
||||
<select v-model="store.targetKey">
|
||||
<option v-for="t in TARGETS" :key="t.key" :value="t.key">{{ t.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="estimate" :class="{ over: packageEstimate.overFiles || packageEstimate.overBytes }">
|
||||
{{ packageEstimate.docCount }} docs ·
|
||||
<span :class="{ bad: packageEstimate.overFiles }">
|
||||
{{ packageEstimate.files }}/{{ target.maxFiles }} files
|
||||
</span>
|
||||
·
|
||||
<span :class="{ bad: packageEstimate.overBytes }">
|
||||
{{ humanBytes(packageEstimate.bytes) }} / {{ humanBytes(target.maxBytes) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button :disabled="!store.selected.size" @click="clearSelection">Clear</button>
|
||||
<button :disabled="!store.selected.size || building" @click="buildPackage">
|
||||
{{ building ? 'Building…' : 'Download package' }}
|
||||
</button>
|
||||
<span v-if="error" class="err">{{ error }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.export-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.estimate {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.estimate.over {
|
||||
color: var(--status-error, #e0a030);
|
||||
}
|
||||
.estimate .bad {
|
||||
color: var(--status-error, #e0a030);
|
||||
font-weight: 600;
|
||||
}
|
||||
.err {
|
||||
color: var(--status-error, #e0a030);
|
||||
}
|
||||
</style>
|
||||
123
ui/doocus-app/src/components/MetadataPanel.vue
Normal file
123
ui/doocus-app/src/components/MetadataPanel.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { store, humanBytes } from '@/store'
|
||||
|
||||
// Flatten meta.json into label/value rows, promoting the common fields to the
|
||||
// top and rendering nested objects/arrays compactly.
|
||||
const rows = computed(() => {
|
||||
const m = store.current?.meta as Record<string, any> | undefined
|
||||
if (!m) return []
|
||||
const out: Array<{ label: string; value: string }> = []
|
||||
const push = (label: string, value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return
|
||||
let s: string
|
||||
if (Array.isArray(value)) s = value.join(', ')
|
||||
else if (typeof value === 'object') s = JSON.stringify(value)
|
||||
else s = String(value)
|
||||
out.push({ label, value: s })
|
||||
}
|
||||
|
||||
const src = (m.source ?? {}) as Record<string, unknown>
|
||||
push('File', src.name)
|
||||
push('Type', m.type)
|
||||
push('Family', m.family)
|
||||
push('Size', typeof src.bytes === 'number' ? humanBytes(src.bytes) : undefined)
|
||||
push('Modified', src.modified)
|
||||
push('Title', m.title)
|
||||
push('Author', m.author)
|
||||
push('Created', m.created)
|
||||
push('Doc modified', m.modified_doc)
|
||||
push('Pages', m.pages)
|
||||
push('Slides', m.slides)
|
||||
push('Sheets', m.sheets)
|
||||
push('Rows', m.rows)
|
||||
push('Columns', m.columns)
|
||||
push('Words', m.word_count)
|
||||
push('Captured', m.captured)
|
||||
push('Duration (s)', m.duration_seconds)
|
||||
push('Delegate', m.delegate)
|
||||
push('Extractor', m.extractor)
|
||||
push('sha256', src.sha256)
|
||||
|
||||
// Any remaining structured extras not already shown.
|
||||
if (m.structure) push('Structure', m.structure)
|
||||
if (m.headings) push('Headings', m.headings)
|
||||
return out
|
||||
})
|
||||
|
||||
const warnings = computed(() => {
|
||||
const w = (store.current?.meta as any)?.warnings
|
||||
return Array.isArray(w) ? w : []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="meta-panel">
|
||||
<div v-if="!store.current" class="empty">No document selected.</div>
|
||||
<template v-else>
|
||||
<img
|
||||
v-if="store.current.thumbUrl"
|
||||
class="thumb"
|
||||
:src="store.current.thumbUrl"
|
||||
alt="preview"
|
||||
/>
|
||||
<dl class="rows">
|
||||
<template v-for="r in rows" :key="r.label">
|
||||
<dt>{{ r.label }}</dt>
|
||||
<dd>{{ r.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<div v-if="warnings.length" class="warnings">
|
||||
<div class="warn-title">Warnings</div>
|
||||
<ul>
|
||||
<li v-for="(w, i) in warnings" :key="i">{{ w }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.meta-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.thumb {
|
||||
width: 100%;
|
||||
border-radius: var(--panel-radius);
|
||||
border: var(--panel-border);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.rows {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.rows dt {
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rows dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.warnings {
|
||||
margin-top: var(--space-4);
|
||||
color: var(--status-error, #e0a030);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.warn-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.warnings ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
120
ui/doocus-app/src/store.ts
Normal file
120
ui/doocus-app/src/store.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Reactive app store for the doocus browser.
|
||||
*
|
||||
* Reads the local docs-output tree via /api/docs (see server/doocusApi.ts).
|
||||
* Holds the doc list, the currently open doc (meta + textified content), and the
|
||||
* selection used by the package builder. Read-only over the pipeline output —
|
||||
* the UI never mutates extracted artifacts.
|
||||
*/
|
||||
import { reactive, computed } from 'vue'
|
||||
|
||||
export interface DocSummary {
|
||||
id: string
|
||||
name: string
|
||||
type: string | null
|
||||
family: string | null
|
||||
title: string | null
|
||||
extractedAt: string | null
|
||||
wordCount: number
|
||||
bytes: number
|
||||
hasThumb: boolean
|
||||
warnings: number
|
||||
}
|
||||
|
||||
export interface DocDetail {
|
||||
id: string
|
||||
meta: Record<string, unknown>
|
||||
content: string
|
||||
hasThumb: boolean
|
||||
thumbUrl: string | null
|
||||
hasOriginal: boolean
|
||||
originalUrl: string | null
|
||||
}
|
||||
|
||||
/** Package targets and their (soft) limits. Surfaced, not hard-enforced. */
|
||||
export interface Target {
|
||||
key: string
|
||||
label: string
|
||||
maxFiles: number
|
||||
maxBytes: number
|
||||
}
|
||||
|
||||
export const TARGETS: Target[] = [
|
||||
{ key: 'gemini', label: 'Gemini web', maxFiles: 10, maxBytes: 100 * 1024 * 1024 },
|
||||
{ key: 'notebooklm', label: 'NotebookLM', maxFiles: 50, maxBytes: 200 * 1024 * 1024 },
|
||||
]
|
||||
|
||||
interface State {
|
||||
docs: DocSummary[]
|
||||
outputDir: string
|
||||
currentId: string | null
|
||||
current: DocDetail | null
|
||||
loading: boolean
|
||||
selected: Set<string>
|
||||
targetKey: string
|
||||
}
|
||||
|
||||
export const store = reactive<State>({
|
||||
docs: [],
|
||||
outputDir: '',
|
||||
currentId: null,
|
||||
current: null,
|
||||
loading: false,
|
||||
selected: new Set<string>(),
|
||||
targetKey: TARGETS[0].key,
|
||||
})
|
||||
|
||||
export async function loadDocs(): Promise<void> {
|
||||
const res = await fetch('/api/docs')
|
||||
const data = await res.json()
|
||||
store.docs = data.docs ?? []
|
||||
store.outputDir = data.outputDir ?? ''
|
||||
if (!store.currentId && store.docs.length) {
|
||||
await openDoc(store.docs[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
export async function openDoc(id: string): Promise<void> {
|
||||
store.loading = true
|
||||
try {
|
||||
const res = await fetch(`/api/docs/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) throw new Error(`open failed: ${res.status}`)
|
||||
store.current = await res.json()
|
||||
store.currentId = id
|
||||
} finally {
|
||||
store.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleSelect(id: string): void {
|
||||
if (store.selected.has(id)) store.selected.delete(id)
|
||||
else store.selected.add(id)
|
||||
}
|
||||
|
||||
export function isSelected(id: string): boolean {
|
||||
return store.selected.has(id)
|
||||
}
|
||||
|
||||
export const target = computed(() => TARGETS.find((t) => t.key === store.targetKey) ?? TARGETS[0])
|
||||
|
||||
/** Rough package estimate: each doc contributes original + content.md + meta.json. */
|
||||
export const packageEstimate = computed(() => {
|
||||
const chosen = store.docs.filter((d) => store.selected.has(d.id))
|
||||
const files = chosen.length * 3 // original + content.md + meta.json
|
||||
// originals dominate size; content/meta are small — approximate with wordCount.
|
||||
const bytes = chosen.reduce((sum, d) => sum + d.bytes + d.wordCount * 6 + 512, 0)
|
||||
const t = target.value
|
||||
return {
|
||||
docCount: chosen.length,
|
||||
files,
|
||||
bytes,
|
||||
overFiles: files > t.maxFiles,
|
||||
overBytes: bytes > t.maxBytes,
|
||||
}
|
||||
})
|
||||
|
||||
export function humanBytes(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`
|
||||
}
|
||||
29
ui/doocus-app/vite.config.ts
Normal file
29
ui/doocus-app/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { doocusApi } from './server/doocusApi'
|
||||
|
||||
// Output directory produced by the doocus pipeline (process_doc.py /
|
||||
// ctrl/batch_docs.sh). Override with DOOCUS_OUTPUT.
|
||||
const outputDir = process.env.DOOCUS_OUTPUT
|
||||
?? fileURLToPath(new URL('../../docs-output', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), doocusApi({ 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 docs-output tree.
|
||||
allow: [
|
||||
fileURLToPath(new URL('.', import.meta.url)),
|
||||
fileURLToPath(new URL('../framework', import.meta.url)),
|
||||
outputDir,
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -20,6 +20,9 @@ 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 {
|
||||
@@ -43,19 +46,66 @@ const VIDEO_MIME: Record<string, string> = {
|
||||
|
||||
export function meetusApi(opts: Options): Plugin {
|
||||
const outputDir = path.resolve(opts.outputDir)
|
||||
const videoDir = opts.videoDir ? path.resolve(opts.videoDir) : null
|
||||
|
||||
/** Resolve a run id to its directory, rejecting traversal. */
|
||||
/** 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 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
|
||||
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'))
|
||||
@@ -67,6 +117,8 @@ export function meetusApi(opts: Options): Plugin {
|
||||
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
|
||||
@@ -128,20 +180,12 @@ export function meetusApi(opts: Options): Plugin {
|
||||
}
|
||||
|
||||
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 dirs = await findRunDirs(outputDir)
|
||||
const runs = []
|
||||
for (const name of entries.sort().reverse()) {
|
||||
const dir = path.join(outputDir, name)
|
||||
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 {
|
||||
@@ -149,13 +193,16 @@ export function meetusApi(opts: Options): Plugin {
|
||||
.filter((f) => f.toLowerCase().endsWith('.jpg')).length
|
||||
} catch { /* no frames */ }
|
||||
runs.push({
|
||||
id: name,
|
||||
name: manifest.video?.name ?? name,
|
||||
id,
|
||||
name: manifest.video?.name ?? id,
|
||||
processedAt: manifest.processed_at ?? null,
|
||||
frameCount,
|
||||
hasVideo: !!manifest.video?.path && fs.existsSync(manifest.video.path),
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -242,7 +289,7 @@ export function meetusApi(opts: Options): Plugin {
|
||||
segments,
|
||||
frames,
|
||||
enhancedAvailable,
|
||||
hasVideo: !!manifest.video?.path && fs.existsSync(manifest.video.path),
|
||||
hasVideo: !!resolveVideoPath(dir, manifest),
|
||||
videoUrl: `/api/runs/${encodeURIComponent(id)}/video`,
|
||||
review,
|
||||
})
|
||||
@@ -266,10 +313,10 @@ export function meetusApi(opts: Options): Plugin {
|
||||
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' })
|
||||
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)
|
||||
@@ -330,6 +377,10 @@ function sendJson(res: ServerResponse, status: number, obj: unknown): void {
|
||||
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 = ''
|
||||
@@ -23,6 +23,9 @@ function onChange(e: Event) {
|
||||
</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>
|
||||
|
||||
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')
|
||||
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"]
|
||||
}
|
||||
@@ -7,8 +7,12 @@ import { meetusApi } from './server/meetusApi'
|
||||
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 })],
|
||||
plugins: [vue(), meetusApi({ outputDir, videoDir })],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@framework': fileURLToPath(new URL('../framework/src', import.meta.url)),
|
||||
@@ -22,6 +26,7 @@ export default defineConfig({
|
||||
fileURLToPath(new URL('.', import.meta.url)),
|
||||
fileURLToPath(new URL('../framework', import.meta.url)),
|
||||
outputDir,
|
||||
...(videoDir ? [videoDir] : []),
|
||||
],
|
||||
},
|
||||
},
|
||||
Reference in New Issue
Block a user