diff options
| author | Thomas Grothe <grothe.tr@gmail.com> | 2026-03-07 23:32:05 -0500 |
|---|---|---|
| committer | Thomas Grothe <grothe.tr@gmail.com> | 2026-03-07 23:32:05 -0500 |
| commit | dbd1386a43ae9e7013809be2e0bd0e1c049059fc (patch) | |
| tree | 22588cb21dfa1cc941e13031e73cb85cdfb7f402 /src/routes | |
Diffstat (limited to 'src/routes')
| -rw-r--r-- | src/routes/+layout.svelte | 151 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 50 | ||||
| -rw-r--r-- | src/routes/api/channel/[id]/+server.ts | 18 | ||||
| -rw-r--r-- | src/routes/api/playlist/+server.ts | 23 | ||||
| -rw-r--r-- | src/routes/api/related/[id]/+server.ts | 19 | ||||
| -rw-r--r-- | src/routes/api/search/+server.ts | 18 | ||||
| -rw-r--r-- | src/routes/api/trending/+server.ts | 13 | ||||
| -rw-r--r-- | src/routes/api/video/[id]/+server.ts | 18 | ||||
| -rw-r--r-- | src/routes/channel/[id]/+page.svelte | 175 | ||||
| -rw-r--r-- | src/routes/playlists/+page.svelte | 353 | ||||
| -rw-r--r-- | src/routes/playlists/[id]/+page.svelte | 294 | ||||
| -rw-r--r-- | src/routes/search/+page.svelte | 77 | ||||
| -rw-r--r-- | src/routes/subscriptions/+page.svelte | 151 | ||||
| -rw-r--r-- | src/routes/watch/[id]/+page.svelte | 381 |
14 files changed, 1741 insertions, 0 deletions
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..24c72c6 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,151 @@ +<script lang="ts"> + import '../app.css'; + import SearchBar from '$lib/components/SearchBar.svelte'; + import { subscriptions } from '$lib/stores/subscriptions'; + import { playlists } from '$lib/stores/playlists'; + import { onMount } from 'svelte'; + import { browser } from '$app/environment'; + + let { children } = $props(); + let theme = $state<'dark' | 'light'>('dark'); + + onMount(() => { + subscriptions.init(); + playlists.init(); + + const stored = localStorage.getItem('actualyt-theme'); + if (stored === 'light' || stored === 'dark') { + theme = stored; + } + }); + + function toggleTheme() { + theme = theme === 'dark' ? 'light' : 'dark'; + if (browser) { + localStorage.setItem('actualyt-theme', theme); + } + } +</script> + +<svelte:head> + <title>ActualYT</title> +</svelte:head> + +<div class="app" data-theme={theme}> + <header class="header"> + <div class="header-content container"> + <a href="/" class="logo">ActualYT</a> + <SearchBar /> + <nav class="nav"> + <a href="/subscriptions" class="nav-link">Subscriptions</a> + <a href="/playlists" class="nav-link">Playlists</a> + <button class="theme-toggle" onclick={toggleTheme} aria-label="Toggle theme"> + {#if theme === 'dark'} + <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"> + <path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0a.996.996 0 0 0 0-1.41l-1.06-1.06zm1.06-10.96a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/> + </svg> + {:else} + <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"> + <path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"/> + </svg> + {/if} + </button> + </nav> + </div> + </header> + + <main class="main"> + {@render children()} + </main> +</div> + +<style> + .app { + min-height: 100vh; + background: var(--bg-primary); + } + + .header { + position: sticky; + top: 0; + z-index: 100; + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + } + + .header-content { + display: flex; + align-items: center; + gap: 2rem; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + } + + .logo { + font-size: 1.25rem; + font-weight: 700; + text-decoration: none; + color: var(--accent-color); + flex-shrink: 0; + } + + .nav { + display: flex; + align-items: center; + gap: 1rem; + margin-left: auto; + } + + .nav-link { + text-decoration: none; + color: var(--text-secondary); + font-size: 0.9rem; + padding: 0.5rem; + border-radius: 4px; + transition: color 0.15s ease; + } + + .nav-link:hover { + color: var(--text-primary); + } + + .theme-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + + .theme-toggle:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + .main { + padding: 2rem 0; + } + + @media (max-width: 768px) { + .header-content { + flex-wrap: wrap; + gap: 1rem; + } + + .nav { + order: -1; + width: 100%; + justify-content: flex-end; + } + + .logo { + order: -2; + } + } +</style> diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..8477c7c --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,50 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import { getTrending, type VideoInfo } from '$lib/api/youtube'; + import VideoCard from '$lib/components/VideoCard.svelte'; + + let videos = $state<VideoInfo[]>([]); + let loading = $state(true); + let error = $state(''); + + onMount(async () => { + try { + videos = await getTrending(); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load videos'; + } finally { + loading = false; + } + }); +</script> + +<svelte:head> + <title>ActualYT - Home</title> +</svelte:head> + +<div class="container"> + <h1 class="section-title">Trending</h1> + + {#if loading} + <div class="loading">Loading videos</div> + {:else if error} + <div class="error">{error}</div> + {:else if videos.length === 0} + <div class="empty">No videos found</div> + {:else} + <div class="video-grid"> + {#each videos as video (video.videoId)} + <VideoCard + videoId={video.videoId} + title={video.title} + author={video.author} + authorId={video.authorId} + thumbnails={video.videoThumbnails} + viewCount={video.viewCount} + publishedText={video.publishedText} + lengthSeconds={video.lengthSeconds} + /> + {/each} + </div> + {/if} +</div> diff --git a/src/routes/api/channel/[id]/+server.ts b/src/routes/api/channel/[id]/+server.ts new file mode 100644 index 0000000..c9aa67f --- /dev/null +++ b/src/routes/api/channel/[id]/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import { getChannel } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const { id } = params; + if (!id) { + return json({ error: 'Missing channel ID' }, { status: 400 }); + } + + try { + const channel = await getChannel(id); + return json(channel); + } catch (e) { + console.error('Channel fetch error:', e); + return json({ error: 'Failed to fetch channel' }, { status: 500 }); + } +}; diff --git a/src/routes/api/playlist/+server.ts b/src/routes/api/playlist/+server.ts new file mode 100644 index 0000000..b70ac26 --- /dev/null +++ b/src/routes/api/playlist/+server.ts @@ -0,0 +1,23 @@ +import { json } from '@sveltejs/kit'; +import { getPlaylist } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url }) => { + const playlistUrl = url.searchParams.get('url'); + if (!playlistUrl) { + return json({ error: 'Missing playlist URL' }, { status: 400 }); + } + + // Validate it's a YouTube playlist URL + if (!playlistUrl.includes('youtube.com/playlist') && !playlistUrl.includes('list=')) { + return json({ error: 'Invalid YouTube playlist URL' }, { status: 400 }); + } + + try { + const playlist = await getPlaylist(playlistUrl); + return json(playlist); + } catch (e) { + console.error('Playlist fetch error:', e); + return json({ error: 'Failed to fetch playlist' }, { status: 500 }); + } +}; diff --git a/src/routes/api/related/[id]/+server.ts b/src/routes/api/related/[id]/+server.ts new file mode 100644 index 0000000..9fcd9c3 --- /dev/null +++ b/src/routes/api/related/[id]/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import { getVideo, getRelatedVideos } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const { id } = params; + if (!id) { + return json({ error: 'Missing video ID' }, { status: 400 }); + } + + try { + const video = await getVideo(id); + const related = await getRelatedVideos(video.title, id); + return json(related); + } catch (e) { + console.error('Related videos fetch error:', e); + return json({ error: 'Failed to fetch related videos' }, { status: 500 }); + } +}; diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts new file mode 100644 index 0000000..57bf347 --- /dev/null +++ b/src/routes/api/search/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import { search } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url }) => { + const query = url.searchParams.get('q'); + if (!query) { + return json({ error: 'Missing query parameter' }, { status: 400 }); + } + + try { + const results = await search(query); + return json(results); + } catch (e) { + console.error('Search error:', e); + return json({ error: 'Search failed' }, { status: 500 }); + } +}; diff --git a/src/routes/api/trending/+server.ts b/src/routes/api/trending/+server.ts new file mode 100644 index 0000000..b19ed21 --- /dev/null +++ b/src/routes/api/trending/+server.ts @@ -0,0 +1,13 @@ +import { json } from '@sveltejs/kit'; +import { getTrending } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + try { + const videos = await getTrending(); + return json(videos); + } catch (e) { + console.error('Trending fetch error:', e); + return json({ error: 'Failed to fetch trending' }, { status: 500 }); + } +}; diff --git a/src/routes/api/video/[id]/+server.ts b/src/routes/api/video/[id]/+server.ts new file mode 100644 index 0000000..9a64c36 --- /dev/null +++ b/src/routes/api/video/[id]/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import { getVideo } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const { id } = params; + if (!id) { + return json({ error: 'Missing video ID' }, { status: 400 }); + } + + try { + const video = await getVideo(id); + return json(video); + } catch (e) { + console.error('Video fetch error:', e); + return json({ error: 'Failed to fetch video' }, { status: 500 }); + } +}; diff --git a/src/routes/channel/[id]/+page.svelte b/src/routes/channel/[id]/+page.svelte new file mode 100644 index 0000000..05ba2d7 --- /dev/null +++ b/src/routes/channel/[id]/+page.svelte @@ -0,0 +1,175 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { getChannel, type ChannelInfo } from '$lib/api/youtube'; + import { subscriptions } from '$lib/stores/subscriptions'; + import VideoCard from '$lib/components/VideoCard.svelte'; + + let channel = $state<ChannelInfo | null>(null); + let loading = $state(true); + let error = $state(''); + + const channelId = $derived($page.params.id); + const isSubscribed = $derived( + channel ? subscriptions.isSubscribed($subscriptions, channel.authorId) : false + ); + + $effect(() => { + const id = $page.params.id; + if (id) loadChannel(id); + }); + + async function loadChannel(id: string) { + loading = true; + error = ''; + + try { + channel = await getChannel(id); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load channel'; + channel = null; + } finally { + loading = false; + } + } + + function handleSubscribe() { + if (!channel) return; + if (isSubscribed) { + subscriptions.remove(channel.authorId); + } else { + subscriptions.add(channel.authorId, channel.author, channel.authorThumbnails); + } + } + + function formatSubCount(count: number): string { + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M subscribers`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}K subscribers`; + return `${count} subscribers`; + } +</script> + +<svelte:head> + <title>{channel?.author || 'Channel'} - ActualYT</title> +</svelte:head> + +<div class="container"> + {#if loading} + <div class="loading">Loading channel</div> + {:else if error} + <div class="error">{error}</div> + {:else if channel} + <div class="channel-page"> + <div class="channel-header"> + <div class="channel-info"> + {#if channel.authorThumbnails && channel.authorThumbnails.length > 0} + <img + src={channel.authorThumbnails[0].url} + alt={channel.author} + class="avatar" + /> + {/if} + <div class="channel-text"> + <h1 class="channel-name">{channel.author}</h1> + {#if channel.subCount > 0} + <p class="sub-count">{formatSubCount(channel.subCount)}</p> + {/if} + </div> + </div> + <button + class="btn" + class:btn-primary={!isSubscribed} + class:btn-secondary={isSubscribed} + onclick={handleSubscribe} + > + {isSubscribed ? 'Subscribed' : 'Subscribe'} + </button> + </div> + + {#if channel.description} + <p class="description">{channel.description}</p> + {/if} + + <h2 class="section-title">Videos</h2> + + {#if channel.videos.length === 0} + <div class="empty">No videos found</div> + {:else} + <div class="video-grid"> + {#each channel.videos as video (video.videoId)} + <VideoCard + videoId={video.videoId} + title={video.title} + author={video.author} + authorId={video.authorId} + thumbnails={video.videoThumbnails} + viewCount={video.viewCount} + publishedText={video.publishedText} + lengthSeconds={video.lengthSeconds} + /> + {/each} + </div> + {/if} + </div> + {/if} +</div> + +<style> + .channel-page { + max-width: 1200px; + } + + .channel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + } + + .channel-info { + display: flex; + align-items: center; + gap: 1rem; + } + + .avatar { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + } + + .channel-name { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.25rem; + } + + .sub-count { + color: var(--text-muted); + margin: 0; + } + + .description { + color: var(--text-secondary); + margin-bottom: 2rem; + white-space: pre-wrap; + line-height: 1.6; + } + + @media (max-width: 600px) { + .channel-header { + flex-direction: column; + align-items: flex-start; + } + + .avatar { + width: 60px; + height: 60px; + } + + .channel-name { + font-size: 1.25rem; + } + } +</style> 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> diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte new file mode 100644 index 0000000..29bf7dc --- /dev/null +++ b/src/routes/search/+page.svelte @@ -0,0 +1,77 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { search, type VideoInfo } from '$lib/api/youtube'; + import VideoCard from '$lib/components/VideoCard.svelte'; + + let results = $state<VideoInfo[]>([]); + let loading = $state(true); + let error = $state(''); + let currentQuery = $state(''); + + $effect(() => { + const query = $page.url.searchParams.get('q') || ''; + if (query !== currentQuery) { + currentQuery = query; + loadResults(query); + } + }); + + async function loadResults(query: string) { + if (!query) { + results = []; + loading = false; + return; + } + + loading = true; + error = ''; + + try { + results = await search(query); + } catch (e) { + error = e instanceof Error ? e.message : 'Search failed'; + results = []; + } finally { + loading = false; + } + } +</script> + +<svelte:head> + <title>{currentQuery ? `${currentQuery} - Search` : 'Search'} - ActualYT</title> +</svelte:head> + +<div class="container"> + {#if currentQuery} + <h1 class="section-title">Results for "{currentQuery}"</h1> + {:else} + <h1 class="section-title">Search</h1> + {/if} + + {#if loading} + <div class="loading">Searching</div> + {:else if error} + <div class="error">{error}</div> + {:else if !currentQuery} + <div class="empty">Enter a search term to find videos</div> + {:else if results.length === 0} + <div class="empty">No results found for "{currentQuery}"</div> + {:else} + <div class="video-grid"> + {#each results as result (result.videoId)} + {#if result.videoId && result.title} + <VideoCard + videoId={result.videoId} + title={result.title} + author={result.author || ''} + authorId={result.authorId || ''} + thumbnails={result.videoThumbnails || []} + viewCount={result.viewCount || 0} + publishedText={result.publishedText || ''} + lengthSeconds={result.lengthSeconds || 0} + /> + {/if} + {/each} + </div> + {/if} +</div> diff --git a/src/routes/subscriptions/+page.svelte b/src/routes/subscriptions/+page.svelte new file mode 100644 index 0000000..9f318ac --- /dev/null +++ b/src/routes/subscriptions/+page.svelte @@ -0,0 +1,151 @@ +<script lang="ts"> + import { subscriptions } from '$lib/stores/subscriptions'; + import { getChannel, type VideoInfo } from '$lib/api/youtube'; + import VideoCard from '$lib/components/VideoCard.svelte'; + import ChannelCard from '$lib/components/ChannelCard.svelte'; + + let videos = $state<VideoInfo[]>([]); + let loading = $state(true); + let error = $state(''); + let activeTab = $state<'feed' | 'channels'>('feed'); + + $effect(() => { + if ($subscriptions.length > 0 && activeTab === 'feed') { + loadFeed(); + } else if ($subscriptions.length === 0) { + loading = false; + } + }); + + async function loadFeed() { + loading = true; + error = ''; + + try { + const results = await Promise.all( + $subscriptions.map(sub => + getChannel(sub.channelId).catch(() => ({ videos: [] })) + ) + ); + + const allVideos = results.flatMap(r => r.videos); + videos = allVideos.slice(0, 50); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load feed'; + } finally { + loading = false; + } + } +</script> + +<svelte:head> + <title>Subscriptions - ActualYT</title> +</svelte:head> + +<div class="container"> + <div class="header"> + <h1 class="section-title">Subscriptions</h1> + <div class="tabs"> + <button + class="tab" + class:active={activeTab === 'feed'} + onclick={() => activeTab = 'feed'} + > + Feed + </button> + <button + class="tab" + class:active={activeTab === 'channels'} + onclick={() => activeTab = 'channels'} + > + Channels ({$subscriptions.length}) + </button> + </div> + </div> + + {#if $subscriptions.length === 0} + <div class="empty"> + <p>You haven't subscribed to any channels yet.</p> + <p>Find channels you like and click Subscribe to see their videos here.</p> + </div> + {:else if activeTab === 'feed'} + {#if loading} + <div class="loading">Loading your feed</div> + {:else if error} + <div class="error">{error}</div> + {:else if videos.length === 0} + <div class="empty">No recent videos from your subscriptions</div> + {:else} + <div class="video-grid"> + {#each videos as video (video.videoId)} + <VideoCard + videoId={video.videoId} + title={video.title} + author={video.author} + authorId={video.authorId} + thumbnails={video.videoThumbnails} + viewCount={video.viewCount} + publishedText={video.publishedText} + lengthSeconds={video.lengthSeconds} + /> + {/each} + </div> + {/if} + {:else} + <div class="channel-list"> + {#each $subscriptions as sub (sub.channelId)} + <ChannelCard + channelId={sub.channelId} + channelName={sub.channelName} + thumbnails={[{ url: sub.thumbnail, width: 88, height: 88 }]} + /> + {/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; + } + + .tabs { + display: flex; + gap: 0.5rem; + } + + .tab { + padding: 0.5rem 1rem; + border: none; + border-radius: 20px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.9rem; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + + .tab:hover { + background: var(--bg-hover); + } + + .tab.active { + background: var(--text-primary); + color: var(--bg-primary); + } + + .channel-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } +</style> diff --git a/src/routes/watch/[id]/+page.svelte b/src/routes/watch/[id]/+page.svelte new file mode 100644 index 0000000..f96d54a --- /dev/null +++ b/src/routes/watch/[id]/+page.svelte @@ -0,0 +1,381 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { getVideo, getRelatedVideos, formatViews, getBestThumbnail, type VideoDetails, type VideoInfo } from '$lib/api/youtube'; + import { subscriptions } from '$lib/stores/subscriptions'; + import { playlists, type PlaylistVideo } from '$lib/stores/playlists'; + import VideoPlayer from '$lib/components/VideoPlayer.svelte'; + import VideoCard from '$lib/components/VideoCard.svelte'; + + let video = $state<VideoDetails | null>(null); + let relatedVideos = $state<VideoInfo[]>([]); + let loading = $state(true); + let loadingRelated = $state(false); + let error = $state(''); + let showDescription = $state(false); + let showPlaylistMenu = $state(false); + + const videoId = $derived($page.params.id); + const isSubscribed = $derived( + video ? subscriptions.isSubscribed($subscriptions, video.authorId) : false + ); + + $effect(() => { + const id = $page.params.id; + if (id) loadVideo(id); + }); + + async function loadVideo(id: string) { + loading = true; + error = ''; + showDescription = false; + relatedVideos = []; + + try { + video = await getVideo(id); + loadRelated(id); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load video'; + video = null; + } finally { + loading = false; + } + } + + async function loadRelated(id: string) { + loadingRelated = true; + try { + relatedVideos = await getRelatedVideos(id); + } catch (e) { + console.error('Failed to load related videos:', e); + } finally { + loadingRelated = false; + } + } + + function handleSubscribe() { + if (!video) return; + if (isSubscribed) { + subscriptions.remove(video.authorId); + } else { + subscriptions.add(video.authorId, video.author, video.videoThumbnails); + } + } + + function addToPlaylist(playlistId: string) { + if (!video) return; + const playlistVideo: PlaylistVideo = { + videoId: video.videoId, + title: video.title, + author: video.author, + authorId: video.authorId, + thumbnail: getBestThumbnail(video.videoThumbnails), + lengthSeconds: video.lengthSeconds + }; + playlists.addVideo(playlistId, playlistVideo); + showPlaylistMenu = false; + } + + async function createAndAddToPlaylist() { + if (!video) return; + const name = prompt('Playlist name:'); + if (!name) return; + const id = await playlists.create(name); + addToPlaylist(id); + } +</script> + +<svelte:head> + <title>{video?.title || 'Watch'} - ActualYT</title> +</svelte:head> + +<div class="container"> + {#if loading} + <div class="loading">Loading video</div> + {:else if error} + <div class="error">{error}</div> + {:else if video} + <div class="watch-page"> + <div class="main-content"> + <div class="player-section"> + <VideoPlayer + formats={video.formats} + title={video.title} + /> + + <div class="video-info"> + <h1 class="title">{video.title}</h1> + + <div class="meta-row"> + <div class="stats"> + <span>{formatViews(video.viewCount)}</span> + <span class="separator">-</span> + <span>{video.publishedText}</span> + {#if video.likeCount} + <span class="separator">-</span> + <span>{formatViews(video.likeCount).replace('views', 'likes')}</span> + {/if} + </div> + + <div class="actions"> + <div class="playlist-dropdown"> + <button + class="btn btn-secondary" + onclick={() => showPlaylistMenu = !showPlaylistMenu} + > + + Save + </button> + {#if showPlaylistMenu} + <div class="playlist-menu"> + {#each $playlists as playlist (playlist.id)} + <button onclick={() => addToPlaylist(playlist.id)}> + {playlist.name} + </button> + {/each} + <button class="new-playlist" onclick={createAndAddToPlaylist}> + + Create new playlist + </button> + </div> + {/if} + </div> + </div> + </div> + + <div class="channel-row"> + <a href="/channel/{video.authorId}" class="channel"> + <span class="channel-name">{video.author}</span> + </a> + <button + class="btn" + class:btn-primary={!isSubscribed} + class:btn-secondary={isSubscribed} + onclick={handleSubscribe} + > + {isSubscribed ? 'Subscribed' : 'Subscribe'} + </button> + </div> + + <div class="description" class:expanded={showDescription}> + <p>{video.description || 'No description'}</p> + {#if video.description && video.description.length > 200} + <button class="show-more" onclick={() => showDescription = !showDescription}> + {showDescription ? 'Show less' : 'Show more'} + </button> + {/if} + </div> + </div> + </div> + </div> + + <aside class="related"> + <h2 class="related-title">Related Videos</h2> + {#if loadingRelated} + <div class="loading-small">Loading related videos...</div> + {:else if relatedVideos.length > 0} + <div class="related-list"> + {#each relatedVideos as related (related.videoId)} + <VideoCard + videoId={related.videoId} + title={related.title} + author={related.author} + authorId={related.authorId} + thumbnails={related.videoThumbnails} + viewCount={related.viewCount} + publishedText={related.publishedText} + lengthSeconds={related.lengthSeconds} + /> + {/each} + </div> + {:else} + <p class="no-related">No related videos found</p> + {/if} + </aside> + </div> + {/if} +</div> + +<style> + .watch-page { + display: grid; + grid-template-columns: 1fr 350px; + gap: 2rem; + align-items: start; + } + + .main-content { + min-width: 0; + } + + .player-section { + width: 100%; + } + + .video-info { + margin-top: 1rem; + } + + .title { + font-size: 1.25rem; + font-weight: 600; + line-height: 1.4; + margin-bottom: 0.75rem; + } + + .meta-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); + } + + .stats { + color: var(--text-secondary); + font-size: 0.9rem; + } + + .separator { + margin: 0 0.5rem; + } + + .actions { + display: flex; + gap: 0.5rem; + } + + .playlist-dropdown { + position: relative; + } + + .playlist-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.5rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + min-width: 200px; + z-index: 10; + overflow: hidden; + } + + .playlist-menu button { + display: block; + width: 100%; + padding: 0.75rem 1rem; + text-align: left; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 0.9rem; + cursor: pointer; + } + + .playlist-menu button:hover { + background: var(--bg-hover); + } + + .playlist-menu .new-playlist { + border-top: 1px solid var(--border-color); + color: var(--accent-color); + } + + .channel-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 0; + } + + .channel { + text-decoration: none; + } + + .channel-name { + font-weight: 500; + color: var(--text-primary); + } + + .description { + padding: 1rem; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 0.9rem; + line-height: 1.6; + } + + .description:not(.expanded) p { + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .description p { + white-space: pre-wrap; + word-break: break-word; + } + + .show-more { + display: block; + margin-top: 0.5rem; + padding: 0; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 0.85rem; + cursor: pointer; + } + + .show-more:hover { + color: var(--text-primary); + } + + .related { + position: sticky; + top: 80px; + } + + .related-title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 1rem; + } + + .related-list { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .loading-small { + color: var(--text-muted); + font-size: 0.9rem; + padding: 1rem 0; + } + + .no-related { + color: var(--text-muted); + font-size: 0.9rem; + } + + @media (max-width: 1024px) { + .watch-page { + grid-template-columns: 1fr; + } + + .related { + position: static; + margin-top: 2rem; + } + + .related-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + } + } +</style> |
