mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-21 16:25:36 +02:00
replace all occurrences of fetch with openapi-fetch in routes
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user