Merge pull request #339 from maxdorninger/deduplicate-frontend-code

deduplicate code in the download movie/season dialogs
This commit is contained in:
Maximilian Dorninger
2026-01-03 16:30:20 +01:00
committed by GitHub
5 changed files with 307 additions and 342 deletions

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { buttonVariants } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { type Snippet } from 'svelte';
let {
open = $bindable(),
triggerText,
title,
description,
children
}: {
open: boolean;
triggerText: string;
title: string;
description: string;
children: Snippet;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Trigger class={buttonVariants({ variant: 'default' })}>{triggerText}</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] w-fit min-w-[80vw] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{description}
</Dialog.Description>
</Dialog.Header>
{@render children()}
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,19 +1,18 @@
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import { Badge } from '$lib/components/ui/badge';
import { ArrowDown, ArrowUp, LoaderCircle } from 'lucide-svelte';
import * as Dialog from '$lib/components/ui/dialog';
import * as Tabs from '$lib/components/ui/tabs';
import * as Table from '$lib/components/ui/table';
import client from '$lib/api';
import type { components } from '$lib/api/api';
import SelectFilePathSuffixDialog from '$lib/components/download-dialogs/select-file-path-suffix-dialog.svelte';
import { invalidateAll } from '$app/navigation';
import TorrentTable from '$lib/components/download-dialogs/torrent-table.svelte';
import SearchTabs from '$lib/components/download-dialogs/search-tabs.svelte';
import DownloadDialogWrapper from '$lib/components/download-dialogs/download-dialog-wrapper.svelte';
let { movie } = $props();
let { movie }: { movie: components['schemas']['Movie'] } = $props();
let dialogueState = $state(false);
let torrentsError: string | null = $state(null);
let queryOverride: string = $state('');
@@ -23,7 +22,6 @@
let torrentsData: any[] | null = $state(null);
let tabState: string = $state('basic');
let isLoading: boolean = $state(false);
let sortBy = $state({ col: 'score', ascending: false });
let advancedMode: boolean = $derived(tabState === 'advanced');
@@ -35,32 +33,12 @@
{ name: 'Indexer Flags', id: 'flags' }
];
function getSortedColumnState(column: string | undefined): boolean | null {
if (sortBy.col !== column) return null;
return sortBy.ascending;
}
function sortData(column?: string | undefined) {
if (column !== undefined) {
if (column === sortBy.col) {
sortBy.ascending = !sortBy.ascending;
} else {
sortBy = { col: column, ascending: true };
}
}
let modifier = sortBy.ascending ? 1 : -1;
torrentsData?.sort((a, b) =>
a[sortBy.col] < b[sortBy.col] ? -1 * modifier : a[sortBy.col] > b[sortBy.col] ? modifier : 0
);
}
async function downloadTorrent(result_id: string) {
torrentsError = null;
const { data, response } = await client.POST(`/api/v1/movies/{movie_id}/torrents`, {
params: {
path: {
movie_id: movie.id
movie_id: movie.id!
},
query: {
public_indexer_result_id: result_id,
@@ -96,7 +74,7 @@
search_query_override: advancedMode ? queryOverride : undefined
},
path: {
movie_id: movie.id
movie_id: movie.id!
}
}
})
@@ -105,135 +83,49 @@
toast.info('Searching for torrents...');
torrentsData = await torrentsPromise;
sortData();
toast.info('Found ' + torrentsData?.length + ' torrents.');
}
</script>
<Dialog.Root bind:open={dialogueState}>
<Dialog.Trigger class={buttonVariants({ variant: 'default' })}>Download Movie</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] w-fit min-w-[80vw] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Download a Movie</Dialog.Title>
<Dialog.Description>
Search and download torrents for a specific season or season packs.
</Dialog.Description>
</Dialog.Header>
<Tabs.Root class="w-full" bind:value={tabState}>
<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">
<Button
disabled={isLoading}
class="w-fit"
onclick={() => {
search();
}}
>
Search for Torrents
</Button>
</div>
</Tabs.Content>
<Tabs.Content value="advanced">
<div class="grid w-full items-center gap-1.5">
<Label for="query-override">Enter a custom query</Label>
<div class="flex w-full max-w-sm items-center space-x-2">
<Input bind:value={queryOverride} id="query-override" type="text" />
<Button
disabled={isLoading}
class="w-fit"
onclick={() => {
search();
}}
>
Search for Torrents
</Button>
</div>
<p class="text-sm text-muted-foreground">
The custom query will override the default search string like "A Minecraft Movie
(2025)".
</p>
</div>
</Tabs.Content>
</Tabs.Root>
{#if torrentsError}
<div class="my-2 w-full text-center text-red-500">An error occurred: {torrentsError}</div>
{/if}
<div class="mt-4 items-center">
{#await torrentsPromise}
<div class="flex w-full max-w-sm items-center space-x-2">
<LoaderCircle class="animate-spin" />
<p>Loading torrents...</p>
</div>
{:then}
<h3 class="mb-2 text-lg font-semibold">Found Torrents:</h3>
<div class="overflow-y-auto rounded-md border p-2">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
{#each tableColumnHeadings as { name, id } (id)}
<Table.Head onclick={() => sortData(id)} class="cursor-pointer">
<div class="inline-flex items-center">
{name}
{#if getSortedColumnState(id) === true}
<ArrowUp />
{:else if getSortedColumnState(id) === false}
<ArrowDown />
{:else}
<!-- Preserve layout (column width) when no sort is applied -->
<ArrowUp class="invisible"></ArrowUp>
{/if}
</div>
</Table.Head>
{/each}
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each torrentsData 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>{torrent.score}</Table.Cell>
<Table.Cell>{torrent.indexer ?? 'Unknown'}</Table.Cell>
<Table.Cell>
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
</Table.Cell>
<Table.Cell class="text-right">
<SelectFilePathSuffixDialog
media={movie}
bind:filePathSuffix
callback={() => downloadTorrent(torrent.id!)}
/>
</Table.Cell>
</Table.Row>
{:else}
{#if torrentsData === null}
<Table.Cell colspan={7}>
<div class="font-light text-center w-full">
Start searching by clicking the search button!
</div>
</Table.Cell>
{:else}
<Table.Cell colspan={7}>
<div class="font-light text-center w-full">No torrents found.</div>
</Table.Cell>
{/if}
{/each}
</Table.Body>
</Table.Root>
</div>
{:catch error}
<div class="w-full text-center text-red-500">Failed to load torrents.</div>
<div class="w-full text-center text-red-500">Error: {error.message}</div>
{/await}
</div>
</Dialog.Content>
</Dialog.Root>
<DownloadDialogWrapper
bind:open={dialogueState}
triggerText="Download Movie"
title="Download a Movie"
description="Search and download torrents for a specific season or season packs."
>
<SearchTabs
bind:tabState
{isLoading}
bind:queryOverride
onSearch={search}
advancedModeHelpText="The custom query will override the default search string like 'A Minecraft Movie (2025)'."
>
{#snippet basicModeContent()}
<Button disabled={isLoading} class="w-fit" onclick={search}>Search for Torrents</Button>
{/snippet}
</SearchTabs>
{#if torrentsError}
<div class="my-2 w-full text-center text-red-500">An error occurred: {torrentsError}</div>
{/if}
<TorrentTable {torrentsPromise} columns={tableColumnHeadings}>
{#snippet rowSnippet(torrent)}
<Table.Cell class="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>{torrent.score}</Table.Cell>
<Table.Cell>{torrent.indexer ?? 'Unknown'}</Table.Cell>
<Table.Cell>
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
</Table.Cell>
<Table.Cell class="text-right">
<SelectFilePathSuffixDialog
media={movie}
bind:filePathSuffix
callback={() => downloadTorrent(torrent.id)}
/>
</Table.Cell>
{/snippet}
</TorrentTable>
</DownloadDialogWrapper>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { toast } from 'svelte-sonner';
@@ -7,15 +7,15 @@
convertTorrentSeasonRangeToIntegerRange,
formatSecondsToOptimalUnit
} from '$lib/utils.ts';
import { ArrowDown, ArrowUp, LoaderCircle } from 'lucide-svelte';
import * as Dialog from '$lib/components/ui/dialog';
import * as Tabs from '$lib/components/ui/tabs';
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import client from '$lib/api';
import type { components } from '$lib/api/api';
import SelectFilePathSuffixDialog from '$lib/components/download-dialogs/select-file-path-suffix-dialog.svelte';
import { invalidateAll } from '$app/navigation';
import TorrentTable from '$lib/components/download-dialogs/torrent-table.svelte';
import SearchTabs from '$lib/components/download-dialogs/search-tabs.svelte';
import DownloadDialogWrapper from '$lib/components/download-dialogs/download-dialog-wrapper.svelte';
let { show }: { show: components['schemas']['Show'] } = $props();
let dialogueState = $state(false);
@@ -28,7 +28,6 @@
let torrentsData: any[] | null = $state(null);
let tabState: string = $state('basic');
let isLoading: boolean = $state(false);
let sortBy = $state({ col: 'score', ascending: false });
let advancedMode: boolean = $derived(tabState === 'advanced');
@@ -43,26 +42,6 @@
{ name: 'Seasons', id: 'season' }
];
function getSortedColumnState(column: string | undefined): boolean | null {
if (sortBy.col !== column) return null;
return sortBy.ascending;
}
function sortData(column?: string | undefined) {
if (column !== undefined) {
if (column === sortBy.col) {
sortBy.ascending = !sortBy.ascending;
} else {
sortBy = { col: column, ascending: true };
}
}
let modifier = sortBy.ascending ? 1 : -1;
torrentsData?.sort((a, b) =>
a[sortBy.col] < b[sortBy.col] ? -1 * modifier : a[sortBy.col] > b[sortBy.col] ? modifier : 0
);
}
async function downloadTorrent(result_id: string) {
torrentsError = null;
const { response } = await client.POST('/api/v1/tv/torrents', {
@@ -110,166 +89,80 @@
toast.info('Searching for torrents...');
torrentsData = await torrentsPromise;
sortData();
toast.info('Found ' + torrentsData?.length + ' torrents.');
}
</script>
<Dialog.Root bind:open={dialogueState}>
<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" bind:value={tabState}>
<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">
<Label for="season-number">
Enter a season number from 1 to {show.seasons.at(-1)?.number}
</Label>
<div class="flex w-full max-w-sm items-center space-x-2">
<Input
type="number"
id="season-number"
bind:value={selectedSeasonNumber}
max={show.seasons.at(-1)?.number}
/>
<Button
disabled={isLoading}
class="w-fit"
onclick={() => {
search();
}}
>
Search for Torrents
</Button>
</div>
<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>
</div>
</Tabs.Content>
<Tabs.Content value="advanced">
<div class="grid w-full items-center gap-1.5">
<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
disabled={isLoading}
class="w-fit"
onclick={() => {
search();
}}
>
Search for Torrents
</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>
</div>
</Tabs.Content>
</Tabs.Root>
{#if torrentsError}
<div class="my-2 w-full text-center text-red-500">An error occurred: {torrentsError}</div>
{/if}
<div class="mt-4 items-center">
{#await torrentsPromise}
<div class="flex w-full max-w-sm items-center space-x-2">
<LoaderCircle class="animate-spin" />
<p>Loading torrents...</p>
</div>
{:then}
<h3 class="mb-2 text-lg font-semibold">Found Torrents:</h3>
<div class="overflow-y-auto rounded-md border p-2">
<Table.Root class="torrentResult">
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
{#each tableColumnHeadings as { name, id } (id)}
<Table.Head onclick={() => sortData(id)} class="cursor-pointer">
<div class="inline-flex items-center">
{name}
{#if getSortedColumnState(id) === true}
<ArrowUp />
{:else if getSortedColumnState(id) === false}
<ArrowDown />
{:else}
<!-- Preserve layout (column width) when no sort is applied -->
<ArrowUp class="invisible"></ArrowUp>
{/if}
</div>
</Table.Head>
{/each}
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each torrentsData 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.usenet}</Table.Cell>
<Table.Cell>{torrent.usenet ? 'N/A' : torrent.seeders}</Table.Cell>
<Table.Cell
>{torrent.age
? formatSecondsToOptimalUnit(torrent.age)
: torrent.usenet
? 'N/A'
: ''}</Table.Cell
>
<Table.Cell>{torrent.score}</Table.Cell>
<Table.Cell>{torrent.indexer ?? 'unknown'}</Table.Cell>
<Table.Cell>
{#if torrent.flags}
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
{/if}
</Table.Cell>
<Table.Cell>
{#if torrent.season}
{convertTorrentSeasonRangeToIntegerRange(torrent.season)}
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<SelectFilePathSuffixDialog
bind:filePathSuffix
media={show}
callback={() => downloadTorrent(torrent.id!)}
/>
</Table.Cell>
</Table.Row>
{:else}
{#if torrentsData === null}
<Table.Cell colspan={7}>
<div class="font-light text-center w-full">
Start searching by clicking the search button!
</div>
</Table.Cell>
{:else}
<Table.Cell colspan={7}>
<div class="font-light text-center w-full">No torrents found.</div>
</Table.Cell>
{/if}
{/each}
</Table.Body>
</Table.Root>
</div>
{:catch error}
<div class="w-full text-center text-red-500">Failed to load torrents.</div>
<div class="w-full text-center text-red-500">Error: {error.message}</div>
{/await}
</div>
</Dialog.Content>
</Dialog.Root>
<DownloadDialogWrapper
bind:open={dialogueState}
triggerText="Download Seasons"
title="Download a Season"
description="Search and download torrents for a specific season or season packs."
>
<SearchTabs
bind:tabState
{isLoading}
bind:queryOverride
onSearch={search}
advancedModeHelpText="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!"
>
{#snippet basicModeContent()}
<Label for="season-number">
Enter a season number from 1 to {show.seasons.at(-1)?.number}
</Label>
<div class="flex w-full max-w-sm items-center space-x-2">
<Input
type="number"
id="season-number"
bind:value={selectedSeasonNumber}
max={show.seasons.at(-1)?.number}
/>
<Button disabled={isLoading} class="w-fit" onclick={search}>Search for Torrents</Button>
</div>
<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>
{/snippet}
</SearchTabs>
{#if torrentsError}
<div class="my-2 w-full text-center text-red-500">An error occurred: {torrentsError}</div>
{/if}
<TorrentTable {torrentsPromise} columns={tableColumnHeadings}>
{#snippet rowSnippet(torrent)}
<Table.Cell class="font-medium">{torrent.title}</Table.Cell>
<Table.Cell>{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB</Table.Cell>
<Table.Cell>{torrent.usenet}</Table.Cell>
<Table.Cell>{torrent.usenet ? 'N/A' : torrent.seeders}</Table.Cell>
<Table.Cell
>{torrent.age
? formatSecondsToOptimalUnit(torrent.age)
: torrent.usenet
? 'N/A'
: ''}</Table.Cell
>
<Table.Cell>{torrent.score}</Table.Cell>
<Table.Cell>{torrent.indexer ?? 'unknown'}</Table.Cell>
<Table.Cell>
{#if torrent.flags}
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
{/if}
</Table.Cell>
<Table.Cell>
{#if torrent.season}
{convertTorrentSeasonRangeToIntegerRange(torrent.season)}
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<SelectFilePathSuffixDialog
bind:filePathSuffix
media={show}
callback={() => downloadTorrent(torrent.id)}
/>
</Table.Cell>
{/snippet}
</TorrentTable>
</DownloadDialogWrapper>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import * as Tabs from '$lib/components/ui/tabs';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { type Snippet } from 'svelte';
let {
tabState = $bindable(),
isLoading,
queryOverride = $bindable(),
onSearch,
basicModeContent,
advancedModeHelpText
}: {
tabState: string;
isLoading: boolean;
queryOverride: string;
onSearch: () => void;
basicModeContent: Snippet;
advancedModeHelpText: string;
} = $props();
</script>
<Tabs.Root class="w-full" bind:value={tabState}>
<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">
{@render basicModeContent()}
</div>
</Tabs.Content>
<Tabs.Content value="advanced">
<div class="grid w-full items-center gap-1.5">
<Label for="query-override">Enter a custom query</Label>
<div class="flex w-full max-w-sm items-center space-x-2">
<Input bind:value={queryOverride} id="query-override" type="text" />
<Button disabled={isLoading} class="w-fit" onclick={onSearch}>Search for Torrents</Button>
</div>
<p class="text-sm text-muted-foreground">
{advancedModeHelpText}
</p>
</div>
</Tabs.Content>
</Tabs.Root>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { ArrowDown, ArrowUp, LoaderCircle } from 'lucide-svelte';
import * as Table from '$lib/components/ui/table';
import { type Snippet } from 'svelte';
let {
torrentsPromise,
columns,
rowSnippet
}: {
torrentsPromise: Promise<any>;
columns: { name: string; id: string }[];
rowSnippet: Snippet<[any]>;
} = $props();
let sortBy = $state({ col: 'score', ascending: false });
function getSortedColumnState(column: string | undefined): boolean | null {
if (sortBy.col !== column) return null;
return sortBy.ascending;
}
function toggleSort(column: string) {
if (column === sortBy.col) {
sortBy.ascending = !sortBy.ascending;
} else {
sortBy = { col: column, ascending: true };
}
}
function sort(data: any[], column: string, ascending: boolean): any[] {
let modifier = ascending ? 1 : -1;
return [...data].sort((a, b) => {
if (a[column] < b[column]) {
return -1 * modifier;
} else if (a[column] > b[column]) {
return modifier;
} else {
return 0;
}
});
}
</script>
<div class="mt-4 items-center">
{#await torrentsPromise}
<div class="flex w-full max-w-sm items-center space-x-2">
<LoaderCircle class="animate-spin" />
<p>Loading torrents...</p>
</div>
{:then data}
<h3 class="mb-2 text-lg font-semibold">Found Torrents:</h3>
<div class="overflow-y-auto rounded-md border p-2">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
{#each columns as column (column.id)}
<Table.Head onclick={() => toggleSort(column.id)} class="cursor-pointer">
<div class="inline-flex items-center">
{column.name}
{#if getSortedColumnState(column.id) === true}
<ArrowUp />
{:else if getSortedColumnState(column.id) === false}
<ArrowDown />
{:else}
<!-- Preserve layout (column width) when no sort is applied -->
<ArrowUp class="invisible"></ArrowUp>
{/if}
</div>
</Table.Head>
{/each}
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if data}
{@const sortedData = sort(data, sortBy.col, sortBy.ascending)}
{#each sortedData as torrent (torrent.id)}
<Table.Row>
{@render rowSnippet(torrent)}
</Table.Row>
{:else}
<Table.Cell colspan={columns.length + 2}>
<div class="font-light text-center w-full">No torrents found.</div>
</Table.Cell>
{/each}
{:else}
<Table.Cell colspan={columns.length + 2}>
<div class="w-full text-center font-light">
Start searching by clicking the search button!
</div>
</Table.Cell>
{/if}
</Table.Body>
</Table.Root>
</div>
{:catch error}
<div class="w-full text-center text-red-500">Failed to load torrents.</div>
<div class="w-full text-center text-red-500">Error: {error.message}</div>
{/await}
</div>