feat: Manage page tabs
This commit is contained in:
@@ -30,7 +30,7 @@
|
||||
document.documentElement.setAttribute('data-platform', navigator.platform );
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-stone-950 min-h-screen text-white touch-manipulation relative -z-10">
|
||||
<body class="bg-secondary-900 min-h-screen text-white touch-manipulation relative -z-10">
|
||||
<div id="app" class="h-screen w-screen overflow-hidden relative"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
export const hasFocus = rest.hasFocus;
|
||||
export const hasFocusWithin = rest.hasFocusWithin;
|
||||
export const focusIndex = rest.focusIndex;
|
||||
export const activeChild = rest.activeChild;
|
||||
|
||||
export let tag = 'div';
|
||||
|
||||
|
||||
118
src/lib/components/Integrations/JellyfinIntegration.svelte
Normal file
118
src/lib/components/Integrations/JellyfinIntegration.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import TextField from '../TextField.svelte';
|
||||
import { appState } from '../../stores/app-state.store';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import SelectField from '../SelectField.svelte';
|
||||
import { jellyfinApi, type JellyfinUser } from '../../apis/jellyfin/jellyfin-api';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: { baseUrl: string; apiKey: string; stale: boolean };
|
||||
'click-user': { user: JellyfinUser | undefined; users: JellyfinUser[] };
|
||||
}>();
|
||||
|
||||
export let baseUrl = '';
|
||||
export let apiKey = '';
|
||||
let originalBaseUrl: string | undefined;
|
||||
let originalApiKey: string | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
let error = '';
|
||||
let jellyfinUsers: Promise<JellyfinUser[]> | undefined = undefined;
|
||||
export let jellyfinUser: JellyfinUser | undefined;
|
||||
|
||||
appState.subscribe((appState) => {
|
||||
baseUrl = baseUrl || appState.user?.settings.jellyfin.baseUrl || '';
|
||||
apiKey = apiKey || appState.user?.settings.jellyfin.apiKey || '';
|
||||
|
||||
originalBaseUrl = baseUrl;
|
||||
originalApiKey = apiKey;
|
||||
|
||||
handleChange();
|
||||
});
|
||||
|
||||
$: if (jellyfinUser)
|
||||
dispatch('change', {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
stale:
|
||||
baseUrl && apiKey
|
||||
? jellyfinUser?.Id !== get(appState).user?.settings.jellyfin.userId
|
||||
: !jellyfinUser
|
||||
});
|
||||
|
||||
function handleChange() {
|
||||
clearTimeout(timeout);
|
||||
error = '';
|
||||
jellyfinUsers = undefined;
|
||||
jellyfinUser = undefined;
|
||||
|
||||
const baseUrlCopy = baseUrl;
|
||||
const apiKeyCopy = apiKey;
|
||||
|
||||
dispatch('change', {
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
stale: baseUrl === '' && apiKey === '' && jellyfinUser === undefined
|
||||
});
|
||||
|
||||
if (baseUrlCopy === '' || apiKeyCopy === '') return;
|
||||
|
||||
timeout = setTimeout(async () => {
|
||||
jellyfinUsers = jellyfinApi.getJellyfinUsers(baseUrl, apiKey);
|
||||
|
||||
const users = await jellyfinUsers;
|
||||
if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return;
|
||||
|
||||
if (users.length) {
|
||||
jellyfinUser = users.find((u) => u.Id === get(appState).user?.settings.jellyfin.userId);
|
||||
const stale =
|
||||
(baseUrlCopy !== originalBaseUrl || apiKeyCopy !== originalApiKey) &&
|
||||
jellyfinUser !== undefined;
|
||||
dispatch('change', { baseUrl: baseUrlCopy, apiKey: apiKeyCopy, stale });
|
||||
} else {
|
||||
error = 'Could not connect';
|
||||
}
|
||||
|
||||
// if (res.status !== 200) {
|
||||
// error =
|
||||
// res.status === 404
|
||||
// ? 'Server not found'
|
||||
// : res.status === 401
|
||||
// ? 'Invalid api key'
|
||||
// : 'Could not connect';
|
||||
//
|
||||
// return; // TODO add notification
|
||||
// } else {
|
||||
// dispatch('change', { baseUrl: baseUrlCopy, apiKey: apiKeyCopy, stale });
|
||||
// }
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 mb-4">
|
||||
<TextField
|
||||
bind:value={baseUrl}
|
||||
isValid={jellyfinUsers?.then((u) => !!u?.length)}
|
||||
on:change={handleChange}>Base Url</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={apiKey}
|
||||
isValid={jellyfinUsers?.then((u) => !!u?.length)}
|
||||
on:change={handleChange}>API Key</TextField
|
||||
>
|
||||
</div>
|
||||
|
||||
{#await jellyfinUsers then users}
|
||||
{#if users?.length}
|
||||
<SelectField
|
||||
value={jellyfinUser?.Name || 'Select User'}
|
||||
on:clickOrSelect={() => dispatch('click-user', { user: jellyfinUser, users })}
|
||||
>
|
||||
User
|
||||
</SelectField>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
{#if error}
|
||||
<div class="text-red-500 mb-4">{error}</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import Dialog from '../Dialog/Dialog.svelte';
|
||||
import type { JellyfinUser } from '../../apis/jellyfin/jellyfin-api';
|
||||
import SelectItem from '../SelectItem.svelte';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
|
||||
export let users: JellyfinUser[];
|
||||
export let selectedUser: JellyfinUser | undefined;
|
||||
export let handleSelectUser: (user: JellyfinUser) => void;
|
||||
|
||||
function handleSelect(user: JellyfinUser) {
|
||||
handleSelectUser(user);
|
||||
modalStack.closeTopmost();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog>
|
||||
{#each users as user}
|
||||
<SelectItem selected={user.Id === selectedUser?.Id} on:clickOrSelect={() => handleSelect(user)}>
|
||||
{user.Name}
|
||||
</SelectItem>
|
||||
{/each}
|
||||
</Dialog>
|
||||
76
src/lib/components/Integrations/RadarrIntegration.svelte
Normal file
76
src/lib/components/Integrations/RadarrIntegration.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import TextField from '../TextField.svelte';
|
||||
import { appState } from '../../stores/app-state.store';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { radarrApi } from '../../apis/radarr/radarr-api';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: { baseUrl: string; apiKey: string; stale: boolean };
|
||||
}>();
|
||||
|
||||
export let baseUrl = '';
|
||||
export let apiKey = '';
|
||||
let originalBaseUrl: string | undefined;
|
||||
let originalApiKey: string | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
let error = '';
|
||||
let healthCheck: Promise<boolean> | undefined;
|
||||
|
||||
appState.subscribe((appState) => {
|
||||
baseUrl = baseUrl || appState.user?.settings.radarr.baseUrl || '';
|
||||
apiKey = apiKey || appState.user?.settings.radarr.apiKey || '';
|
||||
|
||||
originalBaseUrl = baseUrl;
|
||||
originalApiKey = apiKey;
|
||||
|
||||
handleChange();
|
||||
});
|
||||
|
||||
function handleChange() {
|
||||
clearTimeout(timeout);
|
||||
error = '';
|
||||
healthCheck = undefined;
|
||||
|
||||
const baseUrlCopy = baseUrl;
|
||||
const apiKeyCopy = apiKey;
|
||||
const stale = baseUrlCopy !== originalBaseUrl || apiKeyCopy !== originalApiKey;
|
||||
|
||||
dispatch('change', {
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
stale: baseUrl === '' && apiKey === ''
|
||||
});
|
||||
|
||||
if (baseUrlCopy === '' || apiKeyCopy === '') return;
|
||||
|
||||
timeout = setTimeout(async () => {
|
||||
const p = radarrApi.getHealth(baseUrlCopy, apiKeyCopy);
|
||||
healthCheck = p.then((res) => res.status === 200);
|
||||
|
||||
const res = await p;
|
||||
if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return;
|
||||
if (res.status !== 200) {
|
||||
error =
|
||||
res.status === 404
|
||||
? 'Server not found'
|
||||
: res.status === 401
|
||||
? 'Invalid api key'
|
||||
: 'Could not connect';
|
||||
|
||||
return; // TODO add notification
|
||||
} else {
|
||||
dispatch('change', { baseUrl: baseUrlCopy, apiKey: apiKeyCopy, stale });
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 mb-4">
|
||||
<TextField bind:value={baseUrl} isValid={healthCheck} on:change={handleChange}>Base Url</TextField
|
||||
>
|
||||
<TextField bind:value={apiKey} isValid={healthCheck} on:change={handleChange}>API Key</TextField>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="text-red-500 mb-4">{error}</div>
|
||||
{/if}
|
||||
76
src/lib/components/Integrations/SonarrIntegration.svelte
Normal file
76
src/lib/components/Integrations/SonarrIntegration.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import TextField from '../TextField.svelte';
|
||||
import { appState } from '../../stores/app-state.store';
|
||||
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: { baseUrl: string; apiKey: string; stale: boolean };
|
||||
}>();
|
||||
|
||||
export let baseUrl = '';
|
||||
export let apiKey = '';
|
||||
let originalBaseUrl: string | undefined;
|
||||
let originalApiKey: string | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
let error = '';
|
||||
let healthCheck: Promise<boolean> | undefined;
|
||||
|
||||
appState.subscribe((appState) => {
|
||||
baseUrl = baseUrl || appState.user?.settings.sonarr.baseUrl || '';
|
||||
apiKey = apiKey || appState.user?.settings.sonarr.apiKey || '';
|
||||
|
||||
originalBaseUrl = baseUrl;
|
||||
originalApiKey = apiKey;
|
||||
|
||||
handleChange();
|
||||
});
|
||||
|
||||
function handleChange() {
|
||||
clearTimeout(timeout);
|
||||
error = '';
|
||||
healthCheck = undefined;
|
||||
|
||||
const baseUrlCopy = baseUrl;
|
||||
const apiKeyCopy = apiKey;
|
||||
const stale = baseUrlCopy !== originalBaseUrl || apiKeyCopy !== originalApiKey;
|
||||
|
||||
dispatch('change', {
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
stale: baseUrl === '' && apiKey === ''
|
||||
});
|
||||
|
||||
if (baseUrlCopy === '' || apiKeyCopy === '') return;
|
||||
|
||||
timeout = setTimeout(async () => {
|
||||
const p = sonarrApi.getHealth(baseUrlCopy, apiKeyCopy);
|
||||
healthCheck = p.then((res) => res.status === 200);
|
||||
|
||||
const res = await p;
|
||||
if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return;
|
||||
if (res.status !== 200) {
|
||||
error =
|
||||
res.status === 404
|
||||
? 'Server not found'
|
||||
: res.status === 401
|
||||
? 'Invalid api key'
|
||||
: 'Could not connect';
|
||||
|
||||
return; // TODO add notification
|
||||
} else {
|
||||
dispatch('change', { baseUrl: baseUrlCopy, apiKey: apiKeyCopy, stale });
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 mb-4">
|
||||
<TextField bind:value={baseUrl} isValid={healthCheck} on:change={handleChange}>Base Url</TextField
|
||||
>
|
||||
<TextField bind:value={apiKey} isValid={healthCheck} on:change={handleChange}>API Key</TextField>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="text-red-500 mb-4">{error}</div>
|
||||
{/if}
|
||||
76
src/lib/components/Integrations/TmdbIntegration.svelte
Normal file
76
src/lib/components/Integrations/TmdbIntegration.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import TextField from '../TextField.svelte';
|
||||
import { appState } from '../../stores/app-state.store';
|
||||
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: { baseUrl: string; apiKey: string; stale: boolean };
|
||||
}>();
|
||||
|
||||
export let baseUrl = '';
|
||||
export let apiKey = '';
|
||||
let originalBaseUrl: string | undefined;
|
||||
let originalApiKey: string | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
let error = '';
|
||||
let healthCheck: Promise<boolean> | undefined;
|
||||
|
||||
appState.subscribe((appState) => {
|
||||
baseUrl = baseUrl || appState.user?.settings.sonarr.baseUrl || '';
|
||||
apiKey = apiKey || appState.user?.settings.sonarr.apiKey || '';
|
||||
|
||||
originalBaseUrl = baseUrl;
|
||||
originalApiKey = apiKey;
|
||||
|
||||
handleChange();
|
||||
});
|
||||
|
||||
function handleChange() {
|
||||
clearTimeout(timeout);
|
||||
error = '';
|
||||
healthCheck = undefined;
|
||||
|
||||
const baseUrlCopy = baseUrl;
|
||||
const apiKeyCopy = apiKey;
|
||||
const stale = baseUrlCopy !== originalBaseUrl || apiKeyCopy !== originalApiKey;
|
||||
|
||||
dispatch('change', {
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
stale: baseUrl === '' && apiKey === ''
|
||||
});
|
||||
|
||||
if (baseUrlCopy === '' || apiKeyCopy === '') return;
|
||||
|
||||
timeout = setTimeout(async () => {
|
||||
const p = sonarrApi.getHealth(baseUrlCopy, apiKeyCopy);
|
||||
healthCheck = p.then((res) => res.status === 200);
|
||||
|
||||
const res = await p;
|
||||
if (baseUrlCopy !== baseUrl || apiKeyCopy !== apiKey) return;
|
||||
if (res.status !== 200) {
|
||||
error =
|
||||
res.status === 404
|
||||
? 'Server not found'
|
||||
: res.status === 401
|
||||
? 'Invalid api key'
|
||||
: 'Could not connect';
|
||||
|
||||
return; // TODO add notification
|
||||
} else {
|
||||
dispatch('change', { baseUrl: baseUrlCopy, apiKey: apiKeyCopy, stale });
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 mb-4">
|
||||
<TextField bind:value={baseUrl} isValid={healthCheck} on:change={handleChange}>Base Url</TextField
|
||||
>
|
||||
<TextField bind:value={apiKey} isValid={healthCheck} on:change={handleChange}>API Key</TextField>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="text-red-500 mb-4">{error}</div>
|
||||
{/if}
|
||||
@@ -1,27 +1,51 @@
|
||||
<script lang="ts">
|
||||
import Container from '../../../Container.svelte';
|
||||
import classNames from 'classnames';
|
||||
import type { Selectable } from '../../selectable';
|
||||
import type { NavigateEvent, Selectable } from '../../selectable';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export let tab: number;
|
||||
export let index: number = tab;
|
||||
export let openTab: number;
|
||||
export let openTab: Writable<number>;
|
||||
|
||||
let selectable: Selectable;
|
||||
|
||||
$: active = tab === openTab;
|
||||
$: if (active) selectable?.focus();
|
||||
$: active = tab === $openTab;
|
||||
$: if (active) selectable?.activate();
|
||||
|
||||
function handleNavigate({ detail }: CustomEvent<NavigateEvent>) {
|
||||
// if (detail.willLeaveContainer) {
|
||||
// if (
|
||||
// (trapFocus === 'all' || trapFocus === 'horizontal') &&
|
||||
// (detail.direction === 'left' || detail.direction === 'right')
|
||||
// ) {
|
||||
// detail.preventNavigation();
|
||||
// detail.stopPropagation();
|
||||
// } else if (
|
||||
// (trapFocus === 'all' || trapFocus === 'vertical') &&
|
||||
// (detail.direction === 'up' || detail.direction === 'down')
|
||||
// ) {
|
||||
// detail.preventNavigation();
|
||||
// detail.stopPropagation();
|
||||
// }
|
||||
// }
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container
|
||||
trapFocus
|
||||
class={classNames($$restProps.class, 'transition-all', {
|
||||
'opacity-0 pointer-events-none': !active,
|
||||
'-translate-x-10': !active && openTab >= index,
|
||||
'translate-x-10': !active && openTab < index
|
||||
})}
|
||||
class={classNames(
|
||||
$$restProps.class,
|
||||
'transition-all col-start-1 col-end-1 row-start-1 row-end-1',
|
||||
{
|
||||
'opacity-0 pointer-events-none': !active,
|
||||
'-translate-x-10': !active && $openTab >= index,
|
||||
'translate-x-10': !active && $openTab < index
|
||||
}
|
||||
)}
|
||||
bind:selectable
|
||||
on:back
|
||||
on:navigate={handleNavigate}
|
||||
disabled={!active}
|
||||
>
|
||||
<slot />
|
||||
</Container>
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
enum TestTabs {
|
||||
Tab1 = 'Tab1',
|
||||
Tab2 = 'Tab2',
|
||||
Tab3 = 'Tab3'
|
||||
}
|
||||
|
||||
const test = useTabs<TestTabs>(TestTabs.Tab1);
|
||||
|
||||
export function useTabs<T extends string>(defaultTab: T) {
|
||||
const tab = writable<string>(defaultTab);
|
||||
|
||||
return { subscribe: tab.subscribe };
|
||||
export function useTabs(defaultTab: number) {
|
||||
const openTab = writable<number>(defaultTab);
|
||||
|
||||
const next = () => openTab.update((n) => n + 1);
|
||||
const previous = () => openTab.update((n) => n - 1);
|
||||
|
||||
return { subscribe: openTab.subscribe, openTab, set: openTab.set, next, previous };
|
||||
}
|
||||
|
||||
10
src/lib/components/Tab/TabContainer.svelte
Normal file
10
src/lib/components/Tab/TabContainer.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import Container from '../../../Container.svelte';
|
||||
import type { NavigateEvent } from '../../selectable';
|
||||
|
||||
function handleNavigate({ detail }: CustomEvent<NavigateEvent>) {}
|
||||
</script>
|
||||
|
||||
<Container on:navigate={handleNavigate}>
|
||||
<slot />
|
||||
</Container>
|
||||
@@ -22,6 +22,8 @@
|
||||
icon = Cross1;
|
||||
} else if (isValid === true) {
|
||||
icon = Check;
|
||||
} else {
|
||||
icon = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,40 @@
|
||||
import Container from '../../Container.svelte';
|
||||
import { appState } from '../stores/app-state.store';
|
||||
import Button from '../components/Button.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { isTizen } from '../utils/browser-detection';
|
||||
import Toggle from '../components/Toggle.svelte';
|
||||
import { localSettings } from '../stores/localstorage.store';
|
||||
import classNames from 'classnames';
|
||||
import Tab from '../components/Tab/Tab.svelte';
|
||||
import { useTabs } from '../components/Tab/Tab';
|
||||
import TextField from '../components/TextField.svelte';
|
||||
import SonarrIntegration from '../components/Integrations/SonarrIntegration.svelte';
|
||||
import RadarrIntegration from '../components/Integrations/RadarrIntegration.svelte';
|
||||
import type { JellyfinUser } from '../apis/jellyfin/jellyfin-api';
|
||||
import JellyfinIntegration from '../components/Integrations/JellyfinIntegration.svelte';
|
||||
import { createModal } from '../components/Modal/modal.store';
|
||||
import JellyfinIntegrationUsersDialog from '../components/Integrations/JellyfinIntegrationUsersDialog.svelte';
|
||||
import TmdbIntegration from '../components/Integrations/TmdbIntegration.svelte';
|
||||
|
||||
enum Tabs {
|
||||
Interface,
|
||||
Integrations,
|
||||
About
|
||||
}
|
||||
|
||||
const tab = useTabs(Tabs.Integrations);
|
||||
|
||||
let jellyfinBaseUrl = '';
|
||||
let jellyfinApiKey = '';
|
||||
let jellyfinStale = false;
|
||||
let jellyfinUser: JellyfinUser | undefined = undefined;
|
||||
|
||||
let sonarrBaseUrl = '';
|
||||
let sonarrApiKey = '';
|
||||
let sonarrStale = false;
|
||||
|
||||
let radarrBaseUrl = '';
|
||||
let radarrApiKey = '';
|
||||
let radarrStale = false;
|
||||
|
||||
let lastKeyCode = 0;
|
||||
let lastKey = '';
|
||||
@@ -25,31 +55,50 @@
|
||||
// (tizen as any)?.mediakey?.setMediaKeyEventListener?.(myMediaKeyChangeListener);
|
||||
// }
|
||||
// });
|
||||
</script>
|
||||
|
||||
<Container class="pl-24 flex flex-col items-start" focusOnMount>
|
||||
User agent: {window?.navigator?.userAgent}
|
||||
<div>Last key code: {lastKeyCode}</div>
|
||||
<div>Last key: {lastKey}</div>
|
||||
{#if tizenMediaKey}
|
||||
<div>Tizen media key: {tizenMediaKey}</div>
|
||||
{/if}
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mr-2">Animate scrolling</label>
|
||||
<Toggle
|
||||
checked={$localSettings.animateScrolling}
|
||||
on:change={({ detail }) => localSettings.update((p) => ({ ...p, animateScrolling: detail }))}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mr-2">Use CSS Transitions</label>
|
||||
<Toggle
|
||||
checked={$localSettings.useCssTransitions}
|
||||
on:change={({ detail }) => localSettings.update((p) => ({ ...p, useCssTransitions: detail }))}
|
||||
/>
|
||||
</div>
|
||||
<Button on:clickOrSelect={appState.logOut} class="hover:bg-red-500">Log Out</Button>
|
||||
</Container>
|
||||
async function handleSaveJellyfin() {
|
||||
return appState.updateUser((prev) => ({
|
||||
...prev,
|
||||
settings: {
|
||||
...prev.settings,
|
||||
jellyfin: {
|
||||
...prev.settings.jellyfin,
|
||||
baseUrl: jellyfinBaseUrl,
|
||||
apiKey: jellyfinApiKey,
|
||||
userId: jellyfinUser?.Id ?? ''
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSaveSonarr() {
|
||||
return appState.updateUser((prev) => ({
|
||||
...prev,
|
||||
settings: {
|
||||
...prev.settings,
|
||||
sonarr: {
|
||||
...prev.settings.sonarr,
|
||||
baseUrl: sonarrBaseUrl,
|
||||
apiKey: sonarrApiKey
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSaveRadarr() {
|
||||
return appState.updateUser((prev) => ({
|
||||
...prev,
|
||||
settings: {
|
||||
...prev.settings,
|
||||
radarr: {
|
||||
...prev.settings.radarr,
|
||||
baseUrl: radarrBaseUrl,
|
||||
apiKey: radarrApiKey
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(e) => {
|
||||
@@ -58,3 +107,157 @@
|
||||
lastKey = e.key;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Container class="px-32 py-16 flex flex-col items-start" focusOnMount>
|
||||
<Container
|
||||
direction="horizontal"
|
||||
class="flex space-x-8 header2 pb-3 border-b-2 border-secondary-700 w-full mb-8"
|
||||
>
|
||||
<Container
|
||||
on:enter={() => tab.set(Tabs.Interface)}
|
||||
on:clickOrSelect={() => tab.set(Tabs.Interface)}
|
||||
let:hasFocus
|
||||
>
|
||||
<span
|
||||
class={classNames('cursor-pointer', {
|
||||
'text-secondary-400': $tab !== Tabs.Interface,
|
||||
'text-primary-500': hasFocus
|
||||
})}
|
||||
>
|
||||
Interface
|
||||
</span>
|
||||
</Container>
|
||||
<Container
|
||||
on:enter={() => tab.set(Tabs.Integrations)}
|
||||
on:clickOrSelect={() => tab.set(Tabs.Integrations)}
|
||||
let:hasFocus
|
||||
>
|
||||
<span
|
||||
class={classNames('cursor-pointer', {
|
||||
'text-secondary-400': $tab !== Tabs.Integrations,
|
||||
'text-primary-500': hasFocus
|
||||
})}
|
||||
>
|
||||
Integrations
|
||||
</span>
|
||||
</Container>
|
||||
<Container
|
||||
on:enter={() => tab.set(Tabs.About)}
|
||||
on:clickOrSelect={() => tab.set(Tabs.About)}
|
||||
let:hasFocus
|
||||
>
|
||||
<span
|
||||
class={classNames('cursor-pointer', {
|
||||
'text-secondary-400': $tab !== Tabs.About,
|
||||
'text-primary-500': hasFocus
|
||||
})}
|
||||
>
|
||||
About
|
||||
</span>
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
<Container class="grid">
|
||||
<Tab {...tab} tab={Tabs.Integrations} class="space-y-8">
|
||||
<div class="bg-primary-800 rounded-xl p-8">
|
||||
<h1 class="mb-4 header2">Tmdb Account</h1>
|
||||
<TmdbIntegration
|
||||
on:change={({ detail }) => {
|
||||
sonarrBaseUrl = detail.baseUrl;
|
||||
sonarrApiKey = detail.apiKey;
|
||||
sonarrStale = detail.stale;
|
||||
}}
|
||||
/>
|
||||
<div class="flex">
|
||||
<Button disabled={!sonarrStale} type="primary-dark" action={handleSaveSonarr}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-primary-800 rounded-xl p-8">
|
||||
<h1 class="mb-4 header2">Jellyfin</h1>
|
||||
<JellyfinIntegration
|
||||
bind:jellyfinUser
|
||||
on:change={({ detail }) => {
|
||||
jellyfinBaseUrl = detail.baseUrl;
|
||||
jellyfinApiKey = detail.apiKey;
|
||||
jellyfinStale = detail.stale;
|
||||
}}
|
||||
on:click-user={({ detail }) =>
|
||||
createModal(JellyfinIntegrationUsersDialog, {
|
||||
selectedUser: detail.user,
|
||||
users: detail.users,
|
||||
handleSelectUser: (u) => (jellyfinUser = u)
|
||||
})}
|
||||
/>
|
||||
<div class="flex">
|
||||
<Button disabled={!jellyfinStale} type="primary-dark" action={handleSaveJellyfin}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-primary-800 rounded-xl p-8">
|
||||
<h1 class="mb-4 header2">Sonarr</h1>
|
||||
<SonarrIntegration
|
||||
on:change={({ detail }) => {
|
||||
sonarrBaseUrl = detail.baseUrl;
|
||||
sonarrApiKey = detail.apiKey;
|
||||
sonarrStale = detail.stale;
|
||||
}}
|
||||
/>
|
||||
<div class="flex">
|
||||
<Button disabled={!sonarrStale} type="primary-dark" action={handleSaveSonarr}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-primary-800 rounded-xl p-8">
|
||||
<h1 class="mb-4 header2">Radarr</h1>
|
||||
<RadarrIntegration
|
||||
on:change={({ detail }) => {
|
||||
radarrBaseUrl = detail.baseUrl;
|
||||
radarrApiKey = detail.apiKey;
|
||||
radarrStale = detail.stale;
|
||||
}}
|
||||
/>
|
||||
<div class="flex">
|
||||
<Button disabled={!radarrStale} type="primary-dark" action={handleSaveRadarr}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab {...tab} tab={Tabs.Interface}>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mr-2">Animate scrolling</label>
|
||||
<Toggle
|
||||
checked={$localSettings.animateScrolling}
|
||||
on:change={({ detail }) =>
|
||||
localSettings.update((p) => ({ ...p, animateScrolling: detail }))}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mr-2">Use CSS Transitions</label>
|
||||
<Toggle
|
||||
checked={$localSettings.useCssTransitions}
|
||||
on:change={({ detail }) =>
|
||||
localSettings.update((p) => ({ ...p, useCssTransitions: detail }))}
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab {...tab} tab={Tabs.About}>
|
||||
User agent: {window?.navigator?.userAgent}
|
||||
<div>Last key code: {lastKeyCode}</div>
|
||||
<div>Last key: {lastKey}</div>
|
||||
{#if tizenMediaKey}
|
||||
<div>Tizen media key: {tizenMediaKey}</div>
|
||||
{/if}
|
||||
<Button on:clickOrSelect={appState.logOut} class="hover:bg-red-500">Log Out</Button>
|
||||
</Tab>
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { sonarrApi } from '../apis/sonarr/sonarr-api';
|
||||
import { radarrApi } from '../apis/radarr/radarr-api';
|
||||
import { get } from 'svelte/store';
|
||||
import { useTabs } from '../components/Tab/Tab';
|
||||
|
||||
enum Tabs {
|
||||
Welcome,
|
||||
@@ -24,7 +25,7 @@
|
||||
TmdbConnect = Tmdb + 0.1
|
||||
}
|
||||
|
||||
let openTab: Tabs = Tabs.Welcome;
|
||||
const tab = useTabs(Tabs.Welcome);
|
||||
|
||||
let tmdbConnectRequestToken: string | undefined = undefined;
|
||||
let tmdbConnectLink: string | undefined = undefined;
|
||||
@@ -122,7 +123,7 @@
|
||||
}
|
||||
}));
|
||||
|
||||
openTab++;
|
||||
tab.next();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,7 +146,7 @@
|
||||
}
|
||||
}));
|
||||
|
||||
openTab++;
|
||||
tab.next();
|
||||
}
|
||||
|
||||
async function handleConnectSonarr() {
|
||||
@@ -177,7 +178,7 @@
|
||||
}
|
||||
}));
|
||||
|
||||
openTab++;
|
||||
tab.next();
|
||||
}
|
||||
|
||||
async function handleConnectRadarr() {
|
||||
@@ -219,7 +220,7 @@
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
openTab--;
|
||||
tab.previous();
|
||||
}
|
||||
|
||||
const tabContainer =
|
||||
@@ -227,7 +228,7 @@
|
||||
</script>
|
||||
|
||||
<Container focusOnMount class="h-full w-full grid justify-items-center items-center">
|
||||
<Tab {openTab} tab={Tabs.Welcome} class={tabContainer}>
|
||||
<Tab {...tab} tab={Tabs.Welcome} class={tabContainer}>
|
||||
<h1 class="header2 mb-2">Welcome to Reiverr</h1>
|
||||
<div class="body mb-8">
|
||||
Looks like this is a new account. This setup will get you started with connecting your
|
||||
@@ -236,7 +237,7 @@
|
||||
<Container direction="horizontal" class="flex space-x-4">
|
||||
<Button type="primary-dark" on:clickOrSelect={() => appState.logOut()}>Log Out</Button>
|
||||
<div class="flex-1">
|
||||
<Button type="primary-dark" on:clickOrSelect={() => openTab++}>
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
|
||||
Next
|
||||
<div class="absolute inset-y-0 right-0 flex items-center justify-center">
|
||||
<ArrowRight size={24} />
|
||||
@@ -246,7 +247,7 @@
|
||||
</Container>
|
||||
</Tab>
|
||||
|
||||
<Tab {openTab} tab={Tabs.Tmdb} class={tabContainer} on:back={handleBack}>
|
||||
<Tab {...tab} tab={Tabs.Tmdb} class={tabContainer} on:back={handleBack}>
|
||||
<h1 class="header2 mb-2">Connect a TMDB Account</h1>
|
||||
<div class="body mb-8">
|
||||
Connect to TMDB for personalized recommendations based on your movie reviews and preferences.
|
||||
@@ -258,7 +259,7 @@
|
||||
<SelectField
|
||||
value={account.username || ''}
|
||||
on:clickOrSelect={() => {
|
||||
openTab = Tabs.TmdbConnect;
|
||||
tab.set(Tabs.TmdbConnect);
|
||||
handleGenerateTMDBLink();
|
||||
}}>Logged in as</SelectField
|
||||
>
|
||||
@@ -266,7 +267,7 @@
|
||||
<Button
|
||||
type="primary-dark"
|
||||
on:clickOrSelect={() => {
|
||||
openTab = Tabs.TmdbConnect;
|
||||
tab.set(Tabs.TmdbConnect);
|
||||
handleGenerateTMDBLink();
|
||||
}}
|
||||
>
|
||||
@@ -276,7 +277,7 @@
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
<Button type="primary-dark" on:clickOrSelect={() => openTab++}>
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
|
||||
{#if $appState.user?.settings.tmdb.userId}
|
||||
Next
|
||||
{:else}
|
||||
@@ -287,7 +288,7 @@
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab {openTab} tab={Tabs.TmdbConnect} class={tabContainer} on:back={() => (openTab = Tabs.Tmdb)}>
|
||||
<Tab {...tab} tab={Tabs.TmdbConnect} class={tabContainer} on:back={() => tab.set(Tabs.Tmdb)}>
|
||||
<h1 class="header2 mb-2">Connect a TMDB Account</h1>
|
||||
<div class="body mb-8">
|
||||
To connect your TMDB account, log in via the link below and then click "Complete Connection".
|
||||
@@ -313,7 +314,7 @@
|
||||
</Container>
|
||||
</Tab>
|
||||
|
||||
<Tab {openTab} tab={Tabs.Jellyfin} class={tabContainer}>
|
||||
<Tab {...tab} tab={Tabs.Jellyfin} class={tabContainer}>
|
||||
<h1 class="header2 mb-2">Connect to Jellyfin</h1>
|
||||
<div class="mb-8 body">Connect to Jellyfin to watch movies and tv shows.</div>
|
||||
|
||||
@@ -330,7 +331,7 @@
|
||||
{#if users.length}
|
||||
<SelectField
|
||||
value={jellyfinUser?.Name || 'Select User'}
|
||||
on:clickOrSelect={() => (openTab = Tabs.SelectUser)}
|
||||
on:clickOrSelect={() => tab.set(Tabs.SelectUser)}
|
||||
>
|
||||
User
|
||||
</SelectField>
|
||||
@@ -342,20 +343,15 @@
|
||||
{/if}
|
||||
|
||||
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
|
||||
<Button type="primary-dark" on:clickOrSelect={() => openTab--}>Back</Button>
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
|
||||
{#if jellyfinBaseUrl && jellyfinApiKey && jellyfinUser}
|
||||
<Button type="primary-dark" action={handleConnectJellyfin}>Connect</Button>
|
||||
{:else}
|
||||
<Button type="primary-dark" on:clickOrSelect={() => openTab++}>Skip</Button>
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
</Tab>
|
||||
<Tab
|
||||
{openTab}
|
||||
tab={Tabs.SelectUser}
|
||||
on:back={() => (openTab = Tabs.Jellyfin)}
|
||||
class={tabContainer}
|
||||
>
|
||||
<Tab {...tab} tab={Tabs.SelectUser} on:back={() => tab.set(Tabs.Jellyfin)} class={tabContainer}>
|
||||
<h1 class="header1 mb-2">Select User</h1>
|
||||
{#await jellyfinUsers then users}
|
||||
{#each users as user}
|
||||
@@ -363,7 +359,7 @@
|
||||
selected={user?.Id === jellyfinUser?.Id}
|
||||
on:clickOrSelect={() => {
|
||||
jellyfinUser = user;
|
||||
openTab = Tabs.Jellyfin;
|
||||
tab.set(Tabs.Jellyfin);
|
||||
}}
|
||||
>
|
||||
{user.Name}
|
||||
@@ -372,7 +368,7 @@
|
||||
{/await}
|
||||
</Tab>
|
||||
|
||||
<Tab {openTab} tab={Tabs.Sonarr} class={tabContainer}>
|
||||
<Tab {...tab} tab={Tabs.Sonarr} class={tabContainer}>
|
||||
<h1 class="header2 mb-2">Connect to Sonarr</h1>
|
||||
<div class="mb-8">Connect to Sonarr for requesting and managing tv shows.</div>
|
||||
|
||||
@@ -386,16 +382,16 @@
|
||||
{/if}
|
||||
|
||||
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
|
||||
<Button type="primary-dark" on:clickOrSelect={() => openTab--}>Back</Button>
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
|
||||
{#if sonarrBaseUrl && sonarrApiKey}
|
||||
<Button type="primary-dark" action={handleConnectSonarr}>Connect</Button>
|
||||
{:else}
|
||||
<Button type="primary-dark" on:clickOrSelect={() => openTab++}>Skip</Button>
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
</Tab>
|
||||
|
||||
<Tab {openTab} tab={Tabs.Radarr} class={tabContainer}>
|
||||
<Tab {...tab} tab={Tabs.Radarr} class={tabContainer}>
|
||||
<h1 class="header2 mb-2">Connect to Radarr</h1>
|
||||
<div class="mb-8">Connect to Radarr for requesting and managing movies.</div>
|
||||
|
||||
@@ -409,7 +405,7 @@
|
||||
{/if}
|
||||
|
||||
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
|
||||
<Button type="primary-dark" on:clickOrSelect={() => openTab--}>Back</Button>
|
||||
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
|
||||
{#if radarrBaseUrl && radarrApiKey}
|
||||
<Button type="primary-dark" action={handleConnectRadarr}>Connect</Button>
|
||||
{:else}
|
||||
|
||||
@@ -81,6 +81,8 @@ export type NavigationHandler = (
|
||||
) => void;
|
||||
export type KeyEventHandler = (selectable: Selectable, options: KeyEventOptions) => void;
|
||||
|
||||
export type ActiveChildStore = typeof Selectable.prototype.activeChild;
|
||||
|
||||
export class Selectable {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -109,6 +111,22 @@ export class Selectable {
|
||||
static focusedObject: Writable<Selectable | undefined> = writable(undefined);
|
||||
|
||||
focusIndex: Writable<number> = writable(0);
|
||||
|
||||
activeChild = (() => {
|
||||
const store = derived(this.focusIndex, (focusIndex) => {
|
||||
return this.children[focusIndex];
|
||||
});
|
||||
|
||||
const set = (selectable: Selectable) => {
|
||||
const index = this.children.indexOf(selectable);
|
||||
if (index !== -1) this.focusIndex.set(index);
|
||||
};
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
set
|
||||
};
|
||||
})();
|
||||
hasFocus: Readable<boolean> = derived(Selectable.focusedObject, ($focusedObject) => {
|
||||
return $focusedObject === this;
|
||||
});
|
||||
@@ -219,6 +237,24 @@ export class Selectable {
|
||||
return false;
|
||||
}
|
||||
|
||||
activate() {
|
||||
const parent = this.parent;
|
||||
if (!parent) {
|
||||
console.error('No parent, undefined behavior?');
|
||||
return;
|
||||
}
|
||||
|
||||
const parentHasFocus = get(parent.hasFocusWithin);
|
||||
|
||||
if (parentHasFocus) {
|
||||
this.focus();
|
||||
} else {
|
||||
const index = parent.children.indexOf(this);
|
||||
if (index === -1) console.error('Child not found in parent when activating', this, parent);
|
||||
this.parent?.focusIndex.update((prev) => (index >= 0 ? index : prev));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} whether the selectable is focusable
|
||||
*/
|
||||
@@ -623,13 +659,15 @@ export class Selectable {
|
||||
hasFocusWithin: Readable<boolean>;
|
||||
registerer: Registerer;
|
||||
focusIndex: Writable<number>;
|
||||
activeChild: ActiveChildStore;
|
||||
} {
|
||||
return {
|
||||
container: this,
|
||||
hasFocus: this.hasFocus,
|
||||
hasFocusWithin: this.hasFocusWithin,
|
||||
registerer: this.getRegisterer(),
|
||||
focusIndex: this.focusIndex
|
||||
focusIndex: this.focusIndex,
|
||||
activeChild: this.activeChild
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user