mirror of
https://git.atakanozban.com/Songs2VID/songs2vid.git
synced 2026-11-14 21:29:49 +01:00
196 lines
6.1 KiB
TypeScript
196 lines
6.1 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useCallback, useEffect, useState } from "react";
|
|||
|
|
import { UpgradeProLink } from "./UpgradeProLink";
|
|||
|
|
|
|||
|
|
type Playlist = {
|
|||
|
|
id: string;
|
|||
|
|
title: string;
|
|||
|
|
itemCount: number;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type Props = {
|
|||
|
|
value: string;
|
|||
|
|
onChange: (playlistId: string) => void;
|
|||
|
|
enabled: boolean;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export function PlaylistSelect({ value, onChange, enabled }: Props) {
|
|||
|
|
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const [creating, setCreating] = useState(false);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
const [showCreate, setShowCreate] = useState(false);
|
|||
|
|
const [title, setTitle] = useState("");
|
|||
|
|
const [description, setDescription] = useState("");
|
|||
|
|
const [privacy, setPrivacy] = useState<"public" | "unlisted" | "private">("private");
|
|||
|
|
|
|||
|
|
const loadPlaylists = useCallback(async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/youtube/playlists");
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (!res.ok) throw new Error(data.error || "Failed to load playlists");
|
|||
|
|
setPlaylists(data.playlists ?? []);
|
|||
|
|
} catch (err) {
|
|||
|
|
setError(err instanceof Error ? err.message : "Failed to load playlists");
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!enabled) return;
|
|||
|
|
void loadPlaylists();
|
|||
|
|
}, [enabled, loadPlaylists]);
|
|||
|
|
|
|||
|
|
async function handleCreate() {
|
|||
|
|
if (!title.trim()) {
|
|||
|
|
setError("Playlist title is required");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setCreating(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/youtube/playlists", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
title: title.trim(),
|
|||
|
|
description: description.trim() || undefined,
|
|||
|
|
privacy,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (!res.ok) throw new Error(data.error || "Failed to create playlist");
|
|||
|
|
|
|||
|
|
const playlist = data.playlist as Playlist;
|
|||
|
|
setPlaylists((prev) => [playlist, ...prev]);
|
|||
|
|
onChange(playlist.id);
|
|||
|
|
setShowCreate(false);
|
|||
|
|
setTitle("");
|
|||
|
|
setDescription("");
|
|||
|
|
setPrivacy("private");
|
|||
|
|
} catch (err) {
|
|||
|
|
setError(err instanceof Error ? err.message : "Failed to create playlist");
|
|||
|
|
} finally {
|
|||
|
|
setCreating(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!enabled) {
|
|||
|
|
return (
|
|||
|
|
<p className="text-sm text-gray-400">
|
|||
|
|
Add uploaded videos to a YouTube playlist with{" "}
|
|||
|
|
<UpgradeProLink className="text-accent hover:underline" />.
|
|||
|
|
</p>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<label className="block text-sm text-gray-400">Add to YouTube playlist (optional)</label>
|
|||
|
|
<select
|
|||
|
|
value={value}
|
|||
|
|
onChange={(e) => onChange(e.target.value)}
|
|||
|
|
disabled={loading || creating}
|
|||
|
|
className="input-field w-full"
|
|||
|
|
>
|
|||
|
|
<option value="">None (don’t add to a playlist)</option>
|
|||
|
|
{playlists.map((playlist) => (
|
|||
|
|
<option key={playlist.id} value={playlist.id}>
|
|||
|
|
{playlist.title} ({playlist.itemCount})
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex flex-wrap gap-3">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setShowCreate((prev) => !prev)}
|
|||
|
|
className="text-sm text-accent hover:underline"
|
|||
|
|
>
|
|||
|
|
{showCreate ? "Cancel" : "Create new playlist"}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => void loadPlaylists()}
|
|||
|
|
disabled={loading}
|
|||
|
|
className="text-sm text-gray-400 hover:text-white"
|
|||
|
|
>
|
|||
|
|
Refresh list
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{showCreate && (
|
|||
|
|
<div className="space-y-3 rounded border border-gray-700 bg-surface-dark/60 p-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="mb-1 block text-sm text-gray-400">Playlist title</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={title}
|
|||
|
|
onChange={(e) => setTitle(e.target.value)}
|
|||
|
|
onKeyDown={(e) => {
|
|||
|
|
if (e.key === "Enter") {
|
|||
|
|
e.preventDefault();
|
|||
|
|
void handleCreate();
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
className="input-field w-full"
|
|||
|
|
placeholder="My Songs2VID uploads"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="mb-1 block text-sm text-gray-400">Description (optional)</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={description}
|
|||
|
|
onChange={(e) => setDescription(e.target.value)}
|
|||
|
|
onKeyDown={(e) => {
|
|||
|
|
if (e.key === "Enter") {
|
|||
|
|
e.preventDefault();
|
|||
|
|
void handleCreate();
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
className="input-field w-full"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="mb-1 block text-sm text-gray-400">Privacy</label>
|
|||
|
|
<select
|
|||
|
|
value={privacy}
|
|||
|
|
onChange={(e) => setPrivacy(e.target.value as typeof privacy)}
|
|||
|
|
className="input-field w-full"
|
|||
|
|
>
|
|||
|
|
<option value="private">Private</option>
|
|||
|
|
<option value="unlisted">Unlisted</option>
|
|||
|
|
<option value="public">Public</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => void handleCreate()}
|
|||
|
|
disabled={creating || !title.trim()}
|
|||
|
|
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{creating ? "Creating…" : "Create & select"}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loading && <p className="text-xs text-gray-500">Loading playlists…</p>}
|
|||
|
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
|||
|
|
{!loading && !error && playlists.length === 0 && !showCreate && (
|
|||
|
|
<p className="text-xs text-gray-500">
|
|||
|
|
No playlists yet. Create one above. If playlist access was just added, sign out and sign
|
|||
|
|
in again to grant the new Google permission.
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|