doocus improvemnts
This commit is contained in:
10
ui/doocus-app/package-lock.json
generated
10
ui/doocus-app/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "doocus-browser-ui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.10",
|
||||
"jszip": "^3.10",
|
||||
"mammoth": "^1.8",
|
||||
"marked": "^14",
|
||||
@@ -1319,6 +1320,15 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.10",
|
||||
"jszip": "^3.10",
|
||||
"mammoth": "^1.8",
|
||||
"marked": "^14",
|
||||
|
||||
@@ -80,7 +80,7 @@ export function doocusApi(opts: Options): Plugin {
|
||||
server.middlewares.use('/api', (req, res, next) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost')
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
handle(res, parts, url.searchParams).catch((err) => {
|
||||
handle(res, parts, url.searchParams, req.headers.range).catch((err) => {
|
||||
server.config.logger.error(`[doocus-api] ${String(err)}`)
|
||||
sendJson(res, 500, { error: String(err) })
|
||||
}).then((handled) => { if (!handled) next() })
|
||||
@@ -88,7 +88,7 @@ export function doocusApi(opts: Options): Plugin {
|
||||
},
|
||||
}
|
||||
|
||||
async function handle(res: ServerResponse, parts: string[], q: URLSearchParams): Promise<boolean> {
|
||||
async function handle(res: ServerResponse, parts: string[], q: URLSearchParams, range?: string): Promise<boolean> {
|
||||
if (parts[0] === 'tree') {
|
||||
const index = readIndex()
|
||||
if (!index) { sendJson(res, 404, { error: `index.json not found in ${outputDir}` }); return true }
|
||||
@@ -102,7 +102,7 @@ export function doocusApi(opts: Options): Plugin {
|
||||
}
|
||||
|
||||
if (parts[0] === 'original') {
|
||||
await serveOriginal(res, q.get('path') ?? '')
|
||||
await serveOriginal(res, q.get('path') ?? '', range)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -137,13 +137,34 @@ export function doocusApi(opts: Options): Plugin {
|
||||
})
|
||||
}
|
||||
|
||||
async function serveOriginal(res: ServerResponse, rel: string): Promise<void> {
|
||||
async function serveOriginal(res: ServerResponse, rel: string, range?: string): Promise<void> {
|
||||
const index = readIndex()
|
||||
if (!index) { sendJson(res, 404, { error: 'no index' }); return }
|
||||
const abs = originalAbs(index, rel)
|
||||
if (!abs || !isFile(abs)) { sendJson(res, 404, { error: 'original not reachable' }); return }
|
||||
res.setHeader('Content-Type', MIME[path.extname(abs).toLowerCase()] ?? 'application/octet-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
const mime = MIME[path.extname(abs).toLowerCase()] ?? 'application/octet-stream'
|
||||
const size = fs.statSync(abs).size
|
||||
res.setHeader('Content-Type', mime)
|
||||
res.setHeader('Accept-Ranges', 'bytes')
|
||||
|
||||
// Range support — needed so large meeting videos can seek/stream.
|
||||
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) : size - 1
|
||||
if (start >= size || end >= size || start > end) {
|
||||
res.statusCode = 416
|
||||
res.setHeader('Content-Range', `bytes */${size}`)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
res.statusCode = 206
|
||||
res.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
|
||||
res.setHeader('Content-Length', String(end - start + 1))
|
||||
fs.createReadStream(abs, { start, end }).pipe(res)
|
||||
return
|
||||
}
|
||||
res.setHeader('Content-Length', String(size))
|
||||
fs.createReadStream(abs).pipe(res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, computed } from 'vue'
|
||||
import Panel from '@framework/components/Panel.vue'
|
||||
import SplitPane from '@framework/components/SplitPane.vue'
|
||||
import FileTree from '@/components/FileTree.vue'
|
||||
@@ -8,6 +8,10 @@ import ExportBar from '@/components/ExportBar.vue'
|
||||
import { store, loadTree } from '@/store'
|
||||
|
||||
onMounted(loadTree)
|
||||
|
||||
const detailTitle = computed(() =>
|
||||
store.detail?.node.mode === 'meeting' ? 'Meeting' : 'File')
|
||||
const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,18 +28,29 @@ onMounted(loadTree)
|
||||
|
||||
<main class="body">
|
||||
<div v-if="store.error" class="err">{{ store.error }}</div>
|
||||
<SplitPane v-else direction="vertical" :initial-size="0.9" :min="0.3" :max="3">
|
||||
|
||||
<!-- expanded: resizable tree / viewer split -->
|
||||
<SplitPane v-else-if="!store.detailCollapsed" direction="vertical" :initial-size="0.9" :min="0.2" :max="4">
|
||||
<template #first>
|
||||
<Panel title="Tree">
|
||||
<FileTree :items="store.tree" />
|
||||
</Panel>
|
||||
<Panel title="Tree"><FileTree :items="store.tree" /></Panel>
|
||||
</template>
|
||||
<template #second>
|
||||
<Panel title="File">
|
||||
<Panel :title="detailTitle">
|
||||
<template #actions>
|
||||
<button class="chev" title="Collapse viewer" @click="store.detailCollapsed = true">▾</button>
|
||||
</template>
|
||||
<DocDetail />
|
||||
</Panel>
|
||||
</template>
|
||||
</SplitPane>
|
||||
|
||||
<!-- collapsed: tree fills, viewer is a bar docked at the bottom -->
|
||||
<div v-else class="stack">
|
||||
<Panel title="Tree" class="grow"><FileTree :items="store.tree" /></Panel>
|
||||
<button class="expand-bar" title="Expand viewer" @click="store.detailCollapsed = false">
|
||||
▴ <span>{{ barLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,4 +67,14 @@ onMounted(loadTree)
|
||||
.spacer { flex: 1; }
|
||||
.body { flex: 1; min-height: 0; padding: var(--space-2); }
|
||||
.err { color: var(--status-error, #e0a030); padding: var(--space-4); }
|
||||
.stack { display: flex; flex-direction: column; height: 100%; gap: var(--space-2); }
|
||||
.grow { flex: 1; min-height: 0; }
|
||||
.expand-bar {
|
||||
flex-shrink: 0; display: flex; align-items: center; gap: var(--space-2);
|
||||
justify-content: flex-start; text-align: left; width: 100%;
|
||||
background: var(--surface-2); border: var(--panel-border); border-radius: var(--panel-radius);
|
||||
padding: var(--space-1) var(--space-3); color: var(--text-secondary);
|
||||
}
|
||||
.expand-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chev { padding: 0 var(--space-2); background: transparent; border: 0; color: var(--text-secondary); }
|
||||
</style>
|
||||
|
||||
@@ -33,7 +33,8 @@ const metaRows = computed(() => {
|
||||
<template>
|
||||
<div v-if="!node" class="empty">Select a file to view it.</div>
|
||||
<div v-else class="detail">
|
||||
<header class="head">
|
||||
<!-- meetings get no selection header — the viewer is the meeting itself -->
|
||||
<header v-if="node.mode !== 'meeting'" class="head">
|
||||
<span class="badge" :data-mode="node.mode">{{ node.mode }}</span>
|
||||
<span class="name">{{ node.path }}</span>
|
||||
<span class="dim">{{ node.ext }} · {{ humanBytes(node.bytes) }}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { store, select, toggleSelect, toggleFolder, type TreeItem } from '@/store'
|
||||
import { store, select, toggleSelect, toggleFolder, isUnhandled, type TreeItem } from '@/store'
|
||||
|
||||
defineProps<{ items: TreeItem[]; depth?: number }>()
|
||||
|
||||
@@ -36,8 +36,9 @@ const MODE_BADGE: Record<string, string> = {
|
||||
/>
|
||||
<span class="badge" :data-mode="it.node?.mode">{{ MODE_BADGE[it.node?.mode ?? ''] ?? it.node?.mode }}</span>
|
||||
<span class="ext">{{ it.node?.ext }}</span>
|
||||
<span class="fname">{{ it.node?.title || it.name }}</span>
|
||||
<span v-if="it.node?.warnings?.length" class="warn" title="extraction warnings">⚠</span>
|
||||
<span class="fname" :class="{ unhandled: it.node && isUnhandled(it.node) }">{{ it.node?.title || it.name }}</span>
|
||||
<span v-if="it.node && isUnhandled(it.node)" class="attn" title="no viewer for this type — needs a handling decision">⚠ needs attention</span>
|
||||
<span v-else-if="it.node?.warnings?.length" class="warn" title="extraction warnings">⚠</span>
|
||||
</div>
|
||||
</template>
|
||||
</li>
|
||||
@@ -69,5 +70,7 @@ const MODE_BADGE: Record<string, string> = {
|
||||
.badge[data-mode='extracted'] { background: var(--status-live, #2a6); color: #fff; }
|
||||
.badge[data-mode='meeting'] { background: var(--status-processing, #a60); color: #fff; }
|
||||
.ext { color: var(--text-secondary); font-size: 10px; width: 34px; flex-shrink: 0; }
|
||||
.fname.unhandled { color: var(--text-secondary); font-style: italic; }
|
||||
.attn { margin-left: auto; font-size: 10px; color: var(--status-error, #e0a030); flex-shrink: 0; }
|
||||
.warn { margin-left: auto; color: var(--status-error, #e0a030); }
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import type { FileNode } from '@/store'
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import yaml from 'highlight.js/lib/languages/yaml'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import ini from 'highlight.js/lib/languages/ini'
|
||||
import { kindFor, type FileNode } from '@/store'
|
||||
|
||||
hljs.registerLanguage('yaml', yaml)
|
||||
hljs.registerLanguage('json', json)
|
||||
hljs.registerLanguage('xml', xml)
|
||||
hljs.registerLanguage('ini', ini)
|
||||
|
||||
const props = defineProps<{
|
||||
node: FileNode
|
||||
originalUrl: string
|
||||
content?: string | null // extracted text (pptx fallback render)
|
||||
text?: string | null // inline text for text-native originals
|
||||
text?: string | null // inline verbatim text for text-native originals
|
||||
}>()
|
||||
|
||||
type Kind = 'pdf' | 'image' | 'html' | 'markdown' | 'text' | 'docx' | 'xlsx' | 'pptx' | 'video' | 'none'
|
||||
const kind = computed(() => kindFor(props.node.ext))
|
||||
|
||||
const kind = computed<Kind>(() => {
|
||||
const e = props.node.ext
|
||||
if (e === 'pdf') return 'pdf'
|
||||
if (['png', 'jpg', 'jpeg'].includes(e)) return 'image'
|
||||
if (['html', 'htm'].includes(e)) return 'html'
|
||||
if (['md', 'markdown'].includes(e)) return 'markdown'
|
||||
if (['txt', 'json', 'yaml', 'yml', 'csv'].includes(e)) return 'text'
|
||||
if (e === 'docx') return 'docx'
|
||||
if (e === 'xlsx') return 'xlsx'
|
||||
if (e === 'pptx') return 'pptx'
|
||||
if (e === 'mp4') return 'video'
|
||||
return 'none'
|
||||
// Verbatim highlighting for code-ish originals (yaml/json/csv/txt/…).
|
||||
const HLJS_LANG: Record<string, string> = {
|
||||
yaml: 'yaml', yml: 'yaml', json: 'json', xml: 'xml', html: 'xml',
|
||||
ini: 'ini', toml: 'ini', csv: 'plaintext', txt: 'plaintext', log: 'plaintext',
|
||||
}
|
||||
const highlighted = computed(() => {
|
||||
if (kind.value !== 'code') return ''
|
||||
const src = props.text ?? ''
|
||||
const lang = HLJS_LANG[props.node.ext] ?? 'plaintext'
|
||||
try {
|
||||
return hljs.highlight(src, { language: lang }).value
|
||||
} catch {
|
||||
return src.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]!))
|
||||
}
|
||||
})
|
||||
|
||||
// Async-rendered HTML for docx (mammoth) and xlsx (SheetJS).
|
||||
const mdHtml = computed(() => (props.text ? marked.parse(props.text) : ''))
|
||||
const pptxHtml = computed(() => (props.content ? marked.parse(props.content) : ''))
|
||||
|
||||
// docx / xlsx rendered client-side (lazy).
|
||||
const rendered = ref('')
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
@@ -37,16 +52,15 @@ async function loadRich(): Promise<void> {
|
||||
if (kind.value !== 'docx' && kind.value !== 'xlsx') return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch(props.originalUrl)
|
||||
const buf = await res.arrayBuffer()
|
||||
const buf = await (await fetch(props.originalUrl)).arrayBuffer()
|
||||
if (kind.value === 'docx') {
|
||||
const mammoth = await import('mammoth/mammoth.browser')
|
||||
rendered.value = (await mammoth.convertToHtml({ arrayBuffer: buf })).value
|
||||
} else {
|
||||
const XLSX = await import('xlsx')
|
||||
const wb = XLSX.read(new Uint8Array(buf), { type: 'array' })
|
||||
rendered.value = wb.SheetNames.map((name) =>
|
||||
`<h3>${name}</h3>` + XLSX.utils.sheet_to_html(wb.Sheets[name])).join('\n')
|
||||
rendered.value = wb.SheetNames.map((n) =>
|
||||
`<h3>${n}</h3>` + XLSX.utils.sheet_to_html(wb.Sheets[n])).join('\n')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError.value = String(e)
|
||||
@@ -54,11 +68,11 @@ async function loadRich(): Promise<void> {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.node.path, loadRich, { immediate: true })
|
||||
|
||||
const mdHtml = computed(() => (props.text ? marked.parse(props.text) : ''))
|
||||
const pptxHtml = computed(() => (props.content ? marked.parse(props.content) : ''))
|
||||
function openOriginal(): void {
|
||||
window.open(props.node.url ?? props.originalUrl, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -71,27 +85,31 @@ const pptxHtml = computed(() => (props.content ? marked.parse(props.content) : '
|
||||
|
||||
<div v-else-if="kind === 'markdown'" class="doc md" v-html="mdHtml" />
|
||||
|
||||
<pre v-else-if="kind === 'text'" class="mono">{{ text }}</pre>
|
||||
<pre v-else-if="kind === 'code'" class="hljs code"><code v-html="highlighted" /></pre>
|
||||
|
||||
<div v-else-if="kind === 'video'" class="center">
|
||||
<video class="video" :src="originalUrl" controls />
|
||||
<p class="dim">Meeting — its transcript is produced by meetus, not doocus.</p>
|
||||
<video class="video" :src="originalUrl" controls preload="metadata" />
|
||||
</div>
|
||||
|
||||
<!-- docx / xlsx rendered client-side -->
|
||||
<div v-else-if="kind === 'docx' || kind === 'xlsx'" class="doc rich">
|
||||
<div v-if="loading" class="dim">Rendering…</div>
|
||||
<div v-else-if="loadError" class="dim">Preview failed: {{ loadError }}</div>
|
||||
<div v-else v-html="rendered" />
|
||||
</div>
|
||||
|
||||
<!-- pptx: no faithful client render; show the per-slide extracted text -->
|
||||
<div v-else-if="kind === 'pptx'" class="doc md">
|
||||
<div class="dim label">Slide text (no visual render)</div>
|
||||
<div v-html="pptxHtml" />
|
||||
</div>
|
||||
|
||||
<div v-else class="center dim">No preview for .{{ node.ext }}.</div>
|
||||
<!-- unhandled type — flagged for a handling decision -->
|
||||
<div v-else class="center attention">
|
||||
<div class="warn-icon">⚠</div>
|
||||
<p>No inline viewer for <b>.{{ node.ext }}</b> — needs a handling decision.</p>
|
||||
<button @click="openOriginal">
|
||||
{{ node.url ? 'Open in Drive' : 'Open file' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -100,13 +118,14 @@ const pptxHtml = computed(() => (props.content ? marked.parse(props.content) : '
|
||||
.frame { width: 100%; height: 100%; border: 0; }
|
||||
.center { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-2); padding: var(--space-3); }
|
||||
.center img { max-width: 100%; }
|
||||
.video { max-width: 100%; max-height: 70vh; }
|
||||
.video { max-width: 100%; max-height: 78vh; }
|
||||
.doc { padding: var(--space-4); }
|
||||
.doc.rich :deep(table) { border-collapse: collapse; }
|
||||
.doc.rich :deep(td), .doc.rich :deep(th) { border: 1px solid var(--surface-3); padding: 2px 6px; }
|
||||
.doc.rich :deep(h3) { margin: var(--space-3) 0 var(--space-1); }
|
||||
.md :deep(img) { max-width: 100%; }
|
||||
.mono { margin: 0; padding: var(--space-4); white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono, monospace); font-size: var(--font-size-sm); }
|
||||
.code { margin: 0; padding: var(--space-3); border-radius: 0; overflow: auto; font-size: var(--font-size-sm); line-height: 1.5; }
|
||||
.attention .warn-icon { font-size: 28px; color: var(--status-error, #e0a030); }
|
||||
.dim { color: var(--text-secondary); }
|
||||
.label { font-size: 11px; margin-bottom: var(--space-2); }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import '@framework/tokens.css'
|
||||
import 'highlight.js/styles/atom-one-dark.css'
|
||||
import './styles.css'
|
||||
import App from './App.vue'
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ interface State {
|
||||
loading: boolean
|
||||
selected: Set<string>
|
||||
collapsed: Set<string> // collapsed folder paths (folders default open)
|
||||
detailCollapsed: boolean // bottom viewer collapsed to a bar
|
||||
targetKey: string
|
||||
error: string
|
||||
}
|
||||
@@ -76,6 +77,7 @@ export const store = reactive<State>({
|
||||
loading: false,
|
||||
selected: new Set<string>(),
|
||||
collapsed: new Set<string>(),
|
||||
detailCollapsed: false,
|
||||
targetKey: TARGETS[0].key,
|
||||
error: '',
|
||||
})
|
||||
@@ -163,3 +165,26 @@ export function humanBytes(n: number): string {
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export type ViewerKind =
|
||||
| 'pdf' | 'image' | 'html' | 'markdown' | 'code' | 'docx' | 'xlsx' | 'pptx' | 'video' | 'unhandled'
|
||||
|
||||
/** Single source of truth for how a file is viewed — shared by the tree (to flag
|
||||
* unhandled types) and the viewer. 'unhandled' = no inline view; default action
|
||||
* is to open the original file (or the Drive link once we have it). */
|
||||
export function kindFor(ext: string): ViewerKind {
|
||||
if (ext === 'pdf') return 'pdf'
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return 'image'
|
||||
if (['html', 'htm'].includes(ext)) return 'html'
|
||||
if (['md', 'markdown'].includes(ext)) return 'markdown'
|
||||
if (['txt', 'json', 'yaml', 'yml', 'csv', 'log', 'ini', 'xml', 'toml'].includes(ext)) return 'code'
|
||||
if (ext === 'docx') return 'docx'
|
||||
if (ext === 'xlsx') return 'xlsx'
|
||||
if (ext === 'pptx') return 'pptx'
|
||||
if (['mp4', 'mkv', 'mov', 'm4v', 'webm', 'avi', 'wmv'].includes(ext)) return 'video'
|
||||
return 'unhandled'
|
||||
}
|
||||
|
||||
export function isUnhandled(node: FileNode): boolean {
|
||||
return node.mode !== 'meeting' && kindFor(node.ext) === 'unhandled'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user