feat: Migrations, profile pictures, editing profile
9
backend/data-source.ts
Normal 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}'],
|
||||
});
|
||||
17
backend/migrations/1718397524237-initial-migration.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
20
backend/migrations/1718397928862-add-profile-picture.ts
Normal 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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
public/profile-pictures/ana.webp
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
public/profile-pictures/emma.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/profile-pictures/glen.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/profile-pictures/henry.webp
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
public/profile-pictures/keanu.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/profile-pictures/leo.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/profile-pictures/sydney.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/profile-pictures/zendaya.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
16
src/lib/apis/reiverr/reiverr.generated.d.ts
vendored
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
213
src/lib/components/Dialog/EditProfileModal.svelte
Normal 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>
|
||||
@@ -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>
|
||||
23
src/lib/components/IconToggle.svelte
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
34
src/lib/components/ProfileIcon.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
14
src/lib/profile-pictures.ts
Normal 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 {
|
||||
|
||||