Initial open-source self-hosted Songs2YT edition

This commit is contained in:
Songs2YT
2026-07-17 02:23:59 +02:00
commit 2aa8a84781
134 changed files with 17758 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
"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 (dont 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 Songs2YT 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>
);
}