feat: Migrations, profile pictures, editing profile

This commit is contained in:
Aleksi Lassila
2024-06-16 02:40:07 +03:00
parent 6cd7d5ffbf
commit c62bc83a1a
34 changed files with 784 additions and 360 deletions

9
backend/data-source.ts Normal file
View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
export default new DataSource({
type: 'sqlite',
database: './config/reiverr.sqlite',
entities: ['dist/**/*.entity.js'],
migrations: ['dist/migrations/*.js'],
// migrations: [__dirname + '/../**/*.migration{.ts,.js}'],
});

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialMigration1718397524237 implements MigrationInterface {
name = 'InitialMigration1718397524237';
public async up(queryRunner: QueryRunner): Promise<void> {
try {
await queryRunner.query(
`CREATE TABLE "user" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "password" varchar NOT NULL, "isAdmin" boolean NOT NULL DEFAULT (0), "onboardingDone" boolean NOT NULL DEFAULT (0), "settings" json NOT NULL DEFAULT ('{"autoplayTrailers":true,"language":"en","animationDuration":300,"sonarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":"","languageProfileId":0},"radarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":""},"jellyfin":{"apiKey":"","baseUrl":"","userId":""},"tmdb":{"sessionId":"","userId":""}}'), CONSTRAINT "UQ_065d4d8f3b5adb4a08841eae3c8" UNIQUE ("name"))`,
);
} catch (ignored) {}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "user"`);
}
}

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddProfilePicture1718397928862 implements MigrationInterface {
name = 'AddProfilePicture1718397928862'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "password" varchar NOT NULL, "isAdmin" boolean NOT NULL DEFAULT (0), "onboardingDone" boolean NOT NULL DEFAULT (0), "settings" json NOT NULL DEFAULT ('{"autoplayTrailers":true,"language":"en","animationDuration":300,"sonarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":"","languageProfileId":0},"radarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":""},"jellyfin":{"apiKey":"","baseUrl":"","userId":""},"tmdb":{"sessionId":"","userId":""}}'), "profilePicture" blob, CONSTRAINT "UQ_065d4d8f3b5adb4a08841eae3c8" UNIQUE ("name"))`);
await queryRunner.query(`INSERT INTO "temporary_user"("id", "name", "password", "isAdmin", "onboardingDone", "settings") SELECT "id", "name", "password", "isAdmin", "onboardingDone", "settings" FROM "user"`);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
await queryRunner.query(`CREATE TABLE "user" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "password" varchar NOT NULL, "isAdmin" boolean NOT NULL DEFAULT (0), "onboardingDone" boolean NOT NULL DEFAULT (0), "settings" json NOT NULL DEFAULT ('{"autoplayTrailers":true,"language":"en","animationDuration":300,"sonarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":"","languageProfileId":0},"radarr":{"apiKey":"","baseUrl":"","qualityProfileId":0,"rootFolderPath":""},"jellyfin":{"apiKey":"","baseUrl":"","userId":""},"tmdb":{"sessionId":"","userId":""}}'), CONSTRAINT "UQ_065d4d8f3b5adb4a08841eae3c8" UNIQUE ("name"))`);
await queryRunner.query(`INSERT INTO "user"("id", "name", "password", "isAdmin", "onboardingDone", "settings") SELECT "id", "name", "password", "isAdmin", "onboardingDone", "settings" FROM "temporary_user"`);
await queryRunner.query(`DROP TABLE "temporary_user"`);
}
}

View File

