1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 12:55:00 +02:00

feat(frontend): add text-capture form to CaptureDialog

- Add submitTextCapture API client function with same error handling as submitCapture
- Create makeTextItem() factory for text capture state
- Implement CaptureTextRow component with title, body textarea, and MIME selector
- Add 'Add text' button in capture dialog toolbar
- Update handleArchive to filter and route text submissions
- Modify submitBgJob to detect and submit text items via submitTextCapture
- Skip probe and conflict checks for text items
- Reuse job tracking and batch settlement for text captures

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
TheGeneralist 2026-08-22 14:11:49 +02:00
parent 81dc7519a8
commit e97a31c0d1
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
5 changed files with 213 additions and 65 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,7 +6,7 @@
<title>Archivr</title> <title>Archivr</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
<script type="module" crossorigin src="/assets/index-B67momER.js"></script> <script type="module" crossorigin src="/assets/index-CthL4K20.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DicH9MNh.css"> <link rel="stylesheet" crossorigin href="/assets/index-DicH9MNh.css">
</head> </head>
<body> <body>

View file

@ -174,6 +174,24 @@ export async function submitCapture(archiveId, locator, quality = null, extensio
return res.json(); // { job_uid, status: "pending" } return res.json(); // { job_uid, status: "pending" }
} }
export async function submitTextCapture(archiveId, {title, body, mime = 'text/markdown'}) {
const payload = { title, body };
if (mime && mime !== 'text/markdown') payload.mime = mime;
const res = await fetch(`/api/archives/${archiveId}/captures/text`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err = new Error(body.error || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json(); // { job_uid, status: "pending" }
}
// Returns { has_video: bool, qualities: string[] } e.g. { has_video: true, qualities: ["1080p","720p","480p"] } // Returns { has_video: bool, qualities: string[] } e.g. { has_video: true, qualities: ["1080p","720p","480p"] }
// Throws on network error; returns { has_video: false, qualities: [] } on non-video locators. // Throws on network error; returns { has_video: false, qualities: [] } on non-video locators.
export async function probeCapture(archiveId, locator) { export async function probeCapture(archiveId, locator) {

View file

@ -1,5 +1,5 @@
import { useRef, useEffect, useState, useCallback } from 'react' import { useRef, useEffect, useState, useCallback } from 'react'
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings, uploadFile, deleteUpload } from '../api' import { submitCapture, submitTextCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings, uploadFile, deleteUpload } from '../api'
let nextItemId = 1 let nextItemId = 1
@ -157,6 +157,30 @@ function makeFileItem(filename) {
} }
} }
function makeTextItem() {
return {
id: nextItemId++,
kind: 'text',
title: '',
body: '',
mime: 'text/markdown',
// Fields present for submission-logic compatibility
locator: '',
quality: 'best',
probeState: 'idle',
probeQualities: null,
probeHasAudio: false,
playlistProbeState: 'idle',
playlistInfo: null,
playlistItems: null,
playlistQuality: null,
playlistExpanded: false,
syncEnabled: false,
error: null,
status: 'idle',
}
}
function applyPlaylistQuality(newQ, currentItems) { function applyPlaylistQuality(newQ, currentItems) {
if (newQ === 'best') { if (newQ === 'best') {
return currentItems.map(item => ({ ...item, quality: 'best' })) return currentItems.map(item => ({ ...item, quality: 'best' }))
@ -430,9 +454,30 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
onToastRef.current(text, null, type, headline) onToastRef.current(text, null, type, headline)
} }
async function submitBgJob(locator, quality, batchId, extraExtensions = {}) { async function submitBgJob(submission, batchId) {
const aid = archiveIdRef.current const aid = archiveIdRef.current
const id = crypto.randomUUID?.() ?? `job-${Date.now()}-${Math.random()}` const id = crypto.randomUUID?.() ?? `job-${Date.now()}-${Math.random()}`
// Text submission
if (submission.type === 'text') {
try {
const job = await submitTextCapture(aid, { title: submission.title, body: submission.body, mime: submission.mime })
const locator = `text:${submission.title}`
// Notify App to add skeleton + persist
onJobStartedRef.current?.({ id, jobUid: job.job_uid, locator, archiveId: aid })
startPolling(id, job.job_uid, locator, aid, batchId)
} catch (e) {
const msg = e.message || 'Submission failed.'
onToastRef.current(msg, `text:${submission.title}`)
settleBatch(batchId, 'failed', `text:${submission.title}`)
}
return
}
// URL/file submission
const locator = submission.locator
const quality = submission.quality
const extraExtensions = submission.extraExtensions || {}
// Capture session options at call time (synchronous — before first await) // Capture session options at call time (synchronous — before first await)
const extensions = { const extensions = {
ublock_enabled: ublockEnabled, ublock_enabled: ublockEnabled,
@ -466,16 +511,18 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
// Guard against the Enter-key shortcut in CaptureRow bypassing the // Guard against the Enter-key shortcut in CaptureRow bypassing the
// disabled button — uploads must be complete before archiving starts. // disabled button — uploads must be complete before archiving starts.
if (items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')) return if (items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')) return
const toSubmit = items.filter(it => const toSubmit = items.filter(it => {
it.kind === 'file' ? (it.uploadStatus === 'done' && it.uploadLocator) : it.locator.trim() if (it.kind === 'file') return it.uploadStatus === 'done' && it.uploadLocator
) if (it.kind === 'text') return it.title.trim() && it.body.trim()
return it.locator.trim()
})
if (toSubmit.length === 0) return if (toSubmit.length === 0) return
if (toSubmit.some(it => it.kind !== 'file' && hasConflict(it))) return if (toSubmit.some(it => it.kind !== 'file' && it.kind !== 'text' && hasConflict(it))) return
if (toSubmit.some(it => it.kind !== 'file' && ( if (toSubmit.some(it => it.kind !== 'file' && it.kind !== 'text' && (
it.probeState === 'probing' || it.probeState === 'probing' ||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done')))) (isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))))
return return
if (toSubmit.some(it => it.kind !== 'file' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return if (toSubmit.some(it => it.kind !== 'file' && it.kind !== 'text' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
const batchId = toSubmit.length > 1 const batchId = toSubmit.length > 1
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`) ? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
: null : null
@ -485,9 +532,13 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
// Capture all submission data before any state changes // Capture all submission data before any state changes
const submissions = toSubmit.map(it => { const submissions = toSubmit.map(it => {
if (it.kind === 'file') { if (it.kind === 'file') {
return { locator: it.uploadLocator, quality: 'best', extraExtensions: {} } return { type: 'file', locator: it.uploadLocator, quality: 'best', extraExtensions: {} }
}
if (it.kind === 'text') {
return { type: 'text', title: it.title.trim(), body: it.body.trim(), mime: it.mime }
} }
return { return {
type: 'url',
locator: it.locator.trim(), locator: it.locator.trim(),
quality: it.playlistItems !== null ? null : (it.quality || 'best'), quality: it.playlistItems !== null ? null : (it.quality || 'best'),
extraExtensions: it.playlistItems !== null extraExtensions: it.playlistItems !== null
@ -502,8 +553,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
setItems([makeItem()]) setItems([makeItem()])
dialogRef.current?.close() dialogRef.current?.close()
// Submit each in background // Submit each in background
submissions.forEach(({ locator, quality, extraExtensions }) => submissions.forEach(submission =>
submitBgJob(locator, quality, batchId, extraExtensions) submitBgJob(submission, batchId)
) )
} }
@ -687,16 +738,18 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const anyUploading = items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading') const anyUploading = items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')
const pendingCount = items.filter(it => const pendingCount = items.filter(it => {
it.kind === 'file' ? (it.uploadStatus === 'done' && it.uploadLocator) : it.locator.trim() if (it.kind === 'file') return it.uploadStatus === 'done' && it.uploadLocator
).length if (it.kind === 'text') return it.title.trim() && it.body.trim()
const anyConflict = items.some(it => it.kind !== 'file' && hasConflict(it)) return it.locator.trim()
}).length
const anyConflict = items.some(it => it.kind !== 'file' && it.kind !== 'text' && hasConflict(it))
// True if any playlist row has had all its videos deleted — archive would be a no-op. // True if any playlist row has had all its videos deleted — archive would be a no-op.
const anyEmptyPlaylist = items.some(it => const anyEmptyPlaylist = items.some(it =>
it.kind !== 'file' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0 it.kind !== 'file' && it.kind !== 'text' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0
) )
const anyProbing = items.some(it => const anyProbing = items.some(it =>
it.kind !== 'file' && ( it.kind !== 'file' && it.kind !== 'text' && (
it.probeState === 'probing' || it.probeState === 'probing' ||
// For playlist sources block unless probe completed successfully: // For playlist sources block unless probe completed successfully:
// idle = debounce not yet fired; probing = in flight; error = no quality data. // idle = debounce not yet fired; probing = in flight; error = no quality data.
@ -735,6 +788,17 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
item={item} item={item}
onRemove={() => removeRow(item.id)} onRemove={() => removeRow(item.id)}
/> />
) : item.kind === 'text' ? (
<CaptureTextRow
key={item.id}
item={item}
autoFocus={idx === items.length - 1}
onTitleChange={val => setItems(prev => prev.map(it => it.id === item.id ? { ...it, title: val } : it))}
onBodyChange={val => setItems(prev => prev.map(it => it.id === item.id ? { ...it, body: val } : it))}
onMimeChange={val => setItems(prev => prev.map(it => it.id === item.id ? { ...it, mime: val } : it))}
onRemove={() => removeRow(item.id)}
onSubmit={handleArchive}
/>
) : ( ) : (
<CaptureRow <CaptureRow
key={item.id} key={item.id}
@ -781,6 +845,12 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
</svg> </svg>
Upload file Upload file
</button> </button>
<button type="button" className="capture-add-row capture-add-text" onClick={() => setItems(prev => [...prev, makeTextItem()])}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h12M2 7h12M2 11h8"/>
</svg>
Add text
</button>
</div> </div>
{/* ── Advanced options ────────────────────────────── */} {/* ── Advanced options ────────────────────────────── */}
@ -1139,3 +1209,63 @@ function CaptureFileRow({ item, onRemove }) {
</div> </div>
) )
} }
function CaptureTextRow({ item, autoFocus, onTitleChange, onBodyChange, onMimeChange, onRemove, onSubmit }) {
const titleInputRef = useRef(null)
useEffect(() => {
if (autoFocus) {
titleInputRef.current?.focus()
}
}, [autoFocus]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="capture-row capture-text-row">
<div className="capture-row-main">
<span className="capture-text-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}>
<path d="M2 3h12M2 7h12M2 11h8"/>
</svg>
</span>
<div className="capture-text-inputs">
<input
ref={titleInputRef}
className="capture-text-input capture-text-title"
type="text"
placeholder="Title"
value={item.title}
onChange={e => onTitleChange(e.target.value)}
maxLength={500}
/>
<textarea
className="capture-text-input capture-text-body"
placeholder="Body (markdown or plain text)"
value={item.body}
onChange={e => onBodyChange(e.target.value)}
rows={6}
/>
<div className="capture-text-footer">
<select
className="capture-text-mime"
value={item.mime}
onChange={e => onMimeChange(e.target.value)}
>
<option value="text/markdown">Markdown</option>
<option value="text/plain">Plain text</option>
</select>
</div>
</div>
<button
type="button"
className="capture-row-action capture-row-remove"
onClick={onRemove}
aria-label="Remove"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
</svg>
</button>
</div>
</div>
)
}