mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-22 08:45:13 +02:00
Project Refactoring
This commit is contained in:
42
src/lib/components/Button.svelte
Normal file
42
src/lib/components/Button.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let size: 'md' | 'sm' | 'lg' = 'md';
|
||||
export let type: 'primary' | 'secondary' | 'tertiary' = 'primary';
|
||||
export let disabled = false;
|
||||
|
||||
export let href: string | undefined = undefined;
|
||||
export let target: string | undefined = undefined;
|
||||
|
||||
let buttonStyle;
|
||||
$: buttonStyle = classNames(
|
||||
'border-2 border-white transition-all uppercase tracking-widest text-xs whitespace-nowrap',
|
||||
{
|
||||
'bg-white text-zinc-900 font-extrabold': type === 'primary',
|
||||
'hover:bg-amber-400 hover:border-amber-400': type === 'primary' && !disabled,
|
||||
'font-semibold': type === 'secondary',
|
||||
'hover:bg-white hover:text-black': type === 'secondary' && !disabled,
|
||||
'px-8 py-3.5': size === 'lg',
|
||||
'px-6 py-2.5': size === 'md',
|
||||
'px-5 py-2': size === 'sm',
|
||||
'opacity-70': disabled,
|
||||
'cursor-pointer': !disabled
|
||||
}
|
||||
);
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (href) {
|
||||
if (target === '_blank') window.open(href, target).focus();
|
||||
else window.open(href, target as string);
|
||||
} else {
|
||||
dispatch('click', event);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<button class={buttonStyle} on:click={handleClick} on:mouseover on:mouseleave {disabled}>
|
||||
<slot />
|
||||
</button>
|
||||
84
src/lib/components/Card/Card.svelte
Normal file
84
src/lib/components/Card/Card.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { formatMinutesToTime } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import { Clock, Star } from 'radix-icons-svelte';
|
||||
|
||||
export let tmdbId: string;
|
||||
export let title: string;
|
||||
export let genres: string[];
|
||||
export let runtimeMinutes: number;
|
||||
export let completionTime = '';
|
||||
export let backdropUrl: string;
|
||||
export let rating: number;
|
||||
|
||||
export let available = true;
|
||||
export let progress = 0;
|
||||
export let type: 'dynamic' | 'normal' | 'large' = 'normal';
|
||||
export let randomProgress = false;
|
||||
if (randomProgress) {
|
||||
progress = Math.random() > 0.3 ? Math.random() * 100 : 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('rounded overflow-hidden relative shadow-2xl shrink-0 aspect-video', {
|
||||
'h-40': type === 'normal',
|
||||
'h-60': type === 'large',
|
||||
'w-full': type === 'dynamic'
|
||||
})}
|
||||
>
|
||||
<div style={'width: ' + progress + '%'} class="h-[2px] bg-zinc-200 bottom-0 absolute z-[1]" />
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
on:click={() => window.open('/movie/' + tmdbId, '_self')}
|
||||
class="h-full w-full opacity-0 hover:opacity-100 transition-opacity flex flex-col justify-between cursor-pointer p-2 px-3 relative z-[1] peer"
|
||||
style={progress > 0 ? 'padding-bottom: 0.6rem;' : ''}
|
||||
>
|
||||
<div>
|
||||
<h1 class="font-bold tracking-wider text-lg">{title}</h1>
|
||||
<div class="text-xs text-zinc-300 tracking-wider font-medium">
|
||||
{genres.map((genre) => genre.charAt(0).toUpperCase() + genre.slice(1)).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-end">
|
||||
{#if completionTime}
|
||||
<div class="text-sm font-medium text-zinc-200 tracking-wide">
|
||||
Downloaded in <b
|
||||
>{formatMinutesToTime((new Date(completionTime).getTime() - Date.now()) / 1000 / 60)}</b
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
{#if runtimeMinutes}
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<Clock />
|
||||
<div class="text-sm text-zinc-200">
|
||||
{progress
|
||||
? formatMinutesToTime(runtimeMinutes - runtimeMinutes * (progress / 100)) + ' left'
|
||||
: formatMinutesToTime(runtimeMinutes)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rating}
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<Star />
|
||||
<div class="text-sm text-zinc-200">
|
||||
{rating.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={"background-image: url('" + TMDB_IMAGES + backdropUrl + "')"}
|
||||
class="absolute inset-0 bg-center bg-cover peer-hover:scale-105 transition-transform"
|
||||
/>
|
||||
<div
|
||||
class={classNames('absolute inset-0 transition-opacity', {
|
||||
'bg-darken opacity-0 peer-hover:opacity-100': available,
|
||||
'bg-[#00000055] peer-hover:bg-darken': !available
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
15
src/lib/components/Card/CardPlaceholder.svelte
Normal file
15
src/lib/components/Card/CardPlaceholder.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let index = 0;
|
||||
export let type: 'dynamic' | 'normal' | 'large' = 'normal';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('rounded overflow-hidden shadow-2xl placeholder shrink-0 aspect-video', {
|
||||
'h-40': type === 'normal',
|
||||
'h-60': type === 'large',
|
||||
'w-full': type === 'dynamic'
|
||||
})}
|
||||
style={'animation-delay: ' + ((index * 100) % 2000) + 'ms;'}
|
||||
/>
|
||||
38
src/lib/components/Card/CardProvider.svelte
Normal file
38
src/lib/components/Card/CardProvider.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { TmdbMovie } from '$lib/apis/tmdbApi';
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchTmdbMovie, fetchTmdbMovieImages } from '$lib/apis/tmdbApi';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import CardPlaceholder from './CardPlaceholder.svelte';
|
||||
import Card from './Card.svelte';
|
||||
|
||||
export let tmdbId: string;
|
||||
|
||||
export let type: 'default' | 'download' | 'in-library' = 'default';
|
||||
|
||||
let tmdbMoviePromise: Promise<TmdbMovie>;
|
||||
let jellyfinItemPromise;
|
||||
let radarrItemPromise;
|
||||
let backdropUrlPromise;
|
||||
|
||||
onMount(async () => {
|
||||
if (!tmdbId) throw new Error('No tmdbId provided');
|
||||
|
||||
backdropUrlPromise = fetchTmdbMovieImages(String(tmdbId)).then(
|
||||
(r) => TMDB_IMAGES + r.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path
|
||||
);
|
||||
tmdbMoviePromise = fetchTmdbMovie(tmdbId);
|
||||
if (type === 'in-library') jellyfinItemPromise = getJellyfinItemByTmdbId(tmdbId);
|
||||
if (type === 'download')
|
||||
radarrItemPromise = fetch(`/movie/${tmdbId}/radarr`).then((r) => r.json());
|
||||
});
|
||||
</script>
|
||||
|
||||
{#await Promise.all([tmdbMoviePromise, jellyfinItemPromise, backdropUrlPromise])}
|
||||
<CardPlaceholder {...$$restProps} />
|
||||
{:then [tmdbMovie, jellyfinItem, backdropUrl]}
|
||||
<Card {...$$restProps} {tmdbMovie} {backdropUrl} {jellyfinItem} />
|
||||
{:catch err}
|
||||
Error
|
||||
{/await}
|
||||
15
src/lib/components/Card/PosterTag.svelte
Normal file
15
src/lib/components/Card/PosterTag.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let value = '';
|
||||
export let filled = false;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('border rounded p-[0px] px-1 text-[10px] font-medium', {
|
||||
'text-zinc-200 border-zinc-500': !filled,
|
||||
'bg-zinc-200 border-zinc-200 text-zinc-900': filled
|
||||
})}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
42
src/lib/components/Card/card.ts
Normal file
42
src/lib/components/Card/card.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { RadarrMovie } from '$lib/apis/radarr/radarrApi';
|
||||
import { fetchTmdbMovieImages } from '$lib/apis/tmdbApi';
|
||||
import type { TmdbMovie } from '$lib/apis/tmdbApi';
|
||||
|
||||
export interface CardProps {
|
||||
tmdbId: string;
|
||||
title: string;
|
||||
genres: string[];
|
||||
runtimeMinutes: number;
|
||||
backdropUrl: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export const fetchCardProps = async (movie: RadarrMovie): Promise<CardProps> => {
|
||||
const backdropUrl = fetchTmdbMovieImages(String(movie.tmdbId)).then(
|
||||
(r) => r.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path
|
||||
);
|
||||
|
||||
return {
|
||||
tmdbId: String(movie.tmdbId),
|
||||
title: String(movie.title),
|
||||
genres: movie.genres as string[],
|
||||
runtimeMinutes: movie.runtime as any,
|
||||
backdropUrl: await backdropUrl,
|
||||
rating: movie.ratings?.tmdb?.value || movie.ratings?.imdb?.value || 0
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchCardPropsTmdb = async (movie: TmdbMovie): Promise<CardProps> => {
|
||||
const backdropUrl = fetchTmdbMovieImages(String(movie.id))
|
||||
.then((r) => r.backdrops.filter((b) => b.iso_639_1 === 'en')[0]?.file_path)
|
||||
.catch(console.error);
|
||||
|
||||
return {
|
||||
tmdbId: String(movie.id),
|
||||
title: String(movie.original_title),
|
||||
genres: movie.genres.map((g) => g.name),
|
||||
runtimeMinutes: movie.runtime,
|
||||
backdropUrl: (await backdropUrl) || '',
|
||||
rating: movie.vote_average || 0
|
||||
};
|
||||
};
|
||||
37
src/lib/components/Carousel/Carousel.svelte
Normal file
37
src/lib/components/Carousel/Carousel.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { ChevronLeft, ChevronRight } from 'radix-icons-svelte';
|
||||
|
||||
let carousel;
|
||||
let scrollX;
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between items-center mx-8">
|
||||
<slot name="title" />
|
||||
<div class="flex gap-2">
|
||||
<IconButton>
|
||||
<ChevronLeft size="20" />
|
||||
</IconButton>
|
||||
<IconButton>
|
||||
<ChevronRight size="20" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex overflow-x-scroll items-center overflow-y-hidden gap-4 relative pl-8 scrollbar-hide py-4"
|
||||
bind:this={carousel}
|
||||
on:scroll={() => (scrollX = carousel.scrollLeft)}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
{#if scrollX > 0}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="absolute inset-y-4 left-0 w-24 bg-gradient-to-r from-darken"
|
||||
/>
|
||||
{/if}
|
||||
<div class="absolute inset-y-4 right-0 w-24 bg-gradient-to-l from-darken" />
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import CardPlaceholder from '../Card/CardPlaceholder.svelte';
|
||||
export let type: 'dynamic' | 'normal' | 'large' = 'normal';
|
||||
</script>
|
||||
|
||||
{#each Array(10) as _, i (i)}
|
||||
<CardPlaceholder {type} />
|
||||
{/each}
|
||||
16
src/lib/components/HeightHider.svelte
Normal file
16
src/lib/components/HeightHider.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let visible = false;
|
||||
export let duration = 300;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('transition-[max-height] duration-1000 overflow-hidden', {
|
||||
'max-h-0': !visible,
|
||||
'max-h-screen': visible
|
||||
})}
|
||||
style="transition-duration: {duration}ms;"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
15
src/lib/components/IconButton.svelte
Normal file
15
src/lib/components/IconButton.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let disabled = false;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('text-zinc-300 hover:text-zinc-50 p-1 flex items-center justify-center', {
|
||||
'opacity-30 cursor-not-allowed pointer-events-none': disabled,
|
||||
'cursor-pointer': !disabled
|
||||
})}
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
20
src/lib/components/Modal/Modal.svelte
Normal file
20
src/lib/components/Modal/Modal.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
export let visible = false;
|
||||
export let close: () => void;
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
class={classNames('fixed inset-0 bg-[#00000088] justify-center items-center z-20', {
|
||||
hidden: !visible,
|
||||
'flex overflow-hidden': visible
|
||||
})}
|
||||
on:click|self={close}
|
||||
transition:fade={{ duration: 100 }}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
10
src/lib/components/Modal/ModalContent.svelte
Normal file
10
src/lib/components/Modal/ModalContent.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="max-w-3xl self-start mt-[10vh] bg-[#33333388] backdrop-blur-xl rounded overflow-hidden flex flex-col flex-1 mx-4 sm:mx-16 lg:mx-24 drop-shadow-xl"
|
||||
in:fly={{ y: 20, duration: 200 }}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
17
src/lib/components/Modal/ModalHeader.svelte
Normal file
17
src/lib/components/Modal/ModalHeader.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { Cross2 } from 'radix-icons-svelte';
|
||||
|
||||
export let text;
|
||||
export let close;
|
||||
</script>
|
||||
|
||||
<div class="flex text-zinc-200 items-center p-3 px-5 gap-4 border-b border-zinc-700">
|
||||
<slot />
|
||||
{#if text}
|
||||
<p class="font flex-1">{text}</p>
|
||||
{/if}
|
||||
<IconButton on:click={close}>
|
||||
<Cross2 size="20" />
|
||||
</IconButton>
|
||||
</div>
|
||||
57
src/lib/components/Navbar/Navbar.svelte
Normal file
57
src/lib/components/Navbar/Navbar.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { MagnifyingGlass, Person } from 'radix-icons-svelte';
|
||||
import classNames from 'classnames';
|
||||
import { page } from '$app/stores';
|
||||
import TitleSearchModal from './TitleSearchModal.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
|
||||
let y = 0;
|
||||
let transparent = true;
|
||||
let baseStyle = '';
|
||||
|
||||
let isSearchVisible = false;
|
||||
|
||||
function getLinkStyle(path: string) {
|
||||
return $page.url.pathname === path ? 'text-amber-200' : 'hover:text-zinc-50 cursor-pointer';
|
||||
}
|
||||
|
||||
$: {
|
||||
transparent = y <= 0;
|
||||
baseStyle = classNames(
|
||||
'fixed px-8 inset-x-0 grid grid-cols-[min-content_1fr_min-content] items-center z-10',
|
||||
'transition-all',
|
||||
{
|
||||
'bg-zinc-900 bg-opacity-50 backdrop-blur-2xl h-16': !transparent,
|
||||
'h-24': transparent
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY={y} />
|
||||
|
||||
<div class={baseStyle}>
|
||||
<a href="/" class="flex gap-2 items-center hover:text-inherit">
|
||||
<div class="rounded-full bg-amber-300 h-4 w-4" />
|
||||
<h1 class="font-display uppercase font-semibold tracking-wider text-xl">Reiverr</h1>
|
||||
</a>
|
||||
<div
|
||||
class="flex items-center justify-center gap-8 font-normal text-sm tracking-wider text-zinc-200"
|
||||
>
|
||||
<a href="/" class={$page && getLinkStyle('/')}>Home</a>
|
||||
<a href="/discover" class={$page && getLinkStyle('/discover')}>Discover</a>
|
||||
<a href="/library" class={$page && getLinkStyle('/library')}>Library</a>
|
||||
<a href="/sources" class={$page && getLinkStyle('/sources')}>Sources</a>
|
||||
<a href="/settings" class={$page && getLinkStyle('/settings')}>Settings</a>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton on:click={() => (isSearchVisible = true)}>
|
||||
<MagnifyingGlass size={20} />
|
||||
</IconButton>
|
||||
<IconButton>
|
||||
<Person size={20} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TitleSearchModal bind:visible={isSearchVisible} />
|
||||
87
src/lib/components/Navbar/TitleSearchModal.svelte
Normal file
87
src/lib/components/Navbar/TitleSearchModal.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import ModalContent from '../Modal/ModalContent.svelte';
|
||||
import { Cross1, Cross2, MagnifyingGlass } from 'radix-icons-svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { TmdbApi } from '$lib/apis/tmdbApi';
|
||||
import type { MultiSearchResponse } from '$lib/apis/tmdbApi';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import ModalHeader from '../Modal/ModalHeader.svelte';
|
||||
export let visible = false;
|
||||
let searchValue = '';
|
||||
|
||||
export let close = () => {
|
||||
visible = false;
|
||||
searchValue = '';
|
||||
fetching = false;
|
||||
results = null;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
};
|
||||
|
||||
let timeout;
|
||||
let fetching = false;
|
||||
let results: MultiSearchResponse['results'] | null = null;
|
||||
const searchTimeout = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
searchMovie(searchValue);
|
||||
}, 700);
|
||||
};
|
||||
|
||||
const searchMovie = (query: string) => {
|
||||
fetching = true;
|
||||
TmdbApi.get<MultiSearchResponse>('/search/movie', {
|
||||
params: {
|
||||
query
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data) results = res.data.results;
|
||||
})
|
||||
.finally(() => (fetching = false));
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal {visible} {close}>
|
||||
<ModalContent>
|
||||
<ModalHeader {close}>
|
||||
<MagnifyingGlass size="20" class="text-zinc-400" />
|
||||
<input
|
||||
bind:value={searchValue}
|
||||
on:input={searchTimeout}
|
||||
type="text"
|
||||
class="flex-1 bg-transparent font-light outline-none"
|
||||
placeholder="Search for Movies and Shows..."
|
||||
/>
|
||||
</ModalHeader>
|
||||
{#if !results || searchValue === ''}
|
||||
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">No recent searches</div>
|
||||
{:else if results?.length === 0 && !fetching}
|
||||
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">No search results</div>
|
||||
{:else}
|
||||
<div class="py-2">
|
||||
{#each results.filter((m) => m).slice(0, 5) as result}
|
||||
<div
|
||||
class="flex px-4 py-2 gap-4 hover:bg-highlight-dim cursor-pointer"
|
||||
on:click={() => window.open('/movie/' + result.id)}
|
||||
>
|
||||
<div
|
||||
style={"background-image: url('" + TMDB_IMAGES + result.poster_path + "');"}
|
||||
class="bg-center bg-cover w-16 h-24 rounded-sm"
|
||||
/>
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<div class="flex gap-2">
|
||||
<div class="font-normal tracking-wide">{result.original_title}</div>
|
||||
<div class="text-zinc-400">
|
||||
{new Date(result.release_date).getFullYear()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-zinc-300 line-clamp-3">{result.overview}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
98
src/lib/components/Poster/Poster.svelte
Normal file
98
src/lib/components/Poster/Poster.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { TmdbApi } from '$lib/apis/tmdbApi';
|
||||
import type { TmdbMovie } from '$lib/apis/tmdbApi';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import { formatMinutesToTime } from '$lib/utils';
|
||||
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
|
||||
export let tmdbId;
|
||||
export let progress = 0;
|
||||
export let length = 0;
|
||||
export let randomProgress = false;
|
||||
if (randomProgress) progress = Math.random() > 0.5 ? Math.round(Math.random() * 100) : 100;
|
||||
|
||||
const { streamJellyfinId } = getContext('player');
|
||||
|
||||
export let type: 'movie' | 'tv' = 'movie';
|
||||
|
||||
let bg = '';
|
||||
let title = 'Loading...';
|
||||
|
||||
onMount(() => {
|
||||
TmdbApi.get('/' + type + '/' + tmdbId)
|
||||
.then((res) => res.data)
|
||||
.then((data: any) => {
|
||||
bg = TMDB_IMAGES + data.poster_path;
|
||||
title = data.title;
|
||||
});
|
||||
});
|
||||
|
||||
let streamFetching = false;
|
||||
function stream() {
|
||||
if (streamFetching || !tmdbId) return;
|
||||
streamFetching = true;
|
||||
getJellyfinItemByTmdbId(tmdbId).then((item: any) => {
|
||||
if (item.Id) streamJellyfinId(item.Id);
|
||||
streamFetching = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="group grid grid-cols-[2px_1fr_2px] grid-rows-[2px_1fr_2px]">
|
||||
<div
|
||||
style={'width: ' + progress + '%'}
|
||||
class="h-full bg-zinc-200 opacity-100 group-hover:opacity-80 transition-all col-span-3"
|
||||
/>
|
||||
<div
|
||||
style={'height: ' + progress + '%'}
|
||||
class="w-full bg-zinc-200 opacity-100 group-hover:opacity-80 transition-all"
|
||||
/>
|
||||
<div
|
||||
class="bg-center bg-cover aspect-[2/3] h-72 m-1.5"
|
||||
style={"background-image: url('" + bg + "')"}
|
||||
>
|
||||
<div class="w-full h-full hover:bg-darken transition-all flex">
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-2 flex flex-col justify-between flex-1 cursor-pointer"
|
||||
on:click={() => (window.location.href = '/' + type + '/' + tmdbId)}
|
||||
>
|
||||
<div>
|
||||
<h1 class="font-bold text-lg tracking-wide">
|
||||
{title}
|
||||
</h1>
|
||||
{#if type === 'movie'}
|
||||
<h2 class="text-xs uppercase text-zinc-300"><b>December</b> 2022</h2>
|
||||
{:else}
|
||||
<h2 class="text-xs uppercase text-zinc-300">S1 <b>E2</b></h2>
|
||||
{/if}
|
||||
{#if progress && length}
|
||||
<h2 class="mt-2 text-sm tracking-wide text-zinc-300">
|
||||
<b>{formatMinutesToTime(length * (1 - progress / 100))}</b> left
|
||||
</h2>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
class="bg-white border-2 border-white hover:bg-amber-400 hover:border-amber-400 transition-colors text-zinc-900 px-8 py-2.5 uppercase tracking-widest font-extrabold cursor-pointer text-xs"
|
||||
on:click|stopPropagation={stream}>Stream</button
|
||||
>
|
||||
<a
|
||||
on:click|stopPropagation
|
||||
href={'/' + type + '/' + tmdbId}
|
||||
class="border-2 border-white cursor-pointer transition-colors px-8 py-2.5 uppercase tracking-widest font-semibold text-xs hover:bg-amber-400 hover:text-black text-center"
|
||||
>Details</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={'height: ' + progress + '%'}
|
||||
class="w-full bg-zinc-200 opacity-100 group-hover:opacity-80 transition-all self-end"
|
||||
/>
|
||||
<div
|
||||
style={'width: ' + progress + '%'}
|
||||
class="h-full bg-zinc-200 opacity-100 group-hover:opacity-80 transition-all col-span-3 justify-self-end"
|
||||
/>
|
||||
</div>
|
||||
127
src/lib/components/RequestModal/RequestModal.svelte
Normal file
127
src/lib/components/RequestModal/RequestModal.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import ModalContent from '../Modal/ModalContent.svelte';
|
||||
import { formatMinutesToTime, formatSize } from '$lib/utils';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { DotFilled, Download, Plus } from 'radix-icons-svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ModalHeader from '../Modal/ModalHeader.svelte';
|
||||
import { log } from '$lib/utils.js';
|
||||
import HeightHider from '../HeightHider.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let visible = true; // FIXME
|
||||
function close() {
|
||||
visible = false;
|
||||
downloadFetching = false;
|
||||
downloadingGuid = null;
|
||||
}
|
||||
|
||||
export let radarrId;
|
||||
|
||||
let releasesResponse;
|
||||
$: if (visible) {
|
||||
releasesResponse = fetch(`/movie/${radarrId}/releases`).then((res) => log(res.json()));
|
||||
}
|
||||
|
||||
let downloadFetching;
|
||||
let downloadingGuid;
|
||||
function handleDownload(guid) {
|
||||
downloadFetching = guid;
|
||||
fetch('/movie/0/releases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ guid })
|
||||
}).then((res) => {
|
||||
dispatch('download');
|
||||
downloadFetching = false;
|
||||
if (res.ok) {
|
||||
downloadingGuid = guid;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let showAllReleases = false;
|
||||
function toggleShowAll() {
|
||||
showAllReleases = !showAllReleases;
|
||||
}
|
||||
|
||||
let showDetailsId;
|
||||
function toggleShowDetails(id) {
|
||||
if (showDetailsId === id) {
|
||||
showDetailsId = null;
|
||||
} else {
|
||||
showDetailsId = id;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {visible} {close}>
|
||||
<ModalContent>
|
||||
<ModalHeader {close} text="Releases" />
|
||||
{#await releasesResponse}
|
||||
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">Loading...</div>
|
||||
{:then data}
|
||||
{#if showAllReleases ? data?.allReleases?.length : data?.filtered?.length}
|
||||
<div class="flex flex-col py-2 divide-y divide-zinc-700 max-h-[60vh] overflow-y-scroll">
|
||||
{#each showAllReleases ? data.allReleases : data.filtered as release}
|
||||
<div>
|
||||
<div
|
||||
class="flex px-4 py-2 gap-4 hover:bg-highlight-dim items-center justify-between cursor-pointer text-sm"
|
||||
on:click={() => toggleShowDetails(release.guid)}
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="tracking-wide font-medium">{release.indexer}</div>
|
||||
<div class="text-zinc-400">{release.quality.quality.name}</div>
|
||||
<div class="text-zinc-400">{release.seeders} seeders</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="text-zinc-400">{formatSize(release.size)}</div>
|
||||
{#if release.guid !== downloadingGuid}
|
||||
<IconButton
|
||||
on:click={() => handleDownload(release.guid)}
|
||||
disabled={downloadFetching === release.guid}
|
||||
>
|
||||
<Plus size="20" />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<div class="p-1">
|
||||
<Download size="20" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<HeightHider visible={showDetailsId === release.guid}>
|
||||
<div class="flex gap-1 text-xs text-zinc-400 px-4 py-2 items-center flex-wrap">
|
||||
<div>
|
||||
{release.title}
|
||||
</div>
|
||||
<DotFilled size="15" />
|
||||
<div>{formatMinutesToTime(release.ageMinutes)} old</div>
|
||||
<DotFilled size="15" />
|
||||
<div><b>{release.seeders} seeders</b> / {release.leechers} leechers</div>
|
||||
<DotFilled size="15" />
|
||||
{#if release.seeders}
|
||||
<div>
|
||||
{formatSize(release.size / release.seeders)} per seeder
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</HeightHider>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if data?.releasesSkipped > 0}
|
||||
<div
|
||||
class="text-sm text-zinc-200 opacity-50 font-light px-4 py-2 hover:underline cursor-pointer"
|
||||
on:click={toggleShowAll}
|
||||
>
|
||||
{showAllReleases ? 'Show less' : `Show all ${data.releasesSkipped} releases`}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">No releases found.</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
224
src/lib/components/ResourceDetails/LibraryDetails.svelte
Normal file
224
src/lib/components/ResourceDetails/LibraryDetails.svelte
Normal file
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { formatMinutesToTime, formatSize } from '$lib/utils';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { DotFilled, Minus, Plus, Trash, Update } from 'radix-icons-svelte';
|
||||
import RequestModal from '../RequestModal/RequestModal.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import classNames from 'classnames';
|
||||
import { log } from '$lib/utils.js';
|
||||
|
||||
let isRequestModalVisible = false;
|
||||
export let tmdbId: string;
|
||||
export let jellyfinStreamDisabled;
|
||||
export let openJellyfinStream;
|
||||
|
||||
let response;
|
||||
|
||||
const headerStyle = 'uppercase tracking-widest font-bold';
|
||||
|
||||
let refetchTimeout;
|
||||
let isRefetching = false;
|
||||
async function refetch() {
|
||||
console.log('refetching...');
|
||||
isRefetching = true;
|
||||
const req = fetch(`/movie/${tmdbId}`)
|
||||
.then((res) => log(res.json()))
|
||||
.then((res: any) => {
|
||||
if (res?.radarrDownloads?.length) {
|
||||
clearTimeout(refetchTimeout);
|
||||
refetchTimeout = setTimeout(() => refetch(), 10000);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.finally(() => (isRefetching = false));
|
||||
|
||||
if (!response) response = req;
|
||||
else response = await req;
|
||||
}
|
||||
|
||||
let addToRadarrLoading = false;
|
||||
function addToRadarr() {
|
||||
if (!tmdbId || addToRadarrLoading) return;
|
||||
|
||||
addToRadarrLoading = true;
|
||||
console.log('here');
|
||||
|
||||
fetch(`/movie/${tmdbId}/radarr`, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then((res) => {
|
||||
console.log('res', res);
|
||||
if (res.ok) {
|
||||
refetch();
|
||||
}
|
||||
})
|
||||
.finally(() => (addToRadarrLoading = false));
|
||||
}
|
||||
|
||||
let cancelDownloadFetching = false;
|
||||
function cancelDownload(downloadId: number) {
|
||||
if (cancelDownloadFetching) return;
|
||||
cancelDownloadFetching = true;
|
||||
fetch(`/movie/${downloadId}/releases`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.ok) refetch();
|
||||
})
|
||||
.finally(() => (cancelDownloadFetching = false));
|
||||
}
|
||||
|
||||
let deleteMovieFetching = false;
|
||||
function deleteFile(movieId: number) {
|
||||
deleteMovieFetching = true;
|
||||
fetch(`/movie/${movieId}/file`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.ok) refetch();
|
||||
})
|
||||
.finally(() => (deleteMovieFetching = false));
|
||||
}
|
||||
|
||||
function openRequestModal() {
|
||||
isRequestModalVisible = true;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refetch();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#await response then data}
|
||||
{#if data}
|
||||
<div class="flex flex-col gap-8 p-8">
|
||||
{#if !data.canStream && !data.isDownloading}
|
||||
<div>
|
||||
<h1 class="text-lg mb-1 font-medium tracking-wide">No sources found</h1>
|
||||
<p class="text-zinc-300">
|
||||
No local or remote sources found for this title. You can configure your sources on the <a
|
||||
href="/sources"
|
||||
class="text-amber-200 hover:text-amber-100">sources</a
|
||||
> page.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.isAdded && data.radarrMovie}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class={headerStyle}>Local Library</div>
|
||||
<IconButton on:click={openRequestModal}>
|
||||
<Plus size={20} />
|
||||
</IconButton>
|
||||
<IconButton>
|
||||
<Trash size={20} />
|
||||
</IconButton>
|
||||
<IconButton disabled={isRefetching} on:click={refetch}>
|
||||
<Update size={15} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{#each data.radarrDownloads || [] as downloadingFile}
|
||||
<div
|
||||
class={classNames('border-l-2 p-1 px-4 flex justify-between items-center py-1', {
|
||||
'border-purple-400': downloadingFile.status === 'downloading',
|
||||
'border-amber-400': downloadingFile.status !== 'downloading'
|
||||
})}
|
||||
>
|
||||
<div class="flex gap-1 items-center">
|
||||
<b class="">{downloadingFile.quality.quality.resolution}p</b>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
<h2 class="text-zinc-200 text-sm">{formatSize(downloadingFile.size)} on disk</h2>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
<h2 class="uppercase text-zinc-200 text-sm">
|
||||
{downloadingFile.quality.quality.source}
|
||||
</h2>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
{#if downloadingFile.timeleft}
|
||||
<h2 class="text-zinc-200 text-sm">
|
||||
Completed in {formatMinutesToTime(
|
||||
(new Date(downloadingFile.estimatedCompletionTime).getTime() - Date.now()) /
|
||||
1000 /
|
||||
60
|
||||
)}
|
||||
</h2>
|
||||
{:else if downloadingFile.status === 'queued'}
|
||||
<h2 class="text-zinc-200 text-sm">Download starting</h2>
|
||||
{:else}
|
||||
<h2 class="text-orange-300 text-sm">Download Stalled</h2>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="secondary"
|
||||
disabled={downloadingFile.status === 'importing' || cancelDownloadFetching}
|
||||
on:click={() => cancelDownload(downloadingFile.id)}>Cancel Download</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if data?.radarrMovie?.movieFile}
|
||||
<div class="border-l-2 border-zinc-200 p-1 px-4 flex justify-between items-center my-1">
|
||||
<div class="flex gap-1 items-center">
|
||||
<b class="">{data.radarrMovie.movieFile.quality.quality.resolution}p</b>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
<h2 class="text-zinc-200 text-sm">
|
||||
{formatSize(data.radarrMovie.movieFile.size)} on disk
|
||||
</h2>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
<h2 class="uppercase text-zinc-200 text-sm">
|
||||
{data.radarrMovie.movieFile.quality.quality.source}
|
||||
</h2>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
<h2 class="uppercase text-zinc-200 text-sm">
|
||||
{data.radarrMovie.movieFile.mediaInfo.videoCodec}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
type="secondary"
|
||||
on:click={() => deleteFile(data.radarrMovie.movieFile.id)}
|
||||
disabled={deleteMovieFetching}>Delete File</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
on:click={() => openJellyfinStream()}
|
||||
disabled={jellyfinStreamDisabled}>Stream</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !data?.radarrMovie?.movieFile && !data?.radarrDownloads?.length}
|
||||
<div class="text-zinc-400 text-sm font-light">Click + to add files</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-center bg-black p-8 py-4 empty:hidden">
|
||||
{#if !data.isAdded || data.hasLocalFiles}
|
||||
{#if !data.isAdded}
|
||||
<Button on:click={() => addToRadarr()} disabled={addToRadarrLoading}>Add to Radarr</Button
|
||||
>
|
||||
{/if}
|
||||
<!--{#if data.hasLocalFiles}-->
|
||||
<!-- <Button type="secondary">Manage Local Files</Button>-->
|
||||
<!--{/if}-->
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.isAdded && data.radarrMovie}
|
||||
<RequestModal
|
||||
bind:visible={isRequestModalVisible}
|
||||
radarrId={data.radarrMovie.id}
|
||||
on:download={() => setTimeout(refetch, 5000)}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
no data
|
||||
{/if}
|
||||
{:catch err}
|
||||
Could not load local movie data.
|
||||
{JSON.stringify(err)}
|
||||
{/await}
|
||||
307
src/lib/components/ResourceDetails/ResourceDetails.svelte
Normal file
307
src/lib/components/ResourceDetails/ResourceDetails.svelte
Normal file
@@ -0,0 +1,307 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Clock } from 'radix-icons-svelte';
|
||||
import classNames from 'classnames';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import type { CastMember, TmdbMovie, Video } from '$lib/apis/tmdbApi';
|
||||
import { fetchTmdbMovieCredits, fetchTmdbMovieVideos } from '$lib/apis/tmdbApi';
|
||||
import LibraryDetails from './LibraryDetails.svelte';
|
||||
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import HeightHider from '../HeightHider.svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import type { PlayerState } from '../VideoPlayer/VideoPlayer';
|
||||
|
||||
export let movie: TmdbMovie;
|
||||
export let videos: Video[];
|
||||
export let castMembers: CastMember[];
|
||||
export let showDetails = false;
|
||||
export let trailer = true;
|
||||
|
||||
let showTrailer = false;
|
||||
let focusTrailer = false;
|
||||
let trailerStartTime = 0;
|
||||
let detailsVisible = showDetails;
|
||||
let streamButtonDisabled = true;
|
||||
let jellyfinId: string;
|
||||
|
||||
let video: Video;
|
||||
$: video = videos?.filter((v) => v.site === 'YouTube' && v.type === 'Trailer')?.[0];
|
||||
|
||||
let opacityStyle: string;
|
||||
$: opacityStyle =
|
||||
(focusTrailer ? 'opacity: 0;' : 'opacity: 100;') + 'transition: opacity 0.3s ease-in-out;';
|
||||
|
||||
// Transitions
|
||||
const duration = 200;
|
||||
const monthNames = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December'
|
||||
];
|
||||
const releaseDate = new Date(movie.release_date);
|
||||
const { playerState, close, streamJellyfinId } = getContext<PlayerState>('player');
|
||||
|
||||
function openTrailer() {
|
||||
window
|
||||
?.open(
|
||||
'https://www.youtube.com/watch?v=' +
|
||||
video.key +
|
||||
'&autoplay=1&t=' +
|
||||
(trailerStartTime === 0 ? 0 : Math.floor((Date.now() - trailerStartTime) / 1000)),
|
||||
'_blank'
|
||||
)
|
||||
?.focus();
|
||||
}
|
||||
|
||||
let fadeIndex = -1;
|
||||
const getFade = () => {
|
||||
fadeIndex += 1;
|
||||
return { duration: 200, delay: 500 + fadeIndex * 50 };
|
||||
};
|
||||
|
||||
// onMount(() => {});
|
||||
|
||||
let timeout: NodeJS.Timeout;
|
||||
$: {
|
||||
fadeIndex = 0;
|
||||
streamButtonDisabled = true;
|
||||
if (movie) {
|
||||
showTrailer = false;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if (trailer) {
|
||||
showTrailer = true;
|
||||
trailerStartTime = Date.now();
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
fetchTmdbMovieVideos(String(movie.id)).then((result) => {
|
||||
videos = result;
|
||||
});
|
||||
|
||||
fetchTmdbMovieCredits(String(movie.id)).then((result) => {
|
||||
castMembers = result;
|
||||
});
|
||||
|
||||
getJellyfinItemByTmdbId(String(movie.id)).then((r) => {
|
||||
if (!r) return;
|
||||
streamButtonDisabled = !r;
|
||||
if (r.Id) jellyfinId = r.Id;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let localDetailsTop: HTMLElement;
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
<div
|
||||
class="min-h-max h-screen w-screen overflow-hidden row-start-1 col-start-1 relative"
|
||||
out:fade={{ duration }}
|
||||
in:fade={{ delay: duration, duration }}
|
||||
>
|
||||
{#key video?.key + movie.id}
|
||||
<div
|
||||
class="absolute inset-0 bg-center bg-cover transition-[background-image] duration-500 delay-500"
|
||||
style={"background-image: url('" + TMDB_IMAGES + movie.backdrop_path + "');"}
|
||||
transition:fade
|
||||
/>
|
||||
<div class="youtube-container absolute h-full scale-[150%] hidden sm:block" transition:fade>
|
||||
{#if video?.key}
|
||||
<iframe
|
||||
class={classNames('transition-opacity', {
|
||||
'opacity-100': showTrailer,
|
||||
'opacity-0': !showTrailer
|
||||
})}
|
||||
src={'https://www.youtube.com/embed/' +
|
||||
video.key +
|
||||
'?autoplay=1&mute=1&loop=1&controls=0&modestbranding=1&playsinline=1&rel=0&enablejsapi=1'}
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
{#key movie.id}
|
||||
<div
|
||||
class={classNames(
|
||||
'bg-gradient-to-b from-darken via-20% via-transparent transition-opacity absolute inset-0 z-[1]',
|
||||
{
|
||||
'opacity-100': focusTrailer,
|
||||
'opacity-0': !focusTrailer
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
class={classNames(
|
||||
'h-full w-full px-16 pb-8 pt-32',
|
||||
'grid grid-cols-[1fr_max-content] grid-rows-[1fr_min-content] gap-x-16 gap-y-8 relative z-[2]',
|
||||
'transition-colors',
|
||||
{
|
||||
'bg-darken': !focusTrailer,
|
||||
'bg-transparent': focusTrailer
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div class="flex flex-col justify-self-start min-w-0 row-span-full">
|
||||
<div class="relative" style={opacityStyle} in:fly={{ x: -20, duration, delay: 400 }}>
|
||||
<h2 class="text-zinc-300 text-sm self-end">
|
||||
<span class="font-bold uppercase tracking-wider"
|
||||
>{monthNames[releaseDate.getMonth()]}</span
|
||||
>
|
||||
{releaseDate.getFullYear()}
|
||||
</h2>
|
||||
<h2
|
||||
class="tracking-wider font-display font-extrabold text-amber-300 absolute opacity-10 text-8xl -ml-6 mt-8"
|
||||
>
|
||||
<slot name="reason">Popular Now</slot>
|
||||
</h2>
|
||||
<h1 class="uppercase text-8xl font-bold font-display z-[1] relative">
|
||||
{movie.original_title}
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="mt-auto max-w-3xl flex flex-col gap-4"
|
||||
style={opacityStyle}
|
||||
in:fly={{ x: -20, duration, delay: 600 }}
|
||||
>
|
||||
<div class="text-xl font-semibold tracking-wider">{movie.tagline}</div>
|
||||
<div
|
||||
class="tracking-wider text-zinc-200 font-light leading-6 pl-4 border-l-2 border-zinc-300"
|
||||
>
|
||||
{movie.overview}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-6 mt-10" in:fly={{ x: -20, duration, delay: 600 }}>
|
||||
<div class="flex gap-1">
|
||||
<div style={opacityStyle}>
|
||||
<Button
|
||||
disabled={streamButtonDisabled}
|
||||
size="lg"
|
||||
on:click={() => jellyfinId && streamJellyfinId(jellyfinId)}>Stream</Button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="hidden items-center justify-center border-2 border-white w-10 cursor-pointer hover:bg-white hover:text-zinc-900 transition-colors"
|
||||
>
|
||||
<ChevronDown size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={opacityStyle} class:hidden={showDetails}>
|
||||
<Button
|
||||
size="lg"
|
||||
type="secondary"
|
||||
on:click={() => {
|
||||
detailsVisible = true;
|
||||
localDetailsTop?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}>Details</Button
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
type="secondary"
|
||||
on:mouseover={() => (focusTrailer = trailer)}
|
||||
on:mouseleave={() => (focusTrailer = false)}
|
||||
on:click={openTrailer}>Watch Trailer</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6 max-w-[14rem] row-span-full" style={opacityStyle}>
|
||||
<h3 class="text-xs tracking-wide uppercase" in:fade={getFade()}>Details</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="tracking-widest font-extralight text-sm" in:fade={getFade()}>
|
||||
{movie.genres.map((g) => g.name.charAt(0).toUpperCase() + g.name.slice(1)).join(', ')}
|
||||
</div>
|
||||
<div class="flex gap-1.5 items-center" in:fade={getFade()}>
|
||||
<Clock size={14} />
|
||||
<div class="tracking-widest font-extralight text-sm">
|
||||
{Math.floor(movie.runtime / 60)}h {movie.runtime % 60}m
|
||||
</div>
|
||||
</div>
|
||||
<div class="tracking-widest font-extralight text-sm" in:fade={getFade()}>
|
||||
Currently <b>Streaming</b>
|
||||
</div>
|
||||
<a
|
||||
href={'https://www.themoviedb.org/movie/' + movie.id}
|
||||
target="_blank"
|
||||
class="tracking-widest font-extralight text-sm"
|
||||
in:fade={getFade()}
|
||||
>
|
||||
<b>{movie.vote_average.toFixed(1)}</b> TMDB
|
||||
</a>
|
||||
<div class="flex mt-4" in:fade={getFade()}>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="text-white w-4"
|
||||
><g
|
||||
><path d="M0 0h24v24H0z" fill="none" /><path
|
||||
d="M11.29 3.814l2.02 5.707.395 1.116.007-4.81.01-4.818h4.27L18 11.871c.003 5.98-.003 10.89-.015 10.9-.012.009-.209 0-.436-.027-.989-.118-2.29-.236-3.34-.282a14.57 14.57 0 0 1-.636-.038c-.003-.004-.273-.762-.776-2.184v-.004l-2.144-6.061-.34-.954-.008 4.586c-.006 4.365-.01 4.61-.057 4.61-.163 0-1.57.09-2.04.136-.308.027-.926.09-1.37.145-.446.051-.816.085-.823.078C6.006 22.77 6 17.867 6 11.883V1.002h.005V1h4.288l.028.08c.007.016.065.176.157.437l.641 1.778.173.496-.001.023z"
|
||||
fill-rule="evenodd"
|
||||
fill="currentColor"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if castMembers?.length > 0}
|
||||
<h3 class="text-xs tracking-wide uppercase" in:fade={getFade()}>Starring</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each castMembers.slice(0, 5) as a}
|
||||
<a
|
||||
href={'https://www.themoviedb.org/person/' + a.id}
|
||||
target="_blank"
|
||||
class="tracking-widest font-extralight text-sm"
|
||||
in:fade={getFade()}>{a.name}</a
|
||||
>
|
||||
{/each}
|
||||
<a
|
||||
href={'https://www.themoviedb.org/movie/' + movie.id + '/cast'}
|
||||
target="_blank"
|
||||
class="tracking-widest font-extralight text-sm"
|
||||
in:fade={getFade()}>View all...</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<slot name="page-controls" />
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeightHider duration={1000} visible={detailsVisible}>
|
||||
<div bind:this={localDetailsTop} />
|
||||
{#key movie.id}
|
||||
<LibraryDetails
|
||||
openJellyfinStream={() => jellyfinId && streamJellyfinId(jellyfinId)}
|
||||
jellyfinStreamDisabled={streamButtonDisabled}
|
||||
tmdbId={String(movie.id)}
|
||||
/>
|
||||
{/key}
|
||||
</HeightHider>
|
||||
|
||||
<style>
|
||||
.youtube-container {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.youtube-container iframe {
|
||||
width: 300%;
|
||||
height: 100%;
|
||||
margin-left: -100%;
|
||||
}
|
||||
</style>
|
||||
16
src/lib/components/SetupRequired/SetupRequired.svelte
Normal file
16
src/lib/components/SetupRequired/SetupRequired.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
export let missingEnvironmentVariables: Record<string, boolean>;
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col max-w-screen-2xl mx-auto p-4 md:p-8 lg:px-32 gap-2">
|
||||
<h1 class="font-bold text-3xl">Welcome to Reiverr</h1>
|
||||
<p>
|
||||
It seems like the application is missing some environment variables that are necessary for the
|
||||
application to function. Please provide the following environment variables:
|
||||
</p>
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each Object.keys(missingEnvironmentVariables).filter((k) => missingEnvironmentVariables[k]) as variableName}
|
||||
<code class="bg-highlight-dim p-0.5 px-2 rounded self-start">{variableName}</code>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
34
src/lib/components/SourceStats/RadarrStats.svelte
Normal file
34
src/lib/components/SourceStats/RadarrStats.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { formatSize, log } from '$lib/utils.js';
|
||||
import StatsPlaceholder from './StatsPlaceholder.svelte';
|
||||
import StatsContainer from './StatsContainer.svelte';
|
||||
import RadarrIcon from '../svgs/RadarrIcon.svelte';
|
||||
|
||||
export let large = false;
|
||||
|
||||
async function fetchStats() {
|
||||
return fetch('/radarr/stats')
|
||||
.then((res) => res.json())
|
||||
.then(log)
|
||||
.then((data) => ({
|
||||
moviesAmount: data?.movies?.length
|
||||
}));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await fetchStats()}
|
||||
<StatsPlaceholder {large} />
|
||||
{:then { moviesAmount }}
|
||||
<StatsContainer
|
||||
{large}
|
||||
title="Radarr"
|
||||
subtitle="Movies Provider"
|
||||
stats={[
|
||||
{ title: 'Movies', value: String(moviesAmount) },
|
||||
{ title: 'Space Taken', value: formatSize(120_000_000_000) },
|
||||
{ title: 'Space Left', value: formatSize(50_000_000_000) }
|
||||
]}
|
||||
>
|
||||
<RadarrIcon slot="icon" class="absolute opacity-20 p-4 h-full inset-y-0 right-2" />
|
||||
</StatsContainer>
|
||||
{/await}
|
||||
37
src/lib/components/SourceStats/SonarrStats.svelte
Normal file
37
src/lib/components/SourceStats/SonarrStats.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { formatSize } from '$lib/utils.js';
|
||||
import { onMount } from 'svelte';
|
||||
import StatsPlaceholder from './StatsPlaceholder.svelte';
|
||||
import StatsContainer from './StatsContainer.svelte';
|
||||
import SonarrIcon from '../svgs/SonarrIcon.svelte';
|
||||
|
||||
export let large = false;
|
||||
|
||||
let statsRequest: Promise<{ moviesAmount: number }> = new Promise((_) => {}) as any;
|
||||
|
||||
onMount(() => {
|
||||
statsRequest = fetch('/radarr/stats')
|
||||
.then((res) => res.json())
|
||||
.then((data) => ({
|
||||
moviesAmount: data?.movies?.length
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#await statsRequest}
|
||||
<StatsPlaceholder {large} />
|
||||
{:then { moviesAmount }}
|
||||
<StatsContainer
|
||||
{large}
|
||||
title="Sonarr"
|
||||
subtitle="Shows Provider"
|
||||
stats={[
|
||||
{ title: 'Movies', value: String(moviesAmount) },
|
||||
{ title: 'Space Taken', value: formatSize(120_000_000_000) },
|
||||
{ title: 'Space Left', value: formatSize(50_000_000_000) }
|
||||
]}
|
||||
color="#8aacfd21"
|
||||
>
|
||||
<SonarrIcon slot="icon" class="absolute opacity-20 p-4 h-full inset-y-0 right-2" />
|
||||
</StatsContainer>
|
||||
{/await}
|
||||
48
src/lib/components/SourceStats/StatsContainer.svelte
Normal file
48
src/lib/components/SourceStats/StatsContainer.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import RadarrIcon from '../svgs/RadarrIcon.svelte';
|
||||
|
||||
type Stat = {
|
||||
title: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export let title: string;
|
||||
export let subtitle: string;
|
||||
export let stats: Stat[] = [];
|
||||
|
||||
export let color: string = '#fde68a20';
|
||||
export let large = false;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('relative w-full mx-auto px-6 rounded-xl overflow-hidden', {
|
||||
'h-16': !large,
|
||||
'h-28': large
|
||||
})}
|
||||
style={'background-color: ' + color + ';'}
|
||||
>
|
||||
<div class="absolute left-0 inset-y-0 w-[70%] bg-[#ffffff22]" />
|
||||
{#if large}
|
||||
<slot name="icon" />
|
||||
{/if}
|
||||
<div
|
||||
class={classNames('relative z-[1] flex flex-1 h-full', {
|
||||
'justify-between items-center': !large,
|
||||
'flex-col justify-center gap-2': large
|
||||
})}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-zinc-400 font-medium text-xs tracking-wider">{subtitle}</h3>
|
||||
<a href="/static" class="text-zinc-200 font-bold text-xl tracking-wide">{title}</a>
|
||||
</div>
|
||||
<div class="flex gap-8">
|
||||
{#each stats as { title, value }}
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<h3 class="uppercase text-zinc-400 font-medium text-xs tracking-wider">{title}</h3>
|
||||
<div class="font-medium text-sm">{value}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
12
src/lib/components/SourceStats/StatsPlaceholder.svelte
Normal file
12
src/lib/components/SourceStats/StatsPlaceholder.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let large = false;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('placeholder w-full rounded-xl', {
|
||||
'h-16': !large,
|
||||
'h-28': large
|
||||
})}
|
||||
/>
|
||||
108
src/lib/components/VideoPlayer/VideoPlayer.svelte
Normal file
108
src/lib/components/VideoPlayer/VideoPlayer.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
getJellyfinItem,
|
||||
getJellyfinPlaybackInfo,
|
||||
reportJellyfinPlaybackProgress,
|
||||
reportJellyfinPlaybackStarted,
|
||||
reportJellyfinPlaybackStopped
|
||||
} from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import Hls from 'hls.js';
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { Cross2 } from 'radix-icons-svelte';
|
||||
import classNames from 'classnames';
|
||||
import { getContext, onDestroy } from 'svelte';
|
||||
import { PUBLIC_JELLYFIN_URL } from '$env/static/public';
|
||||
import getDeviceProfile from '$lib/apis/jellyfin/playback-profiles';
|
||||
import type { PlayerState, PlayerStateValue } from './VideoPlayer';
|
||||
|
||||
const { playerState, close } = getContext<PlayerState>('player');
|
||||
|
||||
let video: HTMLVideoElement;
|
||||
|
||||
let stopCallback: () => void;
|
||||
|
||||
let progressInterval: ReturnType<typeof setInterval>;
|
||||
onDestroy(() => clearInterval(progressInterval));
|
||||
|
||||
const fetchPlaybackInfo = (itemId: string) =>
|
||||
getJellyfinPlaybackInfo(itemId, getDeviceProfile()).then(
|
||||
async ({ playbackUrl: uri, playSessionId: sessionId, mediaSourceId }) => {
|
||||
if (!uri || !sessionId) return;
|
||||
|
||||
const item = await getJellyfinItem(itemId);
|
||||
|
||||
const hls = new Hls();
|
||||
|
||||
hls.loadSource(PUBLIC_JELLYFIN_URL + uri);
|
||||
hls.attachMedia(video);
|
||||
video
|
||||
.play()
|
||||
.then(() => video.requestFullscreen())
|
||||
.then(() => {
|
||||
if (item?.UserData?.PlaybackPositionTicks) {
|
||||
video.currentTime = item?.UserData?.PlaybackPositionTicks / 10_000_000;
|
||||
}
|
||||
});
|
||||
if (mediaSourceId) await reportJellyfinPlaybackStarted(itemId, sessionId, mediaSourceId);
|
||||
progressInterval = setInterval(() => {
|
||||
reportJellyfinPlaybackProgress(
|
||||
itemId,
|
||||
sessionId,
|
||||
video?.paused == true,
|
||||
video?.currentTime * 10_000_000
|
||||
);
|
||||
}, 5000);
|
||||
stopCallback = () => {
|
||||
reportJellyfinPlaybackStopped(itemId, sessionId, video?.currentTime * 10_000_000);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
close();
|
||||
video?.pause();
|
||||
clearInterval(progressInterval);
|
||||
stopCallback?.();
|
||||
playerState.set({ visible: false, jellyfinId: '' });
|
||||
}
|
||||
|
||||
let uiVisible = false;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
function handleMouseMove() {
|
||||
uiVisible = true;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
uiVisible = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
let state: PlayerStateValue;
|
||||
playerState.subscribe((s) => (state = s));
|
||||
|
||||
$: {
|
||||
if (video && state.jellyfinId) {
|
||||
if (!Hls.isSupported()) {
|
||||
throw new Error('HLS is not supported');
|
||||
}
|
||||
|
||||
if (video.src === '') fetchPlaybackInfo(state.jellyfinId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal visible={$playerState.visible} close={handleClose}>
|
||||
<div class="bg-black w-screen h-screen relative" on:mousemove={handleMouseMove}>
|
||||
<video controls bind:this={video} class="w-full h-full inset-0" />
|
||||
<div
|
||||
class={classNames('absolute top-4 right-8 transition-opacity', {
|
||||
'opacity-0': !uiVisible,
|
||||
'opacity-100': uiVisible
|
||||
})}
|
||||
>
|
||||
<IconButton on:click={handleClose}>
|
||||
<Cross2 />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
17
src/lib/components/VideoPlayer/VideoPlayer.ts
Normal file
17
src/lib/components/VideoPlayer/VideoPlayer.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const initialValue = { visible: false, jellyfinId: '' };
|
||||
export const playerState = writable(initialValue);
|
||||
|
||||
export const initialPlayerState = {
|
||||
playerState,
|
||||
close: () => {
|
||||
playerState.set({ visible: false, jellyfinId: '' });
|
||||
},
|
||||
streamJellyfinId: (id: string) => {
|
||||
playerState.set({ visible: true, jellyfinId: id });
|
||||
}
|
||||
};
|
||||
|
||||
export type PlayerState = typeof initialPlayerState;
|
||||
export type PlayerStateValue = typeof initialValue;
|
||||
9
src/lib/components/svgs/RadarrIcon.svelte
Normal file
9
src/lib/components/svgs/RadarrIcon.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg
|
||||
viewBox="0 0 1000 1115.2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={$$restProps.class || 'h-10 w-10 flex-shrink-0'}
|
||||
><path
|
||||
d="m120.015 174.916-1.433 810.916C50.198 993.53-.595 958.758.245 890.481L0 216.126C2.624 2.76 199.555-46.036 317.994 40.776l601.67 357.383c84.615 60.794 100.32 171.956 56.7 248.25-7.799-59.85-32.984-94.304-83.773-129.073l-678.066-392.46c-50.79-34.768-93.568-26.758-94.513 50.056zm-61.707 852.847c51 17.7 102.314 9.794 145.3-15.285L908.5 611.405c41.94 60.268 32.671 119.903-18.958 153.414L296.44 1098.972c-85.873 41.624-196.297-2.414-238.138-71.217z"
|
||||
fill="#fff"
|
||||
/><path d="m272.941 797.285 414.225-245.888L273.7 327.762z" fill="#ffc230" /></svg
|
||||
>
|
||||
|
After Width: | Height: | Size: 684 B |
34
src/lib/components/svgs/SonarrIcon.svelte
Normal file
34
src/lib/components/svgs/SonarrIcon.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<svg
|
||||
viewBox="0 0 216.7 216.9"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={$$restProps.class || 'h-10 w-10 flex-shrink-0'}
|
||||
><path
|
||||
d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3a92.767 92.767 0 0 1-11 9.25c-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z"
|
||||
clip-rule="evenodd"
|
||||
fill="#EEE"
|
||||
fill-rule="evenodd"
|
||||
/><path
|
||||
d="m194.65 42.5-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3a90.601 90.601 0 0 1-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6a95.782 95.782 0 0 1-10.7-9.5c-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4a134.482 134.482 0 0 1 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7a121.188 121.188 0 0 1 9.55 10.95z"
|
||||
clip-rule="evenodd"
|
||||
fill="#3A3F51"
|
||||
fill-rule="evenodd"
|
||||
/><g clip-rule="evenodd"
|
||||
><path
|
||||
d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55a39.613 39.613 0 0 1 0-4c0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65a27.364 27.364 0 0 1-3.05 2.55c-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6a26.29 26.29 0 0 1-3.75-3.2c-4.532-4.5-7.316-9.734-8.35-15.7z"
|
||||
fill="#0CF"
|
||||
fill-rule="evenodd"
|
||||
/><path
|
||||
d="m157.8 59.75-15 14.65M30.785 32.526 71.65 73.25m84.6 84.25 27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126 27.35-27.4"
|
||||
fill="none"
|
||||
stroke="#0CF"
|
||||
stroke-miterlimit="1"
|
||||
stroke-width="2"
|
||||
/><path
|
||||
d="m157.8 59.75-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396 18.028 17.945"
|
||||
fill="none"
|
||||
stroke="#0CF"
|
||||
stroke-miterlimit="1"
|
||||
stroke-width="7"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
3
src/lib/components/utils/WidthLimited.svelte
Normal file
3
src/lib/components/utils/WidthLimited.svelte
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class={$$restProps.class + ' mx-auto max-w-screen-2xl'}>
|
||||
<slot />
|
||||
</div>
|
||||
Reference in New Issue
Block a user