feat(playlists): Sync to YouTube UI + delete-on-YouTube choice

Editable local playlists get an Export/Sync to YouTube button (write-scope gated):
it fetches a dry-run plan, shows a confirm with the change counts, quota estimate
and divergence warning, then pushes. An 'unsynced changes' badge and an accented
YouTube icon mark linked playlists with local edits. Deleting a linked playlist
offers 'delete on YouTube too' vs 'here only'. Trilingual strings (HU/EN/DE).
This commit is contained in:
npeter83 2026-06-15 21:23:13 +02:00
parent b150337c87
commit 8911e9dc89
6 changed files with 190 additions and 9 deletions

View file

@ -25,6 +25,7 @@ import {
Plus,
RefreshCw,
Trash2,
Upload,
X,
Youtube,
} from "lucide-react";
@ -107,7 +108,7 @@ function Row({
);
}
export default function Playlists() {
export default function Playlists({ canWrite }: { canWrite: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
@ -163,7 +164,63 @@ export default function Playlists() {
// YouTube-mirrored playlists are read-only here until the write-back phase.
const mirrored = detail?.source === "youtube";
const editable = !builtin && !mirrored;
const linked = editable && !!detail?.yt_playlist_id;
const [syncing, setSyncing] = useState(false);
const [pushing, setPushing] = useState(false);
async function pushToYoutube() {
if (selectedId == null || !detail || pushing) return;
setPushing(true);
try {
const plan = await api.playlistPushPlan(selectedId);
if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
notify({ level: "info", message: t("playlists.pushUpToDate") });
return;
}
if (!plan.affordable) {
notify({
level: "warning",
message: t("playlists.pushNoQuota", {
left: plan.remaining_today,
units: plan.units_estimate,
}),
});
return;
}
let message =
plan.action === "create"
? t("playlists.pushPlanCreate", {
count: plan.to_insert,
units: plan.units_estimate,
left: plan.remaining_today,
})
: t("playlists.pushPlanUpdate", {
insert: plan.to_insert,
del: plan.to_delete,
reorder: plan.to_reorder,
units: plan.units_estimate,
left: plan.remaining_today,
});
if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra });
const ok = await confirm({
title: t("playlists.pushTitle"),
message,
confirmLabel: t("playlists.pushConfirm"),
});
if (!ok) return;
const r = await api.pushPlaylist(selectedId);
refreshAll();
if (r.failures.length) {
notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) });
} else {
notify({ level: "success", message: t("playlists.pushDone") });
}
} catch {
notify({ level: "warning", message: t("playlists.pushFailed") });
} finally {
setPushing(false);
}
}
async function syncYoutube() {
if (syncing) return;
@ -232,12 +289,27 @@ export default function Playlists() {
danger: true,
});
if (!ok) return;
// For a YouTube-linked playlist, offer to delete it on YouTube too (Cancel = here only).
let onYoutube = false;
if (linked && canWrite) {
onYoutube = await confirm({
title: t("playlists.deleteOnYoutubeTitle"),
message: t("playlists.deleteOnYoutubeMsg", { name: detail.name }),
confirmLabel: t("playlists.deleteOnYoutubeConfirm"),
cancelLabel: t("playlists.deleteHereOnly"),
danger: true,
});
}
const deletedId = selectedId;
// Pick the next selection from what's left *now* (don't go via null, or the
// auto-select effect would re-pick the just-deleted id from the stale list).
const remaining = playlists.filter((p) => p.id !== deletedId);
setSelectedId(remaining[0]?.id ?? null);
await api.deletePlaylist(deletedId);
try {
await api.deletePlaylist(deletedId, onYoutube);
} catch {
notify({ level: "warning", message: t("playlists.deleteYtFailed") });
}
qc.removeQueries({ queryKey: ["playlist", deletedId] });
qc.invalidateQueries({ queryKey: ["playlists"] });
}
@ -287,8 +359,10 @@ export default function Playlists() {
<div className="min-w-0">
<div className="flex items-center gap-1 text-[13px] text-fg">
<span className="truncate">{plName(pl)}</span>
{pl.source === "youtube" && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
)}
</div>
<div className="text-[11px] text-muted">
@ -373,6 +447,27 @@ export default function Playlists() {
>
<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")}
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 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" />
)}
{linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
</button>
)}
{editable && (
<button
onClick={deletePlaylist}
@ -381,6 +476,11 @@ export default function Playlists() {
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
</button>
)}
{linked && detail.dirty && (
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-accent">
{t("playlists.unsynced")}
</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")}