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:
parent
b89c00f909
commit
05c2399b94
6 changed files with 190 additions and 9 deletions
|
|
@ -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")}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue