polish(playlists): compact header actions + open-on-YouTube link

The detail header overflowed the items column. Make the secondary actions icon-only
with tooltips (Sync/Export, Reset to YouTube, Delete) keeping only Play all labeled;
replace the long 'Unsynced changes' / 'Synced from YouTube' chips with a compact
accent 'Unsynced' dot (tooltip explains). Add an open-on-YouTube external-link button
next to the name + edit pencil (tooltip), shown for any YouTube-linked playlist.
This commit is contained in:
npeter83 2026-06-15 22:40:21 +02:00
parent 02a90f4c21
commit 2dfafd4484
4 changed files with 51 additions and 31 deletions

View file

@ -20,6 +20,7 @@ import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
Check, Check,
ExternalLink,
GripVertical, GripVertical,
ListPlus, ListPlus,
Pencil, Pencil,
@ -320,9 +321,8 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
}, [sortedPlaylists]); }, [sortedPlaylists]);
const builtin = detail?.kind === "watch_later"; const builtin = detail?.kind === "watch_later";
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
// a dirty mirror so it won't clobber unpushed edits); the chip just marks their origin. // dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin.
const mirrored = detail?.source === "youtube";
const editable = !builtin; const editable = !builtin;
const linked = editable && !!detail?.yt_playlist_id; const linked = editable && !!detail?.yt_playlist_id;
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
@ -651,7 +651,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent" className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent"
/> />
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2> <h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && ( {editable && (
<button <button
@ -660,17 +660,30 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
setRenaming(true); setRenaming(true);
}} }}
title={t("playlists.rename")} title={t("playlists.rename")}
className="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" /> <Pencil className="w-3.5 h-3.5" />
</button> </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> </div>
)} )}
<div className="text-xs text-muted mt-0.5"> <div className="text-xs text-muted mt-0.5">
{t("playlists.itemCount", { count: items.length })} {t("playlists.itemCount", { count: items.length })}
</div> </div>
<div className="flex gap-2 mt-3 flex-wrap"> <div className="flex gap-1.5 mt-3 flex-wrap items-center">
<button <button
onClick={() => items.length && setPlayingIndex(0)} onClick={() => items.length && setPlayingIndex(0)}
disabled={!items.length} disabled={!items.length}
@ -683,7 +696,8 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
onClick={pushToYoutube} onClick={pushToYoutube}
disabled={pushing} disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")} title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border transition disabled:opacity-40 ${ 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 detail.dirty
? "border-accent text-accent hover:bg-accent/10" ? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent" : "border-border text-muted hover:text-fg hover:border-accent"
@ -696,37 +710,37 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
) : ( ) : (
<Upload className="w-3.5 h-3.5" /> <Upload className="w-3.5 h-3.5" />
)} )}
{linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")} </button>
)}
{linked && detail.dirty && (
<button
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
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"
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button> </button>
)} )}
{editable && ( {editable && (
<button <button
onClick={deletePlaylist} onClick={deletePlaylist}
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition" 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-red-400 hover:border-red-400/50 transition"
> >
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")} <Trash2 className="w-3.5 h-3.5" />
</button> </button>
)} )}
{linked && detail.dirty && ( {linked && detail.dirty && (
<> <span
<button title={t("playlists.unsyncedHint")}
onClick={revertToYoutube} className="inline-flex items-center gap-1.5 text-[11px] text-accent ml-1"
disabled={reverting}
title={t("playlists.revert")}
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
> >
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} /> <span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.revert")}
</button>
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-accent">
{t("playlists.unsynced")} {t("playlists.unsynced")}
</span> </span>
</>
)}
{mirrored && (
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-muted">
<Youtube className="w-3.5 h-3.5" /> {t("playlists.ytReadonly")}
</span>
)} )}
</div> </div>
</div> </div>

View file

@ -19,7 +19,9 @@
"addToPlaylist": "Zur Wiedergabeliste hinzufügen", "addToPlaylist": "Zur Wiedergabeliste hinzufügen",
"exportToYoutube": "Zu YouTube exportieren", "exportToYoutube": "Zu YouTube exportieren",
"pushToYoutube": "Mit YouTube synchronisieren", "pushToYoutube": "Mit YouTube synchronisieren",
"unsynced": "Nicht synchronisierte Änderungen", "unsynced": "Nicht synchronisiert",
"unsyncedHint": "Du hast lokale Änderungen, die noch nicht mit YouTube synchronisiert sind.",
"openOnYoutube": "Auf YouTube öffnen",
"revert": "Von YouTube zurücksetzen", "revert": "Von YouTube zurücksetzen",
"revertTitle": "Lokale Änderungen verwerfen?", "revertTitle": "Lokale Änderungen verwerfen?",
"revertMsg": "Dies lädt die Wiedergabeliste von YouTube neu und verwirft deine nicht synchronisierten lokalen Änderungen (Reihenfolge, Hinzufügungen, Entfernungen, Umbenennung). Fortfahren?", "revertMsg": "Dies lädt die Wiedergabeliste von YouTube neu und verwirft deine nicht synchronisierten lokalen Änderungen (Reihenfolge, Hinzufügungen, Entfernungen, Umbenennung). Fortfahren?",

View file

@ -19,7 +19,9 @@
"addToPlaylist": "Add to playlist", "addToPlaylist": "Add to playlist",
"exportToYoutube": "Export to YouTube", "exportToYoutube": "Export to YouTube",
"pushToYoutube": "Sync to YouTube", "pushToYoutube": "Sync to YouTube",
"unsynced": "Unsynced changes", "unsynced": "Unsynced",
"unsyncedHint": "You have local changes not yet synced to YouTube.",
"openOnYoutube": "Open on YouTube",
"revert": "Reset to YouTube", "revert": "Reset to YouTube",
"revertTitle": "Discard local changes?", "revertTitle": "Discard local changes?",
"revertMsg": "This reloads the playlist from YouTube and discards your unsynced local changes (order, additions, removals, rename). Continue?", "revertMsg": "This reloads the playlist from YouTube and discards your unsynced local changes (order, additions, removals, rename). Continue?",

View file

@ -19,7 +19,9 @@
"addToPlaylist": "Hozzáadás listához", "addToPlaylist": "Hozzáadás listához",
"exportToYoutube": "Exportálás YouTube-ra", "exportToYoutube": "Exportálás YouTube-ra",
"pushToYoutube": "Szinkron YouTube-ra", "pushToYoutube": "Szinkron YouTube-ra",
"unsynced": "Nem szinkronizált változások", "unsynced": "Nincs szinkronizálva",
"unsyncedHint": "Vannak helyi módosításaid, amelyek még nincsenek a YouTube-ra szinkronizálva.",
"openOnYoutube": "Megnyitás YouTube-on",
"revert": "Visszaállítás YouTube-ról", "revert": "Visszaállítás YouTube-ról",
"revertTitle": "Eldobod a helyi módosításokat?", "revertTitle": "Eldobod a helyi módosításokat?",
"revertMsg": "Ez újratölti a listát a YouTube-ról, és eldobja a nem szinkronizált helyi módosításaidat (sorrend, hozzáadás, eltávolítás, átnevezés). Folytatod?", "revertMsg": "Ez újratölti a listát a YouTube-ról, és eldobja a nem szinkronizált helyi módosításaidat (sorrend, hozzáadás, eltávolítás, átnevezés). Folytatod?",