1
Fork 0
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:
TheGeneralist 2026-07-21 13:18:40 +02:00
parent e2f50a9856
commit f0a6bf1cdc
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
7 changed files with 123 additions and 78 deletions

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

File diff suppressed because one or more lines are too long

View file

@ -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>

View file

@ -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)}
>
&times;
</button>
</div>
))}
{syncToggle}

View file

@ -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;