mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +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
|
|
@ -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