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: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",
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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;
|
@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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
16
src/lib/apis/reiverr/reiverr.generated.d.ts
vendored
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
|
|||||||
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">
|
<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>
|
||||||
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 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;
|
||||||
|
|||||||
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 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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
14
src/lib/profile-pictures.ts
Normal 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 {
|
||||||
|
|||||||