feat: Add context button with "open in" links in title pages & modals

This commit is contained in:
Aleksi Lassila
2023-08-15 02:15:51 +03:00
parent 383d43d52c
commit 95dd5e070c
17 changed files with 220 additions and 82 deletions

View File

@@ -106,6 +106,8 @@ PUBLIC_JELLYFIN_API_KEY=yourapikeyhere
PUBLIC_JELLYFIN_URL=http://127.0.0.1:8096
```
For Webstorm users: I'd recommend using VS Code as it has way better Svelte Typescript support.
# Additional Screenshots
![Landing Page](images/screenshot-1.png)

View File

@@ -6,6 +6,7 @@
export let size: 'md' | 'sm' | 'lg' | 'xs' = 'md';
export let type: 'primary' | 'secondary' | 'tertiary' = 'secondary';
export let slim = false;
export let disabled = false;
export let href: string | undefined = undefined;
@@ -22,10 +23,17 @@
'focus-visible:bg-zinc-200 focus-visible:text-zinc-800 hover:bg-zinc-200 hover:text-zinc-800':
(type === 'secondary' || type === 'tertiary') && !disabled,
'rounded-full': type === 'tertiary',
'py-2 px-6 sm:py-3 sm:px-6': size === 'lg',
'py-2 px-6': size === 'md',
'py-1 px-4': size === 'sm',
'py-1 px-4 text-sm': size === 'xs',
'py-2 px-6 sm:py-3 sm:px-6': size === 'lg' && !slim,
'py-2 px-6': size === 'md' && !slim,
'py-1 px-4': size === 'sm' && !slim,
'py-1 px-4 text-sm': size === 'xs' && !slim,
'p-2 sm:p-3': size === 'lg' && slim,
'p-2': size === 'md' && slim,
'p-1': size === 'sm' && slim,
'p-1 text-sm': size === 'xs' && slim,
'opacity-50': disabled,
'cursor-pointer': !disabled
}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import { library } from '$lib/stores/library.store';
import { createLibraryItemStore, library } from '$lib/stores/library.store';
import { formatMinutesToTime } from '$lib/utils';
import classNames from 'classnames';
import { Clock, Star } from 'radix-icons-svelte';
@@ -10,9 +10,9 @@
import type { TitleType } from '$lib/types';
import { openTitleModal } from '../Modal/Modal';
import ProgressBar from '../ProgressBar.svelte';
import LibraryItemContextItems from '../ContextMenu/LibraryItemContextItems.svelte';
export let tmdbId: number;
export let jellyfinId: string | undefined = undefined;
export let type: TitleType = 'movie';
export let title: string;
export let genres: string[] = [];
@@ -27,30 +27,12 @@
export let size: 'dynamic' | 'md' | 'lg' = 'md';
export let openInModal = true;
let watched = false;
$: watched = !available && !!jellyfinId;
function handleSetWatched() {
if (jellyfinId) {
setJellyfinItemWatched(jellyfinId).finally(() => library.refreshIn(3000));
}
}
function handleSetUnwatched() {
if (jellyfinId) {
setJellyfinItemUnwatched(jellyfinId).finally(() => library.refreshIn(3000));
}
}
let itemStore = createLibraryItemStore(tmdbId);
</script>
<ContextMenu heading={title} disabled={!jellyfinId}>
<ContextMenu heading={title}>
<svelte:fragment slot="menu">
<ContextMenuItem on:click={handleSetWatched} disabled={!jellyfinId || watched}>
Mark as watched
</ContextMenuItem>
<ContextMenuItem on:click={handleSetUnwatched} disabled={!jellyfinId || !watched}>
Mark as unwatched
</ContextMenuItem>
<LibraryItemContextItems {itemStore} {type} {tmdbId} />
</svelte:fragment>
<button
class={classNames(

View File

@@ -4,22 +4,26 @@
export let heading = '';
export let disabled = false;
export let position: 'absolute' | 'fixed' = 'fixed';
let anchored = position === 'absolute';
const id = Symbol();
export let id = Symbol();
let menu: HTMLDivElement;
let position = { x: 0, y: 0 };
let fixedPosition = { x: 0, y: 0 };
function close() {
contextMenu.hide();
}
function handleOpen(event: MouseEvent) {
if (disabled) return;
export function handleOpen(event: MouseEvent) {
if (disabled || (anchored && $contextMenu === id)) return; // Clicking button will close menu
position = { x: event.clientX, y: event.clientY };
fixedPosition = { x: event.clientX, y: event.clientY };
contextMenu.show(id);
event.preventDefault();
event.stopPropagation();
}
function handleClickOutside(event: MouseEvent) {
@@ -50,23 +54,25 @@
<!-- <svelte:body bind:this={body} /> -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:contextmenu|preventDefault={handleOpen}>
<div on:contextmenu|preventDefault={handleOpen} on:click={(e) => anchored && e.stopPropagation()}>
<slot />
</div>
{#if $contextMenu === id}
{#key position}
{#key fixedPosition}
<div
class="fixed z-50 px-1 py-1 bg-zinc-800 bg-opacity-50 rounded-lg backdrop-blur-xl flex flex-col"
style="left: {position.x}px; top: {position.y}px;"
class={`${position} z-50 my-2 px-1 py-1 bg-zinc-800 bg-opacity-50 rounded-lg backdrop-blur-xl flex flex-col w-max`}
style={position === 'fixed'
? `left: ${fixedPosition.x}px; top: ${fixedPosition.y}px;`
: 'left: 0px;'}
bind:this={menu}
in:fly|global={{ y: 5, duration: 100, delay: 100 }}
in:fly|global={{ y: 5, duration: 100, delay: anchored ? 0 : 100 }}
out:fly|global={{ y: 5, duration: 100 }}
>
<slot name="title">
{#if heading}
<h2
class="text-xs text-zinc-200 opacity-60 tracking-wide font-semibold px-3 py-1 line-clamp-1"
class="text-xs text-zinc-200 opacity-60 tracking-wide font-semibold px-3 py-1 line-clamp-1 text-left"
>
{heading}
</h2>

View File

@@ -1,11 +1,11 @@
import { writable } from 'svelte/store';
function createContextMenu() {
const visibleItem = writable<Symbol | null>(null);
const visibleItem = writable<symbol | null>(null);
return {
subscribe: visibleItem.subscribe,
show: (item: Symbol) => {
show: (item: symbol) => {
visibleItem.set(item);
},
hide: () => {

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import ContextMenu from './ContextMenu.svelte';
import { contextMenu } from '../ContextMenu/ContextMenu';
import Button from '../Button.svelte';
import { DotsVertical } from 'radix-icons-svelte';
export let heading = '';
export let contextMenuId = Symbol();
function handleToggleVisibility() {
if ($contextMenu === contextMenuId) contextMenu.hide();
else contextMenu.show(contextMenuId);
}
</script>
<div class="relative">
<ContextMenu position="absolute" {heading} id={contextMenuId}>
<slot name="menu" slot="menu" />
<Button slim on:click={handleToggleVisibility}>
<DotsVertical size={24} />
</Button>
</ContextMenu>
</div>

View File

@@ -0,0 +1 @@
<div class="bg-zinc-200 bg-opacity-20 h-[1.5px] mx-3 my-1 rounded-full" />

View File

@@ -7,9 +7,9 @@
<button
on:click
class={classNames(
'text-sm font-medium px-3 py-1 hover:bg-zinc-200 hover:bg-opacity-10 rounded-md text-left cursor-default',
'text-sm font-medium tracking-wide px-3 py-1 hover:bg-zinc-200 hover:bg-opacity-10 rounded-md text-left cursor-default',
{
'opacity-80 pointer-events-none': disabled
'opacity-75 pointer-events-none': disabled
}
)}
>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import {
PUBLIC_JELLYFIN_URL,
PUBLIC_RADARR_BASE_URL,
PUBLIC_SONARR_BASE_URL
} from '$env/static/public';
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { library, type LibraryItemStore } from '$lib/stores/library.store';
import type { TitleType } from '$lib/types';
import ContextMenuDivider from './ContextMenuDivider.svelte';
import ContextMenuItem from './ContextMenuItem.svelte';
export let itemStore: LibraryItemStore;
export let type: TitleType;
export let tmdbId: number;
let watched = false;
itemStore.subscribe((i) => {
if (i.item?.jellyfinItem) {
watched =
i.item.jellyfinItem.UserData?.Played !== undefined
? i.item.jellyfinItem.UserData?.Played
: watched;
}
});
function handleSetWatched() {
if ($itemStore.item?.jellyfinId) {
watched = true;
setJellyfinItemWatched($itemStore.item?.jellyfinId).finally(() => library.refreshIn(3000));
}
}
function handleSetUnwatched() {
if ($itemStore.item?.jellyfinId) {
watched = false;
setJellyfinItemUnwatched($itemStore.item?.jellyfinId).finally(() => library.refreshIn(3000));
}
}
function handleOpenInJellyfin() {
window.open(
PUBLIC_JELLYFIN_URL + '/web/index.html#!/details?id=' + $itemStore.item?.jellyfinItem?.Id
);
}
</script>
{#if $itemStore.item}
<ContextMenuItem on:click={handleSetWatched} disabled={!$itemStore.item?.jellyfinId || watched}>
Mark as watched
</ContextMenuItem>
<ContextMenuItem
on:click={handleSetUnwatched}
disabled={!$itemStore.item?.jellyfinId || !watched}
>
Mark as unwatched
</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuItem disabled={!$itemStore.item.jellyfinItem} on:click={handleOpenInJellyfin}>
Open in Jellyfin
</ContextMenuItem>
{#if $itemStore.item.type === 'movie'}
<ContextMenuItem
disabled={!$itemStore.item.radarrMovie}
on:click={() =>
window.open(PUBLIC_RADARR_BASE_URL + '/movie/' + $itemStore.item?.radarrMovie?.tmdbId)}
>
Open in Radarr
</ContextMenuItem>
{:else}
<ContextMenuItem
disabled={!$itemStore.item.sonarrSeries}
on:click={() =>
window.open(PUBLIC_SONARR_BASE_URL + '/series/' + $itemStore.item?.sonarrSeries?.titleSlug)}
>
Open in Sonarr
</ContextMenuItem>
{/if}
{/if}
<ContextMenuItem on:click={() => window.open(`https://www.themoviedb.org/${type}/${tmdbId}`)}>
Open in TMDB
</ContextMenuItem>

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import classNames from 'classnames';
export let disabled = false;

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type { LibraryItemStore } from '$lib/stores/library.store';
import type { TitleType } from '$lib/types';
import ContextMenuButton from '../ContextMenu/ContextMenuButton.svelte';
import LibraryItemContextItems from '../ContextMenu/LibraryItemContextItems.svelte';
export let title = '';
export let itemStore: LibraryItemStore;
export let type: TitleType;
export let tmdbId: number;
</script>
<ContextMenuButton heading={$itemStore.loading ? 'Loading...' : title}>
<svelte:fragment slot="menu">
<LibraryItemContextItems {itemStore} {type} {tmdbId} />
</svelte:fragment>
</ContextMenuButton>

