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 ); document.documentElement.setAttribute('data-platform', navigator.platform );
</script> </script>
</head> </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> <div id="app" class="h-screen w-screen overflow-hidden relative"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>

View File

@@ -89,6 +89,7 @@
export const hasFocus = rest.hasFocus; export const hasFocus = rest.hasFocus;
export const hasFocusWithin = rest.hasFocusWithin; export const hasFocusWithin = rest.hasFocusWithin;
export const focusIndex = rest.focusIndex; export const focusIndex = rest.focusIndex;
export const activeChild = rest.activeChild;
export let tag = 'div'; 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"> <script lang="ts">
import Container from '../../../Container.svelte'; import Container from '../../../Container.svelte';
import classNames from 'classnames'; 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 tab: number;
export let index: number = tab; export let index: number = tab;
export let openTab: number; export let openTab: Writable<number>;
let selectable: Selectable; let selectable: Selectable;
$: active = tab === openTab; $: active = tab === $openTab;
$: if (active) selectable?.focus(); $: 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> </script>
<Container <Container
trapFocus class={classNames(
class={classNames($$restProps.class, 'transition-all', { $$restProps.class,
'opacity-0 pointer-events-none': !active, 'transition-all col-start-1 col-end-1 row-start-1 row-end-1',
'-translate-x-10': !active && openTab >= index, {
'translate-x-10': !active && openTab < index 'opacity-0 pointer-events-none': !active,
})} '-translate-x-10': !active && $openTab >= index,
'translate-x-10': !active && $openTab < index
}
)}
bind:selectable bind:selectable
on:back on:back
on:navigate={handleNavigate}
disabled={!active}
> >
<slot /> <slot />
</Container> </Container>

View File

@@ -1,15 +1,10 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
enum TestTabs { export function useTabs(defaultTab: number) {
Tab1 = 'Tab1', const openTab = writable<number>(defaultTab);
Tab2 = 'Tab2',
Tab3 = 'Tab3' const next = () => openTab.update((n) => n + 1);
} const previous = () => openTab.update((n) => n - 1);
const test = useTabs<TestTabs>(TestTabs.Tab1); return { subscribe: openTab.subscribe, openTab, set: openTab.set, next, previous };
export function useTabs<T extends string>(defaultTab: T) {
const tab = writable<string>(defaultTab);
return { subscribe: tab.subscribe };
} }

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; icon = Cross1;
} else if (isValid === true) { } else if (isValid === true) {
icon = Check; icon = Check;
} else {
icon = undefined;
} }
} }

View File

