doocus first ver

This commit is contained in:
Mariano Gabriel
2026-07-05 10:08:42 -03:00
parent e214b17c55
commit ca8b3a784d
57 changed files with 4167 additions and 56 deletions

12
ui/doocus-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>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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

7
ui/doocus-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
}

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')

120
ui/doocus-app/src/store.ts Normal file
View 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`
}

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;
}

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"]
}

View 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,
],
},
},
})