diff options
Diffstat (limited to 'src/routes/playlists')
| -rw-r--r-- | src/routes/playlists/+page.svelte | 353 | ||||
| -rw-r--r-- | src/routes/playlists/[id]/+page.svelte | 294 |
2 files changed, 647 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> diff --git a/src/routes/playlists/[id]/+page.svelte b/src/routes/playlists/[id]/+page.svelte new file mode 100644 index 0000000..21de34f --- /dev/null +++ b/src/routes/playlists/[id]/+page.svelte @@ -0,0 +1,294 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { goto } from '$app/navigation'; + import { playlists, type Playlist } from '$lib/stores/playlists'; + import { formatDuration } from '$lib/api/youtube'; + + const playlistId = $derived($page.params.id ?? ''); + const playlist = $derived(playlists.getById($playlists, playlistId)); + + let editing = $state(false); + let editName = $state(''); + + function startEditing() { + if (!playlist) return; + editName = playlist.name; + editing = true; + } + + function saveEdit() { + if (!editName.trim() || !playlist) return; + playlists.rename(playlist.id, editName.trim()); + editing = false; + } + + function cancelEdit() { + editing = false; + editName = ''; + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') saveEdit(); + else if (e.key === 'Escape') cancelEdit(); + } + + function removeVideo(videoId: string) { + if (!playlist) return; + playlists.removeVideo(playlist.id, videoId); + } + + async function deletePlaylist() { + if (!playlist) return; + if (confirm(`Delete playlist "${playlist.name}"?`)) { + await playlists.delete(playlist.id); + goto('/playlists'); + } + } +</script> + +<svelte:head> + <title>{playlist?.name || 'Playlist'} - ActualYT</title> +</svelte:head> + +<div class="container"> + {#if !playlist} + <div class="error">Playlist not found</div> + {:else} + <div class="playlist-page"> + <div class="playlist-header"> + {#if editing} + <input + type="text" + bind:value={editName} + onkeydown={handleKeydown} + class="edit-input" + autofocus + /> + <button class="btn btn-primary" onclick={saveEdit}>Save</button> + <button class="btn btn-secondary" onclick={cancelEdit}>Cancel</button> + {:else} + <h1 class="title">{playlist.name}</h1> + <div class="actions"> + <button class="btn btn-secondary" onclick={startEditing}>Rename</button> + <button class="btn btn-danger" onclick={deletePlaylist}>Delete</button> + </div> + {/if} + </div> + + <p class="meta">{playlist.videos.length} videos</p> + + {#if playlist.videos.length === 0} + <div class="empty"> + <p>This playlist is empty.</p> + <p>Add videos from the watch page using the "Save" button.</p> + </div> + {:else} + <div class="video-list"> + {#each playlist.videos as video, index (video.videoId)} + <div class="video-item"> + <span class="index">{index + 1}</span> + <a href="/watch/{video.videoId}" class="video-link"> + <div class="thumbnail"> + <img src={video.thumbnail} alt="" /> + <span class="duration">{formatDuration(video.lengthSeconds)}</span> + </div> + <div class="info"> + <h3 class="video-title">{video.title}</h3> + <span + class="author" + role="link" + tabindex="0" + onclick={(e) => { e.preventDefault(); e.stopPropagation(); window.location.href = `/channel/${video.authorId}`; }} + onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); window.location.href = `/channel/${video.authorId}`; } }} + > + {video.author} + </span> + </div> + </a> + <button + class="remove-btn" + onclick={() => removeVideo(video.videoId)} + aria-label="Remove from playlist" + > + <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"> + <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/> + </svg> + </button> + </div> + {/each} + </div> + {/if} + </div> + {/if} +</div> + +<style> + .playlist-page { + max-width: 900px; + } + + .playlist-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.5rem; + } + + .title { + font-size: 1.5rem; + font-weight: 600; + margin: 0; + flex: 1; + } + + .edit-input { + flex: 1; + padding: 0.5rem 1rem; + font-size: 1.25rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-primary); + } + + .edit-input:focus { + outline: none; + border-color: var(--accent-color); + } + + .actions { + display: flex; + gap: 0.5rem; + } + + .meta { + color: var(--text-muted); + margin-bottom: 2rem; + } + + .video-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .video-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem; + border-radius: 8px; + transition: background 0.15s ease; + } + + .video-item:hover { + background: var(--bg-hover); + } + + .index { + width: 24px; + text-align: center; + color: var(--text-muted); + font-size: 0.9rem; + flex-shrink: 0; + } + + .video-link { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; + text-decoration: none; + color: inherit; + min-width: 0; + } + + .thumbnail { + position: relative; + width: 120px; + aspect-ratio: 16 / 9; + background: var(--bg-secondary); + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; + } + + .thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .duration { + position: absolute; + bottom: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: 1px 4px; + border-radius: 2px; + font-size: 0.75rem; + } + + .info { + flex: 1; + min-width: 0; + } + + .video-title { + font-size: 0.95rem; + font-weight: 500; + margin: 0 0 0.25rem; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .author { + font-size: 0.85rem; + color: var(--text-secondary); + text-decoration: none; + cursor: pointer; + } + + .author:hover { + color: var(--text-primary); + } + + .remove-btn { + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s ease, background 0.15s ease; + flex-shrink: 0; + } + + .video-item:hover .remove-btn { + opacity: 1; + } + + .remove-btn:hover { + background: var(--bg-secondary); + color: var(--error-color); + } + + @media (max-width: 600px) { + .thumbnail { + width: 100px; + } + + .index { + display: none; + } + } +</style> |