@@ -2,10 +2,40 @@
import Container from '../../Container.svelte'; import Container from '../../Container.svelte';
import { appState } from '../stores/app-state.store'; import { appState } from '../stores/app-state.store';
import Button from '../components/Button.svelte'; import Button from '../components/Button.svelte';
import { onMount } from 'svelte';
import { isTizen } from '../utils/browser-detection';
import Toggle from '../components/Toggle.svelte'; import Toggle from '../components/Toggle.svelte';
import { localSettings } from '../stores/localstorage.store'; 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 lastKeyCode = 0;
let lastKey = ''; let lastKey = '';
@@ -25,31 +55,50 @@
// (tizen as any)?.mediakey?.setMediaKeyEventListener?.(myMediaKeyChangeListener); // (tizen as any)?.mediakey?.setMediaKeyEventListener?.(myMediaKeyChangeListener);
// } // }
// }); // });
</script>
<Container class="pl-24 flex flex-col items-start" focusOnMount> async function handleSaveJellyfin() {
User agent: {window?.navigator?.userAgent} return appState.updateUser((prev) => ({
<div>Last key code: {lastKeyCode}</div> ...prev,
<div>Last key: {lastKey}</div> settings: {
{#if tizenMediaKey} ...prev.settings,
<div>Tizen media key: {tizenMediaKey}</div> jellyfin: {
{/if} ...prev.settings.jellyfin,
<div class="flex items-center justify-between"> baseUrl: jellyfinBaseUrl,
<label class="mr-2">Animate scrolling</label> apiKey: jellyfinApiKey,
<Toggle userId: jellyfinUser?.Id ?? ''
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> async function handleSaveSonarr() {
<Toggle return appState.updateUser((prev) => ({
checked={$localSettings.useCssTransitions} ...prev,
on:change={({ detail }) => localSettings.update((p) => ({ ...p, useCssTransitions: detail }))} settings: {
/> ...prev.settings,
</div> sonarr: {
<Button on:clickOrSelect={appState.logOut} class="hover:bg-red-500">Log Out</Button> ...prev.settings.sonarr,
</Container> 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 <svelte:window
on:keydown={(e) => { on:keydown={(e) => {
@@ -58,3 +107,157 @@
lastKey = e.key; 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 { sonarrApi } from '../apis/sonarr/sonarr-api';
import { radarrApi } from '../apis/radarr/radarr-api'; import { radarrApi } from '../apis/radarr/radarr-api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { useTabs } from '../components/Tab/Tab';
enum Tabs { enum Tabs {
Welcome, Welcome,
@@ -24,7 +25,7 @@
TmdbConnect = Tmdb + 0.1 TmdbConnect = Tmdb + 0.1
} }
let openTab: Tabs = Tabs.Welcome; const tab = useTabs(Tabs.Welcome);
let tmdbConnectRequestToken: string | undefined = undefined; let tmdbConnectRequestToken: string | undefined = undefined;
let tmdbConnectLink: 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() { async function handleConnectSonarr() {
@@ -177,7 +178,7 @@
} }
})); }));
openTab++; tab.next();
} }
async function handleConnectRadarr() { async function handleConnectRadarr() {
@@ -219,7 +220,7 @@
} }
function handleBack() { function handleBack() {
openTab--; tab.previous();
} }
const tabContainer = const tabContainer =
@@ -227,7 +228,7 @@
</script> </script>
<Container focusOnMount class="h-full w-full grid justify-items-center items-center"> <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> <h1 class="header2 mb-2">Welcome to Reiverr</h1>
<div class="body mb-8"> <div class="body mb-8">
Looks like this is a new account. This setup will get you started with connecting your 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"> <Container direction="horizontal" class="flex space-x-4">
<Button type="primary-dark" on:clickOrSelect={() => appState.logOut()}>Log Out</Button> <Button type="primary-dark" on:clickOrSelect={() => appState.logOut()}>Log Out</Button>
<div class="flex-1"> <div class="flex-1">
<Button type="primary-dark" on:clickOrSelect={() => openTab++}> <Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
Next Next
<div class="absolute inset-y-0 right-0 flex items-center justify-center"> <div class="absolute inset-y-0 right-0 flex items-center justify-center">
<ArrowRight size={24} /> <ArrowRight size={24} />
@@ -246,7 +247,7 @@
</Container> </Container>
</Tab> </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> <h1 class="header2 mb-2">Connect a TMDB Account</h1>
<div class="body mb-8"> <div class="body mb-8">
Connect to TMDB for personalized recommendations based on your movie reviews and preferences. Connect to TMDB for personalized recommendations based on your movie reviews and preferences.
@@ -258,7 +259,7 @@
<SelectField <SelectField
value={account.username || ''} value={account.username || ''}
on:clickOrSelect={() => { on:clickOrSelect={() => {
openTab = Tabs.TmdbConnect; tab.set(Tabs.TmdbConnect);
handleGenerateTMDBLink(); handleGenerateTMDBLink();
}}>Logged in as</SelectField }}>Logged in as</SelectField
> >
@@ -266,7 +267,7 @@
<Button <Button
type="primary-dark" type="primary-dark"
on:clickOrSelect={() => { on:clickOrSelect={() => {
openTab = Tabs.TmdbConnect; tab.set(Tabs.TmdbConnect);
handleGenerateTMDBLink(); handleGenerateTMDBLink();
}} }}
> >
@@ -276,7 +277,7 @@
{/if} {/if}
{/await} {/await}
<Button type="primary-dark" on:clickOrSelect={() => openTab++}> <Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
{#if $appState.user?.settings.tmdb.userId} {#if $appState.user?.settings.tmdb.userId}
Next Next
{:else} {:else}
@@ -287,7 +288,7 @@
</div> </div>
</Tab> </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> <h1 class="header2 mb-2">Connect a TMDB Account</h1>
<div class="body mb-8"> <div class="body mb-8">
To connect your TMDB account, log in via the link below and then click "Complete Connection". To connect your TMDB account, log in via the link below and then click "Complete Connection".
@@ -313,7 +314,7 @@
</Container> </Container>
</Tab> </Tab>
<Tab {openTab} tab={Tabs.Jellyfin} class={tabContainer}> <Tab {...tab} tab={Tabs.Jellyfin} class={tabContainer}>
<h1 class="header2 mb-2">Connect to Jellyfin</h1> <h1 class="header2 mb-2">Connect to Jellyfin</h1>
<div class="mb-8 body">Connect to Jellyfin to watch movies and tv shows.</div> <div class="mb-8 body">Connect to Jellyfin to watch movies and tv shows.</div>
@@ -330,7 +331,7 @@
{#if users.length} {#if users.length}
<SelectField <SelectField
value={jellyfinUser?.Name || 'Select User'} value={jellyfinUser?.Name || 'Select User'}
on:clickOrSelect={() => (openTab = Tabs.SelectUser)} on:clickOrSelect={() => tab.set(Tabs.SelectUser)}
> >
User User
</SelectField> </SelectField>
@@ -342,20 +343,15 @@
{/if} {/if}
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4"> <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} {#if jellyfinBaseUrl && jellyfinApiKey && jellyfinUser}
<Button type="primary-dark" action={handleConnectJellyfin}>Connect</Button> <Button type="primary-dark" action={handleConnectJellyfin}>Connect</Button>
{:else} {:else}
<Button type="primary-dark" on:clickOrSelect={() => openTab++}>Skip</Button> <Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if} {/if}
</Container> </Container>
</Tab> </Tab>
<Tab <Tab {...tab} tab={Tabs.SelectUser} on:back={() => tab.set(Tabs.Jellyfin)} class={tabContainer}>
{openTab}
tab={Tabs.SelectUser}
on:back={() => (openTab = Tabs.Jellyfin)}
class={tabContainer}
>
<h1 class="header1 mb-2">Select User</h1> <h1 class="header1 mb-2">Select User</h1>
{#await jellyfinUsers then users} {#await jellyfinUsers then users}
{#each users as user} {#each users as user}
@@ -363,7 +359,7 @@
selected={user?.Id === jellyfinUser?.Id} selected={user?.Id === jellyfinUser?.Id}
on:clickOrSelect={() => { on:clickOrSelect={() => {
jellyfinUser = user; jellyfinUser = user;
openTab = Tabs.Jellyfin; tab.set(Tabs.Jellyfin);
}} }}
> >
{user.Name} {user.Name}
@@ -372,7 +368,7 @@
{/await} {/await}
</Tab> </Tab>
<Tab {openTab} tab={Tabs.Sonarr} class={tabContainer}> <Tab {...tab} tab={Tabs.Sonarr} class={tabContainer}>
<h1 class="header2 mb-2">Connect to Sonarr</h1> <h1 class="header2 mb-2">Connect to Sonarr</h1>
<div class="mb-8">Connect to Sonarr for requesting and managing tv shows.</div> <div class="mb-8">Connect to Sonarr for requesting and managing tv shows.</div>
@@ -386,16 +382,16 @@
{/if} {/if}
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4"> <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} {#if sonarrBaseUrl && sonarrApiKey}
<Button type="primary-dark" action={handleConnectSonarr}>Connect</Button> <Button type="primary-dark" action={handleConnectSonarr}>Connect</Button>
{:else} {:else}
<Button type="primary-dark" on:clickOrSelect={() => openTab++}>Skip</Button> <Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if} {/if}
</Container> </Container>
</Tab> </Tab>
<Tab {openTab} tab={Tabs.Radarr} class={tabContainer}> <Tab {...tab} tab={Tabs.Radarr} class={tabContainer}>
<h1 class="header2 mb-2">Connect to Radarr</h1> <h1 class="header2 mb-2">Connect to Radarr</h1>
<div class="mb-8">Connect to Radarr for requesting and managing movies.</div> <div class="mb-8">Connect to Radarr for requesting and managing movies.</div>
@@ -409,7 +405,7 @@
{/if} {/if}
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4"> <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} {#if radarrBaseUrl && radarrApiKey}
<Button type="primary-dark" action={handleConnectRadarr}>Connect</Button> <Button type="primary-dark" action={handleConnectRadarr}>Connect</Button>
{:else} {:else}

View File

@@ -81,6 +81,8 @@ export type NavigationHandler = (
) => void; ) => void;
export type KeyEventHandler = (selectable: Selectable, options: KeyEventOptions) => void; export type KeyEventHandler = (selectable: Selectable, options: KeyEventOptions) => void;
export type ActiveChildStore = typeof Selectable.prototype.activeChild;
export class Selectable { export class Selectable {
id: number; id: number;
name: string; name: string;
@@ -109,6 +111,22 @@ export class Selectable {
static focusedObject: Writable<Selectable | undefined> = writable(undefined); static focusedObject: Writable<Selectable | undefined> = writable(undefined);
focusIndex: Writable<number> = writable(0); 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) => { hasFocus: Readable<boolean> = derived(Selectable.focusedObject, ($focusedObject) => {
return $focusedObject === this; return $focusedObject === this;
}); });
@@ -219,6 +237,24 @@ export class Selectable {
return false; 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 * @returns {boolean} whether the selectable is focusable
*/ */
@@ -623,13 +659,15 @@ export class Selectable {
hasFocusWithin: Readable<boolean>; hasFocusWithin: Readable<boolean>;
registerer: Registerer; registerer: Registerer;
focusIndex: Writable<number>; focusIndex: Writable<number>;
activeChild: ActiveChildStore;
} { } {
return { return {
container: this, container: this,
hasFocus: this.hasFocus, hasFocus: this.hasFocus,
hasFocusWithin: this.hasFocusWithin, hasFocusWithin: this.hasFocusWithin,
registerer: this.getRegisterer(), registerer: this.getRegisterer(),
focusIndex: this.focusIndex focusIndex: this.focusIndex,
activeChild: this.activeChild
}; };
} }