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

@@ -4,7 +4,12 @@
import TvIcon from '@lucide/svelte/icons/tv';
import LayoutPanelLeft from '@lucide/svelte/icons/layout-panel-left';
import DownloadIcon from '@lucide/svelte/icons/download';
import Sun from "@lucide/svelte/icons/sun";
import Moon from "@lucide/svelte/icons/moon";
import {resetMode, setMode} from "mode-watcher";
import {buttonVariants} from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
const data = {
navMain: [
{
@@ -18,7 +23,7 @@
url: '/dashboard/tv'
},
{
title: 'Add Show',
title: 'Add a show',
url: '/dashboard/tv/add-show'
},
{

View File

@@ -1,37 +1,54 @@
<script lang="ts">
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import type {ComponentProps} from 'svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import type {ComponentProps} from 'svelte';
import Sun from "@lucide/svelte/icons/sun";
import Moon from "@lucide/svelte/icons/moon";
let {
ref = $bindable(null),
items,
...restProps
}: {
items: {
title: string;
url: string;
// This should be `Component` after @lucide/svelte updates types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: any;
}[];
} & ComponentProps<typeof Sidebar.Group> = $props();
import {toggleMode} from "mode-watcher";
import {Button} from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
items,
...restProps
}: {
items: {
title: string;
url: string;
// This should be `Component` after @lucide/svelte updates types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: any;
}[];
} & ComponentProps<typeof Sidebar.Group> = $props();
</script>
<Sidebar.Group bind:ref {...restProps}>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each items as item (item.title)}
<Sidebar.MenuItem>
<Sidebar.MenuButton size="sm">
{#snippet child({props})}
<a href={item.url} {...props}>
<item.icon/>
<span>{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
<Sidebar.GroupContent>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton size="sm">
{#snippet child({props})}
<div on:click={()=>toggleMode()} {...props}>
<Sun class="dark:hidden "/>
<Moon class="hidden dark:inline"/>
<span>Toggle mode</span>
</div>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{#each items as item (item.title)}
<Sidebar.MenuItem>
<Sidebar.MenuButton size="sm">
{#snippet child({props})}
<a href={item.url} {...props}>
<item.icon/>
<span>{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>

View File

@@ -40,9 +40,9 @@
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="end"
class="w-[var(--bits-dropdown-menu-anchor-width)] min-w-56 rounded-lg"
side={sidebar.isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenu.Label class="p-0 font-normal">

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import {Accordion as AccordionPrimitive, type WithoutChild} from 'bits-ui';
import {cn} from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
</script>
<AccordionPrimitive.Content
bind:ref
class={cn(
'overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
className
)}
{...restProps}
>
<div class="pb-4 pt-0">
{@render children?.()}
</div>
</AccordionPrimitive.Content>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import {Accordion as AccordionPrimitive} from 'bits-ui';
import {cn} from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AccordionPrimitive.ItemProps = $props();
</script>
<AccordionPrimitive.Item {...restProps} bind:ref class={cn('border-b', className)}/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import {Accordion as AccordionPrimitive, type WithoutChild} from 'bits-ui';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import {cn} from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
level = 3,
children,
...restProps
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
level?: AccordionPrimitive.HeaderProps['level'];
} = $props();
</script>
<AccordionPrimitive.Header class="flex" {level}>
<AccordionPrimitive.Trigger
bind:ref
class={cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDown class="size-4 shrink-0 text-muted-foreground transition-transform duration-200"/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>

View File

@@ -0,0 +1,17 @@
import {Accordion as AccordionPrimitive} from 'bits-ui';
import Content from './accordion-content.svelte';
import Item from './accordion-item.svelte';
import Trigger from './accordion-trigger.svelte';
const Root = AccordionPrimitive.Root;
export {
Root,
Content,
Item,
Trigger,
//
Root as Accordion,
Content as AccordionContent,
Item as AccordionItem,
Trigger as AccordionTrigger
};

View File

@@ -1,22 +1,22 @@
<script lang="ts">
import Ellipsis from '@lucide/svelte/icons/ellipsis';
import type {WithElementRef, WithoutChildren} from 'bits-ui';
import type {HTMLAttributes} from 'svelte/elements';
import {cn} from '$lib/utils.js';
import Ellipsis from '@lucide/svelte/icons/ellipsis';
import type {WithElementRef, WithoutChildren} from 'bits-ui';
import type {HTMLAttributes} from 'svelte/elements';
import {cn} from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
role="presentation"
aria-hidden="true"
class={cn('flex size-9 items-center justify-center', className)}
{...restProps}
{...restProps}
aria-hidden="true"
bind:this={ref}
class={cn('flex size-9 items-center justify-center', className)}
role="presentation"
>
<Ellipsis class="size-4"/>
<span class="sr-only">More</span>

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import type {WithElementRef} from 'bits-ui';
import type {HTMLAttributes} from 'svelte/elements';
import {cn} from '$lib/utils.js';
import type {WithElementRef} from 'bits-ui';
import type {HTMLAttributes} from 'svelte/elements';
import {cn} from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
role="link"
aria-disabled="true"
aria-current="page"
class={cn('font-normal text-foreground', className)}
{...restProps}
{...restProps}
aria-current="page"
aria-disabled="true"
bind:this={ref}
class={cn('font-normal text-foreground', className)}
role="link"
>
{@render children?.()}
</span>

View File

@@ -1,33 +1,33 @@
<script lang="ts">
import {DropdownMenu as DropdownMenuPrimitive, type WithoutChildrenOrChild} from 'bits-ui';
import Check from '@lucide/svelte/icons/check';
import Minus from '@lucide/svelte/icons/minus';
import {cn} from '$lib/utils.js';
import type {Snippet} from 'svelte';
import {DropdownMenu as DropdownMenuPrimitive, type WithoutChildrenOrChild} from 'bits-ui';
import Check from '@lucide/svelte/icons/check';
import Minus from '@lucide/svelte/icons/minus';
import {cn} from '$lib/utils.js';
import type {Snippet} from 'svelte';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
checked = $bindable(false),
indeterminate = $bindable(false),
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
ref = $bindable(null),
class: className,
children: childrenProp,
checked = $bindable(false),
indeterminate = $bindable(false),
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
class={cn(
{...restProps}
bind:checked
bind:indeterminate
bind:ref
class={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
className
)}
{...restProps}
>
{#snippet children({checked, indeterminate})}
{#snippet children({checked, indeterminate})}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<Minus class="size-4"/>
@@ -35,6 +35,6 @@
<Check class={cn('size-4', !checked && 'text-transparent')}/>
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import type {HTMLAttributes} from 'svelte/elements';
import {type WithElementRef} from 'bits-ui';
import {cn} from '$lib/utils.js';
import type {HTMLAttributes} from 'svelte/elements';
import {type WithElementRef} from 'bits-ui';
import {cn} from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
class={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...restProps}
{...restProps}
bind:this={ref}
class={cn('ml-auto text-xs tracking-widest opacity-60', className)}
>
{@render children?.()}
</span>

View File

@@ -1,56 +1,56 @@
export interface User {
id: string;
email: string;
is_active?: boolean;
is_superuser?: boolean;
is_verified?: boolean;
id: string;
email: string;
is_active?: boolean;
is_superuser?: boolean;
is_verified?: boolean;
}
export interface BearerResponse {
access_token: string;
token_type: string;
access_token: string;
token_type: string;
}
export interface BodyAuthCookieLoginAuthCookieLoginPost {
grant_type?: string | null; // anyOf string, null, pattern ^password$ implied but not strongly typed in TS interface
username: string;
password: string;
scope?: string; // default: ""
client_id?: string | null; // anyOf string, null
client_secret?: string | null; // anyOf string, null
grant_type?: string | null; // anyOf string, null, pattern ^password$ implied but not strongly typed in TS interface
username: string;
password: string;
scope?: string; // default: ""
client_id?: string | null; // anyOf string, null
client_secret?: string | null; // anyOf string, null
}
export interface BodyAuthJwtLoginAuthJwtLoginPost {
grant_type?: string | null; // anyOf string, null, pattern ^password$ implied but not strongly typed in TS interface
username: string;
password: string;
scope?: string; // default: ""
client_id?: string | null; // anyOf string, null
client_secret?: string | null; // anyOf string, null
grant_type?: string | null; // anyOf string, null, pattern ^password$ implied but not strongly typed in TS interface
username: string;
password: string;
scope?: string; // default: ""
client_id?: string | null; // anyOf string, null
client_secret?: string | null; // anyOf string, null
}
export interface BodyResetForgotPasswordAuthForgotPasswordPost {
email: string; // format: email
email: string; // format: email
}
export interface BodyResetResetPasswordAuthResetPasswordPost {
token: string;
password: string;
token: string;
password: string;
}
export interface BodyVerifyRequestTokenAuthRequestVerifyTokenPost {
email: string; // format: email
email: string; // format: email
}
export interface BodyVerifyVerifyAuthVerifyPost {
token: string;
token: string;
}
export interface Episode {
number: number; // type: integer
external_id: number; // type: integer
title: string;
id?: string; // type: string, format: uuid
number: number; // type: integer
external_id: number; // type: integer
title: string;
id?: string; // type: string, format: uuid
}
export type Quality = 1 | 2 | 3 | 4 | 5;
@@ -59,72 +59,107 @@ export type TorrentStatus = 1 | 2 | 3 | 4;
// You likely want to export these maps and potentially helper functions too
export interface PublicIndexerQueryResult {
title: string;
quality: Quality; // $ref: #/components/schemas/Quality
id: string; // type: string, format: uuid
seeders: number; // type: integer
flags: string[]; // items: { type: string }, type: array
season: number[]; // items: { type: integer }, type: array
size: number;
title: string;
quality: Quality; // $ref: #/components/schemas/Quality
id: string; // type: string, format: uuid
seeders: number; // type: integer
flags: string[]; // items: { type: string }, type: array
season: number[]; // items: { type: integer }, type: array
size: number;
}
export interface Season {
number: number; // type: integer
name: string;
overview: string;
external_id: number; // type: integer
episodes: Episode[]; // items: { $ref: #/components/schemas/Episode }, type: array
id?: string; // type: string, format: uuid
number: number; // type: integer
name: string;
overview: string;
external_id: number; // type: integer
episodes: Episode[]; // items: { $ref: #/components/schemas/Episode }, type: array
id?: string; // type: string, format: uuid
}
export interface PublicSeason {
number: number; // type: integer
name: string;
downloaded: boolean;
overview: string;
external_id: number; // type: integer
episodes: Episode[]; // items: { $ref: #/components/schemas/Episode }, type: array
id?: string; // type: string, format: uuid
}
export interface SeasonRequest {
season_id: string; // type: string, format: uuid
min_quality: Quality; // $ref: #/components/schemas/Quality
wanted_quality: Quality; // $ref: #/components/schemas/Quality
season_id: string; // type: string, format: uuid
min_quality: Quality; // $ref: #/components/schemas/Quality
wanted_quality: Quality; // $ref: #/components/schemas/Quality
}
export interface Show {
name: string;
overview: string;
year: number; // type: integer
external_id: number; // type: integer
metadata_provider: string;
seasons: Season[]; // items: { $ref: #/components/schemas/Season }, type: array
id: string; // type: string, format: uuid
name: string;
overview: string;
year: number; // type: integer
external_id: number; // type: integer
metadata_provider: string;
seasons: Season[]; // items: { $ref: #/components/schemas/Season }, type: array
id: string; // type: string, format: uuid
}
export interface PublicShow {
name: string;
overview: string;
year: number; // type: integer
external_id: number; // type: integer
metadata_provider: string;
seasons: PublicSeason[]; // items: { $ref: #/components/schemas/Season }, type: array
id: string; // type: string, format: uuid
}
export interface Torrent {
status: TorrentStatus; // $ref: #/components/schemas/TorrentStatus
title: string;
quality: Quality; // $ref: #/components/schemas/Quality
imported: boolean;
hash: string;
id?: string; // type: string, format: uuid
status: TorrentStatus; // $ref: #/components/schemas/TorrentStatus
title: string;
quality: Quality; // $ref: #/components/schemas/Quality
imported: boolean;
hash: string;
id?: string; // type: string, format: uuid
}
export interface UserCreate {
email: string; // format: email
password: string;
is_active?: boolean | null; // anyOf boolean, null, default: true
is_superuser?: boolean | null; // anyOf boolean, null, default: false
is_verified?: boolean | null; // anyOf boolean, null, default: false
email: string; // format: email
password: string;
is_active?: boolean | null; // anyOf boolean, null, default: true
is_superuser?: boolean | null; // anyOf boolean, null, default: false
is_verified?: boolean | null; // anyOf boolean, null, default: false
}
export interface UserUpdate {
password?: string | null; // anyOf string, null
email?: string | null; // anyOf string, null, format: email
is_active?: boolean | null; // anyOf boolean, null
is_superuser?: boolean | null; // anyOf boolean, null
is_verified?: boolean | null; // anyOf boolean, null
// No required fields listed in the schema, so all are optional
password?: string | null; // anyOf string, null
email?: string | null; // anyOf string, null, format: email
is_active?: boolean | null; // anyOf boolean, null
is_superuser?: boolean | null; // anyOf boolean, null
is_verified?: boolean | null; // anyOf boolean, null
}
export interface MetaDataProviderShowSearchResult {
poster_path: string | null;
overview: string | null;
name: string;
external_id: number;
year: number | null;
metadata_provider: string;
added: boolean;
poster_path: string | null;
overview: string | null;
name: string;
external_id: number;
year: number | null;
metadata_provider: string;
added: boolean;
}
export interface RichSeasonTorrent {
torrent_id: string;
torrent_title: string;
status: TorrentStatus;
quality: Quality;
imported: boolean;
file_path_suffix: string;
seasons: number[];
}
export interface RichShowTorrent {
show_id: string;
name: string;
year: number | null;
metadata_provider: string;
torrents: RichSeasonTorrent[];
}

View File

@@ -19,10 +19,17 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function getQualityString(value: number): string {
export function getTorrentQualityString(value: number): string {
return qualityMap[value] || 'unknown';
}
export function getTorrentStatusString(value: number): string {
return torrentStatusMap[value] || 'unknown';
}
export function getFullyQualifiedShowName(show: { name: string; year: number }): string {
let name = show.name;
if (show.year != null) {
name += ' (' + show.year + ')';
}
return name;
}

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>