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:
@@ -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
9
src/pages/Brain.test.ts
Normal 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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user