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:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user