View File

@@ -67,7 +67,7 @@
})}
>
<div
class={classNames('flex-1 relative flex pt-24 px-4 sm:px-8 pb-6', {
class={classNames('flex-1 relative flex pt-24 px-2 sm:px-4 lg:px-8 pb-6', {
'min-h-[60vh]': isModal
})}
bind:clientHeight={topHeight}
@@ -127,7 +127,7 @@
</div>
<div
class={classNames('flex flex-col gap-6 bg-stone-950 px-2 sm:px-4 lg:px-8 pb-6 relative z-[1]', {
class={classNames('flex flex-col gap-6 bg-stone-950 px-2 sm:px-4 lg:px-8 pb-6 relative', {
'2xl:px-0': !isModal
})}
>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { version } from '$app/environment';
import { createLocalStorageStore } from '$lib/localstorage';
import { createLocalStorageStore } from '$lib/stores/localstorage.store';
import { Cross2 } from 'radix-icons-svelte';
import IconButton from './IconButton.svelte';
import axios from 'axios';
@@ -12,11 +12,7 @@
async function fetchLatestVersion() {
return axios
.get('https://api.github.com/repos/aleksilassila/reiverr/tags', {
headers: {
'Cache-Control': 'max-age=3600'
}
})
.get('https://api.github.com/repos/aleksilassila/reiverr/tags')
.then((res) => res.data?.[0]?.name);
}
</script>

View File

@@ -245,6 +245,7 @@ function _createLibraryItemStore(tmdbId: number) {
const itemStores: Record<string, ReturnType<typeof _createLibraryItemStore>> = {};
export type LibraryItemStore = ReturnType<typeof _createLibraryItemStore>;
export function createLibraryItemStore(tmdbId: number) {
if (!itemStores[tmdbId]) {
itemStores[tmdbId] = _createLibraryItemStore(tmdbId);

View File

@@ -9,10 +9,12 @@
import Card from '$lib/components/Card/Card.svelte';
import { fetchCardTmdbProps } from '$lib/components/Card/card';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import ContextMenuButton from '$lib/components/ContextMenu/ContextMenuButton.svelte';
import { modalStack } from '$lib/components/Modal/Modal';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import RequestModal from '$lib/components/RequestModal/RequestModal.svelte';
import OpenInButton from '$lib/components/TitlePageLayout/OpenInButton.svelte';
import TitlePageLayout from '$lib/components/TitlePageLayout/TitlePageLayout.svelte';
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
import { createLibraryItemStore, library } from '$lib/stores/library.store';
@@ -46,7 +48,7 @@
)
);
function stream() {
function play() {
if ($itemStore.item?.jellyfinItem?.Id)
playerState.streamJellyfinId($itemStore.item?.jellyfinItem?.Id);
}
@@ -112,21 +114,28 @@
</svelte:fragment>
<svelte:fragment slot="title-right">
{#if $itemStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else if $itemStore.item?.jellyfinItem}
<Button type="primary" on:click={stream}>
<span>Stream</span><ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.radarrMovie}
<Button type="primary" disabled={addToRadarrLoading} on:click={addToRadarr}>
<span>Add to Radarr</span><Plus size={20} />
</Button>
{:else}
<Button type="primary" on:click={openRequestModal}>
<span class="mr-2">Request Movie</span><Plus size={20} />
</Button>
{/if}
<div
class="flex gap-2 items-center flex-row-reverse justify-end lg:flex-row lg:justify-start"
>
{#if $itemStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else}
<OpenInButton title={movie?.title} {itemStore} type="movie" {tmdbId} />
{#if $itemStore.item?.jellyfinItem}
<Button type="primary" on:click={play}>
<span>Play</span><ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.radarrMovie}
<Button type="primary" disabled={addToRadarrLoading} on:click={addToRadarr}>
<span>Add to Radarr</span><Plus size={20} />
</Button>
{:else}
<Button type="primary" on:click={openRequestModal}>
<span class="mr-2">Request Movie</span><Plus size={20} />
</Button>
{/if}
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="info-components">

View File

@@ -13,10 +13,12 @@
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import UiCarousel from '$lib/components/Carousel/UICarousel.svelte';
import ContextMenu from '$lib/components/ContextMenu/ContextMenu.svelte';
import EpisodeCard from '$lib/components/EpisodeCard/EpisodeCard.svelte';
import { modalStack } from '$lib/components/Modal/Modal';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import SeriesRequestModal from '$lib/components/RequestModal/SeriesRequestModal.svelte';
import OpenInButton from '$lib/components/TitlePageLayout/OpenInButton.svelte';
import TitlePageLayout from '$lib/components/TitlePageLayout/TitlePageLayout.svelte';
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
import { createLibraryItemStore, library } from '$lib/stores/library.store';
@@ -171,21 +173,28 @@
</svelte:fragment>
<svelte:fragment slot="title-right">
{#if $itemStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else if $itemStore.item?.sonarrSeries?.statistics?.sizeOnDisk}
<Button type="primary" on:click={playNextEpisode}>
<span>Next Episode</span><ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.sonarrSeries}
<Button type="primary" disabled={addToSonarrLoading} on:click={addToSonarr}>
<span>Add to Sonarr</span><Plus size={20} />
</Button>
{:else}
<Button type="primary" on:click={openRequestModal}>
<span class="mr-2">Request Series</span><Plus size={20} />
</Button>
{/if}
<div
class="flex gap-2 items-center flex-row-reverse justify-end lg:flex-row lg:justify-start"
>
{#if $itemStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else}
<OpenInButton title={series?.name} {itemStore} type="series" {tmdbId} />
{#if $itemStore.item?.sonarrSeries?.statistics?.sizeOnDisk}
<Button type="primary" on:click={playNextEpisode}>
<span>Next Episode</span><ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.sonarrSeries}
<Button type="primary" disabled={addToSonarrLoading} on:click={addToSonarr}>
<span>Add to Sonarr</span><Plus size={20} />
</Button>
{:else}
<Button type="primary" on:click={openRequestModal}>
<span class="mr-2">Request Series</span><Plus size={20} />
</Button>
{/if}
{/if}
</div>
</svelte:fragment>
<div slot="episodes-carousel">