@@ -18,7 +18,12 @@
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"openapi:schema": "ts-node src/generate-openapi.ts" "openapi:schema": "ts-node src/generate-openapi.ts",
"typeorm": "ts-node ./node_modules/typeorm/cli",
"typeorm:run-migrations": "npm run typeorm migration:run -- -d ./data-source.ts",
"typeorm:generate-migration": "npm run typeorm -- -d ./data-source.ts migration:generate ./migrations/$npm_config_name",
"typeorm:create-migration": "npm run typeorm -- migration:create ./migrations/$npm_config_name",
"typeorm:revert-migration": "npm run typeorm -- -d ./data-source.ts migration:revert"
}, },
"dependencies": { "dependencies": {
"@nanogiants/nestjs-swagger-api-exception-decorator": "^1.6.11", "@nanogiants/nestjs-swagger-api-exception-decorator": "^1.6.11",

View File

@@ -1,4 +1,4 @@
import { DataSource } from 'typeorm'; import dataSource from '../../data-source';
export const DATA_SOURCE = 'DATA_SOURCE'; export const DATA_SOURCE = 'DATA_SOURCE';
@@ -6,13 +6,6 @@ export const databaseProviders = [
{ {
provide: DATA_SOURCE, provide: DATA_SOURCE,
useFactory: async () => { useFactory: async () => {
const dataSource = new DataSource({
type: 'sqlite',
database: './config/reiverr.sqlite',
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: true,
});
return dataSource.initialize(); return dataSource.initialize();
}, },
}, },

View File

@@ -5,6 +5,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as fs from 'fs'; import * as fs from 'fs';
import { UserService } from './user/user.service'; import { UserService } from './user/user.service';
import { ADMIN_PASSWORD, ADMIN_USERNAME } from './consts'; import { ADMIN_PASSWORD, ADMIN_USERNAME } from './consts';
import { json, urlencoded } from 'express';
// import * as proxy from 'express-http-proxy'; // import * as proxy from 'express-http-proxy';
async function createAdminUser(userService: UserService) { async function createAdminUser(userService: UserService) {
@@ -21,6 +22,8 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
app.enableCors(); app.enableCors();
app.use(json({ limit: '50mb' }));
app.use(urlencoded({ extended: true, limit: '50mb' }));
// app.use('/api/proxy/jellyfin', proxy('http://192.168.0.129:8096')); // app.use('/api/proxy/jellyfin', proxy('http://192.168.0.129:8096'));

View File

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Get, Get,
@@ -89,6 +90,7 @@ export class UserController {
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@Put(':id') @Put(':id')
@ApiOkResponse({ description: 'User updated', type: UserDto }) @ApiOkResponse({ description: 'User updated', type: UserDto })
@ApiException(() => NotFoundException, { description: 'User not found' })
async updateUser( async updateUser(
@Param('id') id: string, @Param('id') id: string,
@Body() updateUserDto: UpdateUserDto, @Body() updateUserDto: UpdateUserDto,
@@ -99,9 +101,30 @@ export class UserController {
} }
const user = await this.userService.findOne(id); const user = await this.userService.findOne(id);
if (updateUserDto.name) user.name = updateUserDto.name;
if (
updateUserDto.oldPassword === user.password &&
updateUserDto.password !== undefined
)
user.password = updateUserDto.password;
else if (
updateUserDto.password &&
updateUserDto.oldPassword !== user.password
)
throw new BadRequestException("Passwords don't match");
if (updateUserDto.settings) user.settings = updateUserDto.settings; if (updateUserDto.settings) user.settings = updateUserDto.settings;
if (updateUserDto.onboardingDone) if (updateUserDto.onboardingDone)
user.onboardingDone = updateUserDto.onboardingDone; user.onboardingDone = updateUserDto.onboardingDone;
if (updateUserDto.profilePicture) {
try {
user.profilePicture = Buffer.from(
updateUserDto.profilePicture.split(';base64,').pop() as string,
'base64',
);
} catch (e) {
console.error(e);
}
}
const updated = await this.userService.update(user); const updated = await this.userService.update(user);
return UserDto.fromEntity(updated); return UserDto.fromEntity(updated);

View File

@@ -1,7 +1,13 @@
import { OmitType, PartialType, PickType } from '@nestjs/swagger'; import { ApiProperty, OmitType, PartialType, PickType } from '@nestjs/swagger';
import { User } from './user.entity'; import { User } from './user.entity';
export class UserDto extends OmitType(User, ['password'] as const) { export class UserDto extends OmitType(User, [
'password',
'profilePicture',
] as const) {
@ApiProperty({ type: 'string' })
profilePicture: string | null;
static fromEntity(entity: User): UserDto { static fromEntity(entity: User): UserDto {
return { return {
id: entity.id, id: entity.id,
@@ -9,6 +15,8 @@ export class UserDto extends OmitType(User, ['password'] as const) {
isAdmin: entity.isAdmin, isAdmin: entity.isAdmin,
settings: entity.settings, settings: entity.settings,
onboardingDone: entity.onboardingDone, onboardingDone: entity.onboardingDone,
profilePicture:
'data:image;base64,' + entity.profilePicture?.toString('base64'),
}; };
} }
} }
@@ -20,7 +28,13 @@ export class CreateUserDto extends PickType(User, [
] as const) {} ] as const) {}
export class UpdateUserDto extends PartialType( export class UpdateUserDto extends PartialType(
PickType(User, ['settings', 'onboardingDone', 'name'] as const), PickType(User, ['settings', 'onboardingDone', 'name', 'password'] as const),
) {} ) {
@ApiProperty({ type: 'string', required: false })
profilePicture?: string;
@ApiProperty({ type: 'string', required: false })
oldPassword?: string;
}
export class SignInDto extends PickType(User, ['name', 'password'] as const) {} export class SignInDto extends PickType(User, ['name', 'password'] as const) {}

View File

@@ -111,6 +111,11 @@ export class User {
@Column() @Column()
password: string; password: string;
@ApiProperty({ required: false })
@Column({ type: 'blob', nullable: true })
profilePicture: Buffer;
@Column()
@ApiProperty({ required: true }) @ApiProperty({ required: true })
@Column({ default: false }) @Column({ default: false })
isAdmin: boolean = false; isAdmin: boolean = false;

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -92,6 +92,11 @@ html[data-useragent*="Tizen"] .selectable-secondary {
@apply font-semibold text-2xl text-secondary-100; @apply font-semibold text-2xl text-secondary-100;
} }
.header3 {
@apply font-semibold text-3xl text-secondary-100;
}
.header4 { .header4 {
@apply font-semibold text-4xl text-secondary-100 tracking-wider; @apply font-semibold text-4xl text-secondary-100 tracking-wider;
} }

View File

@@ -51,7 +51,7 @@ export class ReiverrApi implements Api<paths> {
}, },
body: user body: user
}) })
.then((res) => res.data); .then((res) => ({ user: res.data, error: res.error?.message }));
} }
export const reiverrApi = new ReiverrApi(); export const reiverrApi = new ReiverrApi();

View File

@@ -62,6 +62,7 @@ export interface components {
isAdmin: boolean; isAdmin: boolean;
onboardingDone?: boolean; onboardingDone?: boolean;
settings: components["schemas"]["Settings"]; settings: components["schemas"]["Settings"];
profilePicture: string;
}; };
CreateUserDto: { CreateUserDto: {
name: string; name: string;
@@ -70,8 +71,11 @@ export interface components {
}; };
UpdateUserDto: { UpdateUserDto: {
name?: string; name?: string;
password?: string;
onboardingDone?: boolean; onboardingDone?: boolean;
settings?: components["schemas"]["Settings"]; settings?: components["schemas"]["Settings"];
profilePicture?: string;
oldPassword?: string;
}; };
SignInDto: { SignInDto: {
name: string; name: string;
@@ -174,6 +178,18 @@ export interface operations {
"application/json": components["schemas"]["UserDto"]; "application/json": components["schemas"]["UserDto"];
}; };
}; };
404: {
content: {
"application/json": {
/** @example 404 */
statusCode: number;
/** @example Not Found */
message: string;
/** @example Not Found */
error?: string;
};
};
};
}; };
}; };
AuthController_signIn: { AuthController_signIn: {

View File

@@ -101,6 +101,7 @@
</div> </div>
{/if} {/if}
{#if iconAbsolute} {#if iconAbsolute}
<div class="w-8" />
<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">
<svelte:component this={iconAbsolute} size={19} /> <svelte:component this={iconAbsolute} size={19} />
</div> </div>

View File

@@ -4,7 +4,7 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { modalStack } from '../Modal/modal.store'; import { modalStack } from '../Modal/modal.store';
export let size: 'sm' | 'full' = 'sm'; export let size: 'sm' | 'full' | 'lg' | 'dynamic' = 'sm';
function handleClose() { function handleClose() {
modalStack.closeTopmost(); modalStack.closeTopmost();
@@ -19,10 +19,12 @@
> >
<div <div
class={classNames( class={classNames(
'flex-1 bg-primary-800 rounded-2xl p-10 relative shadow-xl flex flex-col', 'bg-primary-800 rounded-2xl p-12 relative shadow-xl flex flex-col transition-[max-width]',
{ {
'max-w-lg min-h-0 overflow-y-auto scrollbar-hide': size === 'sm', 'flex-1 max-w-lg min-h-0 overflow-y-auto scrollbar-hide': size === 'sm',
'h-full overflow-hidden': size === 'full' 'flex-1 h-full overflow-hidden': size === 'full',
'flex-1 max-w-[56rem] min-h-0 overflow-y-auto scrollbar-hide': size === 'lg',
'': size === 'dynamic'
}, },
$$restProps.class $$restProps.class
)} )}

View File

@@ -0,0 +1,213 @@
<script lang="ts">
import Dialog from './Dialog.svelte';
import { reiverrApi, type ReiverrUser } from '../../apis/reiverr/reiverr-api';
import TextField from '../TextField.svelte';
import Button from '../Button.svelte';
import { ArrowUp, EyeClosed, EyeOpen, Upload } from 'radix-icons-svelte';
import Container from '../../../Container.svelte';
import IconToggle from '../IconToggle.svelte';
import Tab from '../Tab/Tab.svelte';
import { useTabs } from '../Tab/Tab';
import SelectField from '../SelectField.svelte';
import ProfileIcon from '../ProfileIcon.svelte';
import { profilePictures } from '../../profile-pictures';
import { modalStack } from '../Modal/modal.store';
import { user as userStore } from '../../stores/user.store';
enum Tabs {
EditProfile,
ProfilePictures
}
export let user: ReiverrUser;
const tab = useTabs(Tabs.EditProfile);
let name = user?.name || '';
let oldPassword = '';
let oldPasswordVisible = false;
let newPassword = '';
let newPasswordVisible = false;
let profilePictureFiles: FileList;
let profilePictureBase64: string = user.profilePicture;
let profilePictureTitle: string;
let profilePictureFilesInput: HTMLInputElement;
$: {
const file = profilePictureFiles?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => setProfilePicture(reader.result as string);
reader.readAsDataURL(file);
}
}
$: {
switch (profilePictureBase64) {
case profilePictures.ana:
profilePictureTitle = 'Ana';
break;
case profilePictures.emma:
profilePictureTitle = 'Emma';
break;
case profilePictures.glen:
profilePictureTitle = 'Glen';
break;
case profilePictures.henry:
profilePictureTitle = 'Henry';
break;
case profilePictures.keanu:
profilePictureTitle = 'Keanu';
break;
case profilePictures.leo:
profilePictureTitle = 'Leo';
break;
case profilePictures.sydney:
profilePictureTitle = 'Sydney';
break;
case profilePictures.zendaya:
profilePictureTitle = 'Zendaya';
break;
default:
profilePictureTitle = 'Custom';
break;
}
}
$: stale =
(name !== user.name && name !== '') ||
oldPassword !== newPassword ||
profilePictureBase64 !== user.profilePicture;
let errorMessage = '';
function setProfilePicture(image: string) {
profilePictureBase64 = image;
tab.set(Tabs.EditProfile);
}
async function save() {
const error = await userStore.updateUser((u) => ({
...u,
name,
password: newPassword,
oldPassword,
profilePicture: profilePictureBase64
// password: newPassword
}));
if (error) {
errorMessage = error;
} else {
modalStack.closeTopmost();
}
}
</script>
<Dialog class="grid" size={$tab === Tabs.EditProfile ? 'sm' : 'dynamic'}>
<Tab {...tab} tab={Tabs.EditProfile} class="space-y-4">
<h1 class="header2">Edit Profile</h1>
<TextField bind:value={name}>name</TextField>
<SelectField value={profilePictureTitle} on:clickOrSelect={() => tab.set(Tabs.ProfilePictures)}>
Profile Picture
</SelectField>
<Container direction="horizontal" class="flex space-x-4 items-end">
<TextField
class="flex-1"
bind:value={oldPassword}
type={oldPasswordVisible ? 'text' : 'password'}
>
Old Password
</TextField>
<IconToggle
on:clickOrSelect={() => (oldPasswordVisible = !oldPasswordVisible)}
icon={oldPasswordVisible ? EyeOpen : EyeClosed}
/>
</Container>
<Container direction="horizontal" class="flex space-x-4 items-end">
<TextField
class="flex-1"
bind:value={newPassword}
type={newPasswordVisible ? 'text' : 'password'}
>
New Password
</TextField>
<IconToggle
on:clickOrSelect={() => (newPasswordVisible = !newPasswordVisible)}
icon={newPasswordVisible ? EyeOpen : EyeClosed}
/>
</Container>
{#if errorMessage}
<div class="text-red-500 mb-4">{errorMessage}</div>
{/if}
<Button type="primary-dark" disabled={!stale} action={save} class="mt-8">Save</Button>
</Tab>
<Tab
{...tab}
tab={Tabs.ProfilePictures}
on:back={({ detail }) => {
tab.set(Tabs.EditProfile);
detail.stopPropagation();
}}
>
<h1 class="header2 mb-6">Select Profile Picture</h1>
<Container direction="grid" gridCols={3} class="grid grid-cols-3 gap-4">
<ProfileIcon
url={profilePictures.ana}
on:clickOrSelect={() => setProfilePicture(profilePictures.ana)}
focusOnMount={profilePictureBase64 === profilePictures.ana}
/>
<ProfileIcon
url={profilePictures.emma}
on:clickOrSelect={() => setProfilePicture(profilePictures.emma)}
focusOnMount={profilePictureBase64 === profilePictures.emma}
/>
<ProfileIcon
url={profilePictures.glen}
on:clickOrSelect={() => setProfilePicture(profilePictures.glen)}
focusOnMount={profilePictureBase64 === profilePictures.glen}
/>
<ProfileIcon
url={profilePictures.henry}
on:clickOrSelect={() => setProfilePicture(profilePictures.henry)}
focusOnMount={profilePictureBase64 === profilePictures.henry}
/>
<ProfileIcon
url={profilePictures.keanu}
on:clickOrSelect={() => setProfilePicture(profilePictures.keanu)}
focusOnMount={profilePictureBase64 === profilePictures.keanu}
/>
<ProfileIcon
url={profilePictures.leo}
on:clickOrSelect={() => setProfilePicture(profilePictures.leo)}
focusOnMount={profilePictureBase64 === profilePictures.leo}
/>
<ProfileIcon
url={profilePictures.sydney}
on:clickOrSelect={() => setProfilePicture(profilePictures.sydney)}
focusOnMount={profilePictureBase64 === profilePictures.sydney}
/>
<ProfileIcon
url={profilePictures.zendaya}
on:clickOrSelect={() => setProfilePicture(profilePictures.zendaya)}
focusOnMount={profilePictureBase64 === profilePictures.zendaya}
/>
<ProfileIcon
url="profile-pictures/leo.webp"
on:clickOrSelect={() => profilePictureFilesInput?.click()}
icon={Upload}
/>
<input
bind:this={profilePictureFilesInput}
type="file"
bind:files={profilePictureFiles}
accept="image/png, image/jpeg"
class="hidden"
/>
<!-- <Container>-->
<!-- Select File-->
<!-- <input type="file" bind:files={profilePictureFiles} accept="image/png, image/jpeg" />-->
<!-- </Container>-->
</Container>
</Tab>
</Dialog>

View File

@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Plus } from 'radix-icons-svelte'; import type { ComponentType } from 'svelte';
export let icon: ComponentType;
</script> </script>
<div class="absolute inset-0 bg-secondary-800/75 flex items-center justify-center"> <div class="absolute inset-0 bg-secondary-800/75 flex items-center justify-center">
<div class="rounded-full p-2.5 bg-secondary-800/75"> <div class="rounded-full p-2.5 bg-secondary-800/75">
<Plus size={32} /> <svelte:component this={icon} size={32} />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import Container from '../../Container.svelte';
import classNames from 'classnames';
import type { Readable } from 'svelte/store';
import type { ComponentType } from 'svelte';
export let icon: ComponentType;
let hasFocus: Readable<boolean>;
</script>
<Container
bind:hasFocus
class={classNames(
'bg-primary-900 flex items-center justify-center h-11 w-11 rounded-lg cursor-pointer',
{
selected: $hasFocus,
unselected: !$hasFocus
}
)}
on:clickOrSelect
>
<svelte:component this={icon} size={19} />
</Container>

View File

@@ -13,8 +13,8 @@
export let baseUrl = get(user)?.settings.jellyfin.baseUrl || ''; export let baseUrl = get(user)?.settings.jellyfin.baseUrl || '';
export let apiKey = get(user)?.settings.jellyfin.apiKey || ''; export let apiKey = get(user)?.settings.jellyfin.apiKey || '';
let originalBaseUrl = derived(user, (user) => user?.settings.jellyfin.baseUrl || ''); const originalBaseUrl = derived(user, (user) => user?.settings.jellyfin.baseUrl || '');
let originalApiKey = derived(user, (user) => user?.settings.jellyfin.apiKey || ''); const originalApiKey = derived(user, (user) => user?.settings.jellyfin.apiKey || '');
let timeout: ReturnType<typeof setTimeout>; let timeout: ReturnType<typeof setTimeout>;
let error = ''; let error = '';
let jellyfinUsers: Promise<JellyfinUser[]> | undefined = undefined; let jellyfinUsers: Promise<JellyfinUser[]> | undefined = undefined;

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import classNames from 'classnames';
import Container from '../../Container.svelte';
import type { Readable } from 'svelte/store';
import AnimateScale from './AnimateScale.svelte';
import type { ComponentType } from 'svelte';
import IconOverlay from './IconOverlay.svelte';
export let url: string;
export let icon: ComponentType | undefined = undefined;
export let focusOnMount = false;
let hasFocus: Readable<boolean>;
</script>
<AnimateScale hasFocus={$hasFocus}>
<Container
bind:hasFocus
class={classNames(
'w-40 h-40 rounded-xl overflow-hidden cursor-pointer relative',
{
selected: $hasFocus,
unselected: !$hasFocus
},
$$restProps.class
)}
{focusOnMount}
on:clickOrSelect
>
<div class="bg-center bg-cover w-full h-full" style={`background-image: url('${url}')`} />
{#if icon}
<IconOverlay {icon} />
{/if}
</Container>
</AnimateScale>

View File

@@ -3,9 +3,9 @@
import type { Readable } from 'svelte/store'; import type { Readable } from 'svelte/store';
import AnimateScale from '../AnimateScale.svelte'; import AnimateScale from '../AnimateScale.svelte';
import classNames from 'classnames'; import classNames from 'classnames';
import { Plus, PlusCircled } from 'radix-icons-svelte'; import { Plus } from 'radix-icons-svelte';
import { getCardDimensions } from '../../utils'; import { getCardDimensions } from '../../utils';
import AddElementOverlay from '../AddElementOverlay.svelte'; import IconOverlay from '../IconOverlay.svelte';
export let backdropUrl: string; export let backdropUrl: string;
@@ -32,6 +32,6 @@
class="bg-cover bg-center absolute inset-0" class="bg-cover bg-center absolute inset-0"
style={`background-image: url('${backdropUrl}')`} style={`background-image: url('${backdropUrl}')`}
/> />
<AddElementOverlay /> <IconOverlay icon={Plus} />
</Container> </Container>
</AnimateScale> </AnimateScale>

View File

@@ -37,7 +37,7 @@
$$restProps.class, $$restProps.class,
'transition-all col-start-1 col-end-1 row-start-1 row-end-1', 'transition-all col-start-1 col-end-1 row-start-1 row-end-1',
{ {
'opacity-0 pointer-events-none': !active, 'opacity-0 pointer-events-none absolute inset-0': !active,
'-translate-x-10': !active && $openTab >= index, '-translate-x-10': !active && $openTab >= index,
'translate-x-10': !active && $openTab < index 'translate-x-10': !active && $openTab < index
} }

View File

@@ -13,20 +13,23 @@
import JellyfinIntegrationUsersDialog from '../components/Integrations/JellyfinIntegrationUsersDialog.svelte'; import JellyfinIntegrationUsersDialog from '../components/Integrations/JellyfinIntegrationUsersDialog.svelte';
import { tmdbApi } from '../apis/tmdb/tmdb-api'; import { tmdbApi } from '../apis/tmdb/tmdb-api';
import SelectField from '../components/SelectField.svelte'; import SelectField from '../components/SelectField.svelte';
import { ArrowRight, Trash } from 'radix-icons-svelte'; import { ArrowRight, Exit, Pencil1, Pencil2, Trash } from 'radix-icons-svelte';
import TmdbIntegrationConnectDialog from '../components/Integrations/TmdbIntegrationConnectDialog.svelte'; import TmdbIntegrationConnectDialog from '../components/Integrations/TmdbIntegrationConnectDialog.svelte';
import { createModal } from '../components/Modal/modal.store'; import { createModal } from '../components/Modal/modal.store';
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte'; import DetachedPage from '../components/DetachedPage/DetachedPage.svelte';
import { user } from '../stores/user.store'; import { user } from '../stores/user.store';
import { sessions } from '../stores/session.store'; import { sessions } from '../stores/session.store';
import TextField from '../components/TextField.svelte';
import EditProfileModal from '../components/Dialog/EditProfileModal.svelte';
import { scrollIntoView } from '../selectable';
enum Tabs { enum Tabs {
Interface, Interface,
Integrations, Account,
About About
} }
const tab = useTabs(Tabs.Integrations); const tab = useTabs(Tabs.Account);
let jellyfinBaseUrl = ''; let jellyfinBaseUrl = '';
let jellyfinApiKey = ''; let jellyfinApiKey = '';
@@ -117,11 +120,14 @@
} }
})); }));
} }
function handleLogOut() {
sessions.removeSession();
}
</script> </script>
<svelte:window <svelte:window
on:keydown={(e) => { on:keydown={(e) => {
console.log('keypress', e);
lastKeyCode = e.keyCode; lastKeyCode = e.keyCode;
lastKey = e.key; lastKey = e.key;
}} }}
@@ -130,7 +136,7 @@
<DetachedPage class="px-32 py-16"> <DetachedPage class="px-32 py-16">
<Container <Container
direction="horizontal" direction="horizontal"
class="flex space-x-8 header2 pb-3 border-b-2 border-secondary-700 w-full mb-8" class="flex space-x-8 header3 pb-3 border-b-2 border-secondary-700 w-full mb-8"
> >
<Container <Container
on:enter={() => tab.set(Tabs.Interface)} on:enter={() => tab.set(Tabs.Interface)}
@@ -144,22 +150,22 @@
'text-primary-500': hasFocus 'text-primary-500': hasFocus
})} })}
> >
General Options
</span> </span>
</Container> </Container>
<Container <Container
on:enter={() => tab.set(Tabs.Integrations)} on:enter={() => tab.set(Tabs.Account)}
on:clickOrSelect={() => tab.set(Tabs.Integrations)} on:clickOrSelect={() => tab.set(Tabs.Account)}
let:hasFocus let:hasFocus
focusOnClick focusOnClick
> >
<span <span
class={classNames('cursor-pointer', { class={classNames('cursor-pointer', {
'text-secondary-400': $tab !== Tabs.Integrations, 'text-secondary-400': $tab !== Tabs.Account,
'text-primary-500': hasFocus 'text-primary-500': hasFocus
})} })}
> >
Integrations Account
</span> </span>
</Container> </Container>
<Container <Container
@@ -207,106 +213,131 @@
</div> </div>
</Tab> </Tab>
<Tab {...tab} tab={Tabs.Integrations} class=""> <Tab {...tab} tab={Tabs.Account} class="space-y-16">
<Container direction="horizontal" class="gap-8 grid grid-cols-2"> <div>
<Container class="flex flex-col space-y-8"> <h1 class="font-semibold text-2xl text-secondary-100 mb-8">Profile</h1>
<Container class="bg-primary-800 rounded-xl p-8"> <Container class="bg-primary-800 rounded-xl p-8" on:enter={scrollIntoView({ top: 9999 })}>
<h1 class="mb-4 header2">Sonarr</h1> <SelectField
<SonarrIntegration value={$user?.name || ''}
on:change={({ detail }) => { on:clickOrSelect={() => {
sonarrBaseUrl = detail.baseUrl; const u = $user;
sonarrApiKey = detail.apiKey; if (u)
sonarrStale = detail.stale; createModal(EditProfileModal, {
}} user: u
/> });
<div class="flex"> }}
<Button disabled={!sonarrStale} type="primary-dark" action={handleSaveSonarr}> >
Save Logged in as
</Button> <Pencil2 slot="icon" let:size let:iconClass {size} class={classNames(iconClass)} />
</div> </SelectField>
</Container> <Container direction="horizontal" class="flex space-x-4">
<Button type="primary-dark" icon={Exit} on:clickOrSelect={handleLogOut}>Log Out</Button>
<Container 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>
</Container> </Container>
</Container> </Container>
</div>
<Container class="flex flex-col space-y-8"> <div>
<Container class="bg-primary-800 rounded-xl p-8"> <h1 class="font-semibold text-2xl text-secondary-100 mb-8">Integrations</h1>
<h1 class="mb-4 header2">Tmdb Account</h1> <Container direction="horizontal" class="gap-16 grid grid-cols-2">
{#await tmdbAccount then tmdbAccount} <Container class="flex flex-col space-y-16">
{#if tmdbAccount} <Container
<SelectField value={tmdbAccount.username || ''} action={handleDisconnectTmdb}> class="bg-primary-800 rounded-xl p-8"
Connected to on:enter={scrollIntoView({ vertical: 64 })}
<Trash >
slot="icon" <h1 class="mb-4 header2">Sonarr</h1>
let:size <SonarrIntegration
let:iconClass on:change={({ detail }) => {
{size} sonarrBaseUrl = detail.baseUrl;
class={classNames(iconClass, '')} sonarrApiKey = detail.apiKey;
/> sonarrStale = detail.stale;
</SelectField> }}
{:else} />
<div class="flex space-x-4"> <div class="flex">
<Button <Button disabled={!sonarrStale} type="primary-dark" action={handleSaveSonarr}>
type="primary-dark" Save
iconAfter={ArrowRight} </Button>
on:clickOrSelect={() => createModal(TmdbIntegrationConnectDialog, {})} </div>
>Connect</Button </Container>
>
</div> <Container
{/if} class="bg-primary-800 rounded-xl p-8"
{/await} on:enter={scrollIntoView({ vertical: 64 })}
<!-- <TmdbIntegration--> >
<!-- on:change={({ detail }) => {--> <h1 class="mb-4 header2">Radarr</h1>
<!-- sonarrBaseUrl = detail.baseUrl;--> <RadarrIntegration
<!-- sonarrApiKey = detail.apiKey;--> on:change={({ detail }) => {
<!-- sonarrStale = detail.stale;--> radarrBaseUrl = detail.baseUrl;
<!-- }}--> radarrApiKey = detail.apiKey;
<!-- />--> radarrStale = detail.stale;
<!-- <div class="flex">--> }}
<!-- <Button disabled={!sonarrStale} type="primary-dark" action={handleSaveSonarr}>--> />
<!-- Save--> <div class="flex">
<!-- </Button>--> <Button disabled={!radarrStale} type="primary-dark" action={handleSaveRadarr}>
<!-- </div>--> Save
</Button>
</div>
</Container>
</Container> </Container>
<Container class="bg-primary-800 rounded-xl p-8"> <Container class="flex flex-col space-y-16">
<h1 class="mb-4 header2">Jellyfin</h1> <Container
<JellyfinIntegration class="bg-primary-800 rounded-xl p-8"
bind:jellyfinUser on:enter={scrollIntoView({ vertical: 64 })}
on:change={({ detail }) => { >
jellyfinBaseUrl = detail.baseUrl; <h1 class="mb-4 header2">Tmdb Account</h1>
jellyfinApiKey = detail.apiKey; {#await tmdbAccount then tmdbAccount}
jellyfinStale = detail.stale; {#if tmdbAccount}
}} <SelectField value={tmdbAccount.username || ''} action={handleDisconnectTmdb}>
on:click-user={({ detail }) => Connected to
createModal(JellyfinIntegrationUsersDialog, { <Trash
selectedUser: detail.user, slot="icon"
users: detail.users, let:size
handleSelectUser: (u) => (jellyfinUser = u) let:iconClass
})} {size}
/> class={classNames(iconClass, '')}
<div class="flex"> />
<Button disabled={!jellyfinStale} type="primary-dark" action={handleSaveJellyfin}> </SelectField>
Save {:else}
</Button> <div class="flex space-x-4">
</div> <Button
type="primary-dark"
iconAfter={ArrowRight}
on:clickOrSelect={() => createModal(TmdbIntegrationConnectDialog, {})}
>Connect</Button
>
</div>
{/if}
{/await}
</Container>
<Container
class="bg-primary-800 rounded-xl p-8"
on:enter={scrollIntoView({ vertical: 64 })}
>
<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>
</Container>
</Container> </Container>
</Container> </Container>
</Container> </div>
</Tab> </Tab>
<Tab {...tab} tab={Tabs.About}> <Tab {...tab} tab={Tabs.About}>
@@ -320,9 +351,7 @@
<div>Tizen media key: {tizenMediaKey}</div> <div>Tizen media key: {tizenMediaKey}</div>
{/if} {/if}
<div class="flex space-x-4 mt-4"> <div class="flex space-x-4 mt-4">
<Button on:clickOrSelect={() => sessions.removeSession()} class="hover:bg-red-500"> <Button on:clickOrSelect={handleLogOut} class="hover:bg-red-500">Log Out</Button>
Log Out
</Button>
</div> </div>
</Tab> </Tab>
</Container> </Container>

View File

@@ -10,7 +10,6 @@
import SelectItem from '../components/SelectItem.svelte'; import SelectItem from '../components/SelectItem.svelte';
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 { useTabs } from '../components/Tab/Tab'; import { useTabs } from '../components/Tab/Tab';
import classNames from 'classnames'; import classNames from 'classnames';
import { user } from '../stores/user.store'; import { user } from '../stores/user.store';
@@ -231,208 +230,216 @@
</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 {...tab} tab={Tabs.Welcome} class={tabContainer}> <div class="flex flex-col bg-primary-800 rounded-2xl p-10 shadow-xl max-w-lg">
<h1 class="header2 mb-2">Welcome to Reiverr</h1> <div class="relative">
<div class="body mb-8"> <Tab {...tab} tab={Tabs.Welcome}>
Looks like this is a new account. This setup will get you started with connecting your <h1 class="header2 mb-2">Welcome to Reiverr</h1>
services to get most out of Reiverr. <div class="body mb-8">
</div> Looks like this is a new account. This setup will get you started with connecting your
<Container direction="horizontal" class="flex space-x-4"> services to get most out of Reiverr.
<Button type="primary-dark" on:clickOrSelect={() => sessions.removeSession()}>Log Out</Button> </div>
<div class="flex-1"> <Container direction="horizontal" class="flex space-x-4">
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}> <Button type="primary-dark" on:clickOrSelect={() => sessions.removeSession()}
Next >Log Out</Button
<div class="absolute inset-y-0 right-0 flex items-center justify-center"> >
<ArrowRight size={24} /> <div class="flex-1">
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
Next
<div class="absolute inset-y-0 right-0 flex items-center justify-center">
<ArrowRight size={24} />
</div>
</Button>
</div> </div>
</Button> </Container>
</div> </Tab>
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Tmdb} class={tabContainer} on:back={handleBack}> <Tab {...tab} tab={Tabs.Tmdb} 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
</div> preferences.
</div>
<div class="space-y-4 flex flex-col"> <div class="space-y-4 flex flex-col">
{#await connectedTmdbAccount then account} {#await connectedTmdbAccount then account}
{#if account} {#if account}
<SelectField <SelectField
value={account.username || ''} value={account.username || ''}
on:clickOrSelect={() => { on:clickOrSelect={() => {
tab.set(Tabs.TmdbConnect); tab.set(Tabs.TmdbConnect);
handleGenerateTMDBLink(); handleGenerateTMDBLink();
}}>Logged in as</SelectField }}>Logged in as</SelectField
> >
{:else} {:else}
<Button <Button
type="primary-dark" type="primary-dark"
on:clickOrSelect={() => { on:clickOrSelect={() => {
tab.set(Tabs.TmdbConnect); tab.set(Tabs.TmdbConnect);
handleGenerateTMDBLink(); handleGenerateTMDBLink();
}} }}
> >
Connect Connect
<ArrowRight size={19} slot="icon-absolute" />
</Button>
{/if}
{/await}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
{#if $user?.settings.tmdb.userId}
Next
{:else}
Skip
{/if}
<ArrowRight size={19} slot="icon-absolute" /> <ArrowRight size={19} slot="icon-absolute" />
</Button> </Button>
</div>
</Tab>
<Tab {...tab} tab={Tabs.TmdbConnect} on:back={() => tab.set(Tabs.Tmdb)}>
<h1 class="header2 mb-2">Connect a TMDB Account</h1>
<div class="body mb-8">
To connect your TMDB account, log in via the link below and then click "Complete
Connection".
</div>
{#if tmdbConnectQrCode}
<div
class="w-[150px] h-[150px] bg-contain bg-center mb-8 mx-auto"
style={`background-image: url(${tmdbConnectQrCode})`}
/>
{/if} {/if}
{/await}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}> <Container direction="horizontal" class="flex space-x-4 *:flex-1">
{#if $user?.settings.tmdb.userId} {#if !tmdbConnectRequestToken}
Next <Button type="primary-dark" action={handleGenerateTMDBLink}>Generate Link</Button>
{:else} {:else if tmdbConnectLink}
Skip <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 {...tab} tab={Tabs.Jellyfin}>
<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={() => tab.set(Tabs.SelectUser)}
>
User
</SelectField>
{/if}
{/await}
{#if jellyfinError}
<div class="text-red-500 mb-4">{jellyfinError}</div>
{/if} {/if}
<ArrowRight size={19} slot="icon-absolute" />
</Button> <Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
{#if jellyfinBaseUrl && jellyfinApiKey && jellyfinUser}
<Button type="primary-dark" action={handleConnectJellyfin}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.SelectUser} on:back={() => tab.set(Tabs.Jellyfin)}>
<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;
tab.set(Tabs.Jellyfin);
}}
>
{user.Name}
</SelectItem>
{/each}
{/await}
</Tab>
<Tab {...tab} tab={Tabs.Sonarr}>
<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={() => tab.previous()}>Back</Button>
{#if sonarrBaseUrl && sonarrApiKey}
<Button type="primary-dark" action={handleConnectSonarr}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Radarr}>
<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={() => tab.previous()}>Back</Button>
{#if radarrBaseUrl && radarrApiKey}
<Button type="primary-dark" action={handleConnectRadarr}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Complete} class={classNames('w-full')}>
<div class="flex items-center justify-center text-secondary-500 mb-4">
<CheckCircled size={64} />
</div>
<h1 class="header2 text-center w-full">All Set!</h1>
<div class="header1 mb-8 text-center">Reiverr is now ready to use.</div>
<Container direction="horizontal" class="inline-flex space-x-4 w-full">
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()} icon={ArrowLeft}
>Back</Button
>
<div class="flex-1">
<Button type="primary-dark" on:clickOrSelect={finalizeSetup} iconAbsolute={ArrowRight}
>Done</Button
>
</div>
</Container>
</Tab>
</div> </div>
</Tab> </div>
<Tab {...tab} tab={Tabs.TmdbConnect} class={tabContainer} on:back={() => tab.set(Tabs.Tmdb)}>
<h1 class="header2 mb-2">Connect a TMDB Account</h1>
<div class="body mb-8">
To connect your TMDB account, log in via the link below and then click "Complete Connection".
</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 {...tab} tab={Tabs.Jellyfin} class={tabContainer}>
<h1 class="header2 mb-2">Connect to Jellyfin</h1>
<div class="mb-8 body">Connect to Jellyfin to watch movies and tv shows.</div>
<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={() => tab.set(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={() => tab.previous()}>Back</Button>
{#if jellyfinBaseUrl && jellyfinApiKey && jellyfinUser}
<Button type="primary-dark" action={handleConnectJellyfin}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.SelectUser} on:back={() => tab.set(Tabs.Jellyfin)} class={tabContainer}>
<h1 class="header1 mb-2">Select User</h1>
{#await jellyfinUsers then users}
{#each users as user}
<SelectItem
selected={user?.Id === jellyfinUser?.Id}
on:clickOrSelect={() => {
jellyfinUser = user;
tab.set(Tabs.Jellyfin);
}}
>
{user.Name}
</SelectItem>
{/each}
{/await}
</Tab>
<Tab {...tab} tab={Tabs.Sonarr} class={tabContainer}>
<h1 class="header2 mb-2">Connect to Sonarr</h1>
<div class="mb-8">Connect to Sonarr for requesting and managing tv shows.</div>
<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={() => tab.previous()}>Back</Button>
{#if sonarrBaseUrl && sonarrApiKey}
<Button type="primary-dark" action={handleConnectSonarr}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Radarr} class={tabContainer}>
<h1 class="header2 mb-2">Connect to Radarr</h1>
<div class="mb-8">Connect to Radarr for requesting and managing movies.</div>
<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={() => tab.previous()}>Back</Button>
{#if radarrBaseUrl && radarrApiKey}
<Button type="primary-dark" action={handleConnectRadarr}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Complete} class={classNames(tabContainer, 'w-full')}>
<div class="flex items-center justify-center text-secondary-500 mb-4">
<CheckCircled size={64} />
</div>
<h1 class="header2 text-center w-full">All Set!</h1>
<div class="header1 mb-8 text-center">Reiverr is now ready to use.</div>
<Container direction="horizontal" class="inline-flex space-x-4 w-full">
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()} icon={ArrowLeft}
>Back</Button
>
<div class="flex-1">
<Button type="primary-dark" on:clickOrSelect={finalizeSetup} iconAbsolute={ArrowRight}
>Done</Button
>
</div>
</Container>
</Tab>
</Container> </Container>

View File

@@ -12,7 +12,8 @@
import AddUserDialog from '../components/Dialog/AddUserDialog.svelte'; import AddUserDialog from '../components/Dialog/AddUserDialog.svelte';
import Login from '../components/Login.svelte'; import Login from '../components/Login.svelte';
import { Plus, Trash } from 'radix-icons-svelte'; import { Plus, Trash } from 'radix-icons-svelte';
import AddElementOverlay from '../components/AddElementOverlay.svelte'; import ProfileIcon from '../components/ProfileIcon.svelte';
import { profilePictures } from '../profile-pictures';
$: users = getUsers($sessions.sessions); $: users = getUsers($sessions.sessions);
@@ -40,38 +41,24 @@
<Container direction="grid" gridCols={4} class="flex space-x-8 mb-16"> <Container direction="grid" gridCols={4} class="flex space-x-8 mb-16">
{#each users as item} {#each users as item}
{@const user = item.user} {@const user = item.user}
<Container let:hasFocus on:clickOrSelect={() => user && handleSwitchUser(item)}> <Container let:hasFocusWithin on:clickOrSelect={() => user && handleSwitchUser(item)}>
<AnimateScale {hasFocus}> <ProfileIcon
<div class="mb-4"
class={classNames('w-40 h-40 bg-center bg-cover mb-4 rounded-xl', { url={user?.profilePicture || profilePictures.keanu}
selected: hasFocus on:clickOrSelect={() => user && handleSwitchUser(item)}
})} />
style={`background-image: url('${TMDB_PROFILE_LARGE}/wo2hJpn04vbtmh0B9utCFdsQhxM.jpg')`} <div
/> class={classNames('text-center header1', { '!text-secondary-100': hasFocusWithin })}
<div class={classNames('text-center header1', { '!text-secondary-100': hasFocus })}> >
{user?.name} {user?.name}
</div> </div>
</AnimateScale>
</Container> </Container>
{/each} {/each}
<Container let:hasFocus on:clickOrSelect={() => createModal(AddUserDialog, {})}> <ProfileIcon
<AnimateScale {hasFocus}> url="profile-pictures/leo.webp"
<div on:clickOrSelect={() => createModal(AddUserDialog, {})}
class={classNames('relative overflow-hidden rounded-xl mb-4 w-40 h-40', { icon={Plus}
selected: hasFocus />
})}
>
<div
class={`w-full h-full bg-center bg-cover`}
style={`background-image: url('${TMDB_PROFILE_LARGE}/wo2hJpn04vbtmh0B9utCFdsQhxM.jpg')`}
/>
<AddElementOverlay />
</div>
<!-- <div class={classNames('text-center header1', { '!text-secondary-100': hasFocus })}>-->
<!-- Add User-->
<!-- </div>-->
</AnimateScale>
</Container>
</Container> </Container>
<Container direction="horizontal" class="flex space-x-4"> <Container direction="horizontal" class="flex space-x-4">
<Button <Button

File diff suppressed because one or more lines are too long

View File

@@ -38,11 +38,13 @@ function useUser() {
if (!user) return; if (!user) return;
const updated = updateFn(user); const updated = updateFn(user);
const update = await reiverrApi.updateUser(updated); const { user: update, error } = await reiverrApi.updateUser(updated);
if (update) { if (update) {
userStore.set(update); userStore.set(update);
} }
return error;
} }
return { return {