refactor: standardize formatting and improve code consistency across components

This commit is contained in:
maxDorninger
2025-05-17 23:43:24 +02:00
parent ef7b020043
commit bae450f7a4
31 changed files with 1375 additions and 991 deletions

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import '../app.css';
import {ModeWatcher} from "mode-watcher";
let {children} = $props();
</script>
<ModeWatcher/>
{@render children()}

View File

@@ -5,16 +5,16 @@ import {redirect} from '@sveltejs/kit';
const apiUrl = env.PUBLIC_API_URL;
export const load: LayoutServerLoad = async ({fetch}) => {
const response = await fetch(apiUrl + '/users/me', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (!response.ok) {
console.log('unauthorized, redirecting to login');
throw redirect(303, '/login');
}
return {user: await response.json()};
const response = await fetch(apiUrl + '/users/me', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (!response.ok) {
console.log('unauthorized, redirecting to login');
throw redirect(303, '/login');
}
return {user: await response.json()};
};

View File

@@ -1,65 +1,110 @@
<script lang="ts">
import {page} from '$app/state';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import {getTorrentStatusString} from '$lib/utils'; // Corrected path
import type {Torrent} from '$lib/types';
import {page} from '$app/state';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import {getTorrentQualityString, getTorrentStatusString} from '$lib/utils';
import type {RichShowTorrent, Torrent} from '$lib/types';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import {getFullyQualifiedShowName} from '$lib/utils';
import * as Accordion from '$lib/components/ui/accordion/index.js';
let torrentsPromise: Promise<Torrent[]> = page.data.torrents;
let showsPromise: Promise<RichShowTorrent[]> = $state(page.data.shows);
</script>
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator orientation="vertical" class="mr-2 h-4"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Torrents</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Torrents</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<div class="flex w-full flex-1 flex-col gap-4 p-4 pt-0">
<div
class="grid w-full auto-rows-min gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#await torrentsPromise}
Loading...
{:then torrents}
<Table.Root>
<Table.Caption>A list of the torrents.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Name</Table.Head>
<Table.Head>Download Status</Table.Head>
<Table.Head>Import Status</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each torrents as torrent}
<a href={'/dashboard/torrents/' + torrent.id}>
<Table.Row>
<Table.Cell class="font-medium">{torrent.title}</Table.Cell>
<Table.Cell>{getTorrentStatusString(torrent.status)}</Table.Cell>
<Table.Cell>{torrent.imported ? 'Yes' : 'No'}</Table.Cell>
</Table.Row>
</a>
{/each}
</Table.Body>
</Table.Root>
{/await}
</div>
<div class="flex w-full flex-1 flex-col items-center gap-4 p-4 pt-0">
{#await showsPromise}
Loading...
{:then shows}
<Accordion.Root type="single" class="w-full lg:max-w-[70%]">
{#each shows as show}
<div class="w-full rounded-xl bg-muted/50 p-6">
<Accordion.Item>
<Accordion.Trigger>
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
{getFullyQualifiedShowName(show)}
</h3>
</Accordion.Trigger>
<Accordion.Content>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[500px]">Name</Table.Head>
<Table.Head>Seasons</Table.Head>
<Table.Head>Download Status</Table.Head>
<Table.Head>Quality</Table.Head>
<Table.Head>File Path Suffix</Table.Head>
<Table.Head>Import Status</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each show.torrents as torrent}
<Table.Row>
<Table.Cell class="font-medium">
<a href={'/dashboard/torrents/' + torrent.torrent_id}>
{torrent.torrent_title}
</a>
</Table.Cell>
<Table.Cell>
<a href={'/dashboard/torrents/' + torrent.torrent_id}>
{torrent.seasons}
</a>
</Table.Cell>
<Table.Cell>
<a href={'/dashboard/torrents/' + torrent.torrent_id}>
{getTorrentStatusString(torrent.status)}
</a>
</Table.Cell>
<Table.Cell class="font-medium">
<a href={'/dashboard/torrents/' + torrent.torrent_id}>
{getTorrentQualityString(torrent.quality)}
</a>
</Table.Cell>
<Table.Cell class="font-medium">
<a href={'/dashboard/torrents/' + torrent.torrent_id}>
{torrent.file_path_suffix}
</a>
</Table.Cell>
<Table.Cell>
<a href={'/dashboard/torrents/' + torrent.torrent_id}>
{torrent.imported ? 'Imported' : 'Not Imported'}
</a>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Accordion.Content>
</Accordion.Item>
</div>
{:else}
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
You've not added any torrents yet.
</h3>
{/each}
</Accordion.Root>
{/await}
</div>

View File

@@ -4,12 +4,9 @@ import type {PageLoad} from './$types';
const apiUrl = env.PUBLIC_API_URL;
export const load: PageLoad = async ({fetch}) => {
const response = await fetch(apiUrl + '/torrent', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
return {torrents: response.json()};
const response = await fetch(apiUrl + '/tv/shows/torrents', {
method: 'GET',
credentials: 'include'
});
return {shows: response.json()};
};

View File

@@ -1,68 +1,78 @@
<script lang="ts">
import {page} from '$app/state';
import * as Card from '$lib/components/ui/card/index.js';
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import {toOptimizedURL} from 'sveltekit-image-optimize/components';
import {page} from '$app/state';
import * as Card from '$lib/components/ui/card/index.js';
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import {toOptimizedURL} from 'sveltekit-image-optimize/components';
import {getFullyQualifiedShowName} from '$lib/utils';
import {base} from '$app/paths';
import logo from '$lib/images/svelte-logo.svg';
import {Button} from "$lib/components/ui/button";
import {goto} from "$app/navigation";
let tvShowsPromise = page.data.tvShows;
let tvShowsPromise = page.data.tvShows;
</script>
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator orientation="vertical" class="mr-2 h-4"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Shows</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Shows</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<div class="flex w-full flex-1 flex-col gap-4 p-4 pt-0">
<div
class="grid w-full auto-rows-min gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#await tvShowsPromise}
Loading...
{:then tvShowsJson}
{#await tvShowsJson.json()}
Loading...
{:then tvShows}
{#each tvShows as show}
<a href={'/dashboard/tv/' + show.id}>
<Card.Root>
<Card.Header>
<Card.Title>{show.name}</Card.Title>
<Card.Description class="truncate">{show.overview}</Card.Description>
</Card.Header>
<Card.Content>
<img
class="aspect-9/16 h-auto max-w-full rounded-lg object-cover"
src={toOptimizedURL(`${env.PUBLIC_API_URL}/static/image/${show.id}.jpg`)}
alt="{show.name}'s Poster Image"
/>
</Card.Content>
<Card.Footer>
<p>Card Footer</p>
</Card.Footer>
</Card.Root>
</a>
{/each}
{/await}
{/await}
</div>
<Button class="w-full max-w-[200px]" onclick={()=>{goto("/dashboard/tv/add-show")}} variant="outline">
Add a Show
</Button>
<div
class="grid w-full auto-rows-min gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#await tvShowsPromise}
Loading...
{:then tvShowsJson}
{#await tvShowsJson.json()}
Loading...
{:then tvShows}
{#each tvShows as show}
<a href={'/dashboard/tv/' + show.id}>
<Card.Root class="h-full">
<Card.Header>
<Card.Title>{getFullyQualifiedShowName(show)}</Card.Title>
<Card.Description class="truncate">{show.overview}</Card.Description>
</Card.Header>
<Card.Content>
<img
class="aspect-9/16 center h-auto max-w-full rounded-lg object-cover"
src={toOptimizedURL(`${env.PUBLIC_API_URL}/static/image/${show.id}.jpg`)}
alt="{getFullyQualifiedShowName(show)}'s Poster Image"
on:error={(e) => {
e.target.src = logo;
}}
/>
</Card.Content>
</Card.Root>
</a>
{/each}
{/await}
{/await}
</div>
</div>

View File

@@ -2,43 +2,43 @@ import {env} from '$env/dynamic/public';
import type {LayoutServerLoad} from './$types';
export const load: LayoutServerLoad = async ({params, fetch}) => {
const showId = params.showId;
const showId = params.showId;
if (!showId) {
return {
showData: null,
error: 'Show ID is missing'
};
}
if (!showId) {
return {
showData: null,
error: 'Show ID is missing'
};
}
try {
const response = await fetch(`${env.PUBLIC_API_URL}/tv/shows/${showId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
try {
const response = await fetch(`${env.PUBLIC_API_URL}/tv/shows/${showId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (!response.ok) {
console.error(`Failed to fetch show ${showId}: ${response.statusText}`);
return {
showData: null,
error: `Failed to load show: ${response.statusText}`
};
}
if (!response.ok) {
console.error(`Failed to fetch show ${showId}: ${response.statusText}`);
return {
showData: null,
error: `Failed to load show: ${response.statusText}`
};
}
const showData = await response.json();
console.log('Fetched show data:', showData);
const showData = await response.json();
console.log('Fetched show data:', showData);
return {
showData: showData
};
} catch (error) {
console.error('Error fetching show:', error);
return {
showData: null,
error: 'An error occurred while fetching show data.'
};
}
return {
showData: showData
};
} catch (error) {
console.error('Error fetching show:', error);
return {
showData: null,
error: 'An error occurred while fetching show data.'
};
}
};

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import {setContext} from 'svelte';
import type {LayoutProps} from './$types';
import {setContext} from 'svelte';
import type {LayoutProps} from './$types';
let {data}: LayoutProps = $props();
let {data, children}: LayoutProps = $props();
const showData = $derived(data.showData);
setContext('show', showData);
const fetchError = $derived(data.error);
const showData = $derived(data.showData);
setContext('show', showData);
const fetchError = $derived(data.error);
</script>
{#if fetchError}
<p>Error loading show: {fetchError}</p>
<p>Error loading show: {fetchError}</p>
{:else if showData}
<slot/>
{@render children()}
{:else}
<p>Loading show data...</p>
<p>Loading show data...</p>
{/if}

View File

@@ -1,257 +1,261 @@
<script lang="ts">
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import {Input} from '$lib/components/ui/input';
import {Label} from '$lib/components/ui/label';
import {Button} from '$lib/components/ui/button';
import {ImageOff} from 'lucide-svelte';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import * as Select from '$lib/components/ui/select/index.js';
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import {Input} from '$lib/components/ui/input';
import {Label} from '$lib/components/ui/label';
import {Button} from '$lib/components/ui/button';
import {ImageOff} from 'lucide-svelte';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import * as Select from '$lib/components/ui/select/index.js';
import {buttonVariants} from '$lib/components/ui/button/index.js';
import {buttonVariants} from '$lib/components/ui/button/index.js';
import {getContext} from 'svelte';
import {goto} from '$app/navigation';
import type {PublicIndexerQueryResult, Show} from '$lib/types.js';
import {getContext} from 'svelte';
import {goto} from '$app/navigation';
import type {PublicIndexerQueryResult, Show} from '$lib/types.js';
import {getFullyQualifiedShowName} from '$lib/utils';
import {toOptimizedURL} from 'sveltekit-image-optimize/components';
let show: Show = getContext('show');
console.log('loaded show:', show);
import X from '@lucide/svelte/icons/x';
import Check from '@lucide/svelte/icons/check';
let selectedSeasonNumber: number = $state(1);
let torrents: PublicIndexerQueryResult[] = $state([]);
let isLoadingTorrents: boolean = $state(false);
let torrentsError: string | null = $state(null);
let queryOverride: string = $state('');
let filePathSuffix: string = $state('');
async function downloadTorrent(result_id: string) {
let url = new URL(env.PUBLIC_API_URL + '/tv/torrents');
url.searchParams.append('public_indexer_result_id', result_id);
url.searchParams.append('show_id', show.id);
if (filePathSuffix !== '') {
url.searchParams.append('file_path_suffix', filePathSuffix);
}
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
let show: Show = getContext('show');
console.log('loaded show:', show);
if (!response.ok) {
const errorMessage = `Failed to download torrent for show ${show.id} and season ${selectedSeasonNumber}: ${response.statusText}`;
console.error(errorMessage);
torrentsError = errorMessage;
return false;
}
let selectedSeasonNumber: number = $state(1);
let torrents: PublicIndexerQueryResult[] = $state([]);
let isLoadingTorrents: boolean = $state(false);
let torrentsError: string | null = $state(null);
let queryOverride: string = $state('');
let filePathSuffix: string = $state('');
const data: PublicIndexerQueryResult[] = await response.json();
console.log('Downloading torrent:', data);
async function downloadTorrent(result_id: string) {
let url = new URL(env.PUBLIC_API_URL + '/tv/torrents');
url.searchParams.append('public_indexer_result_id', result_id);
url.searchParams.append('show_id', show.id);
if (filePathSuffix !== '') {
url.searchParams.append('file_path_suffix', filePathSuffix);
}
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
return true;
} catch (err) {
const errorMessage = `Error downloading torrent: ${err instanceof Error ? err.message : 'An unknown error occurred'}`;
console.error(errorMessage);
return false;
}
}
if (!response.ok) {
const errorMessage = `Failed to download torrent for show ${show.id} and season ${selectedSeasonNumber}: ${response.statusText}`;
console.error(errorMessage);
torrentsError = errorMessage;
return false;
}
async function getTorrents(
season_number: number,
override: boolean = false
): Promise<PublicIndexerQueryResult[]> {
isLoadingTorrents = true;
torrentsError = null;
torrents = [];
const data: PublicIndexerQueryResult[] = await response.json();
console.log('Downloading torrent:', data);
let url = new URL(env.PUBLIC_API_URL + '/tv/torrents');
url.searchParams.append('show_id', show.id);
if (override) {
url.searchParams.append('search_query_override', queryOverride);
} else {
url.searchParams.append('season_number', season_number.toString());
}
return true;
} catch (err) {
const errorMessage = `Error downloading torrent: ${err instanceof Error ? err.message : 'An unknown error occurred'}`;
console.error(errorMessage);
return false;
}
}
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
async function getTorrents(
season_number: number,
override: boolean = false
): Promise<PublicIndexerQueryResult[]> {
isLoadingTorrents = true;
torrentsError = null;
torrents = [];
if (!response.ok) {
const errorMessage = `Failed to fetch torrents for show ${show.id} and season ${selectedSeasonNumber}: ${response.statusText}`;
console.error(errorMessage);
torrentsError = errorMessage;
return [];
}
let url = new URL(env.PUBLIC_API_URL + '/tv/torrents');
url.searchParams.append('show_id', show.id);
if (override) {
url.searchParams.append('search_query_override', queryOverride);
} else {
url.searchParams.append('season_number', season_number.toString());
}
const data: PublicIndexerQueryResult[] = await response.json();
console.log('Fetched torrents:', data);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
return data;
} catch (err) {
const errorMessage = `Error fetching torrents: ${err instanceof Error ? err.message : 'An unknown error occurred'}`;
console.error(errorMessage);
torrentsError = errorMessage;
return [];
} finally {
isLoadingTorrents = false;
}
}
if (!response.ok) {
const errorMessage = `Failed to fetch torrents for show ${show.id} and season ${selectedSeasonNumber}: ${response.statusText}`;
console.error(errorMessage);
torrentsError = errorMessage;
return [];
}
$effect(() => {
if (show?.id) {
console.log('selectedSeasonNumber changed:', selectedSeasonNumber);
getTorrents(selectedSeasonNumber).then((fetchedTorrents) => {
if (!isLoadingTorrents) {
torrents = fetchedTorrents;
} else if (fetchedTorrents.length > 0 || torrentsError) {
torrents = fetchedTorrents;
}
});
}
});
const data: PublicIndexerQueryResult[] = await response.json();
console.log('Fetched torrents:', data);
return data;
} catch (err) {
const errorMessage = `Error fetching torrents: ${err instanceof Error ? err.message : 'An unknown error occurred'}`;
console.error(errorMessage);
torrentsError = errorMessage;
return [];
} finally {
isLoadingTorrents = false;
}
}
$effect(() => {
if (show?.id) {
console.log('selectedSeasonNumber changed:', selectedSeasonNumber);
getTorrents(selectedSeasonNumber).then((fetchedTorrents) => {
if (!isLoadingTorrents) {
torrents = fetchedTorrents;
} else if (fetchedTorrents.length > 0 || torrentsError) {
torrents = fetchedTorrents;
}
});
}
});
</script>
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator orientation="vertical" class="mr-2 h-4"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard/tv">Shows</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page
>{show.name} {show.year == null ? '' : '(' + show.year + ')'}
</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard/tv">Shows</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>{getFullyQualifiedShowName(show)}</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
{show.name}
{show.year == null ? '' : '(' + show.year + ')'}
{getFullyQualifiedShowName(show)}
</h1>
<div class="flex flex-1 flex-col gap-4 p-4">
<div class="flex items-center gap-2">
<div class="max-h-50% w-1/3 max-w-sm rounded-xl bg-muted/50">
{#if show?.id}
<img
class="aspect-9/16 h-auto w-full rounded-lg object-cover"
src="{env.PUBLIC_API_URL}/static/image/{show.id}.jpg"
alt="{show.name}'s Poster Image"
/>
{:else}
<div
class="aspect-9/16 flex h-auto w-full items-center justify-center rounded-lg bg-gray-200 text-gray-500"
>
<ImageOff size={48}/>
</div>
{/if}
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4">
<p class="leading-7 [&:not(:first-child)]:mt-6">
{show.overview}
</p>
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4">
<Dialog.Root>
<Dialog.Trigger class={buttonVariants({ variant: 'default' })}
>Download Seasons
</Dialog.Trigger
>
<Dialog.Content class="max-h-[90vh] w-fit min-w-[80vw] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Download a Season</Dialog.Title>
<Dialog.Description>
Search and download torrents for a specific season or season packs.
</Dialog.Description>
</Dialog.Header>
<Tabs.Root value="basic" class="w-full">
<Tabs.List>
<Tabs.Trigger value="basic">Standard Mode</Tabs.Trigger>
<Tabs.Trigger value="advanced">Advanced Mode</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="basic">
<div class="grid w-full items-center gap-1.5">
{#if show?.seasons?.length > 0}
<Label for="season-number"
>Enter a season number from 1 to {show.seasons.at(-1).number}</Label
>
<Input
type="number"
class="max-w-sm"
id="season-number"
bind:value={selectedSeasonNumber}
max={show.seasons.at(-1).number}
/>
<p class="text-sm text-muted-foreground">
Enter the season's number you want to search for. The first, usually 1, or the
last season number usually yield the most season packs. Note that only Seasons
which are listed in the "Seasons" cell will be imported!
</p>
<Label for="file-suffix">Filepath suffix</Label>
<Select.Root type="single" bind:value={filePathSuffix} id="file-suffix">
<Select.Trigger class="w-[180px]">{filePathSuffix}</Select.Trigger>
<Select.Content>
<Select.Item value="">None</Select.Item>
<Select.Item value="2160P">2160p</Select.Item>
<Select.Item value="1080P">1080p</Select.Item>
<Select.Item value="720P">720p</Select.Item>
<Select.Item value="480P">480p</Select.Item>
<Select.Item value="360P">360p</Select.Item>
</Select.Content>
</Select.Root>
<p class="text-sm text-muted-foreground">
This is necessary to differentiate between versions of the same season/show, for
example a 1080p and a 4K version of a season.
</p>
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-sm text-muted-foreground" id="file-suffix-display">
/{show.name} ({show.year}) [{show.metadata_provider}id-{show.external_id}
]/Season
XX/{show.name} SXXEXX {filePathSuffix === '' ? '' : ' - ' + filePathSuffix}.mkv
</p>
{:else}
<p class="text-sm text-muted-foreground">
No season information available for this show.
</p>
{/if}
</div>
</Tabs.Content>
<Tabs.Content value="advanced">
<div class="grid w-full items-center gap-1.5">
{#if show?.seasons?.length > 0}
<Label for="query-override">Enter a custom query</Label>
<div class="flex w-full max-w-sm items-center space-x-2">
<Input type="text" id="query-override" bind:value={queryOverride}/>
<Button
variant="secondary"
onclick={async () => {
<div class="flex items-center gap-2">
<div class="max-h-50% w-1/3 max-w-sm rounded-xl bg-muted/50">
{#if show?.id}
<img
class="aspect-9/16 h-auto w-full rounded-lg object-cover"
src={toOptimizedURL(`${env.PUBLIC_API_URL}/static/image/${show.id}.jpg`)}
alt="{show.name}'s Poster Image"
/>
{:else}
<div
class="aspect-9/16 flex h-auto w-full items-center justify-center rounded-lg bg-gray-200 text-gray-500"
>
<ImageOff size={48}/>
</div>
{/if}
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4">
<p class="leading-7 [&:not(:first-child)]:mt-6">
{show.overview}
</p>
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4">
<Dialog.Root>
<Dialog.Trigger class={buttonVariants({ variant: 'default' })}
>Download Seasons
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] w-fit min-w-[80vw] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Download a Season</Dialog.Title>
<Dialog.Description>
Search and download torrents for a specific season or season packs.
</Dialog.Description>
</Dialog.Header>
<Tabs.Root class="w-full" value="basic">
<Tabs.List>
<Tabs.Trigger value="basic">Standard Mode</Tabs.Trigger>
<Tabs.Trigger value="advanced">Advanced Mode</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="basic">
<div class="grid w-full items-center gap-1.5">
{#if show?.seasons?.length > 0}
<Label for="season-number"
>Enter a season number from 1 to {show.seasons.at(-1).number}</Label
>
<Input
type="number"
class="max-w-sm"
id="season-number"
bind:value={selectedSeasonNumber}
max={show.seasons.at(-1).number}
/>
<p class="text-sm text-muted-foreground">
Enter the season's number you want to search for. The first, usually 1, or the
last season number usually yield the most season packs. Note that only Seasons
which are listed in the "Seasons" cell will be imported!
</p>
<Label for="file-suffix">Filepath suffix</Label>
<Select.Root type="single" bind:value={filePathSuffix} id="file-suffix">
<Select.Trigger class="w-[180px]">{filePathSuffix}</Select.Trigger>
<Select.Content>
<Select.Item value="">None</Select.Item>
<Select.Item value="2160P">2160p</Select.Item>
<Select.Item value="1080P">1080p</Select.Item>
<Select.Item value="720P">720p</Select.Item>
<Select.Item value="480P">480p</Select.Item>
<Select.Item value="360P">360p</Select.Item>
</Select.Content>
</Select.Root>
<p class="text-sm text-muted-foreground">
This is necessary to differentiate between versions of the same season/show, for
example a 1080p and a 4K version of a season.
</p>
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-sm text-muted-foreground" id="file-suffix-display">
/{getFullyQualifiedShowName(show)} [{show.metadata_provider}
id-{show.external_id}
]/Season XX/{show.name} SXXEXX {filePathSuffix === ''
? ''
: ' - ' + filePathSuffix}.mkv
</p>
{:else}
<p class="text-sm text-muted-foreground">
No season information available for this show.
</p>
{/if}
</div>
</Tabs.Content>
<Tabs.Content value="advanced">
<div class="grid w-full items-center gap-1.5">
{#if show?.seasons?.length > 0}
<Label for="query-override">Enter a custom query</Label>
<div class="flex w-full max-w-sm items-center space-x-2">
<Input type="text" id="query-override" bind:value={queryOverride}/>
<Button
variant="secondary"
onclick={async () => {
isLoadingTorrents = true;
torrentsError = null;
torrents = [];
@@ -264,140 +268,152 @@
isLoadingTorrents = false;
}
}}
>
Search
</Button>
</div>
<p class="text-sm text-muted-foreground">
The custom query will override the default search string like "The Simpsons
Season 3". Note that only Seasons which are listed in the "Seasons" cell will be
imported!
</p>
<Label for="file-suffix">Filepath suffix</Label>
<Input
type="text"
class="max-w-sm"
id="file-suffix"
bind:value={filePathSuffix}
placeholder="1080P"
/>
<p class="text-sm text-muted-foreground">
This is necessary to differentiate between versions of the same season/show, for
example a 1080p and a 4K version of a season.
</p>
>
Search
</Button>
</div>
<p class="text-sm text-muted-foreground">
The custom query will override the default search string like "The Simpsons
Season 3". Note that only Seasons which are listed in the "Seasons" cell will be
imported!
</p>
<Label for="file-suffix">Filepath suffix</Label>
<Input
type="text"
class="max-w-sm"
id="file-suffix"
bind:value={filePathSuffix}
placeholder="1080P"
/>
<p class="text-sm text-muted-foreground">
This is necessary to differentiate between versions of the same season/show, for
example a 1080p and a 4K version of a season.
</p>
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-sm text-muted-foreground" id="file-suffix-display">
/{show.name} ({show.year}) [{show.metadata_provider}id-{show.external_id}
]/Season
XX/{show.name} SXXEXX {filePathSuffix === '' ? '' : ' - ' + filePathSuffix}.mkv
</p>
{:else}
<p class="text-sm text-muted-foreground">
No season information available for this show.
</p>
{/if}
</div>
</Tabs.Content>
</Tabs.Root>
<div class="mt-4 items-center">
{#if isLoadingTorrents}
<div class="flex w-full max-w-sm items-center space-x-2">
<LoaderCircle class="animate-spin"/>
<p>Loading torrents...</p>
</div>
{:else if torrentsError}
<p class="text-red-500">Error: {torrentsError}</p>
{:else if torrents.length > 0}
<h3 class="mb-2 text-lg font-semibold">Found Torrents:</h3>
<div class="max-h-[200px] overflow-y-auto rounded-md border p-2">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Size</Table.Head>
<Table.Head>Seeders</Table.Head>
<Table.Head>Indexer Flags</Table.Head>
<Table.Head>Seasons</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each torrents as torrent (torrent.id)}
<Table.Row>
<Table.Cell
class="max-w-[300px] font-medium">{torrent.title}</Table.Cell>
<Table.Cell>{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB
</Table.Cell>
<Table.Cell>{torrent.seeders}</Table.Cell>
<Table.Cell>
{#each torrent.flags as flag}
{flag},&nbsp;
{/each}
</Table.Cell>
<Table.Cell>
{#if torrent.season.length === 1}
{torrent.season[0]}
{:else}
{torrent.season.at(0)}{torrent.season.at(-1)}
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<Button
size="sm"
variant="outline"
onclick={() => {
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-sm text-muted-foreground" id="file-suffix-display">
/{getFullyQualifiedShowName(show)} [{show.metadata_provider}
id-{show.external_id}
]/Season XX/{show.name} SXXEXX {filePathSuffix === ''
? ''
: ' - ' + filePathSuffix}.mkv
</p>
{:else}
<p class="text-sm text-muted-foreground">
No season information available for this show.
</p>
{/if}
</div>
</Tabs.Content>
</Tabs.Root>
<div class="mt-4 items-center">
{#if isLoadingTorrents}
<div class="flex w-full max-w-sm items-center space-x-2">
<LoaderCircle class="animate-spin"/>
<p>Loading torrents...</p>
</div>
{:else if torrentsError}
<p class="text-red-500">Error: {torrentsError}</p>
{:else if torrents.length > 0}
<h3 class="mb-2 text-lg font-semibold">Found Torrents:</h3>
<div class="max-h-[200px] overflow-y-auto rounded-md border p-2">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Size</Table.Head>
<Table.Head>Seeders</Table.Head>
<Table.Head>Indexer Flags</Table.Head>
<Table.Head>Seasons</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each torrents as torrent (torrent.id)}
<Table.Row>
<Table.Cell
class="max-w-[300px] font-medium">{torrent.title}</Table.Cell>
<Table.Cell>{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB
</Table.Cell>
<Table.Cell>{torrent.seeders}</Table.Cell>
<Table.Cell>
{#each torrent.flags as flag}
{flag},&nbsp;
{/each}
</Table.Cell>
<Table.Cell>
{#if torrent.season.length === 1}
{torrent.season[0]}
{:else}
{torrent.season.at(0)}{torrent.season.at(-1)}
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<Button
size="sm"
variant="outline"
onclick={() => {
downloadTorrent(torrent.id);
}}
>
Download
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else if show?.seasons?.length > 0}
<p>No torrents found for season {selectedSeasonNumber}. Try a different season.</p>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
</div>
</div>
<div class="min-h-[100vh] flex-1 rounded-xl bg-muted/50 p-4 md:min-h-min">
<div class="w-full overflow-x-auto">
<Table.Root>
<Table.Caption>A list of all seasons.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Number</Table.Head>
<Table.Head class="min-w-[50px]">Title</Table.Head>
<Table.Head>Overview</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if show?.seasons?.length > 0}
{#each show.seasons as season (season.id)}
<Table.Row
link={true}
onclick={() => goto('/dashboard/tv/' + show.id + '/' + season.number)}
>
<Table.Cell class="w-[100px] font-medium">{season.number}</Table.Cell>
<Table.Cell class="min-w-[50px]">{season.name}</Table.Cell>
<Table.Cell class="max-w-[200px] truncate">{season.overview}</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan="3" class="text-center">No season data available.</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
>
Download
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else if show?.seasons?.length > 0}
<p>No torrents found for season {selectedSeasonNumber}. Try a different season.</p>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
</div>
</div>
<div class="min-h-[100vh] flex-1 rounded-xl bg-muted/50 p-4 md:min-h-min">
<div class="w-full overflow-x-auto">
<Table.Root>
<Table.Caption>A list of all seasons.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>Number</Table.Head>
<Table.Head>Exists on file</Table.Head>
<Table.Head>Title</Table.Head>
<Table.Head>Overview</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if show?.seasons?.length > 0}
{#each show.seasons as season (season.id)}
<Table.Row
link={true}
onclick={() => goto('/dashboard/tv/' + show.id + '/' + season.number)}
>
<Table.Cell class="min-w-[10px] font-medium">{season.number}</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
{#if season.downloaded}
<Check class="stroke-green-500"/>
{:else}
<X class="stroke-rose-600"/>
{/if}
</Table.Cell>
<Table.Cell class="min-w-[50px]">{season.name}</Table.Cell>
<Table.Cell class="max-w-[300px] truncate">{season.overview}</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan="3" class="text-center">No season data available.</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
</div>

View File

@@ -1,93 +1,93 @@
<script lang="ts">
import {page} from '$app/state';
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import {getContext} from 'svelte';
import type {Season, Show} from '$lib/types';
import {page} from '$app/state';
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import {getContext} from 'svelte';
import type {Season, Show} from '$lib/types';
const SeasonNumber = page.params.SeasonNumber;
let show: Show = getContext('show');
let season: Season;
show.seasons.forEach((item) => {
if (item.number === parseInt(SeasonNumber)) season = item;
});
const SeasonNumber = page.params.SeasonNumber;
let show: Show = getContext('show');
let season: Season;
show.seasons.forEach((item) => {
if (item.number === parseInt(SeasonNumber)) season = item;
});
console.log('loaded ', show);
console.log('loaded ', show);
</script>
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator orientation="vertical" class="mr-2 h-4"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard/tv">Shows</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard/tv/{show.id}">
{show.name}
{show.year == null ? '' : '(' + show.year + ')'}
</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Season {SeasonNumber}</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard/tv">Shows</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard/tv/{show.id}">
{show.name}
{show.year == null ? '' : '(' + show.year + ')'}
</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Season {SeasonNumber}</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
{show.name}
{show.year == null ? '' : '(' + show.year + ')'} Season {SeasonNumber}
{show.name}
{show.year == null ? '' : '(' + show.year + ')'} Season {SeasonNumber}
</h1>
<div class="flex flex-1 flex-col gap-4 p-4">
<div class="flex items-center gap-2">
<div class="max-h-50% w-1/3 max-w-sm rounded-xl bg-muted/50">
<img
class="aspect-9/16 h-auto w-full rounded-lg object-cover"
src="{env.PUBLIC_API_URL}/static/image/{show.id}.jpg"
alt="{show.name}'s Poster Image"
/>
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4">
<p class="leading-7 [&:not(:first-child)]:mt-6">
{show.overview}
</p>
</div>
</div>
<div class="min-h-[100vh] flex-1 rounded-xl bg-muted/50 p-4 md:min-h-min">
<div class="w-full overflow-x-auto">
<Table.Root>
<Table.Caption>A list of all episodes.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Number</Table.Head>
<Table.Head class="min-w-[50px]">Title</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each season.episodes as episode (episode.id)}
<Table.Row>
<Table.Cell class="w-[100px] font-medium">{episode.number}</Table.Cell>
<Table.Cell class="min-w-[50px]">{episode.title}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center gap-2">
<div class="max-h-50% w-1/3 max-w-sm rounded-xl bg-muted/50">
<img
class="aspect-9/16 h-auto w-full rounded-lg object-cover"
src="{env.PUBLIC_API_URL}/static/image/{show.id}.jpg"
alt="{show.name}'s Poster Image"
/>
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4">
<p class="leading-7 [&:not(:first-child)]:mt-6">
{show.overview}
</p>
</div>
</div>
<div class="min-h-[100vh] flex-1 rounded-xl bg-muted/50 p-4 md:min-h-min">
<div class="w-full overflow-x-auto">
<Table.Root>
<Table.Caption>A list of all episodes.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Number</Table.Head>
<Table.Head class="min-w-[50px]">Title</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each season.episodes as episode (episode.id)}
<Table.Row>
<Table.Cell class="w-[100px] font-medium">{episode.number}</Table.Cell>
<Table.Cell class="min-w-[50px]">{episode.title}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</div>
</div>

View File

@@ -1,174 +1,172 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import {Input} from '$lib/components/ui/input';
import {Label} from '$lib/components/ui/label';
import {Button} from '$lib/components/ui/button';
import {ChevronDown, ImageOff} from 'lucide-svelte';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import type {MetaDataProviderShowSearchResult} from '$lib/types.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import {Input} from '$lib/components/ui/input';
import {Label} from '$lib/components/ui/label';
import {Button} from '$lib/components/ui/button';
import {ChevronDown, ImageOff} from 'lucide-svelte';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import type {MetaDataProviderShowSearchResult} from '$lib/types.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
let searchTerm: string = $state('');
let metadataProvider: string = $state('tmdb');
let results:
| (MetaDataProviderShowSearchResult & { added?: boolean; downloaded?: boolean })[]
| null = $state(null);
let searchTerm: string = $state('');
let metadataProvider: string = $state('tmdb');
let results:
| (MetaDataProviderShowSearchResult & { added?: boolean; downloaded?: boolean })[]
| null = $state(null);
async function search() {
if (searchTerm.length > 0) {
let url = new URL(env.PUBLIC_API_URL + '/tv/search');
url.searchParams.append('query', searchTerm);
url.searchParams.append('metadata_provider', metadataProvider);
const response = await fetch(url, {
method: 'GET',
credentials: 'include'
});
results = await response.json();
} else {
results = null;
}
}
async function search() {
if (searchTerm.length > 0) {
let url = new URL(env.PUBLIC_API_URL + '/tv/search');
url.searchParams.append('query', searchTerm);
url.searchParams.append('metadata_provider', metadataProvider);
const response = await fetch(url, {
method: 'GET',
credentials: 'include'
});
results = await response.json();
} else {
results = null;
}
}
async function addShow(show: MetaDataProviderShowSearchResult & { added?: boolean }) {
let url = new URL(env.PUBLIC_API_URL + '/tv/shows');
url.searchParams.append('show_id', String(show.external_id));
url.searchParams.append('metadata_provider', show.metadata_provider);
const response = await fetch(url, {
method: 'POST',
credentials: 'include'
});
async function addShow(show: MetaDataProviderShowSearchResult & { added?: boolean }) {
let url = new URL(env.PUBLIC_API_URL + '/tv/shows');
url.searchParams.append('show_id', String(show.external_id));
url.searchParams.append('metadata_provider', show.metadata_provider);
const response = await fetch(url, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
if (results) {
const index = results.findIndex(
(item) =>
item.external_id === show.external_id &&
item.metadata_provider === show.metadata_provider
);
if (index !== -1) {
results[index].added = true;
results = [...results];
}
}
}
return response;
}
if (response.ok) {
if (results) {
const index = results.findIndex(
(item) =>
item.external_id === show.external_id &&
item.metadata_provider === show.metadata_provider
);
if (index !== -1) {
results[index].added = true;
results = [...results];
}
}
}
return response;
}
</script>
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator orientation="vertical" class="mr-2 h-4"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard/tv">Shows</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Add a Show</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard/tv">Shows</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Add a Show</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<div class="flex w-full flex-1 flex-col items-center justify-center gap-4 p-4 pt-0">
<div class="grid w-full max-w-sm items-center gap-12">
<h1
class="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"
>
Add a show
</h1>
<section>
<Label for="search-box">Show Name</Label>
<Input bind:value={searchTerm} type="text" id="search-box" placeholder="Show Name"/>
<p class="text-sm text-muted-foreground">Search for a Show to add.</p>
</section>
<section>
<Collapsible.Root class="w-[350px] space-y-2">
<Collapsible.Trigger>
<div class="flex items-center justify-between space-x-4 px-4">
<h4 class="text-sm font-semibold">Advanced Settings</h4>
<Button variant="ghost" size="sm" class="w-9 p-0">
<ChevronDown/>
<span class="sr-only">Toggle</span>
</Button>
</div>
</Collapsible.Trigger>
<Collapsible.Content class="space-y-2">
<Label for="metadata-provider-selector">Choose which Metadata Provider to query.</Label>
<RadioGroup.Root id="metadata-provider-selector" bind:value={metadataProvider}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="tmdb" id="option-one"/>
<Label for="option-one">TMDB (Recommended)</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="tvdb" id="option-two"/>
<Label for="option-two">TVDB</Label>
</div>
</RadioGroup.Root>
</Collapsible.Content>
</Collapsible.Root>
</section>
<section>
<Button onclick={search} type="submit">Search</Button>
</section>
</div>
<div class="flex w-full flex-1 flex-col items-center gap-4 p-4 pt-0">
<div class="grid w-full max-w-sm items-center gap-12">
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
<Separator class="my-8"/>
Add a show
</h1>
<section>
<Label for="search-box">Show Name</Label>
<Input bind:value={searchTerm} id="search-box" placeholder="Show Name" type="text"/>
<p class="text-sm text-muted-foreground">Search for a Show to add.</p>
</section>
<section>
<Collapsible.Root class="w-[350px] space-y-2">
<Collapsible.Trigger>
<div class="flex items-center justify-between space-x-4 px-4">
<h4 class="text-sm font-semibold">Advanced Settings</h4>
<Button class="w-9 p-0" size="sm" variant="ghost">
<ChevronDown/>
<span class="sr-only">Toggle</span>
</Button>
</div>
</Collapsible.Trigger>
<Collapsible.Content class="space-y-2">
<Label for="metadata-provider-selector">Choose which Metadata Provider to query.</Label>
<RadioGroup.Root bind:value={metadataProvider} id="metadata-provider-selector">
<div class="flex items-center space-x-2">
<RadioGroup.Item id="option-one" value="tmdb"/>
<Label for="option-one">TMDB (Recommended)</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item id="option-two" value="tvdb"/>
<Label for="option-two">TVDB</Label>
</div>
</RadioGroup.Root>
</Collapsible.Content>
</Collapsible.Root>
</section>
<section>
<Button onclick={search} type="submit">Search</Button>
</section>
</div>
{#if results != null}
{#if results.length === 0}
<h3 class="mx-auto">No Shows found.</h3>
{:else}
<div
class="grid w-full max-w-full auto-rows-min gap-4 sm:grid-cols-1
<Separator class="my-8"/>
{#if results != null}
{#if results.length === 0}
<h3 class="mx-auto">No Shows found.</h3>
{:else}
<div
class="grid w-full max-w-full auto-rows-min gap-4 sm:grid-cols-1
md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#each results as result (result.external_id)}
<Card.Root class="h-full max-w-sm">
<Card.Header>
<Card.Title>
{result.name}
{#if result.year != null}
({result.year})
{/if}
</Card.Title>
<Card.Description class="truncate">{result?.overview}</Card.Description>
</Card.Header>
<Card.Content>
{#if result.poster_path != null}
<img
class="h-auto max-w-full rounded-lg object-cover"
src={result.poster_path}
alt="{result.name}'s Poster Image"
/>
{:else}
<ImageOff/>
{/if}
</Card.Content>
<Card.Footer>
<Button onclick={() => addShow(result)} disabled={result.added}>
{result.added ? 'Show already exists' : 'Add Show'}
</Button>
</Card.Footer>
</Card.Root>
{/each}
</div>
{/if}
{/if}
>
{#each results as result (result.external_id)}
<Card.Root class="h-full max-w-sm">
<Card.Header>
<Card.Title>
{result.name}
{#if result.year != null}
({result.year})
{/if}
</Card.Title>
<Card.Description class="truncate">{result?.overview}</Card.Description>
</Card.Header>
<Card.Content>
{#if result.poster_path != null}
<img
class="h-auto max-w-full rounded-lg object-cover"
src={result.poster_path}
alt="{result.name}'s Poster Image"
/>
{:else}
<ImageOff/>
{/if}
</Card.Content>
<Card.Footer>
<Button onclick={() => addShow(result)} disabled={result.added}>
{result.added ? 'Show already exists' : 'Add Show'}
</Button>
</Card.Footer>
</Card.Root>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import LoginForm from '$lib/components/login-form.svelte';
import LoginForm from '$lib/components/login-form.svelte';
</script>
<div class="flex h-screen w-full items-center justify-center px-4">
<LoginForm/>
<LoginForm/>
</div>