Ask before uploading a file twice, and find material by name or status

The same file could be uploaded again and again without a word (three
copies of several catalogues), the list of about 150 materials had no
search or filter, and clicking a material that downloads instead of
opening seemed to do nothing. Uploading a file whose name is already in
the Brain now asks first, and cancelling skips those files; the list
has a search box and a status filter with counts; and a material that
is downloaded says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-30 13:55:02 +07:00
parent 93c665fdbf
commit 822c5fcac9
3 changed files with 65 additions and 5 deletions

View File

@@ -100,6 +100,10 @@ const en = {
'brain.metricAction': 'Needs action', 'brain.metricAction': 'Needs action',
'brain.metricActionSub': 'waiting for your approval', 'brain.metricActionSub': 'waiting for your approval',
'brain.listTitle': 'Material', 'brain.listTitle': 'Material',
'brain.allStatuses': 'All statuses',
'brain.duplicateConfirm': 'Already in the Brain: {names}. Upload another copy anyway? Cancel skips these files.',
'brain.duplicateSkipped': 'Skipped: already in the Brain',
'brain.downloaded': '{name} is downloading: this format opens in its own app.',
'brain.loading': 'Loading material…', 'brain.loading': 'Loading material…',
'brain.engineDown': 'Cannot reach the engine', 'brain.engineDown': 'Cannot reach the engine',
'brain.empty': 'No material yet', 'brain.empty': 'No material yet',
@@ -622,6 +626,10 @@ const id: Record<Key, string> = {
'brain.metricAction': 'Perlu tindakan', 'brain.metricAction': 'Perlu tindakan',
'brain.metricActionSub': 'menunggu persetujuan Anda', 'brain.metricActionSub': 'menunggu persetujuan Anda',
'brain.listTitle': 'Materi', 'brain.listTitle': 'Materi',
'brain.allStatuses': 'Semua status',
'brain.duplicateConfirm': 'Sudah ada di Otak: {names}. Tetap unggah salinan lain? Batal akan melewati file ini.',
'brain.duplicateSkipped': 'Dilewati: sudah ada di Otak',
'brain.downloaded': '{name} sedang diunduh: format ini dibuka di aplikasinya sendiri.',
'brain.loading': 'Memuat materi…', 'brain.loading': 'Memuat materi…',
'brain.engineDown': 'Engine tidak dapat dihubungi', 'brain.engineDown': 'Engine tidak dapat dihubungi',
'brain.empty': 'Belum ada materi', 'brain.empty': 'Belum ada materi',

9
src/pages/Brain.test.ts Normal file
View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { duplicateNames } from './Brain'
describe('duplicateNames', () => {
it('finds files already in the Brain, ignoring case, and repeats within one pick', () => {
const dupes = duplicateNames(['Katalog.pdf', 'new.docx', 'new.docx', 'other.pdf'], ['katalog.PDF'])
expect([...dupes]).toEqual(['Katalog.pdf', 'new.docx'])
})
})

View File

@@ -31,19 +31,49 @@ export const useDocuments = () => useQuery({
refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000), refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000),
}) })
const PHASES: Phase[] = ['ready', 'working', 'answer', 'failed']
/** Files whose name is already in the Brain (or twice in the same pick), compared without case. */
export function duplicateNames(names: string[], existing: string[]): Set<string> {
const seen = new Set(existing.map(n => n.toLowerCase()))
const dupes = new Set<string>()
for (const name of names) {
if (seen.has(name.toLowerCase())) dupes.add(name)
seen.add(name.toLowerCase())
}
return dupes
}
export function BrainPage() { export function BrainPage() {
const docs = useDocuments() const docs = useDocuments()
const [query, setQuery] = useState('')
const [phase, setPhase] = useState<Phase | 'all'>('all')
const needle = query.trim().toLowerCase()
const shown = docs.data?.filter(d => (phase === 'all' || phaseOf(d) === phase)
&& (!needle || `${d.title} ${d.originalName}`.toLowerCase().includes(needle)))
return ( return (
<> <>
<PageHead title={t('brain.title')} description={t('brain.description')} /> <PageHead title={t('brain.title')} description={t('brain.description')} />
<UploadBox /> <UploadBox />
<div className="card"> <div className="card">
<div className="card-head"><h3>{t('brain.listTitle')}</h3><Icon name="brain" /></div> <div className="toolbar">
<h3>{t('brain.listTitle')} {docs.data && <span className="badge neutral">{shown?.length}</span>}</h3>
<div className="toolbar-controls">
<div className="search"><Icon name="search" />
<input type="search" aria-label={t('brainPanel.search')} placeholder={t('brainPanel.search')} value={query} onChange={e => setQuery(e.target.value)} />
</div>
<select aria-label={t('res.filterStatus')} value={phase} onChange={e => setPhase(e.target.value as Phase | 'all')}>
<option value="all">{t('brain.allStatuses')} ({docs.data?.length ?? 0})</option>
{PHASES.map(p => <option key={p} value={p}>{statusName(p)} ({docs.data?.filter(d => phaseOf(d) === p).length ?? 0})</option>)}
</select>
</div>
</div>
{docs.isPending && <SkeletonRows label={t('brain.loading')} />} {docs.isPending && <SkeletonRows label={t('brain.loading')} />}
{docs.isError && <Empty icon="info" title={t('brain.engineDown')}>{docs.error.message}</Empty>} {docs.isError && <Empty icon="info" title={t('brain.engineDown')}>{docs.error.message}</Empty>}
{docs.data?.length === 0 && <Empty icon="upload" title={t('brain.empty')}>{t('brain.emptyCopy')}</Empty>} {docs.data?.length === 0 && <Empty icon="upload" title={t('brain.empty')}>{t('brain.emptyCopy')}</Empty>}
{!!docs.data?.length && <div className="result-list">{docs.data.map(d => <DocumentRow key={d.docId} doc={d} />)}</div>} {!!docs.data?.length && shown?.length === 0 && <Empty icon="search" title={t('res.noMatch')}>{t('res.noMatchCopy')}</Empty>}
{!!shown?.length && <div className="result-list">{shown.map(d => <DocumentRow key={d.docId} doc={d} />)}</div>}
</div> </div>
</> </>
) )
@@ -137,9 +167,15 @@ function UploadBox() {
async function upload(files: File[]) { async function upload(files: File[]) {
if (!files.length) return if (!files.length) return
const allowed = exts.data const allowed = exts.data
// The same file uploaded again is a second copy in the Brain; it is asked about, not assumed.
const existing = queryClient.getQueryData<Source[]>(['documents'])?.map(d => d.originalName) ?? []
const dupes = duplicateNames(files.map(f => f.name), existing)
const skipDupes = dupes.size > 0 && !confirm(t('brain.duplicateConfirm', { names: [...dupes].join(', ') }))
const batch: UploadItem[] = files.map(f => (allowed && !allowed.has(extOf(f.name)) const batch: UploadItem[] = files.map(f => (allowed && !allowed.has(extOf(f.name))
? { id: nextUploadId++, name: f.name, state: 'error', message: t('brain.unsupported') } ? { id: nextUploadId++, name: f.name, state: 'error', message: t('brain.unsupported') }
: { id: nextUploadId++, name: f.name, state: 'uploading' })) : skipDupes && dupes.has(f.name)
? { id: nextUploadId++, name: f.name, state: 'error', message: t('brain.duplicateSkipped') }
: { id: nextUploadId++, name: f.name, state: 'uploading' }))
// Appended, so files dropped while an earlier batch is still uploading keep their own rows. // Appended, so files dropped while an earlier batch is still uploading keep their own rows.
setItems(prev => [...prev.filter(it => it.state === 'uploading'), ...batch]) setItems(prev => [...prev.filter(it => it.state === 'uploading'), ...batch])
// One at a time: each upload enqueues a processing job, and the list refreshes after each. // One at a time: each upload enqueues a processing job, and the list refreshes after each.
@@ -248,8 +284,11 @@ function OpenButton({ doc }: { doc: Source }) {
try { try {
const url = URL.createObjectURL(await documentFile(doc.docId)) const url = URL.createObjectURL(await documentFile(doc.docId))
if (tab) tab.location.href = url if (tab) tab.location.href = url
// Not viewable, or the tab was blocked: save it instead of doing nothing. // Not viewable, or the tab was blocked: save it instead of doing nothing, and say so.
else Object.assign(document.createElement('a'), { href: url, download: doc.originalName }).click() else {
Object.assign(document.createElement('a'), { href: url, download: doc.originalName }).click()
toast(t('brain.downloaded', { name: doc.originalName }))
}
setTimeout(() => URL.revokeObjectURL(url), 60_000) setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (e) { } catch (e) {
tab?.close() tab?.close()
@@ -265,6 +304,10 @@ function OpenButton({ doc }: { doc: Source }) {
) )
} }
const statusName = (phase: Phase) => ({
ready: t('brain.statusReady'), working: t('brain.statusProcessing'), answer: t('brain.statusAnswer'), failed: t('brain.statusFailed'),
}[phase])
function statusLabel(doc: Source, phase: Phase) { function statusLabel(doc: Source, phase: Phase) {
return { return {
ready: t('brain.statusReady'), ready: t('brain.statusReady'),