feat: Manage page tabs

This commit is contained in:
Aleksi Lassila
2024-05-28 23:57:19 +03:00
parent f7bc8f2739
commit 402dd16f2f
14 changed files with 716 additions and 78 deletions

View File

@@ -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>

View File

@@ -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';

View 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}

View File

@@ -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>

View 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}

View 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}

View 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}

View File

@@ -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', {
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
})}
'-translate-x-10': !active && $openTab >= index,
'translate-x-10': !active && $openTab < index
}
)}
bind:selectable
on:back
on:navigate={handleNavigate}
disabled={!active}
>
<slot />
</Container>

View File

@@ -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 };
}

View 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>

View File

@@ -22,6 +22,8 @@
icon = Cross1;
} else if (isValid === true) {
icon = Check;
} else {
icon = undefined;
}
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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
};
}