feat: Onboarding

This commit is contained in:
Aleksi Lassila
2024-05-25 00:28:13 +03:00
parent 47845d1dd9
commit dc1b25dc22
22 changed files with 2320 additions and 65 deletions

View File

@@ -7,13 +7,14 @@ import {
NotFoundException,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { UserService } from './user.service';
import { AuthGuard, GetUser } from '../auth/auth.guard';
import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CreateUserDto, UserDto } from './user.dto';
import { User } from './user.entity';
import { CreateUserDto, UpdateUserDto, UserDto } from './user.dto';
import { Settings, User } from './user.entity';
import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator';
@ApiTags('user')
@@ -72,4 +73,25 @@ export class UserController {
return UserDto.fromEntity(user);
}
@UseGuards(AuthGuard)
@Put(':id')
@ApiOkResponse({ description: 'User updated', type: UserDto })
async updateUser(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
@GetUser() callerUser: User,
): Promise<UserDto> {
if ((!callerUser.isAdmin && callerUser.id !== id) || !id) {
throw new NotFoundException();
}
const user = await this.userService.findOne(id);
if (updateUserDto.settings) user.settings = updateUserDto.settings;
if (updateUserDto.onboardingDone)
user.onboardingDone = updateUserDto.onboardingDone;
const updated = await this.userService.update(user);
return UserDto.fromEntity(updated);
}
}

View File

@@ -1,4 +1,4 @@
import { OmitType, PickType } from '@nestjs/swagger';
import { OmitType, PartialType, PickType } from '@nestjs/swagger';
import { User } from './user.entity';
export class UserDto extends OmitType(User, ['password'] as const) {
@@ -8,6 +8,7 @@ export class UserDto extends OmitType(User, ['password'] as const) {
name: entity.name,
isAdmin: entity.isAdmin,
settings: entity.settings,
onboardingDone: entity.onboardingDone,
};
}
}
@@ -18,6 +19,8 @@ export class CreateUserDto extends PickType(User, [
'isAdmin',
] as const) {}
export class UpdateUserDto extends OmitType(User, ['id'] as const) {}
export class UpdateUserDto extends PartialType(
PickType(User, ['settings', 'onboardingDone', 'name'] as const),
) {}
export class SignInDto extends PickType(User, ['name', 'password'] as const) {}

View File

