feat: Onboarding
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
35
src/lib/apis/reiverr/reiverr.generated.d.ts
vendored
35
src/lib/apis/reiverr/reiverr.generated.d.ts
vendored
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
1592
src/lib/apis/tmdb/tmdb4.generated.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
30
src/lib/components/SelectField.svelte
Normal file
30
src/lib/components/SelectField.svelte
Normal 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>
|
||||
23
src/lib/components/SelectItem.svelte
Normal file
23
src/lib/components/SelectItem.svelte
Normal 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>
|
||||
27
src/lib/components/Tab/Tab.svelte
Normal file
27
src/lib/components/Tab/Tab.svelte
Normal 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>
|
||||
15
src/lib/components/Tab/Tab.ts
Normal file
15
src/lib/components/Tab/Tab.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
420
src/lib/pages/OnboardingPage.svelte
Normal file
420
src/lib/pages/OnboardingPage.svelte
Normal 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>
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user