summaryrefslogtreecommitdiff
path: root/src/routes/playlists/+page.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes/playlists/+page.svelte')
-rw-r--r--src/routes/playlists/+page.svelte353
1 files changed, 353 insertions, 0 deletions
diff --git a/src/routes/playlists/+page.svelte b/src/routes/playlists/+page.svelte
new file mode 100644
index 0000000..eb06e69
--- /dev/null
+++ b/src/routes/playlists/+page.svelte
@@ -0,0 +1,353 @@
+<script lang="ts">
+ import { playlists } from '$lib/stores/playlists';
+ import { importPlaylist, getBestThumbnail } from '$lib/api/youtube';
+
+ let newPlaylistName = $state('');
+ let showCreateForm = $state(false);
+ let showImportForm = $state(false);
+ let importUrl = $state('');
+ let importing = $state(false);
+ let importError = $state('');
+
+ async function createPlaylist() {
+ if (!newPlaylistName.trim()) return;
+ await playlists.create(newPlaylistName.trim());
+ newPlaylistName = '';
+ showCreateForm = false;
+ }
+
+ function handleCreateKeydown(e: KeyboardEvent) {
+ if (e.key === 'Enter') {
+ createPlaylist();
+ } else if (e.key === 'Escape') {
+ showCreateForm = false;
+ newPlaylistName = '';
+ }
+ }
+
+ function handleImportKeydown(e: KeyboardEvent) {
+ if (e.key === 'Enter') {
+ handleImport();
+ } else if (e.key === 'Escape') {
+ showImportForm = false;
+ importUrl = '';
+ importError = '';
+ }
+ }
+
+ async function handleImport() {
+ if (!importUrl.trim()) return;
+
+ importing = true;
+ importError = '';
+
+ try {
+ const imported = await importPlaylist(importUrl.trim());
+
+ // Create the playlist locally
+ const playlistId = await playlists.create(imported.title);
+
+ // Add all videos to it
+ for (const video of imported.videos) {
+ await playlists.addVideo(playlistId, {
+ videoId: video.videoId,
+ title: video.title,
+ author: video.author,
+ authorId: video.authorId,
+ thumbnail: getBestThumbnail(video.videoThumbnails) || `https://i.ytimg.com/vi/${video.videoId}/hqdefault.jpg`,
+ lengthSeconds: video.lengthSeconds
+ });
+ }
+
+ importUrl = '';
+ showImportForm = false;
+ } catch (e) {
+ importError = e instanceof Error ? e.message : 'Failed to import playlist';
+ } finally {
+ importing = false;
+ }
+ }
+
+ async function deletePlaylist(id: string, name: string) {
+ if (confirm(`Delete playlist "${name}"?`)) {
+ await playlists.delete(id);
+ }
+ }
+</script>
+
+<svelte:head>
+ <title>Playlists - ActualYT</title>
+</svelte:head>
+
+<div class="container">
+ <div class="header">
+ <h1 class="section-title">Playlists</h1>
+ <div class="header-actions">
+ {#if !showCreateForm && !showImportForm}
+ <button class="btn btn-secondary" onclick={() => showImportForm = true}>
+ Import from YouTube
+ </button>
+ <button class="btn btn-primary" onclick={() => showCreateForm = true}>
+ + New Playlist
+ </button>
+ {/if}
+ </div>
+ </div>
+
+ {#if showCreateForm}
+ <div class="form-section">
+ <h3 class="form-title">Create New Playlist</h3>
+ <div class="form-row">
+ <input
+ type="text"
+ bind:value={newPlaylistName}
+ placeholder="Playlist name"
+ onkeydown={handleCreateKeydown}
+ />
+ <button class="btn btn-primary" onclick={createPlaylist}>Create</button>
+ <button class="btn btn-secondary" onclick={() => { showCreateForm = false; newPlaylistName = ''; }}>
+ Cancel
+ </button>
+ </div>
+ </div>
+ {/if}
+
+ {#if showImportForm}
+ <div class="form-section">
+ <h3 class="form-title">Import YouTube Playlist</h3>
+ <p class="form-hint">Paste a YouTube playlist URL to import all its videos</p>
+ <div class="form-row">
+ <input
+ type="text"
+ bind:value={importUrl}
+ placeholder="https://www.youtube.com/playlist?list=..."
+ onkeydown={handleImportKeydown}
+ disabled={importing}
+ />
+ <button class="btn btn-primary" onclick={handleImport} disabled={importing}>
+ {importing ? 'Importing...' : 'Import'}
+ </button>
+ <button class="btn btn-secondary" onclick={() => { showImportForm = false; importUrl = ''; importError = ''; }} disabled={importing}>
+ Cancel
+ </button>
+ </div>
+ {#if importError}
+ <p class="form-error">{importError}</p>
+ {/if}
+ </div>
+ {/if}
+
+ {#if $playlists.length === 0 && !showCreateForm && !showImportForm}
+ <div class="empty">
+ <p>You haven't created any playlists yet.</p>
+ <p>Create a playlist or import one from YouTube.</p>
+ </div>
+ {:else if $playlists.length > 0}
+ <div class="playlist-grid">
+ {#each $playlists as playlist (playlist.id)}
+ <a href="/playlists/{playlist.id}" class="playlist-card">
+ <div class="thumbnail">
+ {#if playlist.videos.length > 0}
+ <img src={playlist.videos[0].thumbnail} alt="" />
+ <span class="count">{playlist.videos.length} videos</span>
+ {:else}
+ <div class="empty-thumb">
+ <svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
+ <path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
+ </svg>
+ </div>
+ {/if}
+ </div>
+ <div class="info">
+ <h3 class="name">{playlist.name}</h3>
+ <p class="meta">{playlist.videos.length} videos</p>
+ </div>
+ <button
+ class="delete-btn"
+ onclick={(e) => { e.preventDefault(); deletePlaylist(playlist.id, playlist.name); }}
+ aria-label="Delete playlist"
+ >
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
+ <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
+ </svg>
+ </button>
+ </a>
+ {/each}
+ </div>
+ {/if}
+</div>
+
+<style>
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ }
+
+ .section-title {
+ margin-bottom: 0;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .form-section {
+ background: var(--bg-secondary);
+ border-radius: 8px;
+ padding: 1.5rem;
+ margin-bottom: 2rem;
+ }
+
+ .form-title {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0 0 0.5rem;
+ }
+
+ .form-hint {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ margin: 0 0 1rem;
+ }
+
+ .form-row {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .form-row input {
+ flex: 1;
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 1rem;
+ }
+
+ .form-row input:focus {
+ outline: none;
+ border-color: var(--accent-color);
+ }
+
+ .form-row input:disabled {
+ opacity: 0.6;
+ }
+
+ .form-error {
+ color: var(--error-color);
+ font-size: 0.9rem;
+ margin: 0.75rem 0 0;
+ }
+
+ .playlist-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1.5rem;
+ }
+
+ .playlist-card {
+ display: block;
+ text-decoration: none;
+ color: inherit;
+ border-radius: 8px;
+ overflow: hidden;
+ position: relative;
+ transition: transform 0.15s ease;
+ }
+
+ .playlist-card:hover {
+ transform: translateY(-2px);
+ }
+
+ .thumbnail {
+ position: relative;
+ aspect-ratio: 16 / 9;
+ background: var(--bg-secondary);
+ border-radius: 8px;
+ overflow: hidden;
+ }
+
+ .thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .count {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.8);
+ color: #fff;
+ padding: 4px 8px;
+ font-size: 0.8rem;
+ }
+
+ .empty-thumb {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-muted);
+ }
+
+ .info {
+ padding: 0.75rem 0;
+ }
+
+ .name {
+ font-size: 1rem;
+ font-weight: 500;
+ margin: 0 0 0.25rem;
+ }
+
+ .meta {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin: 0;
+ }
+
+ .delete-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.7);
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ }
+
+ .playlist-card:hover .delete-btn {
+ opacity: 1;
+ }
+
+ .delete-btn:hover {
+ background: var(--error-color);
+ }
+
+ @media (max-width: 600px) {
+ .header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .form-row {
+ flex-direction: column;
+ }
+ }
+</style>