From 7c17191b8b21cdeb24ee631618bfdb8907140ed5 Mon Sep 17 00:00:00 2001 From: Iqbal Date: Fri, 23 Oct 2026 12:48:07 +0700 Subject: [PATCH] Open Brain documents from their title Clicking a document's title fetches the original file from the engine: viewable formats open in a new tab, the rest (or a blocked tab) download under the original name. Co-Authored-By: Claude Opus 5.5 --- src/api/engine.ts | 1 + src/i18n.ts | 2 ++ src/pages/Brain.tsx | 34 ++++++++++++++++++++++++++++++++-- src/styles/app.css | 3 +++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/api/engine.ts b/src/api/engine.ts index f1c3503..c3c164e 100644 --- a/src/api/engine.ts +++ b/src/api/engine.ts @@ -49,6 +49,7 @@ function asSource(d: EngineDocument): Source { export const listDocuments = async () => (await request('/upload/list')).map(asSource) export const documentStatus = (id: number) => request(`/upload/${id}`) export const deleteDocument = (id: number) => request(`/upload/${id}`, { method: 'DELETE' }) +export const documentFile = (id: number) => request(`/upload/${id}/file`, { as: 'blob' }) export async function supportedExtensions(): Promise> { const f = await request<{ extensions: string[] }>('/upload/supported-formats') diff --git a/src/i18n.ts b/src/i18n.ts index 5e36c7d..93e9c63 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -83,6 +83,7 @@ const en = { 'brain.confirmCreditsFallback': 'credits', 'brain.addToBrain': 'Add to Brain', 'brain.delete': 'Delete {name}', + 'brain.open': 'Open {name}', 'brain.deleteConfirm': 'Delete “{name}” from the Brain?', // Instruction @@ -460,6 +461,7 @@ const id: Record = { 'brain.confirmCreditsFallback': 'kredit', 'brain.addToBrain': 'Masukkan ke Otak', 'brain.delete': 'Hapus {name}', + 'brain.open': 'Buka {name}', 'brain.deleteConfirm': 'Hapus “{name}” dari Otak?', 'ins.title': 'Perintah', diff --git a/src/pages/Brain.tsx b/src/pages/Brain.tsx index 529dec6..61836c8 100644 --- a/src/pages/Brain.tsx +++ b/src/pages/Brain.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { - answerVersion, buildKnowledge, deleteDocument, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument, + answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument, type Source, } from '../api/engine' import { Icon } from '../components/Icon' @@ -154,7 +154,7 @@ function DocumentRow({ doc }: { doc: Source }) {
{doc.format.slice(0, 4)}
- {doc.title} +
{new Date(doc.createdAt).toLocaleDateString(locale())}
{phase === 'working' && } {(phase === 'answer' || phase === 'failed') && } @@ -165,6 +165,36 @@ function DocumentRow({ doc }: { doc: Source }) { ) } +// Formats a browser can show in a tab; anything else is saved under its original name. +const VIEWABLE = new Set(['pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'txt', 'md', 'csv', 'mp3', 'wav', 'm4a', 'ogg', 'mp4', 'webm', 'mov']) + +function OpenButton({ doc }: { doc: Source }) { + const [busy, setBusy] = useState(false) + async function open() { + const inTab = VIEWABLE.has(doc.format.toLowerCase()) + // Opened before the fetch: a tab opened after an await counts as a popup and is blocked. + const tab = inTab ? window.open('', '_blank') : null + setBusy(true) + try { + const url = URL.createObjectURL(await documentFile(doc.docId)) + if (tab) tab.location.href = url + // Not viewable, or the tab was blocked: save it instead of doing nothing. + else Object.assign(document.createElement('a'), { href: url, download: doc.originalName }).click() + setTimeout(() => URL.revokeObjectURL(url), 60_000) + } catch (e) { + tab?.close() + toast((e as Error).message, { error: true }) + } finally { + setBusy(false) + } + } + return ( + + ) +} + function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) { const label = { ready: t('brain.statusReady'), diff --git a/src/styles/app.css b/src/styles/app.css index 5c02d21..e80146c 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -10,6 +10,9 @@ .doc-action-buttons{display:flex;gap:7px;flex-wrap:wrap} .doc-action .error{margin:0;font-size:11px} .result-row b{overflow-wrap:anywhere} +.doc-title{display:block;max-width:100%;border:0;padding:0;background:none;text-align:left;font:inherit;font-weight:700;color:var(--ink);overflow-wrap:anywhere;cursor:pointer} +.doc-title:hover{color:var(--blue);text-decoration:underline} +.doc-title:disabled{cursor:progress;color:#8b97a9} .drop-overlay{position:fixed;inset:0;z-index:50;display:grid;place-items:center;background:#142d5059;backdrop-filter:blur(2px);pointer-events:none} .drop-overlay>div{display:flex;flex-direction:column;align-items:center;gap:12px;padding:34px 46px;border:2px dashed var(--blue);border-radius:16px;background:white;color:var(--blue);font-size:14px} .drop-overlay svg{width:34px;height:34px}