@@ -112,9 +112,13 @@ export class User {
password: string;
@ApiProperty({ required: true })
@Column()
@Column({ default: false })
isAdmin: boolean = false;
@ApiProperty({ required: false })
@Column({ default: false })
onboardingDone: boolean = false;
@ApiProperty({ required: true, type: Settings })
@Column('json', { default: JSON.stringify(DEFAULT_SETTINGS) })
settings = DEFAULT_SETTINGS;

View File

@@ -9,6 +9,7 @@
import StackRouter from './lib/components/StackRouter/StackRouter.svelte';
import { defaultStackRouter } from './lib/components/StackRouter/StackRouter';
import Sidebar from './lib/components/Sidebar/Sidebar.svelte';
import OnboardingPage from './lib/pages/OnboardingPage.svelte';
appState.subscribe((s) => console.log('appState', s));
@@ -40,6 +41,8 @@
</div>
{:else if $appState.user === null}
<LoginPage />
{:else if $appState.user.onboardingDone === false}
<OnboardingPage />
{:else}
<!-- <Router primary={false}>-->
<Container class="flex flex-col relative" direction="horizontal" trapFocus>

View File

@@ -96,6 +96,10 @@ html[data-useragent*="Tizen"] .selectable-secondary {
@apply font-semibold text-4xl text-secondary-100 tracking-wider;
}
.body {
@apply text-base text-secondary-200;
}
@media tv {
html {
font-size: 24px;

View File

@@ -8,6 +8,7 @@ import axios from 'axios';
import { log } from '../../utils';
export type JellyfinItem = components['schemas']['BaseItemDto'];
export type JellyfinUser = components['schemas']['UserDto'];
type Type = 'movie' | 'series';
@@ -503,7 +504,7 @@ export class JellyfinApi implements Api<paths> {
getJellyfinUsers = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
): Promise<components['schemas']['UserDto'][]> =>
): Promise<JellyfinUser[]> =>
axios
.get((baseUrl || this.getBaseUrl()) + '/Users', {
headers: {

View File

@@ -212,7 +212,7 @@ export class RadarrApi implements Api<paths> {
})
.then((res) => res.response.ok) || Promise.resolve(false);
getRadarrHealth = async (
getHealth = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
@@ -222,8 +222,7 @@ export class RadarrApi implements Api<paths> {
'X-Api-Key': apiKey || this.getSettings()?.apiKey
}
})
.then((res) => res.status === 200)
.catch(() => false);
.catch((e) => e.response);
getRootFolders = async (
baseUrl: string | undefined = undefined,

View File

@@ -5,6 +5,7 @@ import { appState } from '../../stores/app-state.store';
import type { Api } from '../api.interface';
export type ReiverrUser = components['schemas']['UserDto'];
export type ReiverrSettings = ReiverrUser['settings'];
export class ReiverrApi implements Api<paths> {
getClient(basePath?: string, _token?: string) {
@@ -33,6 +34,18 @@ export class ReiverrApi implements Api<paths> {
}
});
}
updateUser = (user: ReiverrUser) =>
this.getClient()
?.PUT('/user/{id}', {
params: {
path: {
id: get(appState).user?.id as string
}
},
body: user
})
.then((res) => res.data);
}
export const reiverrApi = new ReiverrApi();

View File

@@ -5,17 +5,18 @@
export interface paths {
"/api/user": {
"/user": {
get: operations["UserController_getProfile"];
post: operations["UserController_create"];
};
"/api/user/{id}": {
"/user/{id}": {
get: operations["UserController_findById"];
put: operations["UserController_updateUser"];
};
"/api/auth": {
"/auth": {
post: operations["AuthController_signIn"];
};
"/api": {
"/": {
get: operations["AppController_getHello"];
};
}
@@ -59,6 +60,7 @@ export interface components {
id: string;
name: string;
isAdmin: boolean;
onboardingDone?: boolean;
settings: components["schemas"]["Settings"];
};
CreateUserDto: {
@@ -66,6 +68,11 @@ export interface components {
password: string;
isAdmin: boolean;
};
UpdateUserDto: {
name?: string;
onboardingDone?: boolean;
settings?: components["schemas"]["Settings"];
};
SignInDto: {
name: string;
password: string;
@@ -148,6 +155,26 @@ export interface operations {
};
};
};
UserController_updateUser: {
parameters: {
path: {
id: string;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateUserDto"];
};
};
responses: {
/** @description User updated */
200: {
content: {
"application/json": components["schemas"]["UserDto"];
};
};
};
};
AuthController_signIn: {
requestBody: {
content: {

View File

@@ -393,7 +393,7 @@ export class SonarrApi implements ApiAsync<paths> {
// }));
// };
getSonarrHealth = async (
getHealth = async (
baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined
) =>
@@ -403,8 +403,7 @@ export class SonarrApi implements ApiAsync<paths> {
'X-Api-Key': apiKey || this.getApiKey()
}
})
.then((res) => res.status === 200)
.catch(() => false);
.catch((e) => e.response);
_getSonarrRootFolders = async (
baseUrl: string | undefined = undefined,

View File

@@ -1,6 +1,7 @@
import createClient from 'openapi-fetch';
import { get } from 'svelte/store';
import type { operations, paths } from './tmdb.generated';
import type { operations as operations4, paths as paths4 } from './tmdb4.generated';
import { TMDB_API_KEY, TMDB_BACKDROP_SMALL } from '../../constants';
import { settings } from '../../stores/settings.store';
import type { TitleType } from '../../types';
@@ -62,10 +63,23 @@ export class TmdbApi implements Api<paths> {
});
}
static getClient4() {
return createClient<paths4>({
baseUrl: 'https://api.themoviedb.org',
headers: {
Authorization: `Bearer ${TMDB_API_KEY}`
}
});
}
getClient() {
return TmdbApi.getClient();
}
getClient4() {
return TmdbApi.getClient4();
}
getSessionId() {
return get(appState)?.user?.settings.tmdb.sessionId;
}
@@ -248,8 +262,7 @@ export class TmdbApi implements Api<paths> {
const top100: TmdbMovieSmall[] = await Promise.all(
[...Array(5).keys()].map((i) =>
this.getClient()
// @ts-ignore
this.getClient4()
?.GET('/4/account/{account_object_id}/movie/recommendations', {
params: {
path: {
@@ -324,8 +337,7 @@ export class TmdbApi implements Api<paths> {
const top100: TmdbSeriesSmall[] = await Promise.all(
[...Array(5).keys()].map((i) =>
this.getClient()
// @ts-ignore
this.getClient4()
?.GET('/4/account/{account_object_id}/tv/recommendations', {
params: {
path: {
@@ -377,6 +389,36 @@ export class TmdbApi implements Api<paths> {
mostPopular
};
};
getConnectAccountLink = () =>
this.getClient4()
?.POST('/4/auth/request_token', {})
.then((res) => res.data);
getAccountAccessToken = (requestToken: string) =>
this.getClient4()
?.POST('/4/auth/access_token', {
body: {
// @ts-ignore
request_token: requestToken
}
})
.then((res) => res.data);
getAccountDetails = () => {
const userId = this.getUserId();
if (!userId) return undefined;
return this.getClient()
?.GET('/3/account/{account_id}', {
params: {
path: {
account_id: Number(userId)
}
}
})
.then((res) => res.data);
};
}
export const tmdbApi = new TmdbApi();

1592
src/lib/apis/tmdb/tmdb4.generated.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,7 @@
<Container
bind:hasFocus
class={classNames(
'h-12 rounded-lg font-medium tracking-wide flex items-center group',
'h-12 rounded-xl font-medium tracking-wide flex items-center group',
{
'bg-secondary-800': type === 'primary',
'bg-primary-900': type === 'primary-dark',
@@ -61,14 +61,14 @@
<div
class={classNames({
contents: type === 'primary' || type === 'primary-dark',
'border-2 border-transparent h-full w-full rounded-md flex items-center px-6':
'border-2 border-transparent h-full w-full rounded-lg flex items-center px-6':
type === 'secondary',
'bg-primary-500 text-secondary-950': type === 'secondary' && $hasFocus,
'group-hover:bg-primary-500 group-hover:text-secondary-950': type === 'secondary',
'!bg-red-500': confirmDanger && armed
})}
>
<div class="flex-1 text-center text-nowrap flex items-center justify-center">
<div class="flex-1 text-center text-nowrap flex items-center justify-center relative">
{#if $$slots.icon}
<div class="mr-2">
<slot name="icon" />
@@ -80,6 +80,11 @@
<slot name="icon-after" />
</div>
{/if}
{#if $$slots['icon-absolute']}
<div class="absolute inset-y-0 right-0 flex items-center justify-center">
<slot name="icon-absolute" />
</div>
{/if}
</div>
</div>
</Container>

View File

@@ -13,7 +13,6 @@
import classNames from 'classnames';
import { type BackEvent, scrollIntoView, Selectable } from '../../selectable';
import { fade } from 'svelte/transition';
import { sonarrService } from '../../stores/sonarr-service.store';
import { createLocalStorageStore } from '../../stores/localstorage.store';
import { formatSize } from '../../utils';
import { capitalize } from '../../utils.js';
@@ -50,7 +49,12 @@
monitorOptions: null
});
$sonarrService.then((s) => {
const sonarrOptions = Promise.all([
sonarrApi.getRootFolders(),
sonarrApi.getQualityProfiles()
]).then(([rootFolders, qualityProfiles]) => ({ rootFolders, qualityProfiles }));
sonarrOptions.then((s) => {
addOptionsStore.update((prev) => ({
rootFolderPath: prev.rootFolderPath || s.rootFolders[0]?.path || null,
qualityProfileId: prev.qualityProfileId || s.qualityProfiles[0]?.id || null,
@@ -108,7 +112,7 @@
/>
{/if}
{#await $sonarrService then { qualityProfiles, rootFolders }}
{#await sonarrOptions then { qualityProfiles, rootFolders }}
{@const selectedRootFolder = rootFolders.find(
(f) => f.path === $addOptionsStore.rootFolderPath
)}

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import Container from '../../Container.svelte';
import { ArrowRight } from 'radix-icons-svelte';
import classNames from 'classnames';
export let value: string;
</script>
<Container
class="flex items-center justify-between bg-primary-900 rounded-xl px-6 py-2.5 mb-4 font-medium
border-2 border-transparent focus:border-primary-500 hover:border-primary-500 cursor-pointer group"
on:clickOrSelect
let:hasFocus
>
<div>
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">
<slot />
</h1>
<span>
{value}
</span>
</div>
<ArrowRight
class={classNames('transition-transform', {
'text-primary-500 translate-x-0.5 scale-110': hasFocus,
'group-hover:text-primary-500 group-hover:translate-x-0.5 group-hover:scale-110': true
})}
size={24}
/>
</Container>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import Container from '../../Container.svelte';
import { ArrowRight, Check } from 'radix-icons-svelte';
import classNames from 'classnames';
export let selected: boolean;
</script>
<Container
class="flex items-center justify-between bg-primary-900 rounded-xl px-6 py-2.5 mb-4 font-medium
border-2 border-transparent focus:border-primary-500 hover:border-primary-500 cursor-pointer group"
on:clickOrSelect
on:enter
focusOnClick
focusOnMount={selected}
>
<div>
<slot />
</div>
{#if selected}
<Check size={24} />
{/if}
</Container>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import Container from '../../../Container.svelte';
import classNames from 'classnames';
import type { Selectable } from '../../selectable';
export let tab: number;
export let index: number = tab;
export let openTab: number;
let selectable: Selectable;
$: active = tab === openTab;
$: if (active) selectable?.focus();
</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
})}
bind:selectable
on:back
>
<slot />
</Container>

View File

@@ -0,0 +1,15 @@
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 };
}

View File

@@ -1,12 +1,29 @@
<script lang="ts">
import Container from '../../Container.svelte';
import type { FormEventHandler, HTMLInputTypeAttribute } from 'svelte/elements';
import { createEventDispatcher } from 'svelte';
import { type ComponentType, createEventDispatcher } from 'svelte';
import { PLATFORM_TV } from '../constants';
import classNames from 'classnames';
import Spinner from './Utils/Spinner.svelte';
import { Check, Cross1 } from 'radix-icons-svelte';
export let value = '';
export let type: HTMLInputTypeAttribute = 'text';
export let isValid: Promise<boolean> | boolean | undefined = undefined;
let icon: ComponentType | undefined = undefined;
$: {
if (isValid instanceof Promise) {
icon = Spinner;
isValid.then((valid) => {
icon = valid ? Check : Cross1;
});
} else if (isValid === false) {
icon = Cross1;
} else if (isValid === true) {
icon = Check;
}
}
const dispatch = createEventDispatcher<{
change: string;
@@ -14,7 +31,9 @@
let input: HTMLInputElement;
const handleChange = (e: Event) => {
// @ts-ignore
value = e.target?.value;
// @ts-ignore
dispatch('change', e.target?.value);
};
</script>
@@ -28,18 +47,26 @@
on:clickOrSelect={() => input?.focus()}
class={classNames('flex flex-col', $$restProps.class)}
let:hasFocus
focusOnClick
>
<label class="text-sm text-zinc-300 mb-1">
<label class="text-secondary-300 font-medium tracking-wide text-sm mb-1">
<slot>Label</slot>
</label>
<input
class={classNames('bg-secondary-800 px-4 py-1.5 rounded-lg', {
selected: hasFocus,
unselected: !hasFocus
})}
{type}
{value}
on:input={handleChange}
bind:this={input}
/>
<div class="relative flex flex-col">
<input
class={classNames('bg-primary-900 px-6 py-2 rounded-lg', {
selected: hasFocus,
unselected: !hasFocus
})}
{type}
{value}
on:input={handleChange}
bind:this={input}
/>
{#if icon}
<div class="absolute inset-y-0 right-4 flex items-center justify-center">
<svelte:component this={icon} size={19} />
</div>
{/if}
</div>
</Container>

View File

@@ -0,0 +1,420 @@
<script lang="ts">
import Container from '../../Container.svelte';
import Tab from '../components/Tab/Tab.svelte';
import Button from '../components/Button.svelte';
import { appState } from '../stores/app-state.store';
import { tmdbApi } from '../apis/tmdb/tmdb-api';
import { ArrowRight, ExternalLink } from 'radix-icons-svelte';
import TextField from '../components/TextField.svelte';
import { jellyfinApi, type JellyfinUser } from '../apis/jellyfin/jellyfin-api';
import SelectField from '../components/SelectField.svelte';
import SelectItem from '../components/SelectItem.svelte';
import { sonarrApi } from '../apis/sonarr/sonarr-api';
import { radarrApi } from '../apis/radarr/radarr-api';
import { get } from 'svelte/store';
enum Tabs {
Welcome,
Tmdb,
Jellyfin,
Sonarr,
Radarr,
SelectUser = Jellyfin + 0.1,
TmdbConnect = Tmdb + 0.1
}
let openTab: Tabs = Tabs.Welcome;
let tmdbConnectRequestToken: string | undefined = undefined;
let tmdbConnectLink: string | undefined = undefined;
let tmdbConnectQrCode: string | undefined = undefined;
$: connectedTmdbAccount = $appState.user?.settings.tmdb.userId && tmdbApi.getAccountDetails();
let tmdbError: string = '';
let jellyfinBaseUrl: string = '';
let jellyfinApiKey: string = '';
let jellyfinUser: JellyfinUser | undefined = undefined;
let jellyfinUsers: Promise<JellyfinUser[]> = Promise.resolve([]);
let jellyfinConnectionCheckTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
let jellyfinError: string = '';
let sonarrBaseUrl: string = '';
let sonarrApiKey: string = '';
let sonarrError: string = '';
let radarrBaseUrl: string = '';
let radarrApiKey: string = '';
let radarrError: string = '';
appState.subscribe((appState) => {
jellyfinBaseUrl = jellyfinBaseUrl || appState.user?.settings.jellyfin.baseUrl || '';
jellyfinApiKey = jellyfinApiKey || appState.user?.settings.jellyfin.apiKey || '';
sonarrBaseUrl = sonarrBaseUrl || appState.user?.settings.sonarr.baseUrl || '';
sonarrApiKey = sonarrApiKey || appState.user?.settings.sonarr.apiKey || '';
radarrBaseUrl = radarrBaseUrl || appState.user?.settings.radarr.baseUrl || '';
radarrApiKey = radarrApiKey || appState.user?.settings.radarr.apiKey || '';
// if (
// !jellyfinUser &&
// appState.user?.settings.jellyfin.userId &&
// jellyfinBaseUrl &&
// jellyfinApiKey
// ) {
// jellyfinUsers = jellyfinApi.getJellyfinUsers(jellyfinBaseUrl, jellyfinApiKey);
// jellyfinUsers.then(
// (users) =>
// (jellyfinUser = users.find((u) => u.Id === appState.user?.settings.jellyfin.userId))
// );
// }
});
$: if (jellyfinBaseUrl && jellyfinApiKey) {
clearTimeout(jellyfinConnectionCheckTimeout);
const baseUrlCopy = jellyfinBaseUrl;
const apiKeyCopy = jellyfinApiKey;
jellyfinUser = undefined;
jellyfinConnectionCheckTimeout = setTimeout(async () => {
jellyfinUsers = jellyfinApi
.getJellyfinUsers(jellyfinBaseUrl, jellyfinApiKey)
.then((users) => {
if (baseUrlCopy === jellyfinBaseUrl && apiKeyCopy === jellyfinApiKey) {
jellyfinUser = users.find((u) => u.Id === get(appState).user?.settings.jellyfin.userId);
jellyfinError = users.length ? '' : 'Could not connect';
}
// console.log(users, baseUrlCopy, jellyfinBaseUrl, apiKeyCopy, jellyfinApiKey);
// jellyfinUsers = users;
// return !!users?.length;
return users;
});
}, 500);
}
async function handleGenerateTMDBLink() {
return tmdbApi.getConnectAccountLink().then((res) => {
if (res?.status_code !== 1) return; // TODO add notification
const link = `https://www.themoviedb.org/auth/access?request_token=${res?.request_token}`;
const qrCode = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${link}`;
tmdbConnectRequestToken = res?.request_token;
tmdbConnectLink = link;
tmdbConnectQrCode = qrCode;
});
}
async function completeTMDBConnect() {
if (!tmdbConnectRequestToken) return;
tmdbApi.getAccountAccessToken(tmdbConnectRequestToken).then((res) => {
const { status_code, access_token, account_id } = res || {};
if (status_code !== 1 || !access_token || !account_id) return; // TODO add notification
appState.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
tmdb: {
userId: account_id,
sessionId: access_token
}
}
}));
openTab++;
});
}
async function handleConnectJellyfin() {
const userId = jellyfinUser?.Id;
const baseUrl = jellyfinBaseUrl;
const apiKey = jellyfinApiKey;
if (!userId || !baseUrl || !apiKey) return;
await appState.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
jellyfin: {
...prev.settings.jellyfin,
userId,
baseUrl,
apiKey
}
}
}));
openTab++;
}
async function handleConnectSonarr() {
const baseUrl = sonarrBaseUrl;
const apiKey = sonarrApiKey;
if (!baseUrl || !apiKey) return;
const res = await sonarrApi.getHealth(baseUrl, apiKey);
if (res.status !== 200) {
sonarrError =
res.status === 404
? 'Server not found'
: res.status === 401
? 'Invalid api key'
: 'Could not connect';
return; // TODO add notification
}
await appState.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
sonarr: {
...prev.settings.sonarr,
baseUrl,
apiKey
}
}
}));
openTab++;
}
async function handleConnectRadarr() {
const baseUrl = radarrBaseUrl;
const apiKey = radarrApiKey;
if (!baseUrl || !apiKey) return;
const res = await radarrApi.getHealth(baseUrl, apiKey);
if (res.status !== 200) {
res.status === 404
? 'Server not found'
: res.status === 401
? 'Invalid api key'
: 'Could not connect';
return; // TODO add notification
}
await appState.updateUser((prev) => ({
...prev,
settings: {
...prev.settings,
radarr: {
...prev.settings.radarr,
baseUrl,
apiKey
}
}
}));
await finalizeSetup();
}
async function finalizeSetup() {
await appState.updateUser((prev) => ({
...prev,
onboardingDone: true
}));
}
function handleBack() {
openTab--;
}
const tabContainer =
'col-start-1 col-end-1 row-start-1 row-end-1 flex flex-col bg-primary-800 rounded-2xl p-10 shadow-xl max-w-lg';
</script>
<Container focusOnMount class="h-full w-full grid justify-items-center items-center">
<Tab {openTab} 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
services to get most out of Reiverr.
</div>
<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++}>
Next
<div class="absolute inset-y-0 right-0 flex items-center justify-center">
<ArrowRight size={24} />
</div>
</Button>
</div>
</Container>
</Tab>
<Tab {openTab} 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.
</div>
<div class="space-y-4 flex flex-col">
{#await connectedTmdbAccount then account}
{#if account}
<SelectField
value={account.username || ''}
on:clickOrSelect={() => {
openTab = Tabs.TmdbConnect;
handleGenerateTMDBLink();
}}>Logged in as</SelectField
>
{:else}
<Button
type="primary-dark"
on:clickOrSelect={() => {
openTab = Tabs.TmdbConnect;
handleGenerateTMDBLink();
}}
>
Connect
<ArrowRight size={19} slot="icon-absolute" />
</Button>
{/if}
{/await}
<Button type="primary-dark" on:clickOrSelect={() => openTab++}>
{#if $appState.user?.settings.tmdb.userId}
Next
{:else}
Skip
{/if}
<ArrowRight size={19} slot="icon-absolute" />
</Button>
</div>
</Tab>
<Tab {openTab} tab={Tabs.TmdbConnect} class={tabContainer} on:back={() => (openTab = 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".
</div>
{#if tmdbConnectQrCode}
<div
class="w-[150px] h-[150px] bg-contain bg-center mb-8 mx-auto"
style={`background-image: url(${tmdbConnectQrCode})`}
/>
{/if}
<Container direction="horizontal" class="flex space-x-4 *:flex-1">
{#if !tmdbConnectRequestToken}
<Button type="primary-dark" action={handleGenerateTMDBLink}>Generate Link</Button>
{:else if tmdbConnectLink}
<Button type="primary-dark" action={completeTMDBConnect}>Complete Connection</Button>
<Button type="primary-dark" on:clickOrSelect={() => window.open(tmdbConnectLink)}>
Open Link
<ExternalLink size={19} slot="icon-after" />
</Button>
{/if}
</Container>
</Tab>
<Tab {openTab} 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>
<div class="space-y-4 mb-4">
<TextField bind:value={jellyfinBaseUrl} isValid={jellyfinUsers.then((u) => !!u?.length)}>
Base Url
</TextField>
<TextField bind:value={jellyfinApiKey} isValid={jellyfinUsers.then((u) => !!u?.length)}>
API Key
</TextField>
</div>
{#await jellyfinUsers then users}
{#if users.length}
<SelectField
value={jellyfinUser?.Name || 'Select User'}
on:clickOrSelect={() => (openTab = Tabs.SelectUser)}
>
User
</SelectField>
{/if}
{/await}
{#if jellyfinError}
<div class="text-red-500 mb-4">{jellyfinError}</div>
{/if}
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<Button type="primary-dark" on:clickOrSelect={() => openTab--}>Back</Button>
{#if jellyfinBaseUrl && jellyfinApiKey && jellyfinUser}
<Button type="primary-dark" action={handleConnectJellyfin}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => openTab++}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab
{openTab}
tab={Tabs.SelectUser}
on:back={() => (openTab = Tabs.Jellyfin)}
class={tabContainer}
>
<h1 class="header1 mb-2">Select User</h1>
{#await jellyfinUsers then users}
{#each users as user}
<SelectItem
selected={user?.Id === jellyfinUser?.Id}
on:clickOrSelect={() => {
jellyfinUser = user;
openTab = Tabs.Jellyfin;
}}
>
{user.Name}
</SelectItem>
{/each}
{/await}
</Tab>
<Tab {openTab} 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>
<div class="space-y-4 mb-4">
<TextField bind:value={sonarrBaseUrl}>Base Url</TextField>
<TextField bind:value={sonarrApiKey}>API Key</TextField>
</div>
{#if sonarrError}
<div class="text-red-500 mb-4">{sonarrError}</div>
{/if}
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<Button type="primary-dark" on:clickOrSelect={() => openTab--}>Back</Button>
{#if sonarrBaseUrl && sonarrApiKey}
<Button type="primary-dark" action={handleConnectSonarr}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => openTab++}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {openTab} 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>
<div class="space-y-4 mb-4">
<TextField bind:value={radarrBaseUrl}>Base Url</TextField>
<TextField bind:value={radarrApiKey}>API Key</TextField>
</div>
{#if radarrError}
<div class="text-red-500 mb-4">{radarrError}</div>
{/if}
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<Button type="primary-dark" on:clickOrSelect={() => openTab--}>Back</Button>
{#if radarrBaseUrl && radarrApiKey}
<Button type="primary-dark" action={handleConnectRadarr}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={finalizeSetup}>Skip</Button>
{/if}
</Container>
</Tab>
</Container>

View File

@@ -1,6 +1,11 @@
import { derived, writable } from 'svelte/store';
import { derived, get, writable } from 'svelte/store';
import { createLocalStorageStore } from './localstorage.store';
import { getReiverrApiClient, type ReiverrUser } from '../apis/reiverr/reiverr-api';
import {
getReiverrApiClient,
reiverrApi,
type ReiverrSettings,
type ReiverrUser
} from '../apis/reiverr/reiverr-api';
interface AuthenticationStoreData {
token?: string;
@@ -11,7 +16,7 @@ interface UserStoreData {
user: ReiverrUser | null;
}
interface AppStateData extends AuthenticationStoreData {
export interface AppStateData extends AuthenticationStoreData {
user: ReiverrUser | null;
}
@@ -61,11 +66,25 @@ function createAppState() {
});
});
async function updateUser(updateFn: (user: ReiverrUser) => ReiverrUser) {
const user = get(userStore).user;
if (!user) return;
const updated = updateFn(user);
const update = await reiverrApi.updateUser(updated);
if (update) {
setUser(update);
}
}
return {
subscribe: combinedStore.subscribe,
setBaseUrl,
setToken,
setUser,
updateUser,
logOut,
ready
};

View File

@@ -1,24 +0,0 @@
import { writable } from 'svelte/store';
import { sonarrApi, type SonarrRootFolder } from '../apis/sonarr/sonarr-api';
type SonarrServiceStore = ReturnType<typeof fetchSonarrService>;
async function fetchSonarrService() {
const rootFolders = sonarrApi.getRootFolders();
const qualityProfiles = sonarrApi.getQualityProfiles();
return {
rootFolders: await rootFolders,
qualityProfiles: await qualityProfiles
};
}
function useSonarrService() {
const sonarrService = writable<SonarrServiceStore>(fetchSonarrService());
return {
subscribe: sonarrService.subscribe
};
}
export const sonarrService = useSonarrService();