replace all occurrences of fetch with openapi-fetch in routes

This commit is contained in:
maxDorninger
2025-08-29 21:06:59 +02:00
parent 5a71246623
commit ded9503169
21 changed files with 1043 additions and 1358 deletions

View File

@@ -7,11 +7,12 @@
import { base } from '$app/paths';
import type { MetaDataProviderSearchResult } from '$lib/types.js';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import type {components} from "$lib/api/api";
const apiUrl = env.PUBLIC_API_URL;
let loading = $state(false);
let errorMessage = $state<string | null>(null);
let { result, isShow = true }: { result: MetaDataProviderSearchResult; isShow: boolean } =
let { result, isShow = true }: { result: components['schemas']['MetaDataProviderSearchResult']; isShow: boolean } =
$props();
console.log('Add Show Card Result: ', result);

View File

@@ -5,13 +5,14 @@
import { Button } from '$lib/components/ui/button';
import { ChevronRight } from 'lucide-svelte';
import { base } from '$app/paths';
import type {components} from "$lib/api/api";
let {
media,
isShow,
isLoading
}: {
media: MetaDataProviderSearchResult[];
media: components['schemas']['MetaDataProviderSearchResult'][];
isShow: boolean;
isLoading: boolean;
} = $props();

View File

@@ -4,17 +4,13 @@ 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;
export const load: LayoutLoad = async ({ fetch }) => {
const response = await fetch(apiUrl + '/users/me', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
const { data, response } = await client.GET('/api/v1/users/me', { fetch: fetch });
if (!response.ok) {
console.log('unauthorized, redirecting to login');
if (browser) {
@@ -23,5 +19,5 @@ export const load: LayoutLoad = async ({ fetch }) => {
throw redirect(303, base + '/login');
}
}
return { user: await response.json() };
return { user: data };
};

View File

@@ -1,90 +1,79 @@
<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 {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';
const apiUrl = env.PUBLIC_API_URL;
const apiUrl = env.PUBLIC_API_URL;
let recommendedShows: MetaDataProviderSearchResult[] = [];
let showsLoading = true;
let recommendedShows: components['schemas']['MetaDataProviderSearchResult'][] = [];
let showsLoading = true;
let recommendedMovies: MetaDataProviderSearchResult[] = [];
let moviesLoading = true;
let recommendedMovies: components['schemas']['MetaDataProviderSearchResult'][] = [];
let moviesLoading = true;
onMount(async () => {
const showsRes = await fetch(apiUrl + '/tv/recommended', {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
credentials: 'include',
method: 'GET'
});
recommendedShows = await showsRes.json();
showsLoading = false;
const moviesRes = await fetch(apiUrl + '/movies/recommended', {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
credentials: 'include',
method: 'GET'
});
recommendedMovies = await moviesRes.json();
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,101 +1,93 @@
<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 type { PublicMovie } from '$lib/types';
import { base } from '$app/paths';
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';
const apiUrl = env.PUBLIC_API_URL;
let movies: PublicMovie[] = [];
let loading = false;
onMount(async () => {
loading = true;
const response = await fetch(apiUrl + '/movies', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (!response.ok) {
toast.error(`Failed to fetch movies`);
throw new Error(`Failed to fetch movies: ${response.status} ${response.statusText}`);
}
movies = await response.json();
console.log('got movies: ', movies);
loading = false;
});
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});
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,13 +1,17 @@
import type { PageLoad } from './$types';
import { env } from '$env/dynamic/public';
import { error } from '@sveltejs/kit';
import client from "$lib/api";
export const load: PageLoad = async ({ params, fetch }) => {
const res = await fetch(`${env.PUBLIC_API_URL}/movies/${params.movieId}`, {
credentials: 'include'
const { data } = await client.GET('/api/v1/movies/{movie_id}', {
fetch: fetch,
params: {
path: {
movie_id: params.movieId
},
}
});
if (!res.ok) throw error(res.status, `Failed to load movie`);
const movieData = await res.json();
console.log('got movie data', movieData);
return { movie: movieData };
return { movie: data };
};

View File

@@ -15,53 +15,33 @@
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";
const apiUrl = env.PUBLIC_API_URL;
let searchTerm: string = $state('');
let metadataProvider: string = $state('tmdb');
let results: MetaDataProviderSearchResult[] | null = $state(null);
let metadataProvider: "tmdb" | "tvdb" = $state('tmdb');
let results: components['schemas']['MetaDataProviderSearchResult'][] | null = $state(null);
onMount(() => {
search('');
});
async function search(query: string) {
let urlString = apiUrl + '/movies/recommended';
const urlParams = new SvelteURLSearchParams();
if (query.length > 0) {
urlString = apiUrl + '/movies/search';
urlParams.append('query', query);
toast.info(`Searching for "${query}" using ${metadataProvider.toUpperCase()}...`);
}
urlParams.append('metadata_provider', metadataProvider);
urlString += `?${urlParams.toString()}`;
try {
const response = await fetch(urlString, {
method: 'GET',
credentials: 'include'
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Search failed: ${response.status} ${errorText || response.statusText}`);
const {data} = query.length > 0 ? await client.GET('/api/v1/movies/search', {
params: {
query: {
query: query,
metadata_provider: metadataProvider
}
}
results = await response.json();
console.log('Fetched results:', results);
if (query.length === 0) {
return;
}
if (results && results.length > 0) {
toast.success(`Found ${results.length} result(s) for "${query}".`);
} else {
toast.info(`No results found for "${query}".`);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'An unknown error occurred during search.';
console.error('Search error:', error);
toast.error(errorMessage);
results = null; // Clear previous results on error
}) : 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'][];
} else {
toast.info(`No results found for "${query}".`);
results = null;
}
}
</script>

View File

@@ -1,34 +1,12 @@
import { env } from '$env/dynamic/public';
import type { PageLoad } from './$types';
import {env} from '$env/dynamic/public';
import type {PageLoad} from './$types';
import client from "$lib/api";
const apiUrl = env.PUBLIC_API_URL;
export const load: PageLoad = async ({ fetch }) => {
try {
const requests = await fetch(`${apiUrl}/movies/requests`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
export const load: PageLoad = async ({fetch}) => {
const {data} = await client.GET('/api/v1/movies/requests', {fetch: fetch});
if (!requests.ok) {
console.error(`Failed to fetch season requests ${requests.statusText}`);
return {
requestsData: null
};
}
const requestsData = await requests.json();
console.log('Fetched season requests:', requestsData);
return {
requestsData: requestsData
};
} catch (error) {
console.error('Error fetching season requests:', error);
return {
requestsData: null
};
}
return {
requestsData: data
};
};

View File

@@ -11,20 +11,14 @@
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";
const apiUrl = env.PUBLIC_API_URL;
let torrents: RichMovieTorrent[] = [];
let torrents: components["schemas"]["RichMovieTorrent"][] = [];
onMount(async () => {
const res = await fetch(apiUrl + '/movies/torrents', {
method: 'GET',
credentials: 'include'
});
if (!res.ok) {
toast.error('Failed to fetch torrents');
throw new Error(`Failed to fetch torrents: ${res.status} ${res.statusText}`);
}
torrents = await res.json();
console.log('got torrents: ', torrents);
const { data } = await client.GET('/api/v1/movies/torrents');
torrents = data as components["schemas"]["RichMovieTorrent"][];
});
</script>

View File

@@ -1,332 +1,260 @@
<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 {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';
const apiUrl = env.PUBLIC_API_URL;
import { base } from '$app/paths';
const apiUrl = env.PUBLIC_API_URL;
import {base} from '$app/paths';
import client from "$lib/api";
import type {components} from "$lib/api/api";
interface NotificationResponse {
id: string;
read: boolean;
message: string;
timestamp: string;
}
let unreadNotifications: components["schemas"]["Notification"][] = [];
let readNotifications: components["schemas"]["Notification"][] = [];
let loading = true;
let showRead = false;
let markingAllAsRead = false;
let unreadNotifications: NotificationResponse[] = [];
let readNotifications: NotificationResponse[] = [];
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() {
try {
loading = true;
const [unreadResponse, allResponse] = await Promise.all([
fetch(`${apiUrl}/notification/unread`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
}),
fetch(`${apiUrl}/notification`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
})
]);
async function markAsRead(notificationId: string) {
const {response} = await client.PATCH('/api/v1/notification/{notification_id}/read', {params: {path: {notification_id: notificationId}}});
if (unreadResponse.ok) {
unreadNotifications = await unreadResponse.json();
}
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 (allResponse.ok) {
const allNotifications: NotificationResponse[] = await allResponse.json();
readNotifications = allNotifications.filter((n) => n.read);
}
} catch (error) {
console.error('Failed to fetch notifications:', error);
} finally {
loading = false;
}
}
async function markAsUnread(notificationId: string) {
const {response} = await client.PATCH('/api/v1/notification/{notification_id}/unread', {params: {path: {notification_id: notificationId}}});
async function markAsRead(notificationId: string) {
try {
const response = await fetch(`${apiUrl}/notification/${notificationId}/read`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
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 = unreadNotifications.find((n) => n.id === notificationId);
if (notification) {
notification.read = true;
readNotifications = [notification, ...readNotifications];
unreadNotifications = unreadNotifications.filter((n) => n.id !== notificationId);
}
}
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
}
async function markAllAsRead() {
if (unreadNotifications.length === 0) return;
async function markAsUnread(notificationId: string) {
try {
const response = await fetch(`${apiUrl}/notification/${notificationId}/unread`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
try {
markingAllAsRead = true;
const promises = unreadNotifications.map((notification) =>
client.PATCH('/api/v1/notification/{notification_id}/read', {params: {path: {notification_id: notification.id!}}})
);
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);
}
}
} catch (error) {
console.error('Failed to mark notification as unread:', error);
}
}
await Promise.all(promises);
async function markAllAsRead() {
if (unreadNotifications.length === 0) return;
// 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;
}
}
try {
markingAllAsRead = true;
const promises = unreadNotifications.map((notification) =>
fetch(`${apiUrl}/notification/${notification.id}/read`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
})
);
onMount(() => {
fetchNotifications();
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;
}
}
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
if (diffInMinutes < 1) return 'Just now';
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
if (diffInHours < 24) return `${diffInHours}h ago`;
if (diffInDays < 7) return `${diffInDays}d ago`;
return date.toLocaleDateString();
}
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">
{formatTimestamp(notification.timestamp)}
</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">
{formatTimestamp(notification.timestamp)}
</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,34 +1,12 @@
import { env } from '$env/dynamic/public';
import type { PageLoad } from './$types';
import {env} from '$env/dynamic/public';
import type {PageLoad} from './$types';
import client from "$lib/api";
const apiUrl = env.PUBLIC_API_URL;
export const load: PageLoad = async ({ fetch }) => {
try {
const users = await fetch(apiUrl + '/users/all', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
export const load: PageLoad = async ({fetch}) => {
const {data} = await client.GET('/api/v1/users/all', {fetch: fetch});
if (!users.ok) {
console.error(`Failed to fetch users: ${users.statusText}`);
return {
users: null
};
}
const usersData = await users.json();
console.log('Fetched users:', usersData);
return {
users: usersData
};
} catch (error) {
console.error('Error fetching users:', error);
return {
users: null
};
}
return {
users: data
};
};

View File

@@ -1,85 +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 {Skeleton} from '$lib/components/ui/skeleton';
import {base} from '$app/paths';
let tvShowsPromise = 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>
{#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 " />
{/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">
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"
>
{#await tvShowsPromise}
{@render loadingbar()}
{:then tvShowsJson}
{#await tvShowsJson.json()}
{@render loadingbar()}
{:then tvShows}
{#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}
{/await}
{/await}
</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,16 +1,9 @@
import { env } from '$env/dynamic/public';
import type { PageLoad } from './$types';
import {env} from '$env/dynamic/public';
import client from '$lib/api';
import type {PageLoad} from './$types';
const apiUrl = env.PUBLIC_API_URL;
export const load: PageLoad = async ({ fetch }) => {
const response = fetch(apiUrl + '/tv/shows', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
return { tvShows: response };
export const load: PageLoad = async ({fetch}) => {
const {data} = await client.GET('/api/v1/tv/shows', {fetch: fetch});
return {tvShows: data};
};

View File

@@ -1,56 +1,20 @@
import { env } from '$env/dynamic/public';
import type { LayoutLoad } from './$types';
import {env} from '$env/dynamic/public';
import type {LayoutLoad} from './$types';
import client from "$lib/api";
const apiUrl = env.PUBLIC_API_URL;
export const load: LayoutLoad = async ({ params, fetch }) => {
const showId = 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}}
});
if (!showId) {
return {
showData: null,
torrentsData: null
};
}
try {
const show = await fetch(`${apiUrl}/tv/shows/${showId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
const torrents = await fetch(`${apiUrl}/tv/shows/${showId}/torrents`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (!show.ok || !torrents.ok) {
console.error(`Failed to fetch show ${showId}: ${show.statusText}`);
return {
showData: null,
torrentsData: null
};
}
const showData = await show.json();
const torrentsData = await torrents.json();
console.log('Fetched show data:', showData);
console.log('Fetched torrents data:', torrentsData);
return {
showData: showData,
torrentsData: torrentsData
};
} catch (error) {
console.error('Error fetching show:', error);
return {
showData: null,
torrentsData: null
};
}
return {
showData: show.data,
torrentsData: torrents.data
};
};

View File

@@ -1,214 +1,211 @@
<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 {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';
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';
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 continuousDownloadEnabled = $state(show().continuous_download);
let continuousDownloadEnabled = $state(show().continuous_download);
async function toggle_continuous_download() {
const urlString = `${apiUrl}/tv/shows/${show().id}/continuousDownload?continuous_download=${!continuousDownloadEnabled}`;
console.log(
'Toggling continuous download for show',
show().name,
'to',
!continuousDownloadEnabled
);
const response = await fetch(urlString, {
method: 'POST',
credentials: 'include'
});
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.');
}
}
/* $effect(()=>{
continuousDownloadEnabled;
toggle_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.');
}
}
</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,47 +1,28 @@
import { env } from '$env/dynamic/public';
import type { PageLoad } from './$types';
import client from "$lib/api";
const apiUrl = env.PUBLIC_API_URL;
export const load: PageLoad = async ({ fetch, params }) => {
const url = `${apiUrl}/tv/seasons/${params.SeasonId}/files`;
const url2 = `${apiUrl}/tv/seasons/${params.SeasonId}`;
try {
console.log(`Fetching data from: ${url} and ${url2}`);
const response = await fetch(url, {
method: 'GET',
credentials: 'include'
});
const response2 = await fetch(url2, {
method: 'GET',
credentials: 'include'
});
if (!response.ok) {
const errorText = await response.text();
console.error(`API request failed with status ${response.status}: ${errorText}`);
const season = await client.GET('/api/v1/tv/seasons/{season_id}', {
fetch: fetch,
params: {
path: {
season_id: params.SeasonId
},
}
if (!response2.ok) {
const errorText = await response.text();
console.error(`API request failed with status ${response.status}: ${errorText}`);
});
const seasonFiles = await client.GET('/api/v1/tv/seasons/{season_id}/files', {
fetch: fetch,
params: {
path: {
season_id: params.SeasonId
},
}
const filesData = await response.json();
const seasonData = await response2.json();
console.log('received season_files data: ', filesData);
console.log('received season data: ', seasonData);
return {
files: filesData,
season: seasonData
};
} catch (error) {
console.error('An error occurred while fetching TV show files:', error);
return {
error: `An unexpected error occurred: ${error instanceof Error ? error.message : 'Unknown error'}`,
files: [],
season: null
};
}
});
return {
files: seasonFiles.data,
season: season.data
};
};

View File

@@ -1,155 +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 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 { SvelteURLSearchParams } from 'svelte/reactivity';
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';
const apiUrl = env.PUBLIC_API_URL;
let searchTerm: string = $state('');
let metadataProvider: string = $state('tmdb');
let results: MetaDataProviderSearchResult[] | null = $state(null);
import { base } from '$app/paths';
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";
onMount(() => {
search('');
});
onMount(() => {
search('');
});
async function search(query: string) {
let urlString = apiUrl + '/tv/recommended';
const urlParams = new SvelteURLSearchParams();
if (query.length > 0) {
urlString = apiUrl + '/tv/search';
urlParams.append('query', query);
toast.info(`Searching for "${query}" using ${metadataProvider.toUpperCase()}...`);
}
urlParams.append('metadata_provider', metadataProvider);
urlString += `?${urlParams.toString()}`;
try {
const response = await fetch(urlString, {
method: 'GET',
credentials: 'include'
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Search failed: ${response.status} ${errorText || response.statusText}`);
}
results = await response.json();
console.log('Fetched results:', results);
if (query.length === 0) {
return;
}
if (results && results.length > 0) {
toast.success(`Found ${results.length} result(s) for "${query}".`);
} else {
toast.info(`No results found for "${query}".`);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'An unknown error occurred during search.';
console.error('Search error:', error);
toast.error(errorMessage);
results = null; // Clear previous results on error
}
}
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 results && results.length === 0}
<h3 class="mx-auto">No Shows found.</h3>
{:else if results}
<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 results as result (result.external_id)}
<AddMediaCard {result} isShow={true} />
{/each}
</div>
{/if}
>
{#each data as dataItem (dataItem.external_id)}
<AddMediaCard result={dataItem} isShow={true}/>
{/each}
</div>
{/if}
</main>

View File

@@ -1,34 +1,11 @@
import { env } from '$env/dynamic/public';
import type { LayoutLoad } from './$types';
import client from "$lib/api";
const apiUrl = env.PUBLIC_API_URL;
export const load: LayoutLoad = async ({ fetch }) => {
try {
const requests = await fetch(`${apiUrl}/tv/seasons/requests`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (!requests.ok) {
console.error(`Failed to fetch season requests ${requests.statusText}`);
return {
requestsData: null
};
}
const requestsData = await requests.json();
console.log('Fetched season requests:', requestsData);
return {
requestsData: requestsData
};
} catch (error) {
console.error('Error fetching season requests:', error);
return {
requestsData: null
};
}
const { data, error } = await client.GET('/api/v1/tv/seasons/requests', { fetch: fetch });
return {
requestsData: data
};
};

View File

@@ -1,12 +1,9 @@
import { env } from '$env/dynamic/public';
import type { PageLoad } from './$types';
import client from "$lib/api";
const apiUrl = env.PUBLIC_API_URL;
export const load: PageLoad = async ({ fetch }) => {
const response = await fetch(apiUrl + '/tv/shows/torrents', {
method: 'GET',
credentials: 'include'
});
return { shows: response.json() };
const { data } = await client.GET('/api/v1/tv/shows/torrents', { fetch: fetch });
return { shows: data };
};

View File

@@ -1,128 +1,114 @@
<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 {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";
const apiUrl = env.PUBLIC_API_URL;
let email = $state('');
let isLoading = $state(false);
let isSuccess = $state(false);
const apiUrl = env.PUBLIC_API_URL;
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;
isLoading = true;
const {data, response} = await client.POST('/api/v1/auth/forgot-password', {body: {email: email}});
try {
const response = await fetch(apiUrl + '/auth/forgot-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email }),
credentials: 'include'
});
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 (response.ok) {
isSuccess = true;
toast.success('Password reset email sent! Check your inbox for instructions.');
} else {
const errorText = await response.text();
toast.error(`Failed to send reset email: ${errorText}`);
}
} catch (error) {
console.error('Error requesting password reset:', error);
toast.error('An error occurred while sending the reset email. Please try again.');
} finally {
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 href="{base}/login" class="text-primary font-semibold hover:underline"> 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,129 +1,120 @@
<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 {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";
const apiUrl = env.PUBLIC_API_URL;
let newPassword = $state('');
let confirmPassword = $state('');
let isLoading = $state(false);
let resetToken = $derived(page.data.token);
const apiUrl = env.PUBLIC_API_URL;
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;
try {
const response = await fetch(apiUrl + `/auth/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ password: newPassword, token: resetToken }),
credentials: 'include'
});
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 {
const errorText = await response.text();
toast.error(`Failed to reset password: ${errorText}`);
throw new Error(`Failed to reset password: ${errorText}`);
}
} catch (error) {
console.error('Error resetting password:', error);
toast.error(error instanceof Error ? error.message : 'An unknown error occurred.');
} finally {
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
id="new-password"
type="password"
placeholder="Enter your new password"
bind:value={newPassword}
disabled={isLoading}
required
minlength={1}
/>
</div>
<div class="grid gap-2">
<Label for="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
type="password"
placeholder="Confirm your new password"
bind:value={confirmPassword}
disabled={isLoading}
required
minlength={1}
/>
</div>
<Button type="submit" class="w-full" disabled={isLoading || !newPassword || !confirmPassword}>
{#if isLoading}
Resetting Password...
{:else}
Reset Password
{/if}
</Button>
</form>
<div class="mt-4 text-center text-sm">
<a href="{base}/login" class="text-primary font-semibold hover:underline"> Back to Login </a>
<span class="text-muted-foreground mx-2"></span>
<a href="{base}/login/forgot-password" class="text-primary hover:underline">
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>