mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
fix(frontend): QA fixes — playlist UX, selection stroke, URL expand
CaptureDialog.jsx: - Placeholder no longer appears on idle playlist rows (only during probing); chevron shows only after probe completes — no left-padding while the user is still typing - Per-video delete button added to expanded playlist list; removes item from playlistItems so its ID is absent from per_item_quality on submit - anyEmptyPlaylist guard: Archive button disabled + handleArchive early- return when all videos have been deleted (empty map would otherwise silently download everything) styles.css: - Selection stroke switched from outline to box-shadow:inset everywhere (entry-row-outer, child-entry-row, legacy flat-div selector) — guaranteed inside element bounds, no layout interference - URL cell overflow only on :hover; removed is-selected .url-cell rule that was expanding child URLs when their parent was selected - Playlist items redesign: separator lines instead of background fills, amber left-border for conflicts, thin scrollbar, tighter padding - Remove button: opacity 0.3 always-visible baseline; full opacity on hover/:focus-visible; forced to 1 on coarse-pointer (touch) devices
This commit is contained in:
parent
e2f50a9856
commit
f0a6bf1cdc
7 changed files with 123 additions and 78 deletions
47
crates/archivr-server/static/assets/index-Bxtvq8vS.js
Normal file
47
crates/archivr-server/static/assets/index-Bxtvq8vS.js
Normal file
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-CMScl08q.css
Normal file
1
crates/archivr-server/static/assets/index-CMScl08q.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-DgzD6TCm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dd5Ih3Y1.css">
|
||||
<script type="module" crossorigin src="/assets/index-Bxtvq8vS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CMScl08q.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -395,6 +395,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
if (toSubmit.some(it => hasConflict(it))) return
|
||||
if (toSubmit.some(it => it.probeState === 'probing' ||
|
||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))) return
|
||||
if (toSubmit.some(it => Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
|
||||
const batchId = toSubmit.length > 1
|
||||
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
|
||||
: null
|
||||
|
|
@ -516,9 +517,20 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
function updateSync(id, val) {
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, syncEnabled: val } : it))
|
||||
}
|
||||
function deletePlaylistItem(itemId, videoId) {
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id !== itemId ? it :
|
||||
{ ...it, playlistItems: it.playlistItems.filter(pi => pi.id !== videoId) }
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
const pendingCount = items.filter(it => it.locator.trim()).length
|
||||
const anyConflict = items.some(it => hasConflict(it))
|
||||
// True if any playlist row has had all its videos deleted — archive would be a no-op.
|
||||
const anyEmptyPlaylist = items.some(it =>
|
||||
Array.isArray(it.playlistItems) && it.playlistItems.length === 0
|
||||
)
|
||||
const anyProbing = items.some(it =>
|
||||
it.probeState === 'probing' ||
|
||||
// For playlist sources block unless probe completed successfully:
|
||||
|
|
@ -557,6 +569,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
onPlaylistItemQualityChange={(vid, q) => updatePlaylistItemQuality(item.id, vid, q)}
|
||||
onPlaylistToggle={() => togglePlaylistExpanded(item.id)}
|
||||
onSyncChange={val => updateSync(item.id, val)}
|
||||
onPlaylistItemDelete={(vid) => deletePlaylistItem(item.id, vid)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -680,7 +693,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
type="button"
|
||||
className="capture-submit"
|
||||
onClick={handleArchive}
|
||||
disabled={pendingCount === 0 || anyConflict || anyProbing}
|
||||
disabled={pendingCount === 0 || anyConflict || anyProbing || anyEmptyPlaylist}
|
||||
>
|
||||
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
|
||||
</button>
|
||||
|
|
@ -694,7 +707,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
}
|
||||
|
||||
function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemove, onSubmit,
|
||||
onPlaylistQualityChange, onPlaylistItemQualityChange, onPlaylistToggle, onSyncChange }) {
|
||||
onPlaylistQualityChange, onPlaylistItemQualityChange, onPlaylistToggle, onSyncChange, onPlaylistItemDelete }) {
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -805,9 +818,9 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
|||
>
|
||||
{item.playlistExpanded ? '▲' : '▼'}
|
||||
</button>
|
||||
) : (
|
||||
) : item.playlistProbeState === 'probing' ? (
|
||||
<span className="capture-playlist-toggle-placeholder" aria-hidden="true" />
|
||||
)
|
||||
) : null
|
||||
) : null}
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
|
@ -852,6 +865,14 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
|||
{pi.quality === null && (
|
||||
<span className="capture-playlist-conflict-badge">Choose quality</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="capture-playlist-item-remove"
|
||||
aria-label={`Remove ${pi.title || pi.url}`}
|
||||
onClick={() => onPlaylistItemDelete(pi.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{syncToggle}
|
||||
|
|
|
|||
|
|
@ -303,13 +303,11 @@ select {
|
|||
#entries-body > div:nth-child(odd) { background: var(--paper-3); }
|
||||
#entries-body > div.is-selected {
|
||||
background: #eee2d2;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
#entries-body > div.is-multi-selected {
|
||||
background: #eee2d2;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.col-added { width: 162px; color: var(--muted); }
|
||||
|
|
@ -339,8 +337,7 @@ select {
|
|||
.source-icon > * { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
.source-icon svg { width: 100%; height: 100%; }
|
||||
.url-cell { color: #555b55; white-space: nowrap; text-overflow: ellipsis; word-break: break-all; }
|
||||
#entries-body .url-cell:hover,
|
||||
#entries-body .is-selected .url-cell { overflow: visible; white-space: normal; }
|
||||
#entries-body .url-cell:hover { overflow: visible; white-space: normal; }
|
||||
.type-pill { display: inline-block; padding: 2px 6px; background: #d8e3df; color: #275a5f; border: 1px solid #bfd0ca; border-radius: var(--r); }
|
||||
|
||||
/* ── Multi-select checkbox column ───────────────────────────────────────── */
|
||||
|
|
@ -2763,21 +2760,18 @@ body.has-audio-bar { padding-bottom: 56px; }
|
|||
#entries-body > .entry-row-outer:nth-child(even) { background: #f2ede5; }
|
||||
#entries-body > .entry-row-outer:nth-child(odd) { background: var(--paper-3); }
|
||||
|
||||
/* Selection: class lives on outer wrapper, outline scoped to inner row. */
|
||||
/* Selection: class lives on outer wrapper, stroke scoped to inner row. */
|
||||
#entries-body > .entry-row-outer.is-selected { background: #eee2d2; outline: none; }
|
||||
#entries-body > .entry-row-outer.is-multi-selected { background: #eee2d2; outline: none; }
|
||||
#entries-body > .entry-row-outer.is-selected .entry-row-main {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
#entries-body > .entry-row-outer.is-multi-selected .entry-row-main {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
/* URL overflow on hover / selection — same semantics as original. */
|
||||
#entries-body > .entry-row-outer .url-cell:hover,
|
||||
#entries-body > .entry-row-outer.is-selected .url-cell { overflow: visible; white-space: normal; }
|
||||
/* URL overflow on hover only — explicit pointer, not selection. */
|
||||
#entries-body > .entry-row-outer .url-cell:hover { overflow: visible; white-space: normal; }
|
||||
|
||||
/* child-entries container: reset the padding that #entries-body > div > div applies. */
|
||||
#entries-body > .entry-row-outer > .child-entries {
|
||||
|
|
@ -2801,8 +2795,7 @@ body.has-audio-bar { padding-bottom: 56px; }
|
|||
.child-entry-row:last-child { border-bottom: none; }
|
||||
.child-entry-row.is-selected {
|
||||
background: #eee2d2;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
.child-entry-row.is-multi-selected {
|
||||
|
|
@ -2904,41 +2897,72 @@ body.has-audio-bar { padding-bottom: 56px; }
|
|||
|
||||
.capture-playlist-items {
|
||||
border-top: 1px solid var(--line-soft);
|
||||
padding: 4px 0;
|
||||
max-height: 300px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line) transparent;
|
||||
}
|
||||
|
||||
.capture-playlist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
padding: 5px 10px 5px 14px;
|
||||
font-size: 0.8rem;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.capture-playlist-item:last-of-type { border-bottom: none; }
|
||||
.capture-playlist-item--conflict {
|
||||
background: #fff8f0;
|
||||
border-left-color: #c07000;
|
||||
background: color-mix(in srgb, #c07000 6%, transparent);
|
||||
}
|
||||
.capture-playlist-item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.capture-item-quality {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.73rem;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
border-radius: 3px;
|
||||
background: var(--paper-3);
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.capture-playlist-conflict-badge {
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: #c07000;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.capture-playlist-item-remove {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
}
|
||||
.capture-playlist-item:hover .capture-playlist-item-remove,
|
||||
.capture-playlist-item-remove:focus-visible { opacity: 1; }
|
||||
.capture-playlist-item-remove:hover { color: #c04000; opacity: 1; }
|
||||
@media (pointer: coarse) { .capture-playlist-item-remove { opacity: 1; } }
|
||||
.capture-playlist-item-remove { font-size: 0.75rem; line-height: 1; }
|
||||
|
||||
.capture-sync-row {
|
||||
display: flex;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue