refactor(playlists): compact the playlist header to two rows (E9 12A)

The header portals into the shell's FIXED toolbar band, so its height is
list height the user never gets back — and it was spending four stacked
blocks on it: title, meta line, an action row, then a separate view-control
row under the cover. Option B collapses that into two tight rows beside a
smaller cover: title + rename/YouTube-link on the first, with Play-all,
push/revert/delete and the unsynced badge pushed right; the meta line
(count · playtime · filtered) and the view controls (group-by, reset view,
save order, view status, undo/redo) sharing the second.

Every control and its gating is unchanged — the view-control group still
appears only for `items.length > 1 || viewActive`, so a one-item playlist
shows just the meta line.

Measured on a 32-item playlist: the band goes 170px -> 88px at rest
(122px while a view is active, where the status sentence wraps), which is
~80px more visible list.
This commit is contained in:
2026-09-02 03:45:07 +02:00
parent 4e6bcbc078
commit 95c33e0b0f
+162 -150
View File
@@ -606,173 +606,185 @@ export default function Playlists() {
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's {/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */} fixed band, so only the video list scrolls. */}
<PageToolbar> <PageToolbar>
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto"> {/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
<div className="flex gap-3 items-start mb-4"> view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0"> row it sheds is list height won back — the old layout stacked three rows next to the
cover plus a fourth block under it, for roughly twice this height. */}
<div className="px-4 py-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-center">
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && ( {items[0]?.thumbnail_url && (
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" /> <img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{renaming ? ( <div className="flex items-center gap-2 min-w-0">
<input {renaming ? (
autoFocus <input
value={renameValue} autoFocus
onChange={(e) => setRenameValue(e.target.value)} value={renameValue}
onBlur={saveRename} onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => { onBlur={saveRename}
if (e.key === "Enter") saveRename(); onKeyDown={(e) => {
if (e.key === "Escape") setRenaming(false); if (e.key === "Enter") saveRename();
}} if (e.key === "Escape") setRenaming(false);
aria-label={t("playlists.rename")} }}
className="text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40" aria-label={t("playlists.rename")}
/> className="min-w-0 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
) : ( />
<div className="flex items-center gap-2 min-w-0"> ) : (
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2> <>
{editable && ( <h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
<button {editable && (
onClick={() => { <button
setRenameValue(detail.name); onClick={() => {
setRenaming(true); setRenameValue(detail.name);
}} setRenaming(true);
title={t("playlists.rename")} }}
aria-label={t("playlists.rename")} title={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg" aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{detail.yt_playlist_id && (
<a
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</>
)}
<div className="ml-auto flex gap-1.5 items-center shrink-0">
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
> >
<Pencil className="w-3.5 h-3.5" /> <span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)}
<button
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button> </button>
)} )}
{detail.yt_playlist_id && ( {linked && detail.dirty && (
<a <button
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`} onClick={revertToYoutube}
target="_blank" disabled={reverting}
rel="noreferrer noopener" title={t("playlists.revert")}
title={t("playlists.openOnYoutube")} aria-label={t("playlists.revert")}
aria-label={t("playlists.openOnYoutube")} className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
className="shrink-0 text-muted hover:text-accent"
> >
<ExternalLink className="w-3.5 h-3.5" /> <RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</a> </button>
)}
{editable && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)} )}
</div> </div>
)}
<div className="text-xs text-muted mt-0.5">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</div> </div>
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
<button <div className="flex items-center gap-2 mt-1 flex-wrap">
onClick={() => shown.length && setPlayingId(shown[0]!.id)} <span className="text-xs text-muted">
disabled={!shown.length} {t("playlists.itemCount", { count: items.length })}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition" {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
> {filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")} </span>
</button> {(items.length > 1 || viewActive) && (
{editable && canWrite && ( <>
<button <span aria-hidden="true" className="text-xs text-muted/40">
onClick={pushToYoutube} ·
disabled={pushing} </span>
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")} <label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
aria-label={ <input
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube") type="checkbox"
} checked={groupBy}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${ onChange={(e) => setGroupBy(e.target.checked)}
detail.dirty className="accent-accent"
? "border-accent text-accent hover:bg-accent/10" />
: "border-border text-muted hover:text-fg hover:border-accent" {t("playlists.groupByChannel")}
}`} </label>
> {viewActive && (
{pushing ? ( <button
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> onClick={clearView}
) : linked ? ( className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
<Youtube className="w-3.5 h-3.5" /> >
) : ( {t("playlists.clearView")}
<Upload className="w-3.5 h-3.5" /> </button>
)} )}
</button> {canSaveOrder && (
)} <button
{linked && detail.dirty && ( onClick={saveOrder}
<button className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
onClick={revertToYoutube} >
disabled={reverting} <Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
title={t("playlists.revert")} </button>
aria-label={t("playlists.revert")} )}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40" {/* ONE sentence that names the state. Three loose texts used to pile up here
> (reset link + save hint + "reordering is off"), and none of them answered the
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} /> question a user actually has — "did I just change my playlist?". Worse,
</button> sorting a list whose stored order ALREADY matches the sort produced a
)} warning, no Save button and no visible change, which reads as a malfunction.
{editable && ( Now the line says which of the three situations you are in; the disabled grip
<button still explains itself on hover/focus. */}
onClick={deletePlaylist} {viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
title={t("playlists.delete")} {canEdit && (
aria-label={t("playlists.delete")} <div className="ml-auto">
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition" <UndoToolbar
> canUndo={order.canUndo}
<Trash2 className="w-3.5 h-3.5" /> canRedo={order.canRedo}
</button> onUndo={order.undo}
)} onRedo={order.redo}
{linked && detail.dirty && ( />
<span </div>
title={t("playlists.unsyncedHint")} )}
className="inline-flex items-center gap-1.5 text-2xs text-accent ml-1" </>
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)} )}
</div> </div>
</div> </div>
</div> </div>
{(items.length > 1 || viewActive) && (
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => setGroupBy(e.target.checked)}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
>
{t("playlists.clearView")}
</button>
)}
{canSaveOrder && (
<button
onClick={saveOrder}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
>
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
</button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here (reset
link + save hint + "reordering is off"), and none of them answered the question a
user actually has — "did I just change my playlist?". Worse, sorting a list whose
stored order ALREADY matches the sort produced a warning, no Save button and no
visible change, which reads as a malfunction. Now the line says which of the three
situations you are in; the disabled grip still explains itself on hover/focus. */}
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</div>
)}
</div> </div>
</PageToolbar> </PageToolbar>