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:
parent
81dc7519a8
commit
e97a31c0d1
5 changed files with 213 additions and 65 deletions
File diff suppressed because one or more lines are too long
47
crates/archivr-server/static/assets/index-CthL4K20.js
Normal file
47
crates/archivr-server/static/assets/index-CthL4K20.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue