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: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",
"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": {
"@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';
@@ -6,13 +6,6 @@ export const databaseProviders = [
{
provide: DATA_SOURCE,
useFactory: async () => {
const dataSource = new DataSource({
type: 'sqlite',
database: './config/reiverr.sqlite',
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: true,
});
return dataSource.initialize();
},
},

View File

@@ -5,6 +5,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as fs from 'fs';
import { UserService } from './user/user.service';
import { ADMIN_PASSWORD, ADMIN_USERNAME } from './consts';
import { json, urlencoded } from 'express';
// import * as proxy from 'express-http-proxy';
async function createAdminUser(userService: UserService) {
@@ -21,6 +22,8 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
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'));

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Get,
@@ -89,6 +90,7 @@ export class UserController {
@UseGuards(AuthGuard)
@Put(':id')
@ApiOkResponse({ description: 'User updated', type: UserDto })
@ApiException(() => NotFoundException, { description: 'User not found' })
async updateUser(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
@@ -99,9 +101,30 @@ export class UserController {
}
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.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);
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';
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 {
return {
id: entity.id,
@@ -9,6 +15,8 @@ export class UserDto extends OmitType(User, ['password'] as const) {
isAdmin: entity.isAdmin,
settings: entity.settings,
onboardingDone: entity.onboardingDone,
profilePicture:
'data:image;base64,' + entity.profilePicture?.toString('base64'),
};
}
}
@@ -20,7 +28,13 @@ export class CreateUserDto extends PickType(User, [
] as const) {}
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) {}

View File

@@ -111,6 +111,11 @@ export class User {
@Column()
password: string;
@ApiProperty({ required: false })
@Column({ type: 'blob', nullable: true })
profilePicture: Buffer;
@Column()
@ApiProperty({ required: true })
@Column({ default: 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;
}
.header3 {
@apply font-semibold text-3xl text-secondary-100;
}
.header4 {
@apply font-semibold text-4xl text-secondary-100 tracking-wider;
}

View File

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

View File

@@ -62,6 +62,7 @@ export interface components {
isAdmin: boolean;
onboardingDone?: boolean;
settings: components["schemas"]["Settings"];
profilePicture: string;
};
CreateUserDto: {
name: string;
@@ -70,8 +71,11 @@ export interface components {
};
UpdateUserDto: {
name?: string;
password?: string;
onboardingDone?: boolean;
settings?: components["schemas"]["Settings"];
profilePicture?: string;
oldPassword?: string;
};
SignInDto: {
name: string;
@@ -174,6 +178,18 @@ export interface operations {
"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: {

View File

@@ -101,6 +101,7 @@
</div>
{/if}
{#if iconAbsolute}
<div class="w-8" />
<div class="absolute inset-y-0 right-0 flex items-center justify-center">
<svelte:component this={iconAbsolute} size={19} />
</div>

View File

@@ -4,7 +4,7 @@
import { fade } from 'svelte/transition';
import { modalStack } from '../Modal/modal.store';
export let size: 'sm' | 'full' = 'sm';
export let size: 'sm' | 'full' | 'lg' | 'dynamic' = 'sm';
function handleClose() {
modalStack.closeTopmost();
@@ -19,10 +19,12 @@
>
<div
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',
'h-full overflow-hidden': size === 'full'
'flex-1 max-w-lg min-h-0 overflow-y-auto scrollbar-hide': size === 'sm',
'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
)}

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">
import { Plus } from 'radix-icons-svelte';
import type { ComponentType } from 'svelte';
export let icon: ComponentType;
</script>
<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">
<Plus size={32} />
<svelte:component this={icon} size={32} />
</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 apiKey = get(user)?.settings.jellyfin.apiKey || '';
let originalBaseUrl = derived(user, (user) => user?.settings.jellyfin.baseUrl || '');
let originalApiKey = derived(user, (user) => user?.settings.jellyfin.apiKey || '');
const originalBaseUrl = derived(user, (user) => user?.settings.jellyfin.baseUrl || '');
const originalApiKey = derived(user, (user) => user?.settings.jellyfin.apiKey || '');
let timeout: ReturnType<typeof setTimeout>;
let error = '';
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 AnimateScale from '../AnimateScale.svelte';
import classNames from 'classnames';
import { Plus, PlusCircled } from 'radix-icons-svelte';
import { Plus } from 'radix-icons-svelte';
import { getCardDimensions } from '../../utils';
import AddElementOverlay from '../AddElementOverlay.svelte';
import IconOverlay from '../IconOverlay.svelte';
export let backdropUrl: string;
@@ -32,6 +32,6 @@
class="bg-cover bg-center absolute inset-0"
style={`background-image: url('${backdropUrl}')`}
/>
<AddElementOverlay />
<IconOverlay icon={Plus} />
</Container>
</AnimateScale>

View File

@@ -37,7 +37,7 @@
$$restProps.class,
'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
}

View File

@@ -13,20 +13,23 @@
import JellyfinIntegrationUsersDialog from '../components/Integrations/JellyfinIntegrationUsersDialog.svelte';
import { tmdbApi } from '../apis/tmdb/tmdb-api';
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 { createModal } from '../components/Modal/modal.store';
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte';
import { user } from '../stores/user.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 {
Interface,
Integrations,
Account,
About
}
const tab = useTabs(Tabs.Integrations);
const tab = useTabs(Tabs.Account);
let jellyfinBaseUrl = '';
let jellyfinApiKey = '';
@@ -117,11 +120,14 @@
}
}));
}
function handleLogOut() {
sessions.removeSession();
}
</script>
<svelte:window
on:keydown={(e) => {
console.log('keypress', e);
lastKeyCode = e.keyCode;
lastKey = e.key;
}}
@@ -130,7 +136,7 @@
<DetachedPage class="px-32 py-16">
<Container
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
on:enter={() => tab.set(Tabs.Interface)}
@@ -144,22 +150,22 @@
'text-primary-500': hasFocus
})}
>
General
Options
</span>
</Container>
<Container
on:enter={() => tab.set(Tabs.Integrations)}
on:clickOrSelect={() => tab.set(Tabs.Integrations)}
on:enter={() => tab.set(Tabs.Account)}
on:clickOrSelect={() => tab.set(Tabs.Account)}
let:hasFocus
focusOnClick
>
<span
class={classNames('cursor-pointer', {
'text-secondary-400': $tab !== Tabs.Integrations,
'text-secondary-400': $tab !== Tabs.Account,
'text-primary-500': hasFocus
})}
>
Integrations
Account
</span>
</Container>
<Container
@@ -207,10 +213,37 @@
</div>
</Tab>
<Tab {...tab} tab={Tabs.Integrations} class="">
<Container direction="horizontal" class="gap-8 grid grid-cols-2">
<Container class="flex flex-col space-y-8">
<Container class="bg-primary-800 rounded-xl p-8">
<Tab {...tab} tab={Tabs.Account} class="space-y-16">
<div>
<h1 class="font-semibold text-2xl text-secondary-100 mb-8">Profile</h1>
<Container class="bg-primary-800 rounded-xl p-8" on:enter={scrollIntoView({ top: 9999 })}>
<SelectField
value={$user?.name || ''}
on:clickOrSelect={() => {
const u = $user;
if (u)
createModal(EditProfileModal, {
user: u
});
}}
>
Logged in as
<Pencil2 slot="icon" let:size let:iconClass {size} class={classNames(iconClass)} />
</SelectField>
<Container direction="horizontal" class="flex space-x-4">
<Button type="primary-dark" icon={Exit} on:clickOrSelect={handleLogOut}>Log Out</Button>
</Container>
</Container>
</div>
<div>
<h1 class="font-semibold text-2xl text-secondary-100 mb-8">Integrations</h1>
<Container direction="horizontal" class="gap-16 grid grid-cols-2">
<Container class="flex flex-col space-y-16">
<Container
class="bg-primary-800 rounded-xl p-8"
on:enter={scrollIntoView({ vertical: 64 })}
>
<h1 class="mb-4 header2">Sonarr</h1>
<SonarrIntegration
on:change={({ detail }) => {
@@ -226,7 +259,10 @@
</div>
</Container>
<Container class="bg-primary-800 rounded-xl p-8">
<Container
class="bg-primary-800 rounded-xl p-8"
on:enter={scrollIntoView({ vertical: 64 })}
>
<h1 class="mb-4 header2">Radarr</h1>
<RadarrIntegration
on:change={({ detail }) => {
@@ -243,8 +279,11 @@
</Container>
</Container>
<Container class="flex flex-col space-y-8">
<Container class="bg-primary-800 rounded-xl p-8">
<Container class="flex flex-col space-y-16">
<Container
class="bg-primary-800 rounded-xl p-8"
on:enter={scrollIntoView({ vertical: 64 })}
>
<h1 class="mb-4 header2">Tmdb Account</h1>
{#await tmdbAccount then tmdbAccount}
{#if tmdbAccount}
@@ -269,21 +308,12 @@
</div>
{/if}
{/await}
<!-- <TmdbIntegration-->
<!-- on:change={({ detail }) => {-->
<!-- sonarrBaseUrl = detail.baseUrl;-->
<!-- sonarrApiKey = detail.apiKey;-->
<!-- sonarrStale = detail.stale;-->
<!-- }}-->
<!-- />-->
<!-- <div class="flex">-->
<!-- <Button disabled={!sonarrStale} type="primary-dark" action={handleSaveSonarr}>-->
<!-- Save-->
<!-- </Button>-->
<!-- </div>-->
</Container>
<Container class="bg-primary-800 rounded-xl p-8">
<Container
class="bg-primary-800 rounded-xl p-8"
on:enter={scrollIntoView({ vertical: 64 })}
>
<h1 class="mb-4 header2">Jellyfin</h1>
<JellyfinIntegration
bind:jellyfinUser
@@ -307,6 +337,7 @@
</Container>
</Container>
</Container>
</div>
</Tab>
<Tab {...tab} tab={Tabs.About}>
@@ -320,9 +351,7 @@
<div>Tizen media key: {tizenMediaKey}</div>
{/if}
<div class="flex space-x-4 mt-4">
<Button on:clickOrSelect={() => sessions.removeSession()} class="hover:bg-red-500">
Log Out
</Button>
<Button on:clickOrSelect={handleLogOut} class="hover:bg-red-500">Log Out</Button>
</div>
</Tab>
</Container>

View File

@@ -10,7 +10,6 @@
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';
import { useTabs } from '../components/Tab/Tab';
import classNames from 'classnames';
import { user } from '../stores/user.store';
@@ -231,14 +230,18 @@
</script>
<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">
<div class="relative">
<Tab {...tab} tab={Tabs.Welcome}>
<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={() => sessions.removeSession()}>Log Out</Button>
<Button type="primary-dark" on:clickOrSelect={() => sessions.removeSession()}
>Log Out</Button
>
<div class="flex-1">
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
Next
@@ -250,10 +253,11 @@
</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>
<div class="body mb-8">
Connect to TMDB for personalized recommendations based on your movie reviews and preferences.
Connect to TMDB for personalized recommendations based on your movie reviews and
preferences.
</div>
<div class="space-y-4 flex flex-col">
@@ -291,10 +295,11 @@
</div>
</Tab>
<Tab {...tab} tab={Tabs.TmdbConnect} class={tabContainer} on:back={() => tab.set(Tabs.Tmdb)}>
<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".
To connect your TMDB account, log in via the link below and then click "Complete
Connection".
</div>
{#if tmdbConnectQrCode}
@@ -317,7 +322,7 @@
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Jellyfin} class={tabContainer}>
<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>
@@ -354,7 +359,7 @@
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.SelectUser} on:back={() => tab.set(Tabs.Jellyfin)} class={tabContainer}>
<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}
@@ -371,7 +376,7 @@
{/await}
</Tab>
<Tab {...tab} tab={Tabs.Sonarr} class={tabContainer}>
<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>
@@ -394,7 +399,7 @@
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Radarr} class={tabContainer}>
<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>
@@ -417,7 +422,7 @@
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Complete} class={classNames(tabContainer, 'w-full')}>
<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>
@@ -435,4 +440,6 @@
</div>
</Container>
</Tab>
</div>
</div>
</Container>

View File

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

File diff suppressed because one or more lines are too long

View File

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