format files

This commit is contained in:
maxDorninger
2025-08-30 21:53:00 +02:00
parent 18c0b38c8d
commit e4b8596468
31 changed files with 2055 additions and 2114 deletions

View File

@@ -1,102 +1,100 @@
<script lang="ts">
import {Button} from '$lib/components/ui/button/index.js';
import {env} from '$env/dynamic/public';
import * as Card from '$lib/components/ui/card/index.js';
import {ImageOff} from 'lucide-svelte';
import {goto} from '$app/navigation';
import {base} from '$app/paths';
import type {MetaDataProviderSearchResult} from '$lib/types.js';
import {SvelteURLSearchParams} from 'svelte/reactivity';
import type {components} from "$lib/api/api";
import client from "$lib/api";
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { ImageOff } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import type { components } from '$lib/api/api';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
let loading = $state(false);
let errorMessage = $state<string | null>(null);
let {result, isShow = true}: { result: components['schemas']['MetaDataProviderSearchResult']; isShow: boolean } =
$props();
console.log('Add Show Card Result: ', result);
let loading = $state(false);
let errorMessage = $state<string | null>(null);
let {
result,
isShow = true
}: { result: components['schemas']['MetaDataProviderSearchResult']; isShow: boolean } = $props();
console.log('Add Show Card Result: ', result);
async function addMedia() {
loading = true;
let data;
if (isShow) {
const response = await client.POST("/api/v1/tv/shows", {
params: {
query: {
show_id: result.external_id,
metadata_provider: result.metadata_provider as "tmdb" | "tvdb"
}
}
});
data = response.data;
} else {
const response = await client.POST("/api/v1/movies", {
params: {
query: {
movie_id: result.external_id,
metadata_provider: result.metadata_provider as "tmdb" | "tvdb"
}
}
});
data = response.data;
}
await goto(`${base}/dashboard/${isShow ? 'tv' : 'movies'}/` + data?.id);
loading = false;
}
async function addMedia() {
loading = true;
let data;
if (isShow) {
const response = await client.POST('/api/v1/tv/shows', {
params: {
query: {
show_id: result.external_id,
metadata_provider: result.metadata_provider as 'tmdb' | 'tvdb'
}
}
});
data = response.data;
} else {
const response = await client.POST('/api/v1/movies', {
params: {
query: {
movie_id: result.external_id,
metadata_provider: result.metadata_provider as 'tmdb' | 'tvdb'
}
}
});
data = response.data;
}
await goto(`${base}/dashboard/${isShow ? 'tv' : 'movies'}/` + data?.id);
loading = false;
}
</script>
<Card.Root class="col-span-full flex h-full flex-col overflow-x-hidden sm:col-span-1">
<Card.Header>
<Card.Title class="flex h-12 items-center leading-tight">
{result.name}
{#if result.year != null}
({result.year})
{/if}
</Card.Title>
<Card.Description class="truncate"
>{result.overview !== '' ? result.overview : 'No overview available'}</Card.Description
>
</Card.Header>
<Card.Content class="flex flex-1 items-center justify-center">
{#if result.poster_path != null}
<img
class="h-full w-full rounded-lg object-contain"
src={result.poster_path}
alt="{result.name}'s Poster Image"
/>
{:else}
<div class="flex h-full w-full items-center justify-center">
<ImageOff class="h-12 w-12 text-gray-400"/>
</div>
{/if}
</Card.Content>
<Card.Footer class="bg-card flex flex-col items-start gap-2 rounded-b-lg border-t p-4">
<Button
class="w-full font-semibold"
disabled={result.added || loading}
onclick={() => addMedia()}
>
{#if loading}
<span class="animate-pulse">Loading...</span>
{:else}
{result.added ? 'Show already exists' : `Add ${isShow ? 'Show' : 'Movie'}`}
{/if}
</Button>
<div class="flex w-full items-center gap-2">
{#if result.vote_average != null}
<Card.Header>
<Card.Title class="flex h-12 items-center leading-tight">
{result.name}
{#if result.year != null}
({result.year})
{/if}
</Card.Title>
<Card.Description class="truncate"
>{result.overview !== '' ? result.overview : 'No overview available'}</Card.Description
>
</Card.Header>
<Card.Content class="flex flex-1 items-center justify-center">
{#if result.poster_path != null}
<img
class="h-full w-full rounded-lg object-contain"
src={result.poster_path}
alt="{result.name}'s Poster Image"
/>
{:else}
<div class="flex h-full w-full items-center justify-center">
<ImageOff class="h-12 w-12 text-gray-400" />
</div>
{/if}
</Card.Content>
<Card.Footer class="bg-card flex flex-col items-start gap-2 rounded-b-lg border-t p-4">
<Button
class="w-full font-semibold"
disabled={result.added || loading}
onclick={() => addMedia()}
>
{#if loading}
<span class="animate-pulse">Loading...</span>
{:else}
{result.added ? 'Show already exists' : `Add ${isShow ? 'Show' : 'Movie'}`}
{/if}
</Button>
<div class="flex w-full items-center gap-2">
{#if result.vote_average != null}
<span class="flex items-center text-sm font-medium text-yellow-600">
<svg class="mr-1 h-4 w-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"
><path
d="M10 15l-5.878 3.09 1.122-6.545L.488 6.91l6.561-.955L10 0l2.951 5.955 6.561.955-4.756 4.635 1.122 6.545z"
/></svg
>
><path
d="M10 15l-5.878 3.09 1.122-6.545L.488 6.91l6.561-.955L10 0l2.951 5.955 6.561.955-4.756 4.635 1.122 6.545z"
/></svg
>
Rating: {Math.round(result.vote_average)}/10
</span>
{/if}
</div>
{#if errorMessage}
<p class="w-full rounded bg-red-50 px-2 py-1 text-xs text-red-500">{errorMessage}</p>
{/if}
</Card.Footer>
{/if}
</div>
{#if errorMessage}
<p class="w-full rounded bg-red-50 px-2 py-1 text-xs text-red-500">{errorMessage}</p>
{/if}
</Card.Footer>
</Card.Root>

View File

@@ -1,162 +1,160 @@
<script lang="ts">
import {env} from '$env/dynamic/public';
import {Button, buttonVariants} from '$lib/components/ui/button/index.js';
import {Input} from '$lib/components/ui/input';
import {Label} from '$lib/components/ui/label';
import {toast} from 'svelte-sonner';
import {Badge} from '$lib/components/ui/badge/index.js';
import {SvelteURLSearchParams} from 'svelte/reactivity';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { toast } from 'svelte-sonner';
import { Badge } from '$lib/components/ui/badge/index.js';
import type {PublicIndexerQueryResult} from '$lib/types.js';
import {getFullyQualifiedMediaName} from '$lib/utils';
import {LoaderCircle} from 'lucide-svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import client from "$lib/api";
import type {components} from "$lib/api/api";
import { getFullyQualifiedMediaName } from '$lib/utils';
import { LoaderCircle } from 'lucide-svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import client from '$lib/api';
import type { components } from '$lib/api/api';
const apiUrl = env.PUBLIC_API_URL;
let {movie} = $props();
let dialogueState = $state(false);
let torrents: components["schemas"]["PublicIndexerQueryResult"][] = $state([]);
let isLoadingTorrents: boolean = $state(false);
let torrentsError: string | null = $state(null);
let queryOverride: string = $state('');
let filePathSuffix: string = $state('');
let { movie } = $props();
let dialogueState = $state(false);
let torrents: components['schemas']['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) {
const {data, response} = await client.POST(`/api/v1/movies/{movie_id}/torrents`, {
params: {
path: {
movie_id: movie.id
},
query: {
public_indexer_result_id: result_id,
override_file_path_suffix: filePathSuffix === '' ? undefined : filePathSuffix
}
}
})
if (response.status === 409) {
const errorMessage = `There already is a Movie File using the Filepath Suffix '${filePathSuffix}'. Try again with a different Filepath Suffix.`;
console.warn(errorMessage);
torrentsError = errorMessage;
if (dialogueState) toast.info(errorMessage);
return [];
} else if (!response.ok) {
const errorMessage = `Failed to download torrent for movie ${movie.id}: ${response.statusText}`;
console.error(errorMessage);
torrentsError = errorMessage;
toast.error(errorMessage);
return false;
} else {
console.log('Downloading torrent:', data);
toast.success('Torrent download started successfully!');
async function downloadTorrent(result_id: string) {
const { data, response } = await client.POST(`/api/v1/movies/{movie_id}/torrents`, {
params: {
path: {
movie_id: movie.id
},
query: {
public_indexer_result_id: result_id,
override_file_path_suffix: filePathSuffix === '' ? undefined : filePathSuffix
}
}
});
if (response.status === 409) {
const errorMessage = `There already is a Movie File using the Filepath Suffix '${filePathSuffix}'. Try again with a different Filepath Suffix.`;
console.warn(errorMessage);
torrentsError = errorMessage;
if (dialogueState) toast.info(errorMessage);
return [];
} else if (!response.ok) {
const errorMessage = `Failed to download torrent for movie ${movie.id}: ${response.statusText}`;
console.error(errorMessage);
torrentsError = errorMessage;
toast.error(errorMessage);
return false;
} else {
console.log('Downloading torrent:', data);
toast.success('Torrent download started successfully!');
return true;
}
}
return true;
}
}
async function getTorrents(override: boolean = false): Promise<components["schemas"]["PublicIndexerQueryResult"][]> {
isLoadingTorrents = true;
torrentsError = null;
torrents = [];
let {response, data} = await client.GET('/api/v1/movies/{movie_id}/torrents',{
params: {
query: {
search_query_override: override ? queryOverride : undefined
},
path: {
movie_id: movie.id
}
}
})
data = data as components["schemas"]["PublicIndexerQueryResult"][];
isLoadingTorrents = false;
async function getTorrents(
override: boolean = false
): Promise<components['schemas']['PublicIndexerQueryResult'][]> {
isLoadingTorrents = true;
torrentsError = null;
torrents = [];
let { response, data } = await client.GET('/api/v1/movies/{movie_id}/torrents', {
params: {
query: {
search_query_override: override ? queryOverride : undefined
},
path: {
movie_id: movie.id
}
}
});
data = data as components['schemas']['PublicIndexerQueryResult'][];
isLoadingTorrents = false;
if (!response.ok) {
const errorMessage = `Failed to fetch torrents for movie ${movie.id}: ${response.statusText}`;
torrentsError = errorMessage;
if (dialogueState) toast.error(errorMessage);
return [];
}
if (!response.ok) {
const errorMessage = `Failed to fetch torrents for movie ${movie.id}: ${response.statusText}`;
torrentsError = errorMessage;
if (dialogueState) toast.error(errorMessage);
return [];
}
if (dialogueState) {
if (data.length > 0) {
toast.success(`Found ${data.length} torrents.`);
} else {
toast.info('No torrents found for your query.');
}
}
return data;
}
if (dialogueState) {
if (data.length > 0) {
toast.success(`Found ${data.length} torrents.`);
} else {
toast.info('No torrents found for your query.');
}
}
return data;
}
$effect(() => {
if (movie?.id) {
getTorrents().then((fetchedTorrents) => {
if (!isLoadingTorrents) {
torrents = fetchedTorrents;
} else if (fetchedTorrents.length > 0 || torrentsError) {
torrents = fetchedTorrents;
}
});
}
});
$effect(() => {
if (movie?.id) {
getTorrents().then((fetchedTorrents) => {
if (!isLoadingTorrents) {
torrents = fetchedTorrents;
} else if (fetchedTorrents.length > 0 || torrentsError) {
torrents = fetchedTorrents;
}
});
}
});
</script>
{#snippet saveDirectoryPreview(movie: components["schemas"]["Movie"], filePathSuffix: string)}
/{getFullyQualifiedMediaName(movie)} [{movie.metadata_provider}id-{movie.external_id}
]/{movie.name}{filePathSuffix === '' ? '' : ' - ' + filePathSuffix}.mkv
{#snippet saveDirectoryPreview(movie: components['schemas']['Movie'], filePathSuffix: string)}
/{getFullyQualifiedMediaName(movie)} [{movie.metadata_provider}id-{movie.external_id}
]/{movie.name}{filePathSuffix === '' ? '' : ' - ' + filePathSuffix}.mkv
{/snippet}
<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" 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">
<Label for="file-suffix">Filepath suffix</Label>
<Select.Root bind:value={filePathSuffix} type="single">
<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-muted-foreground text-sm">
This is necessary to differentiate between versions of the same movie, for example a
1080p and a 4K version.
</p>
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-muted-foreground text-sm" id="file-suffix-display">
{@render saveDirectoryPreview(movie, filePathSuffix)}
</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 bind:value={queryOverride} id="query-override" type="text"/>
<Button
onclick={async () => {
<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" 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">
<Label for="file-suffix">Filepath suffix</Label>
<Select.Root bind:value={filePathSuffix} type="single">
<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-muted-foreground text-sm">
This is necessary to differentiate between versions of the same movie, for example a
1080p and a 4K version.
</p>
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-muted-foreground text-sm" id="file-suffix-display">
{@render saveDirectoryPreview(movie, filePathSuffix)}
</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 bind:value={queryOverride} id="query-override" type="text" />
<Button
onclick={async () => {
isLoadingTorrents = true;
torrentsError = null;
torrents = [];
@@ -168,90 +166,90 @@
isLoadingTorrents = false;
}
}}
variant="secondary"
>
Search
</Button>
</div>
<p class="text-muted-foreground text-sm">
The custom query will override the default search string like "A Minecraft Movie
(2025)".
</p>
<Label for="file-suffix">Filepath suffix</Label>
<Input
bind:value={filePathSuffix}
class="max-w-sm"
id="file-suffix"
placeholder="1080P"
type="text"
/>
<p class="text-muted-foreground text-sm">
This is necessary to differentiate between versions of the same movie, for example a
1080p and a 4K version.
</p>
variant="secondary"
>
Search
</Button>
</div>
<p class="text-muted-foreground text-sm">
The custom query will override the default search string like "A Minecraft Movie
(2025)".
</p>
<Label for="file-suffix">Filepath suffix</Label>
<Input
bind:value={filePathSuffix}
class="max-w-sm"
id="file-suffix"
placeholder="1080P"
type="text"
/>
<p class="text-muted-foreground text-sm">
This is necessary to differentiate between versions of the same movie, for example a
1080p and a 4K version.
</p>
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-muted-foreground text-sm" id="file-suffix-display">
{@render saveDirectoryPreview(movie, filePathSuffix)}
</p>
</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="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>Score</Table.Head>
<Table.Head>Indexer Flags</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>{torrent.score}</Table.Cell>
<Table.Cell>
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
</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-muted-foreground text-sm" id="file-suffix-display">
{@render saveDirectoryPreview(movie, filePathSuffix)}
</p>
</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="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>Score</Table.Head>
<Table.Head>Indexer Flags</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>{torrent.score}</Table.Cell>
<Table.Cell>
{#each torrent.flags as flag (flag)}
<Badge variant="outline">{flag}</Badge>
{/each}
</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}
<p>No torrents found!</p>
{/if}
</div>
</Dialog.Content>
>
Download
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<p>No torrents found!</p>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -92,10 +92,7 @@
}
</script>
{#snippet saveDirectoryPreview(
show: components['schemas']['Show'],
filePathSuffix: string
)}
{#snippet saveDirectoryPreview(show: components['schemas']['Show'], filePathSuffix: string)}
/{getFullyQualifiedMediaName(show)} [{show.metadata_provider}id-{show.external_id}]/ Season XX/{show.name}
SXXEXX {filePathSuffix === '' ? '' : ' - ' + filePathSuffix}.mkv
{/snippet}

View File

@@ -1,126 +1,122 @@
<script lang="ts">
import {Button} from '$lib/components/ui/button/index.js';
import * as Command from '$lib/components/ui/command/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import {cn} from '$lib/utils.js';
import {tick} from 'svelte';
import {CheckIcon, ChevronsUpDownIcon} from 'lucide-svelte';
import type {LibraryItem, PublicMovie, PublicShow} from '$lib/types.js';
import {onMount} from 'svelte';
import {env} from '$env/dynamic/public';
import {toast} from 'svelte-sonner';
import {SvelteURLSearchParams} from 'svelte/reactivity';
import client from "$lib/api";
import type {components} from "$lib/api/api";
import { Button } from '$lib/components/ui/button/index.js';
import * as Command from '$lib/components/ui/command/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import { cn } from '$lib/utils.js';
import { tick } from 'svelte';
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-svelte';
import type { PublicMovie, PublicShow } from '$lib/types.js';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import client from '$lib/api';
import type { components } from '$lib/api/api';
const apiUrl = env.PUBLIC_API_URL;
let {
media,
mediaType
}: {
media: PublicShow | PublicMovie;
mediaType: 'tv' | 'movie';
} = $props();
let {
media,
mediaType
}: {
media: PublicShow | PublicMovie;
mediaType: 'tv' | 'movie';
} = $props();
let open = $state(false);
let value = $derived(media.library === '' ? 'Default' : media.library);
let libraries: components['schemas']['LibraryItem'][] = $state([]);
let triggerRef: HTMLButtonElement = $state(null!);
const selectedLabel: string = $derived(
libraries.find((item) => item.name === value)?.name ?? 'Default'
);
onMount(async () => {
const tvLibraries = await client.GET('/api/v1/tv/shows/libraries');
const movieLibraries = await client.GET('/api/v1/movies/libraries');
let open = $state(false);
let value = $derived(media.library === '' ? 'Default' : media.library);
let libraries: components["schemas"]["LibraryItem"][] = $state([]);
let triggerRef: HTMLButtonElement = $state(null!);
const selectedLabel: string = $derived(
libraries.find((item) => item.name === value)?.name ?? 'Default'
);
onMount(async () => {
const tvLibraries = await client.GET("/api/v1/tv/shows/libraries");
const movieLibraries = await client.GET("/api/v1/movies/libraries");
if (mediaType === 'tv') {
libraries = tvLibraries.data;
} else {
libraries = movieLibraries.data;
}
if (mediaType === "tv"){
libraries = tvLibraries.data
}else{
libraries = movieLibraries.data
}
if (!value && libraries.length > 0) {
value = 'Default';
}
libraries.push({
name: 'Default',
path: 'Default'
} as components['schemas']['LibraryItem']);
});
if (!value && libraries.length > 0) {
value = 'Default';
}
libraries.push({
name: 'Default',
path: 'Default'
} as components["schemas"]["LibraryItem"]);
});
async function handleSelect() {
open = false;
await tick();
triggerRef.focus();
let response;
if (mediaType === 'tv') {
response = await client.POST('/api/v1/tv/shows/{show_id}/library', {
params: {
path: { show_id: media.id },
query: { library: selectedLabel }
}
});
} else {
response = await client.POST('/api/v1/movies/{movie_id}/library', {
params: {
path: { movie_id: media.id },
query: { library: selectedLabel }
}
});
}
if (response.error) {
toast.error('Failed to update library');
} else {
toast.success(`Library updated to ${selectedLabel}`);
media.library = selectedLabel;
}
}
async function handleSelect() {
open = false;
await tick();
triggerRef.focus();
const endpoint =
mediaType === 'tv' ? `/tv/shows/${media.id}/library` : `/movies/${media.id}/library`;
const urlParams = new SvelteURLSearchParams();
urlParams.append('library', selectedLabel);
const urlString = `${apiUrl}${endpoint}?${urlParams.toString()}`;
try {
const response = await fetch(urlString, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
toast.success(`Library updated to ${selectedLabel}`);
media.library = selectedLabel;
} else {
const errorText = await response.text();
toast.error(`Failed to update library: ${errorText}`);
}
} catch (error) {
toast.error('Error updating library.');
console.error(error);
}
}
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef.focus();
});
}
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef.focus();
});
}
</script>
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef}>
{#snippet child({props})}
<Button
{...props}
variant="outline"
class="w-[200px] justify-between"
role="combobox"
aria-expanded={open}
>
Select Library
<ChevronsUpDownIcon class="opacity-50"/>
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[200px] p-0">
<Command.Root>
<Command.Input placeholder="Search library..."/>
<Command.List>
<Command.Empty>No library found.</Command.Empty>
<Command.Group value="libraries">
{#each libraries as item (item.name)}
<Command.Item
value={item.name}
onSelect={() => {
<Popover.Trigger bind:ref={triggerRef}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-[200px] justify-between"
role="combobox"
aria-expanded={open}
>
Select Library
<ChevronsUpDownIcon class="opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[200px] p-0">
<Command.Root>
<Command.Input placeholder="Search library..." />
<Command.List>
<Command.Empty>No library found.</Command.Empty>
<Command.Group value="libraries">
{#each libraries as item (item.name)}
<Command.Item
value={item.name}
onSelect={() => {
value = item.name;
handleSelect();
closeAndFocusTrigger();
}}
>
<CheckIcon class={cn(value !== item.name && 'text-transparent')}/>
{item.name}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
>
<CheckIcon class={cn(value !== item.name && 'text-transparent')} />
{item.name}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>

View File

@@ -1,151 +1,146 @@
<script lang="ts">
import {Button} from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import {Input} from '$lib/components/ui/input/index.js';
import {Label} from '$lib/components/ui/label/index.js';
import {goto} from '$app/navigation';
import {env} from '$env/dynamic/public';
import {toast} from 'svelte-sonner';
import {base} from '$app/paths';
import * as Alert from '$lib/components/ui/alert/index.js';
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
import LoadingBar from '$lib/components/loading-bar.svelte';
import {SvelteURLSearchParams} from 'svelte/reactivity';
import client from "$lib/api";
import type {components} from "$lib/api/api";
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { base } from '$app/paths';
import * as Alert from '$lib/components/ui/alert/index.js';
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
import LoadingBar from '$lib/components/loading-bar.svelte';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
let {
oauthProvider
}: {
oauthProvider: {
oauth_name: string;
};
} = $props();
let {
oauthProvider
}: {
oauthProvider: {
oauth_name: string;
};
} = $props();
let email = $state('');
let password = $state('');
let errorMessage = $state('');
let isLoading = $state(false);
let email = $state('');
let password = $state('');
let errorMessage = $state('');
let isLoading = $state(false);
async function handleLogin(event: Event) {
event.preventDefault();
async function handleLogin(event: Event) {
event.preventDefault();
isLoading = true;
errorMessage = '';
isLoading = true;
errorMessage = '';
const { response } = await client.POST('/api/v1/auth/cookie/login', {
requestBody: {
content: {
'application/x-www-form-urlencoded': {
username: email,
password: password
}
}
}
});
isLoading = false;
const {response} = await client.POST('/api/v1/auth/cookie/login', {
requestBody: {
content: {
'application/x-www-form-urlencoded': {
username: email,
password: password
}
}
},
});
isLoading = false;
if (response.ok) {
console.log('Login successful!');
console.log('Received User Data: ', response);
errorMessage = 'Login successful! Redirecting...';
toast.success(errorMessage);
goto(base + '/dashboard');
} else {
let errorText = await response.text();
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.message || 'Login failed. Please check your credentials.';
} catch {
errorMessage = errorText || 'Login failed. Please check your credentials.';
}
toast.error(errorMessage);
console.error('Login failed:', response.status, errorText);
}
}
if (response.ok) {
console.log('Login successful!');
console.log('Received User Data: ', response);
errorMessage = 'Login successful! Redirecting...';
toast.success(errorMessage);
goto(base + '/dashboard');
} else {
let errorText = await response.text();
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.message || 'Login failed. Please check your credentials.';
} catch {
errorMessage = errorText || 'Login failed. Please check your credentials.';
}
toast.error(errorMessage);
console.error('Login failed:', response.status, errorText);
}
}
async function handleOauth() {
const {response, data, error} = await client.GET("/api/v1/auth/cookie/OpenID/authorize", {
params: {
query: {
scopes: 'email'
}
}
});
if (response.ok) {
window.location = data.authorization_url;
} else {
toast.error(data);
}
}
async function handleOauth() {
const { response, data } = await client.GET('/api/v1/auth/cookie/OpenID/authorize', {
params: {
query: {
scopes: 'email'
}
}
});
if (response.ok) {
window.location = data.authorization_url;
} else {
toast.error(data);
}
}
</script>
<Card.Root class="mx-auto max-w-sm">
<Card.Header>
<Card.Title class="text-2xl">Login</Card.Title>
<Card.Description>Enter your email below to log in to your account</Card.Description>
</Card.Header>
<Card.Content>
<form class="grid gap-4" onsubmit={handleLogin}>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
autocomplete="email"
bind:value={email}
id="email"
placeholder="m@example.com"
required
type="email"
/>
</div>
<div class="grid gap-2">
<div class="flex items-center">
<Label for="password">Password</Label>
<a class="ml-auto inline-block text-sm underline" href="{base}/login/forgot-password">
Forgot your password?
</a>
</div>
<Input
autocomplete="current-password"
bind:value={password}
id="password"
required
type="password"
/>
</div>
<Card.Header>
<Card.Title class="text-2xl">Login</Card.Title>
<Card.Description>Enter your email below to log in to your account</Card.Description>
</Card.Header>
<Card.Content>
<form class="grid gap-4" onsubmit={handleLogin}>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
autocomplete="email"
bind:value={email}
id="email"
placeholder="m@example.com"
required
type="email"
/>
</div>
<div class="grid gap-2">
<div class="flex items-center">
<Label for="password">Password</Label>
<a class="ml-auto inline-block text-sm underline" href="{base}/login/forgot-password">
Forgot your password?
</a>
</div>
<Input
autocomplete="current-password"
bind:value={password}
id="password"
required
type="password"
/>
</div>
{#if errorMessage}
<Alert.Root variant="destructive">
<AlertCircleIcon class="size-4"/>
<Alert.Title>Error</Alert.Title>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Root>
{/if}
{#if isLoading}
<LoadingBar/>
{/if}
<Button class="w-full" disabled={isLoading} type="submit">Login</Button>
</form>
{#await oauthProvider}
<LoadingBar/>
{:then result}
{#if result.oauth_name != null}
<div
class="after:border-border relative mt-2 text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
>
{#if errorMessage}
<Alert.Root variant="destructive">
<AlertCircleIcon class="size-4" />
<Alert.Title>Error</Alert.Title>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Root>
{/if}
{#if isLoading}
<LoadingBar />
{/if}
<Button class="w-full" disabled={isLoading} type="submit">Login</Button>
</form>
{#await oauthProvider}
<LoadingBar />
{:then result}
{#if result.oauth_name != null}
<div
class="after:border-border relative mt-2 text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
>
<span class="bg-background text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<Button class="mt-2 w-full" onclick={() => handleOauth()} variant="outline"
>Login with {result.oauth_name}</Button
>
{/if}
{/await}
<div class="mt-4 text-center text-sm">
<Button href="{base}/login/signup/" variant="link">Don't have an account? Sign up</Button>
</div>
</Card.Content>
</div>
<Button class="mt-2 w-full" onclick={() => handleOauth()} variant="outline"
>Login with {result.oauth_name}</Button
>
{/if}
{/await}
<div class="mt-4 text-center text-sm">
<Button href="{base}/login/signup/" variant="link">Don't have an account? Sign up</Button>
</div>
</Card.Content>
</Card.Root>

View File

@@ -1,11 +1,10 @@
<script lang="ts">
import type { MetaDataProviderSearchResult } from '$lib/types';
import AddMediaCard from '$lib/components/add-media-card.svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
import { Button } from '$lib/components/ui/button';
import { ChevronRight } from 'lucide-svelte';
import { base } from '$app/paths';
import type {components} from "$lib/api/api";
import type { components } from '$lib/api/api';
let {
media,

View File

@@ -1,118 +1,115 @@
<script lang="ts">
import {env} from '$env/dynamic/public';
import {Button, buttonVariants} from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import {Label} from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select/index.js';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import type {PublicMovie, Quality} from '$lib/types.js';
import {getFullyQualifiedMediaName, getTorrentQualityString} from '$lib/utils.js';
import {toast} from 'svelte-sonner';
import client from "$lib/api";
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select/index.js';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import type { PublicMovie, Quality } from '$lib/types.js';
import { getFullyQualifiedMediaName, getTorrentQualityString } from '$lib/utils.js';
import { toast } from 'svelte-sonner';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
let {movie}: { movie: PublicMovie } = $props();
let dialogOpen = $state(false);
let minQuality = $state<string | undefined>(undefined);
let wantedQuality = $state<string | undefined>(undefined);
let isSubmittingRequest = $state(false);
let submitRequestError = $state<string | null>(null);
let { movie }: { movie: PublicMovie } = $props();
let dialogOpen = $state(false);
let minQuality = $state<string | undefined>(undefined);
let wantedQuality = $state<string | undefined>(undefined);
let isSubmittingRequest = $state(false);
let submitRequestError = $state<string | null>(null);
const qualityValues: Quality[] = [1, 2, 3, 4];
let qualityOptions = $derived(
qualityValues.map((q) => ({value: q.toString(), label: getTorrentQualityString(q)}))
);
let isFormInvalid = $derived(
!minQuality || !wantedQuality || parseInt(wantedQuality) > parseInt(minQuality)
);
const qualityValues: Quality[] = [1, 2, 3, 4];
let qualityOptions = $derived(
qualityValues.map((q) => ({ value: q.toString(), label: getTorrentQualityString(q) }))
);
let isFormInvalid = $derived(
!minQuality || !wantedQuality || parseInt(wantedQuality) > parseInt(minQuality)
);
async function handleRequestMovie() {
isSubmittingRequest = true;
submitRequestError = null;
const {response} = await client.POST("/api/v1/movies/requests", {
body: {
movie_id: movie.id,
min_quality: parseInt(minQuality!),
wanted_quality: parseInt(wantedQuality!)
}
}
)
isSubmittingRequest = false;
async function handleRequestMovie() {
isSubmittingRequest = true;
submitRequestError = null;
const { response } = await client.POST('/api/v1/movies/requests', {
body: {
movie_id: movie.id,
min_quality: parseInt(minQuality!),
wanted_quality: parseInt(wantedQuality!)
}
});
isSubmittingRequest = false;
if (response.ok) {
dialogOpen = false;
minQuality = undefined;
wantedQuality = undefined;
toast.success('Movie request submitted successfully!');
} else {
toast.error("Failed to submit request");
}
}
if (response.ok) {
dialogOpen = false;
minQuality = undefined;
wantedQuality = undefined;
toast.success('Movie request submitted successfully!');
} else {
toast.error('Failed to submit request');
}
}
</script>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Trigger
class={buttonVariants({ variant: 'default' })}
onclick={() => {
<Dialog.Trigger
class={buttonVariants({ variant: 'default' })}
onclick={() => {
dialogOpen = true;
}}
>
Request Movie
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] w-fit min-w-[clamp(300px,50vw,600px)] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Request {getFullyQualifiedMediaName(movie)}</Dialog.Title>
<Dialog.Description>Select desired qualities to submit a request.</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<!-- Min Quality Select -->
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="min-quality">Min Quality</Label>
<Select.Root bind:value={minQuality} type="single">
<Select.Trigger class="w-full" id="min-quality">
{minQuality ? getTorrentQualityString(parseInt(minQuality)) : 'Select Minimum Quality'}
</Select.Trigger>
<Select.Content>
{#each qualityOptions as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
>
Request Movie
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] w-fit min-w-[clamp(300px,50vw,600px)] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Request {getFullyQualifiedMediaName(movie)}</Dialog.Title>
<Dialog.Description>Select desired qualities to submit a request.</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<!-- Min Quality Select -->
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="min-quality">Min Quality</Label>
<Select.Root bind:value={minQuality} type="single">
<Select.Trigger class="w-full" id="min-quality">
{minQuality ? getTorrentQualityString(parseInt(minQuality)) : 'Select Minimum Quality'}
</Select.Trigger>
<Select.Content>
{#each qualityOptions as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Wanted Quality Select -->
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="wanted-quality">Wanted Quality</Label>
<Select.Root bind:value={wantedQuality} type="single">
<Select.Trigger class="w-full" id="wanted-quality">
{wantedQuality
? getTorrentQualityString(parseInt(wantedQuality))
: 'Select Wanted Quality'}
</Select.Trigger>
<Select.Content>
{#each qualityOptions as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Wanted Quality Select -->
<div class="grid grid-cols-[1fr_3fr] items-center gap-4 md:grid-cols-[100px_1fr]">
<Label class="text-right" for="wanted-quality">Wanted Quality</Label>
<Select.Root bind:value={wantedQuality} type="single">
<Select.Trigger class="w-full" id="wanted-quality">
{wantedQuality
? getTorrentQualityString(parseInt(wantedQuality))
: 'Select Wanted Quality'}
</Select.Trigger>
<Select.Content>
{#each qualityOptions as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if submitRequestError}
<p class="col-span-full text-center text-sm text-red-500">{submitRequestError}</p>
{/if}
</div>
<Dialog.Footer>
<Button disabled={isSubmittingRequest} onclick={() => (dialogOpen = false)} variant="outline"
>Cancel
</Button>
<Button disabled={isFormInvalid || isSubmittingRequest} onclick={handleRequestMovie}>
{#if isSubmittingRequest}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin"/>
Submitting...
{:else}
Submit Request
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
{#if submitRequestError}
<p class="col-span-full text-center text-sm text-red-500">{submitRequestError}</p>
{/if}
</div>
<Dialog.Footer>
<Button disabled={isSubmittingRequest} onclick={() => (dialogOpen = false)} variant="outline"
>Cancel
</Button>
<Button disabled={isFormInvalid || isSubmittingRequest} onclick={handleRequestMovie}>
{#if isSubmittingRequest}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
Submitting...
{:else}
Submit Request
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,16 +1,14 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select/index.js';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import type { CreateSeasonRequest, PublicShow, Quality } from '$lib/types.js';
import type { PublicShow, Quality } from '$lib/types.js';
import { getFullyQualifiedMediaName, getTorrentQualityString } from '$lib/utils.js';
import { toast } from 'svelte-sonner';
import client from "$lib/api";
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
let { show }: { show: PublicShow } = $props();
let dialogOpen = $state(false);
@@ -37,7 +35,7 @@
submitRequestError = null;
for (const id of selectedSeasonsIds) {
const { response, error } = await client.POST("/api/v1/tv/seasons/requests", {
const { response, error } = await client.POST('/api/v1/tv/seasons/requests', {
body: {
season_id: id,
min_quality: parseInt(minQuality!) as Quality,
@@ -46,7 +44,7 @@
});
if (!response.ok) {
toast.error("Failed to submit request: " + error);
toast.error('Failed to submit request: ' + error);
submitRequestError = `Failed to submit request for season ID ${id}: ${error}`;
}
}

View File

@@ -1,201 +1,202 @@
<script lang="ts">
import {getFullyQualifiedMediaName, getTorrentQualityString} from '$lib/utils.js';
import type {MovieRequest, SeasonRequest, User} from '$lib/types.js';
import CheckmarkX from '$lib/components/checkmark-x.svelte';
import * as Table from '$lib/components/ui/table/index.js';
import {getContext} from 'svelte';
import {Button} from '$lib/components/ui/button/index.js';
import {env} from '$env/dynamic/public';
import {toast} from 'svelte-sonner';
import {goto} from '$app/navigation';
import {base} from '$app/paths';
import client from "$lib/api";
import { getFullyQualifiedMediaName, getTorrentQualityString } from '$lib/utils.js';
import type { MovieRequest, SeasonRequest, User } from '$lib/types.js';
import CheckmarkX from '$lib/components/checkmark-x.svelte';
import * as Table from '$lib/components/ui/table/index.js';
import { getContext } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
let {
requests,
filter = () => true,
isShow = true
}: {
requests: (SeasonRequest | MovieRequest)[];
filter?: (request: SeasonRequest | MovieRequest) => boolean;
isShow: boolean;
} = $props();
const user: () => User = getContext('user');
let {
requests,
filter = () => true,
isShow = true
}: {
requests: (SeasonRequest | MovieRequest)[];
filter?: (request: SeasonRequest | MovieRequest) => boolean;
isShow: boolean;
} = $props();
const user: () => User = getContext('user');
async function approveRequest(requestId: string, currentAuthorizedStatus: boolean) {
let response;
if (!isShow) {
const data = await client.PATCH("/api/v1/tv/seasons/requests/{season_request_id}", {
params: {
path: {
season_request_id: requestId,
},
query: {
authorized_status: !currentAuthorizedStatus
}
}
});
response = data.response
} else {
const data = await client.PATCH("/api/v1/movies/requests/{movie_request_id}", {
params: {
path: {
movie_request_id: requestId,
},
query: {
authorized_status: !currentAuthorizedStatus,
}
}
});
response = data.response
}
if (response.ok) {
const requestIndex = requests.findIndex((r) => r.id === requestId);
if (requestIndex !== -1) {
let newAuthorizedStatus = !currentAuthorizedStatus;
requests[requestIndex]!.authorized = newAuthorizedStatus;
requests[requestIndex]!.authorized_by = newAuthorizedStatus ? user() : undefined;
}
toast.success(
`Request ${!currentAuthorizedStatus ? 'approved' : 'unapproved'} successfully.`
);
} else {
const errorText = await response.text();
console.error(`Failed to update request status ${response.statusText}`, errorText);
toast.error(`Failed to update request status: ${response.statusText}`);
}
async function approveRequest(requestId: string, currentAuthorizedStatus: boolean) {
let response;
if (!isShow) {
const data = await client.PATCH('/api/v1/tv/seasons/requests/{season_request_id}', {
params: {
path: {
season_request_id: requestId
},
query: {
authorized_status: !currentAuthorizedStatus
}
}
});
response = data.response;
} else {
const data = await client.PATCH('/api/v1/movies/requests/{movie_request_id}', {
params: {
path: {
movie_request_id: requestId
},
query: {
authorized_status: !currentAuthorizedStatus
}
}
});
response = data.response;
}
if (response.ok) {
const requestIndex = requests.findIndex((r) => r.id === requestId);
if (requestIndex !== -1) {
let newAuthorizedStatus = !currentAuthorizedStatus;
requests[requestIndex]!.authorized = newAuthorizedStatus;
requests[requestIndex]!.authorized_by = newAuthorizedStatus ? user() : undefined;
}
toast.success(
`Request ${!currentAuthorizedStatus ? 'approved' : 'unapproved'} successfully.`
);
} else {
const errorText = await response.text();
console.error(`Failed to update request status ${response.statusText}`, errorText);
toast.error(`Failed to update request status: ${response.statusText}`);
}
}
}
async function deleteRequest(requestId: string) {
if (!window.confirm('Are you sure you want to delete this season request? This action cannot be undone.')) {
return;
}
let response;
if (isShow) {
const data = await client.DELETE("/api/v1/tv/seasons/requests/{request_id}", {
params: {
path: {
request_id: requestId,
}
}
})
response = data.response
} else {
const data = await client.DELETE("/api/v1/movies/requests/{movie_request_id}", {
params: {
path: {
movie_request_id: requestId
}
}
});
response = data.response
}
if (response.ok) {
// remove the request from the list
const index = requests.findIndex((r) => r.id === requestId);
if (index > -1) {
requests.splice(index, 1);
}
toast.success('Request deleted successfully');
} else {
console.error(`Failed to delete request ${response.statusText}`, await response.text());
toast.error('Failed to delete request');
}
}
async function deleteRequest(requestId: string) {
if (
!window.confirm(
'Are you sure you want to delete this season request? This action cannot be undone.'
)
) {
return;
}
let response;
if (isShow) {
const data = await client.DELETE('/api/v1/tv/seasons/requests/{request_id}', {
params: {
path: {
request_id: requestId
}
}
});
response = data.response;
} else {
const data = await client.DELETE('/api/v1/movies/requests/{movie_request_id}', {
params: {
path: {
movie_request_id: requestId
}
}
});
response = data.response;
}
if (response.ok) {
// remove the request from the list
const index = requests.findIndex((r) => r.id === requestId);
if (index > -1) {
requests.splice(index, 1);
}
toast.success('Request deleted successfully');
} else {
console.error(`Failed to delete request ${response.statusText}`, await response.text());
toast.error('Failed to delete request');
}
}
</script>
<Table.Root>
<Table.Caption>A list of all requests.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>{isShow ? 'Show' : 'Movie'}</Table.Head>
{#if isShow}
<Table.Head>Season</Table.Head>
{/if}
<Table.Head>Minimum Quality</Table.Head>
<Table.Head>Wanted Quality</Table.Head>
<Table.Head>Requested by</Table.Head>
<Table.Head>Approved</Table.Head>
<Table.Head>Approved by</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each requests as request (request.id)}
{#if filter(request)}
<Table.Row>
<Table.Cell>
{#if isShow}
{getFullyQualifiedMediaName((request as SeasonRequest).show)}
{:else}
{getFullyQualifiedMediaName((request as MovieRequest).movie)}
{/if}
</Table.Cell>
{#if isShow}
<Table.Cell>
{(request as SeasonRequest).season.number}
</Table.Cell>
{/if}
<Table.Cell>
{getTorrentQualityString(request.min_quality)}
</Table.Cell>
<Table.Cell>
{getTorrentQualityString(request.wanted_quality)}
</Table.Cell>
<Table.Cell>
{request.requested_by?.email ?? 'N/A'}
</Table.Cell>
<Table.Cell>
<CheckmarkX state={request.authorized}/>
</Table.Cell>
<Table.Cell>
{request.authorized_by?.email ?? 'N/A'}
</Table.Cell>
<!-- TODO: ADD DIALOGUE TO MODIFY REQUEST -->
<Table.Cell class="flex max-w-[150px] flex-col gap-1">
{#if user().is_superuser}
<Button
class=""
size="sm"
onclick={() => approveRequest(request.id, request.authorized)}
>
{request.authorized ? 'Unapprove' : 'Approve'}
</Button>
{#if isShow}
<Button
class=""
size="sm"
variant="outline"
onclick={() => goto(base + '/dashboard/tv/' + (request as SeasonRequest).show.id)}
>
Download manually
</Button>
{:else}
<Button
class=""
size="sm"
variant="outline"
onclick={() =>
<Table.Caption>A list of all requests.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>{isShow ? 'Show' : 'Movie'}</Table.Head>
{#if isShow}
<Table.Head>Season</Table.Head>
{/if}
<Table.Head>Minimum Quality</Table.Head>
<Table.Head>Wanted Quality</Table.Head>
<Table.Head>Requested by</Table.Head>
<Table.Head>Approved</Table.Head>
<Table.Head>Approved by</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each requests as request (request.id)}
{#if filter(request)}
<Table.Row>
<Table.Cell>
{#if isShow}
{getFullyQualifiedMediaName((request as SeasonRequest).show)}
{:else}
{getFullyQualifiedMediaName((request as MovieRequest).movie)}
{/if}
</Table.Cell>
{#if isShow}
<Table.Cell>
{(request as SeasonRequest).season.number}
</Table.Cell>
{/if}
<Table.Cell>
{getTorrentQualityString(request.min_quality)}
</Table.Cell>
<Table.Cell>
{getTorrentQualityString(request.wanted_quality)}
</Table.Cell>
<Table.Cell>
{request.requested_by?.email ?? 'N/A'}
</Table.Cell>
<Table.Cell>
<CheckmarkX state={request.authorized} />
</Table.Cell>
<Table.Cell>
{request.authorized_by?.email ?? 'N/A'}
</Table.Cell>
<!-- TODO: ADD DIALOGUE TO MODIFY REQUEST -->
<Table.Cell class="flex max-w-[150px] flex-col gap-1">
{#if user().is_superuser}
<Button
class=""
size="sm"
onclick={() => approveRequest(request.id, request.authorized)}
>
{request.authorized ? 'Unapprove' : 'Approve'}
</Button>
{#if isShow}
<Button
class=""
size="sm"
variant="outline"
onclick={() => goto(base + '/dashboard/tv/' + (request as SeasonRequest).show.id)}
>
Download manually
</Button>
{:else}
<Button
class=""
size="sm"
variant="outline"
onclick={() =>
goto(base + '/dashboard/movies/' + (request as MovieRequest).movie.id)}
>
Download manually
</Button>
{/if}
{/if}
{#if user().is_superuser || user().id === request.requested_by?.id}
<Button variant="destructive" size="sm" onclick={() => deleteRequest(request.id)}
>Delete
</Button>
{/if}
</Table.Cell>
</Table.Row>
{/if}
{:else}
<Table.Row>
<Table.Cell colspan={8} class="text-center">There are currently no requests.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
>
Download manually
</Button>
{/if}
{/if}
{#if user().is_superuser || user().id === request.requested_by?.id}
<Button variant="destructive" size="sm" onclick={() => deleteRequest(request.id)}
>Delete
</Button>
{/if}
</Table.Cell>
</Table.Row>
{/if}
{:else}
<Table.Row>
<Table.Cell colspan={8} class="text-center">There are currently no requests.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,152 +1,148 @@
<script lang="ts">
import {Button} from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import {Input} from '$lib/components/ui/input/index.js';
import {Label} from '$lib/components/ui/label/index.js';
import {env} from '$env/dynamic/public';
import {toast} from 'svelte-sonner';
import * as Alert from '$lib/components/ui/alert/index.js';
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
import LoadingBar from '$lib/components/loading-bar.svelte';
import CheckCircle2Icon from '@lucide/svelte/icons/check-circle-2';
import {base} from '$app/paths';
import client from "$lib/api";
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { toast } from 'svelte-sonner';
import * as Alert from '$lib/components/ui/alert/index.js';
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
import LoadingBar from '$lib/components/loading-bar.svelte';
import CheckCircle2Icon from '@lucide/svelte/icons/check-circle-2';
import { base } from '$app/paths';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
let email = $state('');
let password = $state('');
let errorMessage = $state('');
let successMessage = $state('');
let isLoading = $state(false);
let confirmPassword = $state('');
let {
oauthProvider
}: {
oauthProvider: {
oauth_name: string;
};
} = $props();
let email = $state('');
let password = $state('');
let errorMessage = $state('');
let successMessage = $state('');
let isLoading = $state(false);
let confirmPassword = $state('');
let {
oauthProvider
}: {
oauthProvider: {
oauth_name: string;
};
} = $props();
async function handleSignup(event: Event) {
event.preventDefault();
async function handleSignup(event: Event) {
event.preventDefault();
isLoading = true;
errorMessage = '';
successMessage = '';
const { response } = await client.POST('/api/v1/auth/register', {
body: {
email: email,
password: password
}
});
isLoading = false;
isLoading = true;
errorMessage = '';
successMessage = '';
const {response} = await client.POST("/api/v1/auth/register", {
body: {
email: email,
password: password
}
});
isLoading = false;
if (response.ok) {
successMessage = 'Registration successful! Please login.';
toast.success(successMessage);
} else {
toast.error('Registration failed');
}
}
if (response.ok) {
successMessage = 'Registration successful! Please login.';
toast.success(successMessage);
} else {
toast.error("Registration failed");
}
}
async function handleOauth() {
const {response, data, error} = await client.GET("/api/v1/auth/cookie/OpenID/authorize", {
params: {
query: {
scopes: 'email'
}
}
});
if (response.ok) {
window.location = data.authorization_url;
} else {
toast.error(error);
}
}
async function handleOauth() {
const { response, data, error } = await client.GET('/api/v1/auth/cookie/OpenID/authorize', {
params: {
query: {
scopes: 'email'
}
}
});
if (response.ok) {
window.location = data.authorization_url;
} else {
toast.error(error);
}
}
</script>
<Card.Root class="mx-auto max-w-sm">
<Card.Header>
<Card.Title class="text-xl">Sign Up</Card.Title>
<Card.Description>Enter your information to create an account</Card.Description>
</Card.Header>
<Card.Content>
<form class="grid gap-4" onsubmit={handleSignup}>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
autocomplete="email"
bind:value={email}
id="email"
placeholder="m@example.com"
required
type="email"
/>
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input
autocomplete="new-password"
bind:value={password}
id="password"
required
type="password"
/>
</div>
<div class="grid gap-2">
<Label for="password">Confirm Password</Label>
<Input
autocomplete="new-password"
bind:value={confirmPassword}
id="confirm-password"
required
type="password"
/>
</div>
{#if errorMessage}
<Alert.Root variant="destructive">
<AlertCircleIcon class="size-4"/>
<Alert.Title>Error</Alert.Title>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Root>
{/if}
{#if successMessage}
<Alert.Root variant="default">
<CheckCircle2Icon class="size-4"/>
<Alert.Title>Success</Alert.Title>
<Alert.Description>{successMessage}</Alert.Description>
</Alert.Root>
{/if}
{#if isLoading}
<LoadingBar/>
{/if}
<Button
class="w-full"
disabled={isLoading || password !== confirmPassword || password === ''}
type="submit">Create an account
</Button
>
</form>
{#await oauthProvider}
<LoadingBar/>
{:then result}
{#if result.oauth_name != null}
<div
class="after:border-border relative mt-2 text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
>
<Card.Header>
<Card.Title class="text-xl">Sign Up</Card.Title>
<Card.Description>Enter your information to create an account</Card.Description>
</Card.Header>
<Card.Content>
<form class="grid gap-4" onsubmit={handleSignup}>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
autocomplete="email"
bind:value={email}
id="email"
placeholder="m@example.com"
required
type="email"
/>
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input
autocomplete="new-password"
bind:value={password}
id="password"
required
type="password"
/>
</div>
<div class="grid gap-2">
<Label for="password">Confirm Password</Label>
<Input
autocomplete="new-password"
bind:value={confirmPassword}
id="confirm-password"
required
type="password"
/>
</div>
{#if errorMessage}
<Alert.Root variant="destructive">
<AlertCircleIcon class="size-4" />
<Alert.Title>Error</Alert.Title>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Root>
{/if}
{#if successMessage}
<Alert.Root variant="default">
<CheckCircle2Icon class="size-4" />
<Alert.Title>Success</Alert.Title>
<Alert.Description>{successMessage}</Alert.Description>
</Alert.Root>
{/if}
{#if isLoading}
<LoadingBar />
{/if}
<Button
class="w-full"
disabled={isLoading || password !== confirmPassword || password === ''}
type="submit"
>Create an account
</Button>
</form>
{#await oauthProvider}
<LoadingBar />
{:then result}
{#if result.oauth_name != null}
<div
class="after:border-border relative mt-2 text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
>
<span class="bg-background text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<Button class="mt-2 w-full" onclick={() => handleOauth()} variant="outline"
>Login with {result.oauth_name}</Button
>
{/if}
{/await}
<div class="mt-4 text-center text-sm">
<Button href="{base}/login/" variant="link">Already have an account? Login</Button>
</div>
</Card.Content>
</div>
<Button class="mt-2 w-full" onclick={() => handleOauth()} variant="outline"
>Login with {result.oauth_name}</Button
>
{/if}
{/await}
<div class="mt-4 text-center text-sm">
<Button href="{base}/login/" variant="link">Already have an account? Login</Button>
</div>
</Card.Content>
</Card.Root>

View File

@@ -3,7 +3,6 @@
import CheckmarkX from '$lib/components/checkmark-x.svelte';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { env } from '$env/dynamic/public';
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Label } from '$lib/components/ui/label/index.js';
@@ -11,7 +10,6 @@
import { Input } from '$lib/components/ui/input/index.js';
import { invalidateAll } from '$app/navigation';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
let { users }: { users: User[] } = $props();
let sortedUsers = $derived(users.sort((a, b) => a.email.localeCompare(b.email)));
let selectedUser: User | null = $state(null);
@@ -21,7 +19,7 @@
async function saveUser() {
if (!selectedUser) return;
const {data} = await client.PATCH("/api/v1/users/{id}", {
const { error } = await client.PATCH('/api/v1/users/{id}', {
params: {
query: {
id: selectedUser.id
@@ -34,13 +32,18 @@
...(newPassword !== '' && { password: newPassword }),
...(newEmail !== '' && { email: newEmail })
}
})
toast.success(`User ${selectedUser.email} updated successfully.`);
dialogOpen = false;
selectedUser = null;
newPassword = '';
newEmail = '';
await invalidateAll();
});
if (error) {
toast.error(`Failed to update user ${selectedUser.email}: ${error}`);
} else {
toast.success(`User ${selectedUser.email} updated successfully.`);
dialogOpen = false;
selectedUser = null;
newPassword = '';
newEmail = '';
await invalidateAll();
}
}
</script>

View File

@@ -1,75 +1,72 @@
<script lang="ts">
import {Button} from '$lib/components/ui/button/index.js';
import {env} from '$env/dynamic/public';
import {toast} from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import {Label} from '$lib/components/ui/label/index.js';
import {Input} from '$lib/components/ui/input/index.js';
import client from "$lib/api";
import { Button } from '$lib/components/ui/button/index.js';
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
let newPassword: string = $state('');
let newEmail: string = $state('');
let dialogOpen = $state(false);
let newPassword: string = $state('');
let newEmail: string = $state('');
let dialogOpen = $state(false);
async function saveUser() {
const {error} = await client.PATCH("/api/v1/users/me", {
body: {
...(newPassword !== '' && {password: newPassword}),
...(newEmail !== '' && {email: newEmail})
}
});
if (error) {
toast.error(`Failed to update user`);
} else {
toast.success(`Updated details successfully.`);
dialogOpen = false;
}
newPassword = '';
newEmail = '';
}
async function saveUser() {
const { error } = await client.PATCH('/api/v1/users/me', {
body: {
...(newPassword !== '' && { password: newPassword }),
...(newEmail !== '' && { email: newEmail })
}
});
if (error) {
toast.error(`Failed to update user`);
} else {
toast.success(`Updated details successfully.`);
dialogOpen = false;
}
newPassword = '';
newEmail = '';
}
</script>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Trigger>
<Button class="w-full" onclick={() => (dialogOpen = true)} variant="outline">
Edit my details
</Button>
</Dialog.Trigger>
<Dialog.Content class="w-full max-w-[600px] rounded-lg p-6 shadow-lg">
<Dialog.Header>
<Dialog.Title class="mb-1 text-xl font-semibold">Edit User Details</Dialog.Title>
<Dialog.Description class="mb-4 text-sm">
Change your email or password. Leave fields empty to not change them.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-6">
<!-- Email -->
<div>
<Label class="mb-1 block text-sm font-medium" for="email">Email</Label>
<Input
bind:value={newEmail}
class="w-full"
id="email"
placeholder="Keep empty to not change the email"
type="email"
/>
</div>
<!-- Password -->
<div>
<Label class="mb-1 block text-sm font-medium" for="password">Password</Label>
<Input
bind:value={newPassword}
class="w-full"
id="password"
placeholder="Keep empty to not change the password"
type="password"
/>
</div>
</div>
<div class="mt-8 flex justify-end gap-2">
<Button onclick={() => saveUser()} variant="destructive">Save</Button>
</div>
</Dialog.Content>
<Dialog.Trigger>
<Button class="w-full" onclick={() => (dialogOpen = true)} variant="outline">
Edit my details
</Button>
</Dialog.Trigger>
<Dialog.Content class="w-full max-w-[600px] rounded-lg p-6 shadow-lg">
<Dialog.Header>
<Dialog.Title class="mb-1 text-xl font-semibold">Edit User Details</Dialog.Title>
<Dialog.Description class="mb-4 text-sm">
Change your email or password. Leave fields empty to not change them.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-6">
<!-- Email -->
<div>
<Label class="mb-1 block text-sm font-medium" for="email">Email</Label>
<Input
bind:value={newEmail}
class="w-full"
id="email"
placeholder="Keep empty to not change the email"
type="email"
/>
</div>
<!-- Password -->
<div>
<Label class="mb-1 block text-sm font-medium" for="password">Password</Label>
<Input
bind:value={newPassword}
class="w-full"
id="password"
placeholder="Keep empty to not change the password"
type="password"
/>
</div>
</div>
<div class="mt-8 flex justify-end gap-2">
<Button onclick={() => saveUser()} variant="destructive">Save</Button>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,12 +1,9 @@
import { env } from '$env/dynamic/public';
import type { LayoutLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import client from "$lib/api";
const apiUrl = env.PUBLIC_API_URL;
import client from '$lib/api';
export const load: LayoutLoad = async ({ fetch }) => {
const { data, response } = await client.GET('/api/v1/users/me', { fetch: fetch });

View File

@@ -1,79 +1,75 @@
<script lang="ts">
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 RecommendedMediaCarousel from '$lib/components/recommended-media-carousel.svelte';
import {base} from '$app/paths';
import {onMount} from 'svelte';
import {env} from '$env/dynamic/public';
import type {MetaDataProviderSearchResult} from '$lib/types';
import client from "$lib/api";
import type {operations, components} from '$lib/api/api.d.ts';
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 RecommendedMediaCarousel from '$lib/components/recommended-media-carousel.svelte';
import { base } from '$app/paths';
import { onMount } from 'svelte';
import client from '$lib/api';
import type { components } from '$lib/api/api.d.ts';
const apiUrl = env.PUBLIC_API_URL;
let recommendedShows: components['schemas']['MetaDataProviderSearchResult'][] = [];
let showsLoading = true;
let recommendedShows: components['schemas']['MetaDataProviderSearchResult'][] = [];
let showsLoading = true;
let recommendedMovies: components['schemas']['MetaDataProviderSearchResult'][] = [];
let moviesLoading = true;
let recommendedMovies: components['schemas']['MetaDataProviderSearchResult'][] = [];
let moviesLoading = true;
onMount(async () => {
client.GET('/api/v1/tv/recommended').then((res) => {
recommendedShows = res.data as components['schemas']['MetaDataProviderSearchResult'][];
showsLoading = false;
})
client.GET('/api/v1/movies/recommended').then((res) => {
recommendedMovies = res.data as components['schemas']['MetaDataProviderSearchResult'][];
moviesLoading = false;
})
});
onMount(async () => {
client.GET('/api/v1/tv/recommended').then((res) => {
recommendedShows = res.data as components['schemas']['MetaDataProviderSearchResult'][];
showsLoading = false;
});
client.GET('/api/v1/movies/recommended').then((res) => {
recommendedMovies = res.data as components['schemas']['MetaDataProviderSearchResult'][];
moviesLoading = false;
});
});
</script>
<svelte:head>
<title>Dashboard - MediaManager</title>
<meta
content="MediaManager Dashboard - View your recommended movies and TV shows"
name="description"
/>
<title>Dashboard - MediaManager</title>
<meta
content="MediaManager Dashboard - View your recommended movies and TV shows"
name="description"
/>
</svelte:head>
<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 class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Home</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="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Page>Home</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<div class="flex flex-1 flex-col gap-4 p-4 pt-0">
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
Dashboard
</h1>
<main class="min-h-screen flex-1 items-center justify-center rounded-xl p-4 md:min-h-min">
<div class="mx-auto max-w-[70vw] md:max-w-[80vw]">
<h3 class="my-4 text-center text-2xl font-semibold">Trending Shows</h3>
<RecommendedMediaCarousel isLoading={showsLoading} isShow={true} media={recommendedShows}/>
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
Dashboard
</h1>
<main class="min-h-screen flex-1 items-center justify-center rounded-xl p-4 md:min-h-min">
<div class="mx-auto max-w-[70vw] md:max-w-[80vw]">
<h3 class="my-4 text-center text-2xl font-semibold">Trending Shows</h3>
<RecommendedMediaCarousel isLoading={showsLoading} isShow={true} media={recommendedShows} />
<h3 class="my-4 text-center text-2xl font-semibold">Trending Movies</h3>
<RecommendedMediaCarousel
isLoading={moviesLoading}
isShow={false}
media={recommendedMovies}
/>
</div>
</main>
<h3 class="my-4 text-center text-2xl font-semibold">Trending Movies</h3>
<RecommendedMediaCarousel
isLoading={moviesLoading}
isShow={false}
media={recommendedMovies}
/>
</div>
</main>
<!---
<!---
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div class="aspect-video rounded-xl bg-muted/50"></div>
<div class="aspect-video rounded-xl bg-muted/50"></div>

View File

@@ -1,93 +1,90 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
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 {getFullyQualifiedMediaName} from '$lib/utils';
import MediaPicture from '$lib/components/media-picture.svelte';
import {onMount} from 'svelte';
import {toast} from 'svelte-sonner';
import {env} from '$env/dynamic/public';
import {Skeleton} from '$lib/components/ui/skeleton';
import {base} from '$app/paths';
import client from "$lib/api";
import type { operations, components } from '$lib/api/api.d.ts';
import * as Card from '$lib/components/ui/card/index.js';
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 { getFullyQualifiedMediaName } from '$lib/utils';
import MediaPicture from '$lib/components/media-picture.svelte';
import { onMount } from 'svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
import { base } from '$app/paths';
import client from '$lib/api';
import type { components } from '$lib/api/api.d.ts';
const apiUrl = env.PUBLIC_API_URL;
let movies: components['schemas']['PublicMovie'][] = [];
let loading = false;
onMount(async () => {
loading = true;
const {data} = await client.GET('/api/v1/movies', {fetch: fetch});
let movies: components['schemas']['PublicMovie'][] = [];
let loading = false;
onMount(async () => {
loading = true;
const { data } = await client.GET('/api/v1/movies', { fetch: fetch });
movies = data as components['schemas']['PublicMovie'][];
console.log('got movies: ', movies);
loading = false;
});
movies = data as components['schemas']['PublicMovie'][];
console.log('got movies: ', movies);
loading = false;
});
</script>
<svelte:head>
<title>Movies - MediaManager</title>
<meta content="Browse and manage your movie collection in MediaManager" name="description"/>
<title>Movies - MediaManager</title>
<meta content="Browse and manage your movie collection in MediaManager" name="description" />
</svelte:head>
<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 class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Movies</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="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Page>Movies</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
{#snippet loadingbar()}
<Skeleton class="h-[50vh] w-full "/>
<Skeleton class="h-[50vh] w-full "/>
<Skeleton class="h-[50vh] w-full "/>
<Skeleton class="h-[50vh] w-full "/>
<Skeleton class="h-[50vh] w-full "/>
<Skeleton class="h-[50vh] w-full "/>
<Skeleton class="h-[50vh] w-full "/>
<Skeleton class="h-[50vh] w-full "/>
<Skeleton class="h-[50vh] w-full "/>
<Skeleton class="h-[50vh] w-full "/>
<Skeleton class="h-[50vh] w-full "/>
<Skeleton class="h-[50vh] w-full " />
<Skeleton class="h-[50vh] w-full " />
<Skeleton class="h-[50vh] w-full " />
<Skeleton class="h-[50vh] w-full " />
<Skeleton class="h-[50vh] w-full " />
<Skeleton class="h-[50vh] w-full " />
<Skeleton class="h-[50vh] w-full " />
<Skeleton class="h-[50vh] w-full " />
<Skeleton class="h-[50vh] w-full " />
<Skeleton class="h-[50vh] w-full " />
<Skeleton class="h-[50vh] w-full " />
{/snippet}
<main class="flex w-full flex-1 flex-col gap-4 p-4 pt-0">
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">Movies</h1>
<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"
>
{#if loading}
{@render loadingbar()}
{:else}
{#each movies as movie (movie.id)}
<a href={base + '/dashboard/movies/' + movie.id}>
<Card.Root class="col-span-full max-w-[90vw] ">
<Card.Header>
<Card.Title class="h-6 truncate">{getFullyQualifiedMediaName(movie)}</Card.Title>
<Card.Description class="truncate">{movie.overview}</Card.Description>
</Card.Header>
<Card.Content>
<MediaPicture media={movie}/>
</Card.Content>
</Card.Root>
</a>
{:else}
<div class="col-span-full text-center text-muted-foreground">No movies added yet.</div>
{/each}
{/if}
</div>
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">Movies</h1>
<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"
>
{#if loading}
{@render loadingbar()}
{:else}
{#each movies as movie (movie.id)}
<a href={base + '/dashboard/movies/' + movie.id}>
<Card.Root class="col-span-full max-w-[90vw] ">
<Card.Header>
<Card.Title class="h-6 truncate">{getFullyQualifiedMediaName(movie)}</Card.Title>
<Card.Description class="truncate">{movie.overview}</Card.Description>
</Card.Header>
<Card.Content>
<MediaPicture media={movie} />
</Card.Content>
</Card.Root>
</a>
{:else}
<div class="col-span-full text-center text-muted-foreground">No movies added yet.</div>
{/each}
{/if}
</div>
</main>

View File

@@ -1,7 +1,5 @@
import type { PageLoad } from './$types';
import { env } from '$env/dynamic/public';
import { error } from '@sveltejs/kit';
import client from "$lib/api";
import client from '$lib/api';
export const load: PageLoad = async ({ params, fetch }) => {
const { data } = await client.GET('/api/v1/movies/{movie_id}', {
@@ -9,7 +7,7 @@ export const load: PageLoad = async ({ params, fetch }) => {
params: {
path: {
movie_id: params.movieId
},
}
}
});

View File

@@ -1,5 +1,4 @@
<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';
@@ -8,19 +7,16 @@
import { Button } from '$lib/components/ui/button';
import { ChevronDown } from 'lucide-svelte';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import type { MetaDataProviderSearchResult } from '$lib/types.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import AddMediaCard from '$lib/components/add-media-card.svelte';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { base } from '$app/paths';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import client from "$lib/api";
import type {components} from "$lib/api/api";
import client from '$lib/api';
import type { components } from '$lib/api/api';
const apiUrl = env.PUBLIC_API_URL;
let searchTerm: string = $state('');
let metadataProvider: "tmdb" | "tvdb" = $state('tmdb');
let metadataProvider: 'tmdb' | 'tvdb' = $state('tmdb');
let results: components['schemas']['MetaDataProviderSearchResult'][] | null = $state(null);
onMount(() => {
@@ -28,14 +24,17 @@
});
async function search(query: string) {
const {data} = query.length > 0 ? await client.GET('/api/v1/movies/search', {
params: {
query: {
query: query,
metadata_provider: metadataProvider
}
}
}) : await client.GET('/api/v1/tv/recommended');
const { data } =
query.length > 0
? await client.GET('/api/v1/movies/search', {
params: {
query: {
query: query,
metadata_provider: metadataProvider
}
}
})
: await client.GET('/api/v1/tv/recommended');
if (data && data.length > 0) {
toast.success(`Found ${data.length} result(s) for "${query}".`);
results = data as components['schemas']['MetaDataProviderSearchResult'][];

View File

@@ -1,12 +1,10 @@
import {env} from '$env/dynamic/public';
import type {PageLoad} from './$types';
import client from "$lib/api";
import type { PageLoad } from './$types';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
export const load: PageLoad = async ({fetch}) => {
const {data} = await client.GET('/api/v1/movies/requests', {fetch: fetch});
export const load: PageLoad = async ({ fetch }) => {
const { data } = await client.GET('/api/v1/movies/requests', { fetch: fetch });
return {
requestsData: data
};
return {
requestsData: data
};
};

View File

@@ -2,23 +2,19 @@
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 type { RichMovieTorrent } from '$lib/types';
import { getFullyQualifiedMediaName } from '$lib/utils';
import * as Accordion from '$lib/components/ui/accordion/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import TorrentTable from '$lib/components/torrent-table.svelte';
import { onMount } from 'svelte';
import { env } from '$env/dynamic/public';
import { toast } from 'svelte-sonner';
import { base } from '$app/paths';
import client from "$lib/api";
import type {components} from "$lib/api/api";
import client from '$lib/api';
import type { components } from '$lib/api/api';
const apiUrl = env.PUBLIC_API_URL;
let torrents: components["schemas"]["RichMovieTorrent"][] = [];
let torrents: components['schemas']['RichMovieTorrent'][] = [];
onMount(async () => {
const { data } = await client.GET('/api/v1/movies/torrents');
torrents = data as components["schemas"]["RichMovieTorrent"][];
torrents = data as components['schemas']['RichMovieTorrent'][];
});
</script>

View File

@@ -1,260 +1,264 @@
<script lang="ts">
import {onMount} from 'svelte';
import {env} from '$env/dynamic/public';
import {Button} from '$lib/components/ui/button/index.js';
import {Separator} from '$lib/components/ui/separator';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import { onMount } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Separator } from '$lib/components/ui/separator';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
const apiUrl = env.PUBLIC_API_URL;
import {base} from '$app/paths';
import client from "$lib/api";
import type {components} from "$lib/api/api";
import { base } from '$app/paths';
import client from '$lib/api';
import type { components } from '$lib/api/api';
let unreadNotifications: components["schemas"]["Notification"][] = [];
let readNotifications: components["schemas"]["Notification"][] = [];
let loading = true;
let showRead = false;
let markingAllAsRead = false;
let unreadNotifications: components['schemas']['Notification'][] = [];
let readNotifications: components['schemas']['Notification'][] = [];
let loading = true;
let showRead = false;
let markingAllAsRead = false;
async function fetchNotifications() {
loading = true;
const unread = await client.GET('/api/v1/notification/unread');
const all = await client.GET('/api/v1/notification');
unreadNotifications = unread.data!;
readNotifications = all.data!.filter((n) => n.read);
loading = false;
}
async function fetchNotifications() {
loading = true;
const unread = await client.GET('/api/v1/notification/unread');
const all = await client.GET('/api/v1/notification');
unreadNotifications = unread.data!;
readNotifications = all.data!.filter((n) => n.read);
loading = false;
}
async function markAsRead(notificationId: string) {
const {response} = await client.PATCH('/api/v1/notification/{notification_id}/read', {params: {path: {notification_id: notificationId}}});
async function markAsRead(notificationId: string) {
const { response } = await client.PATCH('/api/v1/notification/{notification_id}/read', {
params: { path: { notification_id: notificationId } }
});
if (response.ok) {
const notification = unreadNotifications.find((n) => n.id === notificationId);
if (notification) {
notification.read = true;
readNotifications = [notification, ...readNotifications];
unreadNotifications = unreadNotifications.filter((n) => n.id !== notificationId);
}
}
}
if (response.ok) {
const notification = unreadNotifications.find((n) => n.id === notificationId);
if (notification) {
notification.read = true;
readNotifications = [notification, ...readNotifications];
unreadNotifications = unreadNotifications.filter((n) => n.id !== notificationId);
}
}
}
async function markAsUnread(notificationId: string) {
const {response} = await client.PATCH('/api/v1/notification/{notification_id}/unread', {params: {path: {notification_id: notificationId}}});
async function markAsUnread(notificationId: string) {
const { response } = await client.PATCH('/api/v1/notification/{notification_id}/unread', {
params: { path: { notification_id: notificationId } }
});
if (response.ok) {
const notification = readNotifications.find((n) => n.id === notificationId);
if (notification) {
notification.read = false;
unreadNotifications = [notification, ...unreadNotifications];
readNotifications = readNotifications.filter((n) => n.id !== notificationId);
}
}
}
if (response.ok) {
const notification = readNotifications.find((n) => n.id === notificationId);
if (notification) {
notification.read = false;
unreadNotifications = [notification, ...unreadNotifications];
readNotifications = readNotifications.filter((n) => n.id !== notificationId);
}
}
}
async function markAllAsRead() {
if (unreadNotifications.length === 0) return;
async function markAllAsRead() {
if (unreadNotifications.length === 0) return;
try {
markingAllAsRead = true;
const promises = unreadNotifications.map((notification) =>
client.PATCH('/api/v1/notification/{notification_id}/read', {params: {path: {notification_id: notification.id!}}})
);
try {
markingAllAsRead = true;
const promises = unreadNotifications.map((notification) =>
client.PATCH('/api/v1/notification/{notification_id}/read', {
params: { path: { notification_id: notification.id! } }
})
);
await Promise.all(promises);
await Promise.all(promises);
// Move all unread to read
readNotifications = [
...unreadNotifications.map((n) => ({...n, read: true})),
...readNotifications
];
unreadNotifications = [];
} catch (error) {
console.error('Failed to mark all notifications as read:', error);
} finally {
markingAllAsRead = false;
}
}
// Move all unread to read
readNotifications = [
...unreadNotifications.map((n) => ({ ...n, read: true })),
...readNotifications
];
unreadNotifications = [];
} catch (error) {
console.error('Failed to mark all notifications as read:', error);
} finally {
markingAllAsRead = false;
}
}
onMount(() => {
fetchNotifications();
onMount(() => {
fetchNotifications();
const interval = setInterval(fetchNotifications, 30000);
return () => clearInterval(interval);
});
const interval = setInterval(fetchNotifications, 30000);
return () => clearInterval(interval);
});
</script>
<svelte:head>
<title>Notifications - MediaManager</title>
<title>Notifications - MediaManager</title>
</svelte:head>
<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 class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Notifications</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="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Page>Notifications</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<main class="container mx-auto px-4 py-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Notifications</h1>
{#if unreadNotifications.length > 0}
<Button onclick={() => markAllAsRead()} disabled={markingAllAsRead} class="flex items-center">
{#if markingAllAsRead}
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
{/if}
Mark All as Read
</Button>
{/if}
</div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Notifications</h1>
{#if unreadNotifications.length > 0}
<Button onclick={() => markAllAsRead()} disabled={markingAllAsRead} class="flex items-center">
{#if markingAllAsRead}
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
{/if}
Mark All as Read
</Button>
{/if}
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
</div>
{:else}
<!-- Unread Notifications -->
<div class="mb-8">
<div class="mb-4 flex items-center gap-2">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
Unread Notifications
{#if unreadNotifications.length > 0}:
{unreadNotifications.length}
{/if}
</h2>
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
</div>
{:else}
<!-- Unread Notifications -->
<div class="mb-8">
<div class="mb-4 flex items-center gap-2">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
Unread Notifications
{#if unreadNotifications.length > 0}:
{unreadNotifications.length}
{/if}
</h2>
</div>
{#if unreadNotifications.length === 0}
<div
class="rounded-lg border border-green-200 bg-green-50 p-6 text-center dark:border-green-800 dark:bg-green-900/20"
>
<p class="font-medium text-green-800 dark:text-green-200">All caught up!</p>
<p class="text-sm text-green-600 dark:text-green-400">No unread notifications</p>
</div>
{:else}
<div class="space-y-3">
{#each unreadNotifications as notification (notification.id)}
<div
class="rounded-lg border border-blue-200 bg-blue-50 p-4 shadow-sm dark:border-blue-800 dark:bg-blue-900/20"
>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-1 items-start gap-3">
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">
{notification.message}
</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{new Date(notification.timestamp ?? 0).toLocaleDateString()}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Button
onclick={() => markAsRead(notification.id ?? '')}
class="rounded-lg p-2 text-blue-600 transition-colors hover:bg-blue-100 dark:hover:bg-blue-800"
title="Mark as read"
variant="outline"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</Button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{#if unreadNotifications.length === 0}
<div
class="rounded-lg border border-green-200 bg-green-50 p-6 text-center dark:border-green-800 dark:bg-green-900/20"
>
<p class="font-medium text-green-800 dark:text-green-200">All caught up!</p>
<p class="text-sm text-green-600 dark:text-green-400">No unread notifications</p>
</div>
{:else}
<div class="space-y-3">
{#each unreadNotifications as notification (notification.id)}
<div
class="rounded-lg border border-blue-200 bg-blue-50 p-4 shadow-sm dark:border-blue-800 dark:bg-blue-900/20"
>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-1 items-start gap-3">
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">
{notification.message}
</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{new Date(notification.timestamp ?? 0).toLocaleDateString()}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Button
onclick={() => markAsRead(notification.id ?? '')}
class="rounded-lg p-2 text-blue-600 transition-colors hover:bg-blue-100 dark:hover:bg-blue-800"
title="Mark as read"
variant="outline"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</Button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Read Notifications Toggle -->
<div class="mb-4">
<button
on:click={() => (showRead = !showRead)}
class="flex items-center gap-2 text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<svg
class="h-4 w-4 transition-transform {showRead ? 'rotate-90' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"
></path>
</svg>
<span>Read Notifications ({readNotifications.length})</span>
</button>
</div>
<!-- Read Notifications Toggle -->
<div class="mb-4">
<button
on:click={() => (showRead = !showRead)}
class="flex items-center gap-2 text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<svg
class="h-4 w-4 transition-transform {showRead ? 'rotate-90' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"
></path>
</svg>
<span>Read Notifications ({readNotifications.length})</span>
</button>
</div>
<!-- Read Notifications -->
{#if showRead}
<div>
{#if readNotifications.length === 0}
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-gray-500 dark:text-gray-400">No read notifications</p>
</div>
{:else}
<div class="space-y-3">
{#each readNotifications as notification (notification.id)}
<div
class="rounded-lg border border-gray-200 bg-white p-4 opacity-75 shadow-sm dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-1 items-start gap-3">
<div class="flex-1">
<p class="text-gray-700 dark:text-gray-300">
{notification.message}
</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{new Date(notification.timestamp ?? 0).toLocaleDateString()}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Button
onclick={() => markAsUnread(notification.id ?? '')}
class="rounded-lg p-2 text-blue-600 transition-colors hover:bg-blue-100 dark:hover:bg-blue-800"
title="Mark as unread"
variant="outline"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 7.89a2 2 0 002.83 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
></path>
</svg>
</Button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
<!-- Read Notifications -->
{#if showRead}
<div>
{#if readNotifications.length === 0}
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-6 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-gray-500 dark:text-gray-400">No read notifications</p>
</div>
{:else}
<div class="space-y-3">
{#each readNotifications as notification (notification.id)}
<div
class="rounded-lg border border-gray-200 bg-white p-4 opacity-75 shadow-sm dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-1 items-start gap-3">
<div class="flex-1">
<p class="text-gray-700 dark:text-gray-300">
{notification.message}
</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{new Date(notification.timestamp ?? 0).toLocaleDateString()}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Button
onclick={() => markAsUnread(notification.id ?? '')}
class="rounded-lg p-2 text-blue-600 transition-colors hover:bg-blue-100 dark:hover:bg-blue-800"
title="Mark as unread"
variant="outline"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 7.89a2 2 0 002.83 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
></path>
</svg>
</Button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</main>

View File

@@ -1,12 +1,10 @@
import {env} from '$env/dynamic/public';
import type {PageLoad} from './$types';
import client from "$lib/api";
import type { PageLoad } from './$types';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
export const load: PageLoad = async ({fetch}) => {
const {data} = await client.GET('/api/v1/users/all', {fetch: fetch});
export const load: PageLoad = async ({ fetch }) => {
const { data } = await client.GET('/api/v1/users/all', { fetch: fetch });
return {
users: data
};
return {
users: data
};
};

View File

@@ -1,64 +1,64 @@
<script lang="ts">
import {page} from '$app/state';
import * as Card from '$lib/components/ui/card/index.js';
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 {getFullyQualifiedMediaName} from '$lib/utils';
import MediaPicture from '$lib/components/media-picture.svelte';
import {Skeleton} from '$lib/components/ui/skeleton';
import {base} from '$app/paths';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card/index.js';
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 { getFullyQualifiedMediaName } from '$lib/utils';
import MediaPicture from '$lib/components/media-picture.svelte';
import { base } from '$app/paths';
let tvShows = page.data.tvShows;
let tvShows = page.data.tvShows;
</script>
<svelte:head>
<title>TV Shows - MediaManager</title>
<meta content="Browse and manage your TV show collection in MediaManager" name="description"/>
<title>TV Shows - MediaManager</title>
<meta content="Browse and manage your TV show collection in MediaManager" name="description" />
</svelte:head>
<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 class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/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="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/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>
<main class="flex w-full flex-1 flex-col gap-4 p-4 pt-0">
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
TV Shows
</h1>
<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"
>)}
{#each tvShows as show (show.id)}
<a href={base + '/dashboard/tv/' + show.id}>
<Card.Root class="col-span-full max-w-[90vw] ">
<Card.Header>
<Card.Title class="h-6 truncate">{getFullyQualifiedMediaName(show)}</Card.Title>
<Card.Description class="truncate">{show.overview}</Card.Description>
</Card.Header>
<Card.Content>
<MediaPicture media={show}/>
</Card.Content>
</Card.Root>
</a>
{:else}
<div class="col-span-full text-center text-muted-foreground">No TV shows added yet.</div>
{/each}
</div>
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
TV Shows
</h1>
<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"
>
)}
{#each tvShows as show (show.id)}
<a href={base + '/dashboard/tv/' + show.id}>
<Card.Root class="col-span-full max-w-[90vw] ">
<Card.Header>
<Card.Title class="h-6 truncate">{getFullyQualifiedMediaName(show)}</Card.Title>
<Card.Description class="truncate">{show.overview}</Card.Description>
</Card.Header>
<Card.Content>
<MediaPicture media={show} />
</Card.Content>
</Card.Root>
</a>
{:else}
<div class="col-span-full text-center text-muted-foreground">No TV shows added yet.</div>
{/each}
</div>
</main>

View File

@@ -1,9 +1,7 @@
import {env} from '$env/dynamic/public';
import client from '$lib/api';
import type {PageLoad} from './$types';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({fetch}) => {
const {data} = await client.GET('/api/v1/tv/shows', {fetch: fetch});
return {tvShows: data};
export const load: PageLoad = async ({ fetch }) => {
const { data } = await client.GET('/api/v1/tv/shows', { fetch: fetch });
return { tvShows: data };
};

View File

@@ -1,20 +1,18 @@
import {env} from '$env/dynamic/public';
import type {LayoutLoad} from './$types';
import client from "$lib/api";
import type { LayoutLoad } from './$types';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
export const load: LayoutLoad = async ({params, fetch}) => {
const show = await client.GET('/api/v1/tv/shows/{show_id}', {
fetch: fetch,
params: {path: {show_id: params.showId}}
});
const torrents = await client.GET('/api/v1/tv/shows/{show_id}/torrents', {
fetch: fetch,
params: {path: {show_id: params.showId}}
});
export const load: LayoutLoad = async ({ params, fetch }) => {
const show = await client.GET('/api/v1/tv/shows/{show_id}', {
fetch: fetch,
params: { path: { show_id: params.showId } }
});
const torrents = await client.GET('/api/v1/tv/shows/{show_id}/torrents', {
fetch: fetch,
params: { path: { show_id: params.showId } }
});
return {
showData: show.data,
torrentsData: torrents.data
};
return {
showData: show.data,
torrentsData: torrents.data
};
};

View File

@@ -1,211 +1,209 @@
<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 {goto} from '$app/navigation';
import {ImageOff} from 'lucide-svelte';
import * as Table from '$lib/components/ui/table/index.js';
import {getContext} from 'svelte';
import type {PublicShow, RichShowTorrent, User} from '$lib/types.js';
import {getFullyQualifiedMediaName} from '$lib/utils';
import DownloadSeasonDialog from '$lib/components/download-season-dialog.svelte';
import CheckmarkX from '$lib/components/checkmark-x.svelte';
import {page} from '$app/state';
import TorrentTable from '$lib/components/torrent-table.svelte';
import RequestSeasonDialog from '$lib/components/request-season-dialog.svelte';
import MediaPicture from '$lib/components/media-picture.svelte';
import {Switch} from '$lib/components/ui/switch/index.js';
import {toast} from 'svelte-sonner';
import {Label} from '$lib/components/ui/label';
import LibraryCombobox from '$lib/components/library-combobox.svelte';
import * as Card from '$lib/components/ui/card/index.js';
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 { goto } from '$app/navigation';
import { ImageOff } from 'lucide-svelte';
import * as Table from '$lib/components/ui/table/index.js';
import { getContext } from 'svelte';
import type { PublicShow, RichShowTorrent, User } from '$lib/types.js';
import { getFullyQualifiedMediaName } from '$lib/utils';
import DownloadSeasonDialog from '$lib/components/download-season-dialog.svelte';
import CheckmarkX from '$lib/components/checkmark-x.svelte';
import { page } from '$app/state';
import TorrentTable from '$lib/components/torrent-table.svelte';
import RequestSeasonDialog from '$lib/components/request-season-dialog.svelte';
import MediaPicture from '$lib/components/media-picture.svelte';
import { Switch } from '$lib/components/ui/switch/index.js';
import { toast } from 'svelte-sonner';
import { Label } from '$lib/components/ui/label';
import LibraryCombobox from '$lib/components/library-combobox.svelte';
import * as Card from '$lib/components/ui/card/index.js';
const apiUrl = env.PUBLIC_API_URL;
let show: () => PublicShow = getContext('show');
let user: () => User = getContext('user');
let torrents: RichShowTorrent = page.data.torrentsData;
import {base} from '$app/paths';
import client from "$lib/api";
let show: () => PublicShow = getContext('show');
let user: () => User = getContext('user');
let torrents: RichShowTorrent = page.data.torrentsData;
import { base } from '$app/paths';
import client from '$lib/api';
let continuousDownloadEnabled = $state(show().continuous_download);
let continuousDownloadEnabled = $state(show().continuous_download);
async function toggle_continuous_download() {
const {response} = await client.POST('/api/v1/tv/shows/{show_id}/continuousDownload', {
params: {
path: {show_id: show().id},
query: {continuous_download: !continuousDownloadEnabled}
}
});
console.log(
'Toggling continuous download for show',
show().name,
'to',
!continuousDownloadEnabled
);
if (!response.ok) {
const errorText = await response.text();
toast.error('Failed to toggle continuous download: ' + errorText);
} else {
continuousDownloadEnabled = !continuousDownloadEnabled;
toast.success('Continuous download toggled successfully.');
}
}
async function toggle_continuous_download() {
const { response } = await client.POST('/api/v1/tv/shows/{show_id}/continuousDownload', {
params: {
path: { show_id: show().id },
query: { continuous_download: !continuousDownloadEnabled }
}
});
console.log(
'Toggling continuous download for show',
show().name,
'to',
!continuousDownloadEnabled
);
if (!response.ok) {
const errorText = await response.text();
toast.error('Failed to toggle continuous download: ' + errorText);
} else {
continuousDownloadEnabled = !continuousDownloadEnabled;
toast.success('Continuous download toggled successfully.');
}
}
</script>
<svelte:head>
<title>{getFullyQualifiedMediaName(show())} - MediaManager</title>
<meta
content="View details and manage downloads for {getFullyQualifiedMediaName(
<title>{getFullyQualifiedMediaName(show())} - MediaManager</title>
<meta
content="View details and manage downloads for {getFullyQualifiedMediaName(
show()
)} in MediaManager"
name="description"
/>
name="description"
/>
</svelte:head>
<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 class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/dashboard/tv">Shows</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>{getFullyQualifiedMediaName(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="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/dashboard/tv">Shows</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Page>{getFullyQualifiedMediaName(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">
{getFullyQualifiedMediaName(show())}
{getFullyQualifiedMediaName(show())}
</h1>
<main class="mx-auto flex w-full flex-1 flex-col gap-4 p-4 md:max-w-[80em]">
<div class="flex flex-col gap-4 md:flex-row md:items-stretch">
<div class="bg-muted/50 w-full overflow-hidden rounded-xl md:w-1/3 md:max-w-sm">
{#if show().id}
<MediaPicture media={show()}/>
{:else}
<div
class="flex aspect-9/16 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 w-full flex-auto rounded-xl md:w-1/4">
<Card.Root class="h-full w-full">
<Card.Header>
<Card.Title>Overview</Card.Title>
</Card.Header>
<Card.Content>
<p class="leading-7 not-first:mt-6">
{show().overview}
</p>
</Card.Content>
</Card.Root>
</div>
<div
class="flex h-full w-full flex-auto flex-col items-center justify-start gap-4 rounded-xl md:w-1/3 md:max-w-[40em]"
>
{#if user().is_superuser}
<Card.Root class="w-full flex-1">
<Card.Header>
<Card.Title>Administrator Controls</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col items-center gap-4">
{#if !show().ended}
<div class="flex items-center gap-3">
<Switch
bind:checked={() => continuousDownloadEnabled, toggle_continuous_download}
id="continuous-download-checkbox"
/>
<Label for="continuous-download-checkbox">
Enable automatic download of future seasons
</Label>
</div>
{/if}
<LibraryCombobox media={show()} mediaType="tv"/>
</Card.Content>
</Card.Root>
{/if}
<Card.Root class="w-full flex-1">
<Card.Header>
<Card.Title>Download Options</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col items-center gap-4">
{#if user().is_superuser}
<DownloadSeasonDialog show={show()}/>
{/if}
<RequestSeasonDialog show={show()}/>
</Card.Content>
</Card.Root>
</div>
</div>
<div class="flex-1 rounded-xl">
<Card.Root class="w-full">
<Card.Header>
<Card.Title>Season Details</Card.Title>
<Card.Description>
A list of all seasons for {getFullyQualifiedMediaName(show())}.
</Card.Description>
</Card.Header>
<Card.Content 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
onclick={() => goto(base + '/dashboard/tv/' + show().id + '/' + season.id)}
>
<Table.Cell class="min-w-[10px] font-medium">{season.number}</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
<CheckmarkX state={season.downloaded}/>
</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>
</Card.Content>
</Card.Root>
</div>
<div class="flex-1 rounded-xl">
<Card.Root>
<Card.Header>
<Card.Title>Torrent Information</Card.Title>
<Card.Description>A list of all torrents associated with this show.</Card.Description>
</Card.Header>
<div class="flex flex-col gap-4 md:flex-row md:items-stretch">
<div class="bg-muted/50 w-full overflow-hidden rounded-xl md:w-1/3 md:max-w-sm">
{#if show().id}
<MediaPicture media={show()} />
{:else}
<div
class="flex aspect-9/16 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 w-full flex-auto rounded-xl md:w-1/4">
<Card.Root class="h-full w-full">
<Card.Header>
<Card.Title>Overview</Card.Title>
</Card.Header>
<Card.Content>
<p class="leading-7 not-first:mt-6">
{show().overview}
</p>
</Card.Content>
</Card.Root>
</div>
<div
class="flex h-full w-full flex-auto flex-col items-center justify-start gap-4 rounded-xl md:w-1/3 md:max-w-[40em]"
>
{#if user().is_superuser}
<Card.Root class="w-full flex-1">
<Card.Header>
<Card.Title>Administrator Controls</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col items-center gap-4">
{#if !show().ended}
<div class="flex items-center gap-3">
<Switch
bind:checked={() => continuousDownloadEnabled, toggle_continuous_download}
id="continuous-download-checkbox"
/>
<Label for="continuous-download-checkbox">
Enable automatic download of future seasons
</Label>
</div>
{/if}
<LibraryCombobox media={show()} mediaType="tv" />
</Card.Content>
</Card.Root>
{/if}
<Card.Root class="w-full flex-1">
<Card.Header>
<Card.Title>Download Options</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col items-center gap-4">
{#if user().is_superuser}
<DownloadSeasonDialog show={show()} />
{/if}
<RequestSeasonDialog show={show()} />
</Card.Content>
</Card.Root>
</div>
</div>
<div class="flex-1 rounded-xl">
<Card.Root class="w-full">
<Card.Header>
<Card.Title>Season Details</Card.Title>
<Card.Description>
A list of all seasons for {getFullyQualifiedMediaName(show())}.
</Card.Description>
</Card.Header>
<Card.Content 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
onclick={() => goto(base + '/dashboard/tv/' + show().id + '/' + season.id)}
>
<Table.Cell class="min-w-[10px] font-medium">{season.number}</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
<CheckmarkX state={season.downloaded} />
</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>
</Card.Content>
</Card.Root>
</div>
<div class="flex-1 rounded-xl">
<Card.Root>
<Card.Header>
<Card.Title>Torrent Information</Card.Title>
<Card.Description>A list of all torrents associated with this show.</Card.Description>
</Card.Header>
<Card.Content class="w-full overflow-x-auto">
<TorrentTable torrents={torrents.torrents}/>
</Card.Content>
</Card.Root>
</div>
<Card.Content class="w-full overflow-x-auto">
<TorrentTable torrents={torrents.torrents} />
</Card.Content>
</Card.Root>
</div>
</main>

View File

@@ -1,8 +1,5 @@
import { env } from '$env/dynamic/public';
import type { PageLoad } from './$types';
import client from "$lib/api";
const apiUrl = env.PUBLIC_API_URL;
import client from '$lib/api';
export const load: PageLoad = async ({ fetch, params }) => {
const season = await client.GET('/api/v1/tv/seasons/{season_id}', {
@@ -10,7 +7,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
params: {
path: {
season_id: params.SeasonId
},
}
}
});
const seasonFiles = await client.GET('/api/v1/tv/seasons/{season_id}/files', {
@@ -18,7 +15,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
params: {
path: {
season_id: params.SeasonId
},
}
}
});
return {

View File

@@ -1,134 +1,134 @@
<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 {ChevronDown} from 'lucide-svelte';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import AddMediaCard from '$lib/components/add-media-card.svelte';
import {toast} from 'svelte-sonner';
import {onMount} from 'svelte';
import {SvelteURLSearchParams} from 'svelte/reactivity';
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 } from 'lucide-svelte';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import AddMediaCard from '$lib/components/add-media-card.svelte';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
const apiUrl = env.PUBLIC_API_URL;
let searchTerm: string = $state('');
let metadataProvider: "tmdb" | "tvdb" = $state('tmdb');
let data: components['schemas']['MetaDataProviderSearchResult'][] | null = $state(null);
import {base} from '$app/paths';
import client from "$lib/api";
import type {components} from "$lib/api/api";
let searchTerm: string = $state('');
let metadataProvider: 'tmdb' | 'tvdb' = $state('tmdb');
let data: components['schemas']['MetaDataProviderSearchResult'][] | null = $state(null);
import { base } from '$app/paths';
import client from '$lib/api';
import type { components } from '$lib/api/api';
onMount(() => {
search('');
});
onMount(() => {
search('');
});
async function search(query: string) {
const results = query.length > 0 ? await client.GET('/api/v1/tv/search', {
params: {
query: {
query: query,
metadata_provider: metadataProvider
}
}
}) : await client.GET('/api/v1/tv/recommended');
if (results.data && results.data.length > 0) {
toast.success(`Found ${results.data.length} result(s) for "${query}".`);
data = results.data as components['schemas']['MetaDataProviderSearchResult'][];
} else {
toast.info(`No results found for "${query}".`);
data = null;
}
}
async function search(query: string) {
const results =
query.length > 0
? await client.GET('/api/v1/tv/search', {
params: {
query: {
query: query,
metadata_provider: metadataProvider
}
}
})
: await client.GET('/api/v1/tv/recommended');
if (results.data && results.data.length > 0) {
toast.success(`Found ${results.data.length} result(s) for "${query}".`);
data = results.data as components['schemas']['MetaDataProviderSearchResult'][];
} else {
toast.info(`No results found for "${query}".`);
data = null;
}
}
</script>
<svelte:head>
<title>Add TV Show - MediaManager</title>
<meta content="Add a new TV show to your MediaManager collection" name="description"/>
<title>Add TV Show - MediaManager</title>
<meta content="Add a new TV show to your MediaManager collection" name="description" />
</svelte:head>
<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 class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/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="{base}/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Link href="{base}/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>
<main class="flex w-full max-w-[90vw] 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">
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-muted-foreground text-sm">Search for a Show to add.</p>
</section>
<section>
<Collapsible.Root class="w-full space-y-1">
<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-1">
<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(searchTerm)} type="submit">Search</Button>
</section>
</div>
<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">
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-muted-foreground text-sm">Search for a Show to add.</p>
</section>
<section>
<Collapsible.Root class="w-full space-y-1">
<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-1">
<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(searchTerm)} type="submit">Search</Button>
</section>
</div>
<Separator class="my-8"/>
<Separator class="my-8" />
{#if data && data.length === 0}
<h3 class="mx-auto">No Shows found.</h3>
{:else if data}
<div
class="grid w-full auto-rows-min gap-4 sm:grid-cols-1
{#if data && data.length === 0}
<h3 class="mx-auto">No Shows found.</h3>
{:else if data}
<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"
>
{#each data as dataItem (dataItem.external_id)}
<AddMediaCard result={dataItem} isShow={true}/>
{/each}
</div>
{/if}
>
{#each data as dataItem (dataItem.external_id)}
<AddMediaCard result={dataItem} isShow={true} />
{/each}
</div>
{/if}
</main>

View File

@@ -1,10 +1,8 @@
import { env } from '$env/dynamic/public';
import type { LayoutLoad } from './$types';
import client from "$lib/api";
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
export const load: LayoutLoad = async ({ fetch }) => {
const { data, error } = await client.GET('/api/v1/tv/seasons/requests', { fetch: fetch });
const { data } = await client.GET('/api/v1/tv/seasons/requests', { fetch: fetch });
return {
requestsData: data
};

View File

@@ -1,7 +1,5 @@
import { env } from '$env/dynamic/public';
import type { PageLoad } from './$types';
import client from "$lib/api";
import client from '$lib/api';
export const load: PageLoad = async ({ fetch }) => {
const { data } = await client.GET('/api/v1/tv/shows/torrents', { fetch: fetch });

View File

@@ -1,114 +1,112 @@
<script lang="ts">
import {Button} from '$lib/components/ui/button';
import {Input} from '$lib/components/ui/input';
import {Label} from '$lib/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import {toast} from 'svelte-sonner';
import {env} from '$env/dynamic/public';
import {base} from '$app/paths';
import client from "$lib/api";
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { toast } from 'svelte-sonner';
import { base } from '$app/paths';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
let email = $state('');
let isLoading = $state(false);
let isSuccess = $state(false);
let email = $state('');
let isLoading = $state(false);
let isSuccess = $state(false);
async function requestPasswordReset() {
if (!email) {
toast.error('Please enter your email address.');
return;
}
async function requestPasswordReset() {
if (!email) {
toast.error('Please enter your email address.');
return;
}
isLoading = true;
const {data, response} = await client.POST('/api/v1/auth/forgot-password', {body: {email: email}});
isLoading = true;
const { error } = await client.POST('/api/v1/auth/forgot-password', { body: { email: email } });
if (response.ok) {
isSuccess = true;
toast.success('Password reset email sent! Check your inbox for instructions.');
} else {
toast.error(`Failed to send reset email`);
}
isLoading = false;
}
if (error) {
toast.error(`Failed to send reset email`);
} else {
isSuccess = true;
toast.success('Password reset email sent! Check your inbox for instructions.');
}
isLoading = false;
}
const handleSubmit = (event: Event) => {
event.preventDefault();
requestPasswordReset();
};
const handleSubmit = (event: Event) => {
event.preventDefault();
requestPasswordReset();
};
</script>
<svelte:head>
<title>Forgot Password - MediaManager</title>
<meta
content="Reset your MediaManager password - Enter your email to receive a reset link"
name="description"
/>
<title>Forgot Password - MediaManager</title>
<meta
content="Reset your MediaManager password - Enter your email to receive a reset link"
name="description"
/>
</svelte:head>
<Card class="mx-auto max-w-sm">
<CardHeader>
<CardTitle class="text-2xl">Forgot Password</CardTitle>
<CardDescription>
{#if isSuccess}
We've sent a password reset link to your email address if a SMTP server is configured. Check
your inbox and follow the instructions to reset your password. If you didn't receive an
email, please contact an administrator, the reset link will be in the logs of MediaManager.
{:else}
Enter your email address and we'll send you a link to reset your password.
{/if}
</CardDescription>
</CardHeader>
<CardContent>
{#if isSuccess}
<div class="space-y-4">
<div class="rounded-lg bg-green-50 p-4 text-center dark:bg-green-950">
<p class="text-sm text-green-700 dark:text-green-300">
Password reset email sent successfully!
</p>
</div>
<div class="text-muted-foreground text-center text-sm">
<p>Didn't receive the email? Check your spam folder or</p>
<button
class="text-primary hover:underline"
onclick={() => {
<CardHeader>
<CardTitle class="text-2xl">Forgot Password</CardTitle>
<CardDescription>
{#if isSuccess}
We've sent a password reset link to your email address if a SMTP server is configured. Check
your inbox and follow the instructions to reset your password. If you didn't receive an
email, please contact an administrator, the reset link will be in the logs of MediaManager.
{:else}
Enter your email address and we'll send you a link to reset your password.
{/if}
</CardDescription>
</CardHeader>
<CardContent>
{#if isSuccess}
<div class="space-y-4">
<div class="rounded-lg bg-green-50 p-4 text-center dark:bg-green-950">
<p class="text-sm text-green-700 dark:text-green-300">
Password reset email sent successfully!
</p>
</div>
<div class="text-muted-foreground text-center text-sm">
<p>Didn't receive the email? Check your spam folder or</p>
<button
class="text-primary hover:underline"
onclick={() => {
isSuccess = false;
email = '';
}}
>
try again
</button>
</div>
</div>
{:else}
<form class="grid gap-4" onsubmit={handleSubmit}>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
bind:value={email}
disabled={isLoading}
required
/>
</div>
<Button type="submit" class="w-full" disabled={isLoading || !email}>
{#if isLoading}
Sending Reset Email...
{:else}
Send Reset Email
{/if}
</Button>
</form>
{/if}
<div class="mt-4 text-center text-sm">
<a class="text-primary font-semibold hover:underline" href="{base}/login"> Back to Login </a>
</div>
</CardContent>
>
try again
</button>
</div>
</div>
{:else}
<form class="grid gap-4" onsubmit={handleSubmit}>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
bind:value={email}
disabled={isLoading}
required
/>
</div>
<Button type="submit" class="w-full" disabled={isLoading || !email}>
{#if isLoading}
Sending Reset Email...
{:else}
Send Reset Email
{/if}
</Button>
</form>
{/if}
<div class="mt-4 text-center text-sm">
<a class="text-primary font-semibold hover:underline" href="{base}/login"> Back to Login </a>
</div>
</CardContent>
</Card>

View File

@@ -1,120 +1,118 @@
<script lang="ts">
import {page} from '$app/state';
import {Button} from '$lib/components/ui/button';
import {Input} from '$lib/components/ui/input';
import {Label} from '$lib/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import {toast} from 'svelte-sonner';
import {goto} from '$app/navigation';
import {env} from '$env/dynamic/public';
import {onMount} from 'svelte';
import {base} from '$app/paths';
import client from "$lib/api";
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { base } from '$app/paths';
import client from '$lib/api';
const apiUrl = env.PUBLIC_API_URL;
let newPassword = $state('');
let confirmPassword = $state('');
let isLoading = $state(false);
let resetToken = $derived(page.data.token);
let newPassword = $state('');
let confirmPassword = $state('');
let isLoading = $state(false);
let resetToken = $derived(page.data.token);
onMount(() => {
if (!resetToken) {
toast.error('Invalid or missing reset token.');
goto(base + '/login');
}
});
onMount(() => {
if (!resetToken) {
toast.error('Invalid or missing reset token.');
goto(base + '/login');
}
});
async function resetPassword() {
if (newPassword !== confirmPassword) {
toast.error('Passwords do not match.');
return;
}
async function resetPassword() {
if (newPassword !== confirmPassword) {
toast.error('Passwords do not match.');
return;
}
if (!resetToken) {
toast.error('Invalid or missing reset token.');
return;
}
if (!resetToken) {
toast.error('Invalid or missing reset token.');
return;
}
isLoading = true;
isLoading = true;
const {response} = await client.POST('/api/v1/auth/reset-password', {
body: {
password: newPassword,
token: resetToken
}
});
const { response } = await client.POST('/api/v1/auth/reset-password', {
body: {
password: newPassword,
token: resetToken
}
});
if (response.ok) {
toast.success('Password reset successfully! You can now log in with your new password.');
goto(base + '/login');
} else {
toast.error(`Failed to reset password`);
}
isLoading = false;
}
if (response.ok) {
toast.success('Password reset successfully! You can now log in with your new password.');
goto(base + '/login');
} else {
toast.error(`Failed to reset password`);
}
isLoading = false;
}
const handleSubmit = (event: Event) => {
event.preventDefault();
resetPassword();
};
const handleSubmit = (event: Event) => {
event.preventDefault();
resetPassword();
};
</script>
<svelte:head>
<title>Reset Password - MediaManager</title>
<meta content="Reset your MediaManager password with a secure token" name="description"/>
<title>Reset Password - MediaManager</title>
<meta content="Reset your MediaManager password with a secure token" name="description" />
</svelte:head>
<Card class="mx-auto max-w-sm">
<CardHeader>
<CardTitle class="text-2xl">Reset Password</CardTitle>
<CardDescription>Enter your new password below.</CardDescription>
</CardHeader>
<CardContent>
<form class="grid gap-4" onsubmit={handleSubmit}>
<div class="grid gap-2">
<Label for="new-password">New Password</Label>
<Input
bind:value={newPassword}
disabled={isLoading}
id="new-password"
minlength={1}
placeholder="Enter your new password"
required
type="password"
/>
</div>
<div class="grid gap-2">
<Label for="confirm-password">Confirm Password</Label>
<Input
bind:value={confirmPassword}
disabled={isLoading}
id="confirm-password"
minlength={1}
placeholder="Confirm your new password"
required
type="password"
/>
</div>
<Button class="w-full" disabled={isLoading || !newPassword || !confirmPassword} type="submit">
{#if isLoading}
Resetting Password...
{:else}
Reset Password
{/if}
</Button>
</form>
<div class="mt-4 text-center text-sm">
<a class="text-primary font-semibold hover:underline" href="{base}/login"> Back to Login </a>
<span class="text-muted-foreground mx-2"></span>
<a class="text-primary hover:underline" href="{base}/login/forgot-password">
Request New Reset Link
</a>
</div>
</CardContent>
<CardHeader>
<CardTitle class="text-2xl">Reset Password</CardTitle>
<CardDescription>Enter your new password below.</CardDescription>
</CardHeader>
<CardContent>
<form class="grid gap-4" onsubmit={handleSubmit}>
<div class="grid gap-2">
<Label for="new-password">New Password</Label>
<Input
bind:value={newPassword}
disabled={isLoading}
id="new-password"
minlength={1}
placeholder="Enter your new password"
required
type="password"
/>
</div>
<div class="grid gap-2">
<Label for="confirm-password">Confirm Password</Label>
<Input
bind:value={confirmPassword}
disabled={isLoading}
id="confirm-password"
minlength={1}
placeholder="Confirm your new password"
required
type="password"
/>
</div>
<Button class="w-full" disabled={isLoading || !newPassword || !confirmPassword} type="submit">
{#if isLoading}
Resetting Password...
{:else}
Reset Password
{/if}
</Button>
</form>
<div class="mt-4 text-center text-sm">
<a class="text-primary font-semibold hover:underline" href="{base}/login"> Back to Login </a>
<span class="text-muted-foreground mx-2"></span>
<a class="text-primary hover:underline" href="{base}/login/forgot-password">
Request New Reset Link
</a>
</div>
</CardContent>
</Card>