mirror of
https://github.com/jorenn92/Maintainerr.git
synced 2026-02-07 00:45:47 +01:00
feat: Migrate UI from Next.js to Vite + React Router (#2100)
This commit is contained in:
24
.github/copilot-instructions.md
vendored
24
.github/copilot-instructions.md
vendored
@@ -10,7 +10,7 @@ This is a **TypeScript monorepo** managed with **Turborepo** and **Yarn workspac
|
||||
|
||||
```
|
||||
├── server/ # Nest.js backend API
|
||||
├── ui/ # Next.js frontend application
|
||||
├── ui/ # Vite + React Router frontend application
|
||||
├── packages/
|
||||
│ └── contracts/ # Shared TypeScript types, DTOs, and interfaces
|
||||
├── package.json # Root package with Turborepo scripts
|
||||
@@ -30,11 +30,14 @@ This is a **TypeScript monorepo** managed with **Turborepo** and **Yarn workspac
|
||||
|
||||
### Frontend (`ui/`)
|
||||
|
||||
- **Framework**: Next.js 15+ with React 19+
|
||||
- **Build System**: Vite 7+ for fast development and production builds
|
||||
- **Framework**: React 19+ with React Router 7+ for client-side routing
|
||||
- **Styling**: TailwindCSS with Headless UI components
|
||||
- **State Management**: TanStack Query (React Query)
|
||||
- **Forms**: React Hook Form with Zod validation
|
||||
- **UI Components**: Custom components with Heroicons
|
||||
- **Page Metadata**: React 19 Document Metadata support
|
||||
- **Environment Variables**: Vite environment variables (import.meta.env.VITE\_\*)
|
||||
|
||||
### Shared (`packages/contracts/`)
|
||||
|
||||
@@ -110,11 +113,14 @@ yarn workspace @maintainerr/contracts build
|
||||
|
||||
#### Frontend Patterns
|
||||
|
||||
- **Pages**: Next.js app router structure
|
||||
- **Routing**: React Router with `createBrowserRouter` for declarative routing
|
||||
- **Routes**: Explicit route configuration in `/src/router.tsx` with nested route support
|
||||
- **Components**: Reusable UI components in `/src/components`
|
||||
- **Hooks**: Custom hooks for data fetching (TanStack Query)
|
||||
- **Hooks**: Custom hooks for data fetching (TanStack Query) and navigation (useNavigate, useLocation)
|
||||
- **Forms**: React Hook Form with Zod resolvers
|
||||
- **API**: Axios client with TypeScript contracts
|
||||
- **Page Metadata**: React Helmet Async for managing `<head>` elements declaratively
|
||||
- **Code Splitting**: React.lazy with Suspense for dynamic imports
|
||||
|
||||
#### Shared Contracts
|
||||
|
||||
@@ -139,11 +145,13 @@ server/src/
|
||||
|
||||
```
|
||||
ui/src/
|
||||
├── pages/ # Next.js pages (app router)
|
||||
├── components/ # Reusable UI components
|
||||
├── hooks/ # Custom React hooks
|
||||
├── pages/ # Page components for routes (DocsPage, PlexLoadingPage)
|
||||
├── utils/ # Client-side utilities
|
||||
└── styles/ # Global styles and Tailwind config
|
||||
├── styles/ # Global styles and Tailwind config
|
||||
├── router.tsx # React Router configuration with route definitions
|
||||
└── main.tsx # Application entry point
|
||||
```
|
||||
|
||||
## Refactoring Guidelines
|
||||
@@ -232,9 +240,11 @@ These specifications provide comprehensive type definitions and endpoint documen
|
||||
### Performance Considerations
|
||||
|
||||
- Use Turborepo caching for faster builds
|
||||
- Leverage Next.js optimizations (SSG, image optimization)
|
||||
- Leverage Vite's fast HMR and optimized production builds
|
||||
- Implement proper database indexing in TypeORM entities
|
||||
- Use React Query for efficient data fetching and caching
|
||||
- Use React.lazy with Suspense for code splitting
|
||||
- Optimize images and assets appropriately
|
||||
|
||||
## Contributing Guidelines
|
||||
|
||||
|
||||
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@@ -14,10 +14,6 @@ updates:
|
||||
- "react-dom"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
next:
|
||||
patterns:
|
||||
- "next"
|
||||
- "eslint-config-next"
|
||||
eslint:
|
||||
patterns:
|
||||
- "@eslint/js"
|
||||
|
||||
@@ -14,11 +14,9 @@ RUN yarn install --network-timeout 99999999
|
||||
RUN yarn cache clean
|
||||
|
||||
RUN <<EOF cat >> ./ui/.env
|
||||
NEXT_PUBLIC_BASE_PATH=/__PATH_PREFIX__
|
||||
VITE_BASE_PATH=/__PATH_PREFIX__
|
||||
EOF
|
||||
|
||||
RUN sed -i "s,basePath: '',basePath: '/__PATH_PREFIX__',g" ./ui/next.config.js
|
||||
|
||||
RUN yarn turbo build
|
||||
|
||||
# Only install production dependencies to reduce image size
|
||||
@@ -41,7 +39,7 @@ COPY --from=builder --chmod=777 --chown=node:node /app/server/package.json ./ser
|
||||
COPY --from=builder --chmod=777 --chown=node:node /app/server/node_modules ./server/node_modules
|
||||
|
||||
# copy UI output to API to be served statically
|
||||
COPY --from=builder --chmod=777 --chown=node:node /app/ui/out ./server/dist/ui
|
||||
COPY --from=builder --chmod=777 --chown=node:node /app/ui/dist ./server/dist/ui
|
||||
|
||||
# Copy packages/contracts
|
||||
COPY --from=builder --chmod=777 --chown=node:node /app/packages/contracts/dist ./packages/contracts/dist
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './app'
|
||||
export * from './collections'
|
||||
export * from './events'
|
||||
export * from './plex'
|
||||
export * from './rules'
|
||||
export * from './settings/jellyseerr'
|
||||
export * from './settings/logs'
|
||||
|
||||
1
packages/contracts/src/plex/index.ts
Normal file
1
packages/contracts/src/plex/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './types'
|
||||
6
packages/contracts/src/plex/types.ts
Normal file
6
packages/contracts/src/plex/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum EPlexDataType {
|
||||
MOVIES = 1,
|
||||
SHOWS = 2,
|
||||
SEASONS = 3,
|
||||
EPISODES = 4,
|
||||
}
|
||||
17
server/src/modules/api/plex-api/guards/plex-setup.guard.ts
Normal file
17
server/src/modules/api/plex-api/guards/plex-setup.guard.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { BadRequestException, CanActivate, Injectable } from '@nestjs/common';
|
||||
import { PlexApiService } from '../plex-api.service';
|
||||
|
||||
@Injectable()
|
||||
export class PlexSetupGuard implements CanActivate {
|
||||
constructor(private readonly plexApiService: PlexApiService) {}
|
||||
|
||||
canActivate(): boolean {
|
||||
if (this.plexApiService.isPlexSetup()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
'Plex is not configured yet. Please finish the Plex setup before using this endpoint.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BasicResponseDto } from './dto/basic-response.dto';
|
||||
import { CollectionHubSettingsDto } from './dto/collection-hub-settings.dto';
|
||||
import { EPlexDataType } from './enums/plex-data-type-enum';
|
||||
import { PlexSetupGuard } from './guards/plex-setup.guard';
|
||||
import {
|
||||
CreateUpdateCollection,
|
||||
PlexCollection,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
import { PlexHub, PlexLibraryItem } from './interfaces/library.interfaces';
|
||||
import { PlexApiService } from './plex-api.service';
|
||||
|
||||
@UseGuards(PlexSetupGuard)
|
||||
@Controller('api/plex')
|
||||
export class PlexApiController {
|
||||
constructor(private readonly plexApiService: PlexApiService) {}
|
||||
@@ -26,10 +29,12 @@ export class PlexApiController {
|
||||
getStatus(): any {
|
||||
return this.plexApiService.getStatus();
|
||||
}
|
||||
|
||||
@Get('libraries')
|
||||
getLibraries() {
|
||||
async getLibraries() {
|
||||
return this.plexApiService.getLibraries();
|
||||
}
|
||||
|
||||
@Get('library/:id/content{/:page}')
|
||||
getLibraryContent(
|
||||
@Param('id') id: string,
|
||||
@@ -43,6 +48,7 @@ export class PlexApiController {
|
||||
size: size,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('library/:id/content/search/:query')
|
||||
searchibraryContent(
|
||||
@Param('id') id: string,
|
||||
@@ -51,32 +57,39 @@ export class PlexApiController {
|
||||
) {
|
||||
return this.plexApiService.searchLibraryContents(id, query, type);
|
||||
}
|
||||
|
||||
@Get('meta/:id')
|
||||
getMetadata(@Param('id') id: string) {
|
||||
return this.plexApiService.getMetadata(id);
|
||||
}
|
||||
|
||||
@Get('meta/:id/seen')
|
||||
getSeenBy(@Param('id') id: string) {
|
||||
return this.plexApiService.getWatchHistory(id);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
getUser() {
|
||||
return this.plexApiService.getUsers();
|
||||
}
|
||||
|
||||
@Get('meta/:id/children')
|
||||
getChildrenMetadata(@Param('id') id: string) {
|
||||
return this.plexApiService.getChildrenMetadata(id);
|
||||
}
|
||||
|
||||
@Get('library/:id/recent')
|
||||
getRecentlyAdded(@Param('id', new ParseIntPipe()) id: number) {
|
||||
return this.plexApiService.getRecentlyAdded(id.toString());
|
||||
}
|
||||
|
||||
@Get('library/:id/collections')
|
||||
async getCollections(@Param('id', new ParseIntPipe()) id: number) {
|
||||
const collection: PlexCollection[] =
|
||||
await this.plexApiService.getCollections(id.toString());
|
||||
return collection;
|
||||
}
|
||||
|
||||
@Get('library/collection/:collectionId')
|
||||
async getCollection(
|
||||
@Param('collectionId', new ParseIntPipe()) collectionId: number,
|
||||
@@ -86,6 +99,7 @@ export class PlexApiController {
|
||||
);
|
||||
return collection;
|
||||
}
|
||||
|
||||
@Get('library/collection/:collectionId/children')
|
||||
async getCollectionChildren(
|
||||
@Param('collectionId', new ParseIntPipe()) collectionId: number,
|
||||
@@ -94,10 +108,12 @@ export class PlexApiController {
|
||||
await this.plexApiService.getCollectionChildren(collectionId.toString());
|
||||
return collection;
|
||||
}
|
||||
|
||||
@Get('/search/:input')
|
||||
async searchLibrary(@Param('input') input: string) {
|
||||
return await this.plexApiService.searchContent(input);
|
||||
}
|
||||
|
||||
@Put('library/collection/:collectionId/child/:childId')
|
||||
async addChildToCollection(
|
||||
@Param('collectionId', new ParseIntPipe()) collectionId: number,
|
||||
@@ -110,6 +126,7 @@ export class PlexApiController {
|
||||
);
|
||||
return collection;
|
||||
}
|
||||
|
||||
@Delete('library/collection/:collectionId/child/:childId')
|
||||
async deleteChildFromCollection(
|
||||
@Param('collectionId', new ParseIntPipe()) collectionId: number,
|
||||
@@ -122,6 +139,7 @@ export class PlexApiController {
|
||||
);
|
||||
return collection;
|
||||
}
|
||||
|
||||
@Put('library/collection/update')
|
||||
async updateCollection(@Body() body: CreateUpdateCollection) {
|
||||
const collection: PlexCollection =
|
||||
@@ -135,6 +153,7 @@ export class PlexApiController {
|
||||
await this.plexApiService.createCollection(body);
|
||||
return collection;
|
||||
}
|
||||
|
||||
@Delete('library/collection/:collectionId')
|
||||
async deleteCollection(
|
||||
@Param('collectionId', new ParseIntPipe()) collectionId: number,
|
||||
@@ -143,6 +162,7 @@ export class PlexApiController {
|
||||
await this.plexApiService.deleteCollection(collectionId.toString());
|
||||
return collection;
|
||||
}
|
||||
|
||||
@Put('library/collection/settings')
|
||||
async UpdateCollectionSettings(@Body() body: CollectionHubSettingsDto) {
|
||||
if (
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlexApiService } from './plex-api.service';
|
||||
import { PlexApiController } from './plex-api.controller';
|
||||
import { SettingsModule } from '../../../modules/settings/settings.module';
|
||||
import { PlexSetupGuard } from './guards/plex-setup.guard';
|
||||
import { PlexApiController } from './plex-api.controller';
|
||||
import { PlexApiService } from './plex-api.service';
|
||||
|
||||
@Module({
|
||||
imports: [SettingsModule],
|
||||
controllers: [PlexApiController],
|
||||
providers: [PlexApiService],
|
||||
providers: [PlexApiService, PlexSetupGuard],
|
||||
exports: [PlexApiService],
|
||||
})
|
||||
export class PlexApiModule {}
|
||||
|
||||
@@ -74,15 +74,23 @@ export class PlexApiService {
|
||||
};
|
||||
}
|
||||
|
||||
public isPlexSetup(): boolean {
|
||||
return this.plexClient != null;
|
||||
}
|
||||
|
||||
public uninitialize() {
|
||||
this.plexClient = undefined;
|
||||
}
|
||||
|
||||
public async initialize({
|
||||
plexToken,
|
||||
timeout,
|
||||
}: {
|
||||
plexToken?: string;
|
||||
// plexSettings?: PlexSettings;
|
||||
timeout?: number;
|
||||
}) {
|
||||
try {
|
||||
this.plexClient = undefined;
|
||||
const settingsPlex = this.getDbSettings();
|
||||
plexToken = plexToken || settingsPlex.auth_token;
|
||||
if (settingsPlex.ip && plexToken) {
|
||||
|
||||
@@ -61,7 +61,7 @@ export class RulesController {
|
||||
return this.rulesService.getRuleGroupCount();
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
@Get('/:id/rules')
|
||||
getRules(@Param('id') id: string) {
|
||||
return this.rulesService.getRules(id);
|
||||
}
|
||||
@@ -86,6 +86,12 @@ export class RulesController {
|
||||
query.typeId ? query.typeId : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
getRuleGroup(@Param('id') id: number): Promise<RulesDto> {
|
||||
return this.rulesService.getRuleGroup(id);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
deleteRuleGroup(@Param('id') id: string) {
|
||||
return this.rulesService.deleteRuleGroup(+id);
|
||||
|
||||
@@ -163,6 +163,24 @@ export class RulesService {
|
||||
}
|
||||
}
|
||||
|
||||
async getRuleGroup(id: number): Promise<RulesDto> {
|
||||
try {
|
||||
const rulegroup = await this.connection
|
||||
.createQueryBuilder('rule_group', 'rg')
|
||||
.innerJoinAndSelect('rg.rules', 'r')
|
||||
.innerJoinAndSelect('rg.collection', 'c')
|
||||
.leftJoinAndSelect('rg.notifications', 'n')
|
||||
.andWhere(`rg.id = ${id}`)
|
||||
.orderBy('r.id')
|
||||
.getOne();
|
||||
return rulegroup as RulesDto;
|
||||
} catch (e) {
|
||||
this.logger.warn(`Rules - Action failed : ${e.message}`);
|
||||
this.logger.debug(e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async getRuleGroupCount(): Promise<number> {
|
||||
return this.ruleGroupRepository.count();
|
||||
}
|
||||
|
||||
4
server/src/modules/settings/dto's/update-setting.dto.ts
Normal file
4
server/src/modules/settings/dto's/update-setting.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { SettingDto } from './setting.dto';
|
||||
|
||||
export class UpdateSettingDto extends PartialType(SettingDto) {}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
@@ -18,6 +19,7 @@ import { CronScheduleDto } from "./dto's/cron.schedule.dto";
|
||||
import { RadarrSettingRawDto } from "./dto's/radarr-setting.dto";
|
||||
import { SettingDto } from "./dto's/setting.dto";
|
||||
import { SonarrSettingRawDto } from "./dto's/sonarr-setting.dto";
|
||||
import { UpdateSettingDto } from "./dto's/update-setting.dto";
|
||||
import { Settings } from './entities/settings.entities';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@@ -55,6 +57,10 @@ export class SettingsController {
|
||||
updateSettings(@Body() payload: SettingDto) {
|
||||
return this.settingsService.updateSettings(payload);
|
||||
}
|
||||
@Patch()
|
||||
patchSettings(@Body() payload: UpdateSettingDto) {
|
||||
return this.settingsService.patchSettings(payload);
|
||||
}
|
||||
@Post('/plex/token')
|
||||
updateAuthToken(@Body() payload: { plex_auth_token: string }) {
|
||||
return this.settingsService.savePlexApiAuthToken(payload.plex_auth_token);
|
||||
|
||||
@@ -446,6 +446,7 @@ export class SettingsService implements SettingDto {
|
||||
);
|
||||
|
||||
this.plex_auth_token = null;
|
||||
this.plexApi.uninitialize();
|
||||
|
||||
return { status: 'OK', code: 1, message: 'Success' };
|
||||
} catch (err) {
|
||||
@@ -479,6 +480,28 @@ export class SettingsService implements SettingDto {
|
||||
}
|
||||
}
|
||||
|
||||
public async patchSettings(
|
||||
settings: Partial<Settings>,
|
||||
): Promise<BasicResponseDto> {
|
||||
const settingsDb = await this.settingsRepo.findOne({ where: {} });
|
||||
|
||||
if (!settingsDb) {
|
||||
this.logger.error('Settings could not be loaded for partial update.');
|
||||
return {
|
||||
status: 'NOK',
|
||||
code: 0,
|
||||
message: 'No settings found to update',
|
||||
};
|
||||
}
|
||||
|
||||
const mergedSettings: Settings = {
|
||||
...settingsDb,
|
||||
...settings,
|
||||
};
|
||||
|
||||
return this.updateSettings(mergedSettings);
|
||||
}
|
||||
|
||||
public async updateSettings(settings: Settings): Promise<BasicResponseDto> {
|
||||
try {
|
||||
settings.plex_hostname = settings.plex_hostname?.toLowerCase();
|
||||
@@ -486,7 +509,6 @@ export class SettingsService implements SettingDto {
|
||||
settings.tautulli_url = settings.tautulli_url?.toLowerCase();
|
||||
|
||||
const settingsDb = await this.settingsRepo.findOne({ where: {} });
|
||||
// Plex SSL specifics
|
||||
|
||||
settings.plex_ssl =
|
||||
settings.plex_hostname?.includes('https://') ||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^lint"]
|
||||
|
||||
10
ui/.gitignore
vendored
10
ui/.gitignore
vendored
@@ -8,9 +8,10 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
# vite
|
||||
/dist
|
||||
/dist-ssr
|
||||
*.local
|
||||
|
||||
# production
|
||||
/build
|
||||
@@ -30,8 +31,5 @@ yarn-error.log*
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
34
ui/README.md
34
ui/README.md
@@ -1,34 +0,0 @@
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
@@ -1,7 +1,10 @@
|
||||
import { FlatCompat } from '@eslint/eslintrc'
|
||||
import js from '@eslint/js'
|
||||
import pluginQuery from '@tanstack/eslint-plugin-query'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import globals from 'globals'
|
||||
import path from 'path'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
@@ -13,14 +16,39 @@ const compat = new FlatCompat({
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
const configs = [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
{
|
||||
ignores: ['dist/**', '*.config.js', '*.config.mjs', '*.config.ts'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...compat.extends(
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
),
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
globals: { ...globals.browser },
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/incompatible-library': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
'react-hooks/refs': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
},
|
||||
...pluginQuery.configs['flat/recommended'],
|
||||
|
||||
13
ui/index.html
Normal file
13
ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Maintainerr</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
ui/next-env.d.ts
vendored
6
ui/next-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
@@ -1,19 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'image.tmdb.org',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
basePath: '',
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -3,10 +3,11 @@
|
||||
"version": "2.22.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p 80",
|
||||
"lint": "next lint",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "vite preview --port 80 --host 0.0.0.0",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write --ignore-path .gitignore .",
|
||||
"format:check": "prettier --check --ignore-path .gitignore ."
|
||||
},
|
||||
@@ -25,12 +26,12 @@
|
||||
"clsx": "^2.1.1",
|
||||
"compare-versions": "^6.1.1",
|
||||
"cron-validator": "^1.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "^15.5.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"react-select": "^5.10.2",
|
||||
"react-toastify": "^11.0.5",
|
||||
"reconnecting-eventsource": "^1.6.4",
|
||||
@@ -44,21 +45,25 @@
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/eslint-plugin-query": "^5.91.2",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "10.4.21",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^15.5.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"monaco-editor": "0.55.1",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.7.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.2",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
26
ui/src/api/plex.ts
Normal file
26
ui/src/api/plex.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { ILibrary } from '../contexts/libraries-context'
|
||||
import GetApiHandler from '../utils/ApiHandler'
|
||||
|
||||
type UsePlexLibrariesQueryKey = ['plex', 'libraries']
|
||||
|
||||
type UsePlexLibrariesOptions = Omit<
|
||||
UseQueryOptions<ILibrary[], Error, ILibrary[], UsePlexLibrariesQueryKey>,
|
||||
'queryKey' | 'queryFn'
|
||||
>
|
||||
|
||||
export const usePlexLibraries = (options?: UsePlexLibrariesOptions) => {
|
||||
const queryEnabled = options?.enabled ?? true
|
||||
|
||||
return useQuery<ILibrary[], Error, ILibrary[], UsePlexLibrariesQueryKey>({
|
||||
queryKey: ['plex', 'libraries'],
|
||||
queryFn: async () => {
|
||||
return await GetApiHandler<ILibrary[]>(`/plex/libraries`)
|
||||
},
|
||||
staleTime: 0,
|
||||
...options,
|
||||
enabled: queryEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
export type UsePlexLibrariesResult = ReturnType<typeof usePlexLibraries>
|
||||
154
ui/src/api/rules.ts
Normal file
154
ui/src/api/rules.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { BasicResponseDto } from '@maintainerr/contracts'
|
||||
import {
|
||||
useMutation,
|
||||
UseMutationOptions,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryOptions,
|
||||
} from '@tanstack/react-query'
|
||||
import type { IRule } from '../components/Rules/Rule/RuleCreator'
|
||||
import type { IRuleGroup } from '../components/Rules/RuleGroup'
|
||||
import { AgentConfiguration } from '../components/Settings/Notifications/CreateNotificationModal'
|
||||
import { IConstants } from '../contexts/constants-context'
|
||||
import GetApiHandler, {
|
||||
PostApiHandler,
|
||||
PutApiHandler,
|
||||
} from '../utils/ApiHandler'
|
||||
import { EPlexDataType } from '../utils/PlexDataType-enum'
|
||||
|
||||
export interface RuleGroupCollectionPayload {
|
||||
visibleOnRecommended: boolean
|
||||
visibleOnHome: boolean
|
||||
deleteAfterDays?: number
|
||||
manualCollection?: boolean
|
||||
manualCollectionName?: string
|
||||
keepLogsForMonths?: number
|
||||
}
|
||||
|
||||
export interface RuleGroupCreatePayload {
|
||||
name: string
|
||||
description: string
|
||||
libraryId: number
|
||||
arrAction: number
|
||||
isActive: boolean
|
||||
useRules: boolean
|
||||
listExclusions: boolean
|
||||
forceOverseerr: boolean
|
||||
tautulliWatchedPercentOverride?: number
|
||||
radarrSettingsId?: number
|
||||
sonarrSettingsId?: number
|
||||
collection: RuleGroupCollectionPayload
|
||||
rules: IRule[]
|
||||
dataType: EPlexDataType
|
||||
notifications: AgentConfiguration[]
|
||||
}
|
||||
|
||||
export type RuleGroupUpdatePayload = RuleGroupCreatePayload & { id: number }
|
||||
|
||||
type UseRuleGroupQueryKey = ['rules', 'group', string]
|
||||
|
||||
type UseRuleGroupOptions = Omit<
|
||||
UseQueryOptions<IRuleGroup, Error, IRuleGroup, UseRuleGroupQueryKey>,
|
||||
'queryKey' | 'queryFn'
|
||||
>
|
||||
|
||||
export const useRuleGroup = (
|
||||
id?: string | number,
|
||||
options?: UseRuleGroupOptions,
|
||||
) => {
|
||||
const normalizedId = id != null ? String(id) : ''
|
||||
const queryEnabled = normalizedId.length > 0 && (options?.enabled ?? true)
|
||||
|
||||
return useQuery<IRuleGroup, Error, IRuleGroup, UseRuleGroupQueryKey>({
|
||||
queryKey: ['rules', 'group', normalizedId],
|
||||
queryFn: async () => {
|
||||
if (!normalizedId) {
|
||||
throw new Error('Rule Group ID is required to fetch rule data.')
|
||||
}
|
||||
|
||||
return await GetApiHandler<IRuleGroup>(`/rules/${normalizedId}`)
|
||||
},
|
||||
staleTime: 0,
|
||||
...options,
|
||||
enabled: queryEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseRuleGroupResult = ReturnType<typeof useRuleGroup>
|
||||
|
||||
type UseRuleConstantsQueryKey = ['rules', 'constants']
|
||||
|
||||
type UseRuleConstantsOptions = Omit<
|
||||
UseQueryOptions<IConstants, Error, IConstants, UseRuleConstantsQueryKey>,
|
||||
'queryKey' | 'queryFn'
|
||||
>
|
||||
|
||||
export const useRuleConstants = (options?: UseRuleConstantsOptions) => {
|
||||
return useQuery({
|
||||
queryKey: ['rules', 'constants'],
|
||||
queryFn: async () => {
|
||||
return await GetApiHandler<IConstants>(`/rules/constants`)
|
||||
},
|
||||
staleTime: Infinity,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseRuleConstants = ReturnType<typeof useRuleConstants>
|
||||
|
||||
type UseCreateRuleGroupOptions = Omit<
|
||||
UseMutationOptions<BasicResponseDto, Error, RuleGroupCreatePayload>,
|
||||
'mutationFn' | 'mutationKey' | 'onSuccess'
|
||||
>
|
||||
|
||||
export const useCreateRuleGroup = (options?: UseCreateRuleGroupOptions) => {
|
||||
return useMutation<BasicResponseDto, Error, RuleGroupCreatePayload>({
|
||||
mutationKey: ['rules', 'groups', 'create'],
|
||||
mutationFn: async (payload) => {
|
||||
const response = await PostApiHandler<BasicResponseDto>('/rules', payload)
|
||||
|
||||
if (response.code !== 1) {
|
||||
throw new Error(response.message ?? 'Failed to create rule group')
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseCreateRuleGroupResult = ReturnType<typeof useCreateRuleGroup>
|
||||
|
||||
type UseUpdateRuleGroupOptions = Omit<
|
||||
UseMutationOptions<BasicResponseDto, Error, RuleGroupUpdatePayload>,
|
||||
'mutationFn' | 'mutationKey' | 'onSuccess'
|
||||
>
|
||||
|
||||
export const useUpdateRuleGroup = (options?: UseUpdateRuleGroupOptions) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<BasicResponseDto, Error, RuleGroupUpdatePayload>({
|
||||
mutationKey: ['rules', 'groups', 'update'],
|
||||
mutationFn: async (payload) => {
|
||||
const response = await PutApiHandler<BasicResponseDto>('/rules', payload)
|
||||
|
||||
if (response.code !== 1) {
|
||||
throw new Error(response.message ?? 'Failed to update rule group')
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
'rules',
|
||||
'group',
|
||||
String(variables.id),
|
||||
] satisfies UseRuleGroupQueryKey,
|
||||
})
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseUpdateRuleGroupResult = ReturnType<typeof useUpdateRuleGroup>
|
||||
132
ui/src/api/settings.ts
Normal file
132
ui/src/api/settings.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { BasicResponseDto } from '@maintainerr/contracts'
|
||||
import {
|
||||
useMutation,
|
||||
UseMutationOptions,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryOptions,
|
||||
} from '@tanstack/react-query'
|
||||
import GetApiHandler, {
|
||||
DeleteApiHandler,
|
||||
PatchApiHandler,
|
||||
PostApiHandler,
|
||||
} from '../utils/ApiHandler'
|
||||
|
||||
interface ISettings {
|
||||
id: number
|
||||
clientId: string
|
||||
applicationTitle: string
|
||||
applicationUrl: string
|
||||
apikey: string
|
||||
overseerr_url: string
|
||||
locale: string
|
||||
plex_name: string
|
||||
plex_hostname: string
|
||||
plex_port: number
|
||||
plex_ssl: number
|
||||
plex_auth_token: string | null
|
||||
overseerr_api_key: string
|
||||
tautulli_url: string
|
||||
tautulli_api_key: string
|
||||
jellyseerr_url: string
|
||||
jellyseerr_api_key: string
|
||||
collection_handler_job_cron: string
|
||||
rules_handler_job_cron: string
|
||||
}
|
||||
|
||||
type UseSettingsQueryKey = ['settings']
|
||||
|
||||
type UseSettingsOptions = Omit<
|
||||
UseQueryOptions<ISettings, Error, ISettings, UseSettingsQueryKey>,
|
||||
'queryKey' | 'queryFn'
|
||||
>
|
||||
|
||||
export const useSettings = (options?: UseSettingsOptions) => {
|
||||
const queryEnabled = options?.enabled ?? true
|
||||
|
||||
return useQuery<ISettings, Error, ISettings, UseSettingsQueryKey>({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
return await GetApiHandler<ISettings>(`/settings`)
|
||||
},
|
||||
staleTime: 0,
|
||||
...options,
|
||||
enabled: queryEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseSettingsResult = ReturnType<typeof useSettings>
|
||||
|
||||
type UsePatchSettingsOptions = Omit<
|
||||
UseMutationOptions<BasicResponseDto, Error, Partial<ISettings>>,
|
||||
'mutationFn' | 'mutationKey' | 'onSuccess'
|
||||
>
|
||||
|
||||
export const usePatchSettings = (options?: UsePatchSettingsOptions) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<BasicResponseDto, Error, Partial<ISettings>>({
|
||||
mutationKey: ['settings', 'patch'],
|
||||
mutationFn: async (payload) => {
|
||||
return await PatchApiHandler<BasicResponseDto>('/settings', payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['settings'] satisfies UseSettingsQueryKey,
|
||||
})
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export type UsePatchSettingsResult = ReturnType<typeof usePatchSettings>
|
||||
|
||||
type UseDeletePlexAuthOptions = Omit<
|
||||
UseMutationOptions<BasicResponseDto, Error, void>,
|
||||
'mutationFn' | 'mutationKey' | 'onSuccess'
|
||||
>
|
||||
|
||||
export const useDeletePlexAuth = (options?: UseDeletePlexAuthOptions) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<BasicResponseDto, Error, void>({
|
||||
mutationKey: ['settings', 'deletePlexAuth'],
|
||||
mutationFn: async () => {
|
||||
return await DeleteApiHandler<BasicResponseDto>('/settings/plex/auth')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['settings'] satisfies UseSettingsQueryKey,
|
||||
})
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseDeletePlexAuthResult = ReturnType<typeof useDeletePlexAuth>
|
||||
|
||||
type UseUpdatePlexAuthOptions = Omit<
|
||||
UseMutationOptions<BasicResponseDto, Error, string>,
|
||||
'mutationFn' | 'mutationKey' | 'onSuccess'
|
||||
>
|
||||
|
||||
export const useUpdatePlexAuth = (options?: UseUpdatePlexAuthOptions) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<BasicResponseDto, Error, string>({
|
||||
mutationKey: ['settings', 'updatePlexAuth'],
|
||||
mutationFn: async (token: string) => {
|
||||
return await PostApiHandler<BasicResponseDto>('/settings/plex/token', {
|
||||
plex_auth_token: token,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['settings'] satisfies UseSettingsQueryKey,
|
||||
})
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export type UseUpdatePlexAuthResult = ReturnType<typeof useUpdatePlexAuth>
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import GetApiHandler, { PostApiHandler } from '../../utils/ApiHandler'
|
||||
import Modal from '../Common/Modal'
|
||||
import FormItem from '../Common/FormItem'
|
||||
import { EPlexDataType } from '../../utils/PlexDataType-enum'
|
||||
import { IAddModal, IAlterableMediaDto, ICollectionMedia } from './interfaces'
|
||||
import Alert from '../Common/Alert'
|
||||
import FormItem from '../Common/FormItem'
|
||||
import Modal from '../Common/Modal'
|
||||
import { IAddModal, IAlterableMediaDto, ICollectionMedia } from './interfaces'
|
||||
|
||||
const AddModal = (props: IAddModal) => {
|
||||
const [selectedCollection, setSelectedCollection] = useState<number>()
|
||||
@@ -106,14 +106,11 @@ const AddModal = (props: IAddModal) => {
|
||||
collectionId: undefined,
|
||||
action: 1,
|
||||
})
|
||||
} else {
|
||||
}
|
||||
props.onSubmit()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Overview'
|
||||
|
||||
setSelectedSeasons(-1)
|
||||
setSelectedEpisodes(-1)
|
||||
|
||||
@@ -215,128 +212,132 @@ const AddModal = (props: IAddModal) => {
|
||||
}, [selectedSeasons, selectedEpisodes])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
loading={loading}
|
||||
backgroundClickable={false}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleOk}
|
||||
okDisabled={false}
|
||||
title={props.modalType === 'add' ? 'Add / Remove Media' : 'Exclude Media'}
|
||||
okText={'Submit'}
|
||||
okButtonType={'primary'}
|
||||
onSecondary={() => {}}
|
||||
specialButtonType="warning"
|
||||
specialDisabled={props.modalType !== 'add'}
|
||||
specialText={'Remove from all collections'}
|
||||
onSpecial={
|
||||
props.modalType === 'add'
|
||||
? () => {
|
||||
setForceRemovalCheck(true)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
iconSvg={''}
|
||||
>
|
||||
{forceRemovalcheck ? (
|
||||
<Modal
|
||||
loading={loading}
|
||||
backgroundClickable={false}
|
||||
onCancel={() => setForceRemovalCheck(false)}
|
||||
onOk={handleForceRemoval}
|
||||
okDisabled={false}
|
||||
title={'Confirmation Required'}
|
||||
okText={'Submit'}
|
||||
>
|
||||
Are you certain you want to proceed? This action will remove the{' '}
|
||||
{props.modalType === 'add' ? 'media ' : 'exclusion '}
|
||||
from all collections. For shows, this entails removing all associated{' '}
|
||||
{props.modalType === 'add' ? '' : 'exclusions for '}
|
||||
seasons and episodes as well.
|
||||
</Modal>
|
||||
) : undefined}
|
||||
|
||||
{alert ? (
|
||||
<Alert title="Please select a collection" type="warning" />
|
||||
) : undefined}
|
||||
|
||||
<div className="mt-6">
|
||||
<FormItem label="Action">
|
||||
<select
|
||||
name={`Action-field`}
|
||||
id={`Action-field`}
|
||||
value={selectedAction}
|
||||
onChange={(e: { target: { value: string } }) => {
|
||||
setSelectedAction(+e.target.value)
|
||||
}}
|
||||
<>
|
||||
<Modal
|
||||
loading={loading}
|
||||
backgroundClickable={false}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleOk}
|
||||
okDisabled={false}
|
||||
title={
|
||||
props.modalType === 'add' ? 'Add / Remove Media' : 'Exclude Media'
|
||||
}
|
||||
okText={'Submit'}
|
||||
okButtonType={'primary'}
|
||||
onSecondary={() => {}}
|
||||
specialButtonType="warning"
|
||||
specialDisabled={props.modalType !== 'add'}
|
||||
specialText={'Remove from all collections'}
|
||||
onSpecial={
|
||||
props.modalType === 'add'
|
||||
? () => {
|
||||
setForceRemovalCheck(true)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
iconSvg={''}
|
||||
>
|
||||
{forceRemovalcheck ? (
|
||||
<Modal
|
||||
loading={loading}
|
||||
backgroundClickable={false}
|
||||
onCancel={() => setForceRemovalCheck(false)}
|
||||
onOk={handleForceRemoval}
|
||||
okDisabled={false}
|
||||
title={'Confirmation Required'}
|
||||
okText={'Submit'}
|
||||
>
|
||||
<option value={0}>Add</option>
|
||||
<option value={1}>Remove</option>
|
||||
</select>
|
||||
</FormItem>
|
||||
Are you certain you want to proceed? This action will remove the{' '}
|
||||
{props.modalType === 'add' ? 'media ' : 'exclusion '}
|
||||
from all collections. For shows, this entails removing all
|
||||
associated {props.modalType === 'add' ? '' : 'exclusions for '}
|
||||
seasons and episodes as well.
|
||||
</Modal>
|
||||
) : undefined}
|
||||
|
||||
{/* For shows */}
|
||||
{props.type === 2 ? (
|
||||
<FormItem label="Seasons">
|
||||
{alert ? (
|
||||
<Alert title="Please select a collection" type="warning" />
|
||||
) : undefined}
|
||||
|
||||
<div className="mt-6">
|
||||
<FormItem label="Action">
|
||||
<select
|
||||
name={`Seasons-field`}
|
||||
id={`Seasons-field`}
|
||||
value={selectedSeasons}
|
||||
name={`Action-field`}
|
||||
id={`Action-field`}
|
||||
value={selectedAction}
|
||||
onChange={(e: { target: { value: string } }) => {
|
||||
setSelectedSeasons(+e.target.value)
|
||||
setSelectedAction(+e.target.value)
|
||||
}}
|
||||
>
|
||||
{seasonOptions.map((e: ICollectionMedia) => {
|
||||
<option value={0}>Add</option>
|
||||
<option value={1}>Remove</option>
|
||||
</select>
|
||||
</FormItem>
|
||||
|
||||
{/* For shows */}
|
||||
{props.type === 2 ? (
|
||||
<FormItem label="Seasons">
|
||||
<select
|
||||
name={`Seasons-field`}
|
||||
id={`Seasons-field`}
|
||||
value={selectedSeasons}
|
||||
onChange={(e: { target: { value: string } }) => {
|
||||
setSelectedSeasons(+e.target.value)
|
||||
}}
|
||||
>
|
||||
{seasonOptions.map((e: ICollectionMedia) => {
|
||||
return (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.title}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</FormItem>
|
||||
) : undefined}
|
||||
{/* For shows and specific seasons */}
|
||||
{props.type === 2 && selectedSeasons !== -1 ? (
|
||||
<FormItem label="Episodes">
|
||||
<select
|
||||
name={`Episodes-field`}
|
||||
id={`Episodes-field`}
|
||||
value={selectedEpisodes}
|
||||
onChange={(e: { target: { value: string } }) => {
|
||||
setSelectedEpisodes(+e.target.value)
|
||||
}}
|
||||
>
|
||||
{episodeOptions.map((e: ICollectionMedia) => {
|
||||
return (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.title}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</FormItem>
|
||||
) : undefined}
|
||||
|
||||
<FormItem label="Collection">
|
||||
<select
|
||||
name={`Collection-field`}
|
||||
id={`Collection-field`}
|
||||
value={selectedCollection}
|
||||
onChange={(e: { target: { value: string } }) => {
|
||||
setSelectedCollection(+e.target.value)
|
||||
}}
|
||||
>
|
||||
{collectionOptions?.map((e: ICollectionMedia) => {
|
||||
return (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.title}
|
||||
<option key={e?.id} value={e?.id}>
|
||||
{e?.title}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</FormItem>
|
||||
) : undefined}
|
||||
{/* For shows and specific seasons */}
|
||||
{props.type === 2 && selectedSeasons !== -1 ? (
|
||||
<FormItem label="Episodes">
|
||||
<select
|
||||
name={`Episodes-field`}
|
||||
id={`Episodes-field`}
|
||||
value={selectedEpisodes}
|
||||
onChange={(e: { target: { value: string } }) => {
|
||||
setSelectedEpisodes(+e.target.value)
|
||||
}}
|
||||
>
|
||||
{episodeOptions.map((e: ICollectionMedia) => {
|
||||
return (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.title}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</FormItem>
|
||||
) : undefined}
|
||||
|
||||
<FormItem label="Collection">
|
||||
<select
|
||||
name={`Collection-field`}
|
||||
id={`Collection-field`}
|
||||
value={selectedCollection}
|
||||
onChange={(e: { target: { value: string } }) => {
|
||||
setSelectedCollection(+e.target.value)
|
||||
}}
|
||||
>
|
||||
{collectionOptions?.map((e: ICollectionMedia) => {
|
||||
return (
|
||||
<option key={e?.id} value={e?.id}>
|
||||
{e?.title}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default AddModal
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
isMetaActionedByRule,
|
||||
} from '@maintainerr/contracts'
|
||||
import { Editor } from '@monaco-editor/react'
|
||||
import _ from 'lodash'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import YAML from 'yaml'
|
||||
import { ICollection } from '../..'
|
||||
@@ -109,7 +109,7 @@ const CollectionInfo = (props: ICollectionInfo) => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedScroll = _.debounce(handleScroll, 200)
|
||||
const debouncedScroll = debounce(handleScroll, 200)
|
||||
window.addEventListener('scroll', debouncedScroll)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', debouncedScroll)
|
||||
@@ -412,7 +412,7 @@ interface LogMetaModalProps {
|
||||
const LogMetaModal = (props: LogMetaModalProps) => {
|
||||
const editorRef = useRef(undefined)
|
||||
|
||||
function handleEditorDidMount(editor: any, monaco: any) {
|
||||
function handleEditorDidMount(editor: any) {
|
||||
editorRef.current = editor
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import _ from 'lodash'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ICollection } from '../..'
|
||||
import GetApiHandler from '../../../../utils/ApiHandler'
|
||||
@@ -59,7 +59,7 @@ const CollectionExcludions = (props: ICollectionExclusions) => {
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedScroll = _.debounce(handleScroll, 200)
|
||||
const debouncedScroll = debounce(handleScroll, 200)
|
||||
window.addEventListener('scroll', debouncedScroll)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', debouncedScroll)
|
||||
|
||||
@@ -86,7 +86,7 @@ const TestMediaItem = (props: ITestMediaItem) => {
|
||||
return false
|
||||
}, [mediaItem, selectedSeasons, selectedEpisodes])
|
||||
|
||||
function handleEditorDidMount(editor: any, monaco: any) {
|
||||
function handleEditorDidMount(editor: any) {
|
||||
editorRef.current = editor
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PlayIcon } from '@heroicons/react/solid'
|
||||
import _ from 'lodash'
|
||||
import Router from 'next/router'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ICollection, ICollectionMedia } from '..'
|
||||
import GetApiHandler from '../../../utils/ApiHandler'
|
||||
@@ -61,7 +60,7 @@ const CollectionDetail: React.FC<ICollectionDetail> = (
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedScroll = _.debounce(handleScroll, 200)
|
||||
const debouncedScroll = debounce(handleScroll, 200)
|
||||
window.addEventListener('scroll', debouncedScroll)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', debouncedScroll)
|
||||
@@ -122,20 +121,6 @@ const CollectionDetail: React.FC<ICollectionDetail> = (
|
||||
totalSizeRef.current = totalSize
|
||||
}, [totalSize])
|
||||
|
||||
useEffect(() => {
|
||||
// trapping next router before-pop-state to manipulate router change on browser back button
|
||||
Router.beforePopState(() => {
|
||||
props.onBack()
|
||||
window.history.forward()
|
||||
return false
|
||||
})
|
||||
return () => {
|
||||
Router.beforePopState(() => {
|
||||
return true
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const tabbedRoutes: TabbedRoute[] = [
|
||||
{
|
||||
text: 'Media',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Image from 'next/image'
|
||||
import { useContext } from 'react'
|
||||
import { ICollection } from '..'
|
||||
import LibrariesContext from '../../../contexts/libraries-context'
|
||||
import { usePlexLibraries } from '../../../api/plex'
|
||||
|
||||
interface ICollectionItem {
|
||||
collection: ICollection
|
||||
@@ -9,7 +7,7 @@ interface ICollectionItem {
|
||||
}
|
||||
|
||||
const CollectionItem = (props: ICollectionItem) => {
|
||||
const LibrariesCtx = useContext(LibrariesContext)
|
||||
const { data: plexLibraries } = usePlexLibraries()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -21,14 +19,14 @@ const CollectionItem = (props: ICollectionItem) => {
|
||||
>
|
||||
{props.collection.media && props.collection.media.length > 1 ? (
|
||||
<div className="absolute inset-0 z-[-100] flex flex-row overflow-hidden">
|
||||
<Image
|
||||
<img
|
||||
className="backdrop-image"
|
||||
width="600"
|
||||
height="800"
|
||||
src={`https://image.tmdb.org/t/p/w500${props.collection.media[0].image_path}`}
|
||||
alt="img"
|
||||
/>
|
||||
<Image
|
||||
<img
|
||||
className="backdrop-image"
|
||||
width="600"
|
||||
height="800"
|
||||
@@ -58,7 +56,7 @@ const CollectionItem = (props: ICollectionItem) => {
|
||||
<div className="mb-5 mr-5 sm:mr-0">
|
||||
<p className="font-bold">Library</p>
|
||||
<p className="text-amber-500">
|
||||
{LibrariesCtx.libraries.find(
|
||||
{plexLibraries?.find(
|
||||
(el) => +el.key === +props.collection.libraryId,
|
||||
)?.title ?? <> </>}
|
||||
</p>
|
||||
|
||||
@@ -16,7 +16,7 @@ const CollectionOverview = (props: ICollectionOverview) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LibrarySwitcher onSwitch={props.onSwitchLibrary} />
|
||||
<LibrarySwitcher onLibraryChange={props.onSwitchLibrary} />
|
||||
|
||||
<div className="m-auto mb-3 flex">
|
||||
<div className="m-auto sm:m-0">
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { AxiosError } from 'axios'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import LibrariesContext, { ILibrary } from '../../contexts/libraries-context'
|
||||
import GetApiHandler, { PostApiHandler } from '../../utils/ApiHandler'
|
||||
import { EPlexDataType } from '../../utils/PlexDataType-enum'
|
||||
import LoadingSpinner from '../Common/LoadingSpinner'
|
||||
import { IPlexMetadata } from '../Overview/Content'
|
||||
import CollectionDetail from './CollectionDetail'
|
||||
import CollectionOverview from './CollectionOverview'
|
||||
|
||||
export interface ICollection {
|
||||
id?: number
|
||||
@@ -47,92 +39,3 @@ export interface ICollectionMedia {
|
||||
collection: ICollection
|
||||
plexData?: IPlexMetadata
|
||||
}
|
||||
|
||||
const Collection = () => {
|
||||
const LibrariesCtx = useContext(LibrariesContext)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [detail, setDetail] = useState<{
|
||||
open: boolean
|
||||
collection: ICollection | undefined
|
||||
}>({ open: false, collection: undefined })
|
||||
const [library, setLibrary] = useState<ILibrary>()
|
||||
const [collections, setCollections] = useState<ICollection[]>()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Collections'
|
||||
}, [])
|
||||
|
||||
const onSwitchLibrary = (id: number) => {
|
||||
const lib =
|
||||
id != 9999
|
||||
? LibrariesCtx.libraries.find((el) => +el.key === id)
|
||||
: undefined
|
||||
setLibrary(lib)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getCollections()
|
||||
}, [library])
|
||||
|
||||
const getCollections = async () => {
|
||||
const colls: ICollection[] = library
|
||||
? await GetApiHandler(`/collections?libraryId=${library.key}`)
|
||||
: await GetApiHandler('/collections')
|
||||
setCollections(colls)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const doActions = async () => {
|
||||
try {
|
||||
await PostApiHandler(`/collections/handle`, {})
|
||||
|
||||
toast.success('Initiated collection handling in the background.')
|
||||
} catch (e) {
|
||||
if (e instanceof AxiosError) {
|
||||
if (e.response?.status === 409) {
|
||||
toast.error('Collection handling is already running.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
toast.error('Failed to initiate collection handling.')
|
||||
}
|
||||
}
|
||||
|
||||
const openDetail = (collection: ICollection) => {
|
||||
setDetail({ open: true, collection: collection })
|
||||
}
|
||||
|
||||
const closeDetail = () => {
|
||||
setIsLoading(true)
|
||||
setDetail({ open: false, collection: undefined })
|
||||
getCollections()
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{detail.open ? (
|
||||
<CollectionDetail
|
||||
libraryId={detail.collection ? detail.collection.libraryId : 0}
|
||||
title={detail.collection ? detail.collection.title : ''}
|
||||
collection={detail.collection!}
|
||||
onBack={closeDetail}
|
||||
/>
|
||||
) : (
|
||||
<CollectionOverview
|
||||
onSwitchLibrary={onSwitchLibrary}
|
||||
collections={collections}
|
||||
doActions={doActions}
|
||||
openDetail={openDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Collection
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Link from 'next/link'
|
||||
import { Link } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
|
||||
interface BadgeProps {
|
||||
@@ -93,13 +93,12 @@ const Badge = (
|
||||
)
|
||||
} else if (href) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<a
|
||||
className={badgeStyle.join(' ')}
|
||||
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
<Link
|
||||
to={href}
|
||||
className={badgeStyle.join(' ')}
|
||||
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
import { useContext, useEffect } from 'react'
|
||||
import LibrariesContext from '../../../contexts/libraries-context'
|
||||
import GetApiHandler from '../../../utils/ApiHandler'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { usePlexLibraries } from '../../../api/plex'
|
||||
|
||||
interface ILibrarySwitcher {
|
||||
onSwitch: (libraryId: number) => void
|
||||
allPossible?: boolean
|
||||
onLibraryChange: (libraryId: number) => void
|
||||
shouldShowAllOption?: boolean
|
||||
}
|
||||
|
||||
const LibrarySwitcher = (props: ILibrarySwitcher) => {
|
||||
const LibrariesCtx = useContext(LibrariesContext)
|
||||
const { onLibraryChange, shouldShowAllOption } = props
|
||||
const { data: plexLibraries } = usePlexLibraries()
|
||||
const lastAutoSelectedLibraryKey = useRef<number | null>(null)
|
||||
|
||||
const onSwitchLibrary = (event: { target: { value: string } }) => {
|
||||
props.onSwitch(+event.target.value)
|
||||
onLibraryChange(+event.target.value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (LibrariesCtx.libraries.length <= 0) {
|
||||
GetApiHandler('/plex/libraries').then((resp) => {
|
||||
if (resp) {
|
||||
LibrariesCtx.addLibraries(resp)
|
||||
if (props.allPossible !== undefined && !props.allPossible) {
|
||||
props.onSwitch(+resp[0].key)
|
||||
}
|
||||
} else {
|
||||
LibrariesCtx.addLibraries([])
|
||||
}
|
||||
})
|
||||
if (!plexLibraries || plexLibraries.length === 0) {
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (shouldShowAllOption === false) {
|
||||
const firstKey = Number(plexLibraries[0].key)
|
||||
|
||||
if (
|
||||
!Number.isNaN(firstKey) &&
|
||||
lastAutoSelectedLibraryKey.current !== firstKey
|
||||
) {
|
||||
lastAutoSelectedLibraryKey.current = firstKey
|
||||
onLibraryChange(firstKey)
|
||||
}
|
||||
} else {
|
||||
lastAutoSelectedLibraryKey.current = null
|
||||
}
|
||||
}, [plexLibraries, shouldShowAllOption, onLibraryChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -37,10 +43,11 @@ const LibrarySwitcher = (props: ILibrarySwitcher) => {
|
||||
className="border-zinc-600 hover:border-zinc-500 focus:border-zinc-500 focus:bg-opacity-100 focus:placeholder-zinc-400 focus:outline-none focus:ring-0"
|
||||
onChange={onSwitchLibrary}
|
||||
>
|
||||
{props.allPossible === undefined || props.allPossible ? (
|
||||
{props.shouldShowAllOption === undefined ||
|
||||
props.shouldShowAllOption ? (
|
||||
<option value={9999}>All</option>
|
||||
) : undefined}
|
||||
{LibrariesCtx.libraries.map((el) => {
|
||||
{plexLibraries?.map((el) => {
|
||||
return (
|
||||
<option key={el.key} value={el.key}>
|
||||
{el.title}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Image from 'next/image'
|
||||
import React, { memo, useEffect, useMemo, useState } from 'react'
|
||||
import GetApiHandler from '../../../../utils/ApiHandler'
|
||||
|
||||
@@ -32,7 +31,7 @@ interface Metadata {
|
||||
Guid: { id: string }[]
|
||||
}
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
|
||||
const basePath = import.meta.env.VITE_BASE_PATH ?? ''
|
||||
const iconMap: Record<string, Record<string, string>> = {
|
||||
imdb: {
|
||||
audience: `${basePath}/icons_logos/imdb_icon.svg`,
|
||||
@@ -62,7 +61,7 @@ const MediaModalContent: React.FC<ModalContentProps> = memo(
|
||||
[mediaType],
|
||||
)
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
|
||||
const basePath = import.meta.env.VITE_BASE_PATH ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
GetApiHandler('/plex').then((resp) =>
|
||||
@@ -166,7 +165,7 @@ const MediaModalContent: React.FC<ModalContentProps> = memo(
|
||||
key={index}
|
||||
className="flex items-center justify-center space-x-1.5 rounded-lg bg-black bg-opacity-70 px-3 py-1 text-white shadow-lg"
|
||||
>
|
||||
<Image
|
||||
<img
|
||||
src={icon}
|
||||
alt={`${prefix} ${type} Icon`}
|
||||
width={24}
|
||||
@@ -192,8 +191,9 @@ const MediaModalContent: React.FC<ModalContentProps> = memo(
|
||||
<a
|
||||
href={`https://themoviedb.org/${mediaTypeOf}/${tmdbid}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Image
|
||||
<img
|
||||
src={`${basePath}/icons_logos/tmdb_logo.svg`}
|
||||
alt="TMDB Logo"
|
||||
width={128}
|
||||
@@ -207,8 +207,9 @@ const MediaModalContent: React.FC<ModalContentProps> = memo(
|
||||
<a
|
||||
href={`https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Image
|
||||
<img
|
||||
src={`${basePath}/icons_logos/plex_logo.svg`}
|
||||
alt="Plex Logo"
|
||||
width={128}
|
||||
@@ -222,8 +223,9 @@ const MediaModalContent: React.FC<ModalContentProps> = memo(
|
||||
<a
|
||||
href={`${tautulliModalUrl}/info?rating_key=${id}&source=history`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Image
|
||||
<img
|
||||
src={`${basePath}/icons_logos/tautulli_logo.svg`}
|
||||
alt="Plex Logo"
|
||||
width={128}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Transition } from '@headlessui/react'
|
||||
import { DocumentAddIcon, DocumentRemoveIcon } from '@heroicons/react/solid'
|
||||
import Image from 'next/image'
|
||||
import React, { memo, useEffect, useState } from 'react'
|
||||
import { useIsTouch } from '../../../hooks/useIsTouch'
|
||||
import GetApiHandler from '../../../utils/ApiHandler'
|
||||
@@ -17,7 +16,6 @@ interface IMediaCard {
|
||||
mediaType: 'movie' | 'show' | 'season' | 'episode'
|
||||
title: string
|
||||
userScore: number
|
||||
canExpand?: boolean
|
||||
inProgress?: boolean
|
||||
tmdbid?: string
|
||||
libraryId?: number
|
||||
@@ -44,11 +42,10 @@ const MediaCard: React.FC<IMediaCard> = ({
|
||||
exclusionId = undefined,
|
||||
tmdbid = undefined,
|
||||
userScore,
|
||||
canExpand = false,
|
||||
collectionPage = false,
|
||||
exclusionType = undefined,
|
||||
isManual = false,
|
||||
onRemove = (id: string) => {},
|
||||
onRemove = () => {},
|
||||
}) => {
|
||||
const isTouch = useIsTouch()
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
@@ -127,13 +124,10 @@ const MediaCard: React.FC<IMediaCard> = ({
|
||||
>
|
||||
<div className="absolute inset-0 h-full w-full overflow-hidden">
|
||||
{image ? (
|
||||
<Image
|
||||
className="absolute inset-0 h-full w-full"
|
||||
<img
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${image}`}
|
||||
fill
|
||||
sizes="100vw"
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
) : undefined}
|
||||
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
|
||||
|
||||
@@ -73,10 +73,8 @@ const Modal: React.FC<ModalProps> = ({
|
||||
tertiaryText,
|
||||
onTertiary,
|
||||
specialButtonType = 'default',
|
||||
specialDisabled = false,
|
||||
specialText,
|
||||
onSpecial,
|
||||
backdrop,
|
||||
size = '3xl',
|
||||
}) => {
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BeakerIcon, CheckIcon, ExclamationIcon } from '@heroicons/react/solid'
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import GetApiHandler, { PostApiHandler } from '../../../utils/ApiHandler'
|
||||
import Button from '../Button'
|
||||
import { SmallLoadingSpinner } from '../LoadingSpinner'
|
||||
@@ -28,7 +28,7 @@ const TestButton = <T,>(props: ITestButton<T>) => {
|
||||
status: false,
|
||||
})
|
||||
|
||||
const performTest = async (e: FormEvent) => {
|
||||
const performTest = async () => {
|
||||
setLoading(true)
|
||||
|
||||
const handler = props.payload
|
||||
|
||||
@@ -16,7 +16,7 @@ const YamlImporterModal = (props: IYamlImporterModal) => {
|
||||
const editorRef = useRef(undefined)
|
||||
const uploadRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function handleEditorDidMount(editor: any, monaco: any) {
|
||||
function handleEditorDidMount(editor: any) {
|
||||
editorRef.current = editor
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@ import {
|
||||
EyeIcon,
|
||||
XIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode, useContext, useEffect, useRef } from 'react'
|
||||
import { ReactNode, useContext, useRef } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import SearchContext from '../../../contexts/search-context'
|
||||
import Messages from '../../Messages/Messages'
|
||||
import VersionStatus from '../../VersionStatus'
|
||||
@@ -16,39 +15,39 @@ import VersionStatus from '../../VersionStatus'
|
||||
interface NavBarLink {
|
||||
key: string
|
||||
href: string
|
||||
selected: boolean
|
||||
svgIcon: ReactNode
|
||||
name: string
|
||||
matchPattern?: RegExp
|
||||
}
|
||||
|
||||
let navBarItems: NavBarLink[] = [
|
||||
const navBarItems: NavBarLink[] = [
|
||||
{
|
||||
key: '0',
|
||||
href: '/overview',
|
||||
selected: false,
|
||||
svgIcon: <EyeIcon className="mr-3 h-6 w-6" />,
|
||||
name: 'Overview',
|
||||
matchPattern: /^\/(?:overview(?:\/.*)?|)$/,
|
||||
},
|
||||
{
|
||||
key: '1',
|
||||
href: '/rules',
|
||||
selected: false,
|
||||
svgIcon: <ClipboardCheckIcon className="mr-3 h-6 w-6" />,
|
||||
name: 'Rules',
|
||||
matchPattern: /^\/rules(?:\/.*)?$/,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
href: '/collections',
|
||||
selected: false,
|
||||
svgIcon: <ArchiveIcon className="mr-3 h-6 w-6" />,
|
||||
name: 'Collections',
|
||||
matchPattern: /^\/collections(?:\/.*)?$/,
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
href: '/settings',
|
||||
selected: false,
|
||||
svgIcon: <CogIcon className="mr-3 h-6 w-6" />,
|
||||
name: 'Settings',
|
||||
matchPattern: /^\/settings(?:\/.*)?$/,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -60,31 +59,15 @@ interface NavBarProps {
|
||||
const NavBar: React.FC<NavBarProps> = ({ open, setClosed }) => {
|
||||
const navRef = useRef<HTMLDivElement>(null)
|
||||
const SearchCtx = useContext(SearchContext)
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
|
||||
const basePath = import.meta.env.VITE_BASE_PATH ?? ''
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (window.location.pathname !== '/')
|
||||
setHighlight(window.location.pathname)
|
||||
else setHighlight(`/overview`)
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (SearchCtx.search.text !== '') {
|
||||
setHighlight('/overview', true)
|
||||
const linkIsActive = (link: NavBarLink) => {
|
||||
if (link.matchPattern) {
|
||||
return link.matchPattern.test(location.pathname)
|
||||
}
|
||||
}, [SearchCtx.search.text])
|
||||
|
||||
const setHighlight = (href: string, closed = false) => {
|
||||
navBarItems = navBarItems.map((el) => {
|
||||
el.selected = href.includes(el.href)
|
||||
return el
|
||||
})
|
||||
|
||||
if (closed && open) {
|
||||
setClosed()
|
||||
}
|
||||
return location.pathname === link.href
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -112,16 +95,13 @@ const NavBar: React.FC<NavBarProps> = ({ open, setClosed }) => {
|
||||
>
|
||||
<div className="flex flex-shrink-0 items-center px-2">
|
||||
<span className="px-4 text-xl text-zinc-50">
|
||||
<a href="/">
|
||||
<Image
|
||||
width={0}
|
||||
height={0}
|
||||
<Link to="/">
|
||||
<img
|
||||
style={{ width: '100%', height: 'auto' }}
|
||||
src={`${basePath}/logo.svg`}
|
||||
alt="Logo"
|
||||
priority
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
<nav className="mt-12 flex-1 space-y-4 px-4">
|
||||
@@ -129,25 +109,20 @@ const NavBar: React.FC<NavBarProps> = ({ open, setClosed }) => {
|
||||
return (
|
||||
<Link
|
||||
key={link.key}
|
||||
href={link.href}
|
||||
to={link.href}
|
||||
onClick={() => {
|
||||
if (link.href === '/overview') {
|
||||
SearchCtx.removeText()
|
||||
}
|
||||
setHighlight(link.href, true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setHighlight(link.href, true)
|
||||
}
|
||||
setClosed()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`flex items-center rounded-md px-2 py-2 text-base font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none ${
|
||||
link.selected
|
||||
className={`flex items-center rounded-md px-2 py-2 text-base font-medium leading-6 text-white transition duration-150 ease-in-out ${
|
||||
linkIsActive(link)
|
||||
? 'bg-gradient-to-br from-amber-600 to-amber-800 hover:from-amber-500 hover:to-amber-700'
|
||||
: 'hover:bg-zinc-700'
|
||||
}`}
|
||||
} focus:bg-amber-800 focus:outline-none`}
|
||||
>
|
||||
{link.svgIcon}
|
||||
{link.name}
|
||||
@@ -175,14 +150,11 @@ const NavBar: React.FC<NavBarProps> = ({ open, setClosed }) => {
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pb-4 pt-4">
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
<span className="px-4 text-2xl text-zinc-50">
|
||||
<Link href="/">
|
||||
<Image
|
||||
width={0}
|
||||
height={0}
|
||||
<Link to="/">
|
||||
<img
|
||||
style={{ width: '100%', height: 'auto' }}
|
||||
src={`${basePath}/logo.svg`}
|
||||
alt="Logo"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</span>
|
||||
@@ -192,16 +164,14 @@ const NavBar: React.FC<NavBarProps> = ({ open, setClosed }) => {
|
||||
return (
|
||||
<Link
|
||||
key={`desktop-${navBarLink.key}`}
|
||||
href={navBarLink.href}
|
||||
to={navBarLink.href}
|
||||
onClick={() => {
|
||||
if (navBarLink.href === '/overview') {
|
||||
SearchCtx.removeText()
|
||||
}
|
||||
|
||||
setHighlight(navBarLink.href)
|
||||
}}
|
||||
className={`group flex items-center rounded-md px-2 py-2 text-lg font-medium leading-6 text-white transition duration-150 ease-in-out ${
|
||||
navBarLink.selected
|
||||
linkIsActive(navBarLink)
|
||||
? 'bg-gradient-to-br from-amber-600 to-amber-800 hover:from-amber-500 hover:to-amber-700'
|
||||
: 'hover:bg-zinc-700'
|
||||
} focus:bg-amber-800 focus:outline-none`}
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { ArrowLeftIcon, MenuAlt2Icon } from '@heroicons/react/solid'
|
||||
import { debounce } from 'lodash'
|
||||
import Head from 'next/head'
|
||||
import router from 'next/router'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { ReactNode, useContext, useEffect, useState } from 'react'
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Outlet,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useRouteError,
|
||||
} from 'react-router-dom'
|
||||
import { ToastContainer } from 'react-toastify'
|
||||
import SearchContext from '../../contexts/search-context'
|
||||
import SettingsContext from '../../contexts/settings-context'
|
||||
import GetApiHandler from '../../utils/ApiHandler'
|
||||
import SearchBar from '../Common/SearchBar'
|
||||
import NavBar from './NavBar'
|
||||
|
||||
const Layout: React.FC<{ children?: ReactNode }> = (props: {
|
||||
children?: ReactNode
|
||||
}) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
type LayoutShellProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const LayoutShell: React.FC<LayoutShellProps> = ({ children }) => {
|
||||
const [navBarOpen, setNavBarOpen] = useState(false)
|
||||
const SearchCtx = useContext(SearchContext)
|
||||
const SettingsCtx = useContext(SettingsContext)
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
|
||||
const navigate = useNavigate()
|
||||
const basePath = import.meta.env.VITE_BASE_PATH ?? ''
|
||||
const location = useLocation()
|
||||
|
||||
const handleNavbar = () => {
|
||||
setNavBarOpen(!navBarOpen)
|
||||
@@ -26,17 +32,15 @@ const Layout: React.FC<{ children?: ReactNode }> = (props: {
|
||||
useEffect(() => {
|
||||
GetApiHandler('/settings/test/setup').then((setupDone) => {
|
||||
if (!setupDone) {
|
||||
router.push('/settings/plex')
|
||||
navigate('/settings/plex')
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
}, [navigate, location.pathname])
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Head>
|
||||
<title>Maintainerr</title>
|
||||
<link rel="icon" href={`${basePath}/favicon.ico`} />
|
||||
</Head>
|
||||
<title>Maintainerr</title>
|
||||
<link rel="icon" href={`${basePath}/favicon.ico`} />
|
||||
<div className="flex h-full min-h-full min-w-0 bg-zinc-900">
|
||||
<div className="pwa-only fixed inset-0 z-20 h-1 w-full border-zinc-700 md:border-t" />
|
||||
<div className="absolute top-0 h-64 w-full bg-gradient-to-bl from-zinc-800 to-zinc-900">
|
||||
@@ -45,29 +49,19 @@ const Layout: React.FC<{ children?: ReactNode }> = (props: {
|
||||
<NavBar open={navBarOpen} setClosed={handleNavbar}></NavBar>
|
||||
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64"></div>
|
||||
<div
|
||||
className={`searchbar fixed left-0 right-0 top-0 z-10 flex flex-shrink-0 bg-opacity-80 transition duration-300 ${
|
||||
isScrolled ? 'bg-zinc-700' : 'bg-transparent'
|
||||
} lg:ml-64`}
|
||||
style={{
|
||||
backdropFilter: isScrolled ? 'blur(5px)' : undefined,
|
||||
WebkitBackdropFilter: isScrolled ? 'blur(5px)' : undefined,
|
||||
}}
|
||||
className={`searchbar fixed left-0 right-0 top-0 z-10 flex flex-shrink-0 bg-transparent bg-opacity-80 transition duration-300 lg:ml-64`}
|
||||
>
|
||||
<div className="transparent-glass-bg flex flex-1 items-center justify-between pr-4 md:pl-4 md:pr-4">
|
||||
<button
|
||||
className={`px-4 text-white ${
|
||||
isScrolled ? 'opacity-90' : 'opacity-70'
|
||||
} transition duration-300 focus:outline-none lg:hidden`}
|
||||
className={`px-4 text-white opacity-70 transition duration-300 focus:outline-none lg:hidden`}
|
||||
aria-label="Open sidebar"
|
||||
onClick={() => setNavBarOpen(true)}
|
||||
>
|
||||
<MenuAlt2Icon className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
className={`mr-2 text-white ${
|
||||
isScrolled ? 'opacity-90' : 'opacity-70'
|
||||
} transition duration-300 hover:text-white focus:text-white focus:outline-none`}
|
||||
onClick={() => router.back()}
|
||||
className={`mr-2 text-white opacity-70 transition duration-300 hover:text-white focus:text-white focus:outline-none`}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeftIcon className="w-7" />
|
||||
</button>
|
||||
@@ -76,7 +70,7 @@ const Layout: React.FC<{ children?: ReactNode }> = (props: {
|
||||
SearchCtx.addText(text)
|
||||
|
||||
if (text !== '') {
|
||||
router.push('/overview')
|
||||
navigate('/overview')
|
||||
}
|
||||
}, 1000)}
|
||||
/>
|
||||
@@ -88,7 +82,17 @@ const Layout: React.FC<{ children?: ReactNode }> = (props: {
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="max-w-8xl mx-auto px-4">{props.children}</div>
|
||||
<div className="max-w-8xl mx-auto px-4">
|
||||
<ToastContainer
|
||||
stacked
|
||||
position="top-right"
|
||||
autoClose={4500}
|
||||
hideProgressBar={false}
|
||||
theme="dark"
|
||||
closeOnClick
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -96,4 +100,83 @@ const Layout: React.FC<{ children?: ReactNode }> = (props: {
|
||||
)
|
||||
}
|
||||
|
||||
const Layout: React.FC = () => {
|
||||
return (
|
||||
<LayoutShell>
|
||||
<Outlet />
|
||||
</LayoutShell>
|
||||
)
|
||||
}
|
||||
|
||||
const describeRouteError = (
|
||||
error: unknown,
|
||||
): { title: string; message: string } => {
|
||||
if (!error) {
|
||||
return {
|
||||
title: 'Unknown error',
|
||||
message: 'An unexpected error occurred.',
|
||||
}
|
||||
}
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
const dataMessage =
|
||||
typeof error.data === 'string'
|
||||
? error.data
|
||||
: (error.data?.message ?? error.data?.error)
|
||||
|
||||
return {
|
||||
title: `${error.status} ${error.statusText}`.trim(),
|
||||
message: dataMessage ?? 'The server returned an unexpected response.',
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
title: error.name ?? 'Error',
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Unexpected error',
|
||||
message: String(error),
|
||||
}
|
||||
}
|
||||
|
||||
export const LayoutErrorBoundary: React.FC = () => {
|
||||
const error = useRouteError()
|
||||
const navigate = useNavigate()
|
||||
const { title, message } = describeRouteError(error)
|
||||
|
||||
return (
|
||||
<LayoutShell>
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded border border-red-500/60 bg-red-500/10 p-6 text-red-100 shadow-lg"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-red-200">{title}</h2>
|
||||
<p className="mt-2 text-sm text-red-100">{message}</p>
|
||||
<p className="mt-4 text-xs text-red-200/80">
|
||||
You can try going back or reloading the page. If the problem persists,
|
||||
please check the browser console for more details.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
className="rounded bg-red-500/30 px-4 py-2 text-sm font-medium text-red-50 transition hover:bg-red-500/40 focus:outline-none focus:ring-2 focus:ring-red-300/60"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-100 transition hover:bg-zinc-700 focus:outline-none focus:ring-2 focus:ring-zinc-500/60"
|
||||
onClick={() => navigate('/overview')}
|
||||
>
|
||||
Go To Overview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutShell>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import _ from 'lodash'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { useEffect } from 'react'
|
||||
import { ICollectionMedia } from '../../Collection'
|
||||
import LoadingSpinner, {
|
||||
@@ -86,7 +86,7 @@ const OverviewContent = (props: IOverviewContent) => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedScroll = _.debounce(handleScroll, 200)
|
||||
const debouncedScroll = debounce(handleScroll, 200)
|
||||
window.addEventListener('scroll', debouncedScroll, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('scroll', debouncedScroll)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clone } from 'lodash'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import LibrariesContext from '../../contexts/libraries-context'
|
||||
import { usePlexLibraries } from '../../api/plex'
|
||||
import SearchContext from '../../contexts/search-context'
|
||||
import GetApiHandler from '../../utils/ApiHandler'
|
||||
import LibrarySwitcher from '../Common/LibrarySwitcher'
|
||||
@@ -20,11 +20,12 @@ const Overview = () => {
|
||||
|
||||
const [selectedLibrary, setSelectedLibrary] = useState<number>()
|
||||
const selectedLibraryRef = useRef<number>(undefined)
|
||||
const [searchUsed, setsearchUsed] = useState<boolean>(false)
|
||||
const [searchUsed, setSearchUsed] = useState<boolean>(false)
|
||||
|
||||
const pageData = useRef<number>(0)
|
||||
const SearchCtx = useContext(SearchContext)
|
||||
const LibrariesCtx = useContext(LibrariesContext)
|
||||
|
||||
const { data: plexLibraries } = usePlexLibraries()
|
||||
|
||||
const fetchAmount = 30
|
||||
|
||||
@@ -33,17 +34,15 @@ const Overview = () => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Overview'
|
||||
if (!plexLibraries || plexLibraries.length === 0) return
|
||||
|
||||
setTimeout(() => {
|
||||
if (
|
||||
loadingRef.current &&
|
||||
data.length === 0 &&
|
||||
SearchCtx.search.text === '' &&
|
||||
LibrariesCtx.libraries.length > 0
|
||||
SearchCtx.search.text === ''
|
||||
) {
|
||||
switchLib(
|
||||
selectedLibrary ? selectedLibrary : +LibrariesCtx.libraries[0].key,
|
||||
)
|
||||
switchLib(selectedLibrary ? selectedLibrary : +plexLibraries[0].key)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
@@ -54,22 +53,24 @@ const Overview = () => {
|
||||
totalSizeRef.current = 999
|
||||
pageData.current = 0
|
||||
}
|
||||
}, [])
|
||||
}, [plexLibraries])
|
||||
|
||||
useEffect(() => {
|
||||
if (!plexLibraries || plexLibraries.length === 0) return
|
||||
|
||||
if (SearchCtx.search.text !== '') {
|
||||
GetApiHandler(`/plex/search/${SearchCtx.search.text}`).then(
|
||||
(resp: IPlexMetadata[]) => {
|
||||
setsearchUsed(true)
|
||||
setSearchUsed(true)
|
||||
setTotalSize(resp.length)
|
||||
pageData.current = resp.length * 50
|
||||
setData(resp ? resp : [])
|
||||
setIsLoading(false)
|
||||
},
|
||||
)
|
||||
setSelectedLibrary(+LibrariesCtx.libraries[0]?.key)
|
||||
setSelectedLibrary(+plexLibraries[0]?.key)
|
||||
} else {
|
||||
setsearchUsed(false)
|
||||
setSearchUsed(false)
|
||||
setData([])
|
||||
setTotalSize(999)
|
||||
pageData.current = 0
|
||||
@@ -98,7 +99,7 @@ const Overview = () => {
|
||||
setTotalSize(999)
|
||||
setData([])
|
||||
dataRef.current = []
|
||||
setsearchUsed(false)
|
||||
setSearchUsed(false)
|
||||
setSelectedLibrary(libraryId)
|
||||
}
|
||||
|
||||
@@ -131,30 +132,36 @@ const Overview = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{!searchUsed ? (
|
||||
<LibrarySwitcher allPossible={false} onSwitch={switchLib} />
|
||||
) : undefined}
|
||||
{selectedLibrary ? (
|
||||
<OverviewContent
|
||||
dataFinished={
|
||||
!(totalSizeRef.current >= pageData.current * fetchAmount)
|
||||
}
|
||||
fetchData={() => {
|
||||
setLoadingExtra(true)
|
||||
fetchData()
|
||||
}}
|
||||
loading={loadingRef.current}
|
||||
extrasLoading={
|
||||
loadingExtra &&
|
||||
!loadingRef.current &&
|
||||
totalSizeRef.current >= pageData.current * fetchAmount
|
||||
}
|
||||
data={data}
|
||||
libraryId={selectedLibrary}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
<>
|
||||
<title>Overview - Maintainerr</title>
|
||||
<div className="w-full">
|
||||
{!searchUsed ? (
|
||||
<LibrarySwitcher
|
||||
shouldShowAllOption={false}
|
||||
onLibraryChange={switchLib}
|
||||
/>
|
||||
) : undefined}
|
||||
{selectedLibrary ? (
|
||||
<OverviewContent
|
||||
dataFinished={
|
||||
!(totalSizeRef.current >= pageData.current * fetchAmount)
|
||||
}
|
||||
fetchData={() => {
|
||||
setLoadingExtra(true)
|
||||
fetchData()
|
||||
}}
|
||||
loading={loadingRef.current}
|
||||
extrasLoading={
|
||||
loadingExtra &&
|
||||
!loadingRef.current &&
|
||||
totalSizeRef.current >= pageData.current * fetchAmount
|
||||
}
|
||||
data={data}
|
||||
libraryId={selectedLibrary}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default Overview
|
||||
|
||||
@@ -43,7 +43,7 @@ const CommunityRuleUpload = (props: ICommunityRuleUpload) => {
|
||||
setFailed(resp.result)
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch(() => {
|
||||
setFailed('Failed to connect to the server. Please try again later.')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { TrashIcon } from '@heroicons/react/solid'
|
||||
import _ from 'lodash'
|
||||
import { FormEvent, useContext, useEffect, useState } from 'react'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { IRule } from '../'
|
||||
import ConstantsContext, {
|
||||
import { useRuleConstants } from '../../../../../api/rules'
|
||||
import {
|
||||
IProperty,
|
||||
MediaType,
|
||||
RulePossibility,
|
||||
RulePossibilityTranslations,
|
||||
} from '../../../../../contexts/constants-context'
|
||||
import { EPlexDataType } from '../../../../../utils/PlexDataType-enum'
|
||||
import LoadingSpinner from '../../../../Common/LoadingSpinner'
|
||||
|
||||
enum RuleType {
|
||||
NUMBER,
|
||||
@@ -46,7 +48,6 @@ interface IRuleInput {
|
||||
}
|
||||
|
||||
const RuleInput = (props: IRuleInput) => {
|
||||
const ConstantsCtx = useContext(ConstantsContext)
|
||||
const [operator, setOperator] = useState<string>()
|
||||
const [firstval, setFirstVal] = useState<string>()
|
||||
const [action, setAction] = useState<RulePossibility>()
|
||||
@@ -59,6 +60,8 @@ const RuleInput = (props: IRuleInput) => {
|
||||
const [possibilities, setPossibilities] = useState<RulePossibility[]>([])
|
||||
const [ruleType, setRuleType] = useState<RuleType>(RuleType.NUMBER)
|
||||
|
||||
const { data: constants, isLoading: constantsLoading } = useRuleConstants()
|
||||
|
||||
useEffect(() => {
|
||||
if (props.editData?.rule) {
|
||||
setOperator(props.editData.rule.operator?.toString())
|
||||
@@ -206,21 +209,21 @@ const RuleInput = (props: IRuleInput) => {
|
||||
}, [secondVal, customVal, operator, action, firstval, customValType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!constants) return
|
||||
|
||||
// reset firstval & secondval in case of type switch & choices don't exist
|
||||
const apps = _.cloneDeep(ConstantsCtx.constants.applications)?.map(
|
||||
(app) => {
|
||||
app.props = app.props.filter((prop) => {
|
||||
return (
|
||||
(prop.mediaType === MediaType.BOTH ||
|
||||
props.mediaType === prop.mediaType) &&
|
||||
(props.mediaType === MediaType.MOVIE ||
|
||||
prop.showType === undefined ||
|
||||
prop.showType.includes(props.dataType!))
|
||||
)
|
||||
})
|
||||
return app
|
||||
},
|
||||
)
|
||||
const apps = cloneDeep(constants.applications)?.map((app) => {
|
||||
app.props = app.props.filter((prop) => {
|
||||
return (
|
||||
(prop.mediaType === MediaType.BOTH ||
|
||||
props.mediaType === prop.mediaType) &&
|
||||
(props.mediaType === MediaType.MOVIE ||
|
||||
prop.showType === undefined ||
|
||||
prop.showType.includes(props.dataType!))
|
||||
)
|
||||
})
|
||||
return app
|
||||
})
|
||||
if (firstval) {
|
||||
const val = JSON.parse(firstval)
|
||||
const appId = val[0]
|
||||
@@ -228,7 +231,7 @@ const RuleInput = (props: IRuleInput) => {
|
||||
setFirstVal(undefined)
|
||||
}
|
||||
}
|
||||
}, [props.dataType, props.mediaType])
|
||||
}, [props.dataType, props.mediaType, constants])
|
||||
|
||||
useEffect(() => {
|
||||
if (firstval) {
|
||||
@@ -281,10 +284,12 @@ const RuleInput = (props: IRuleInput) => {
|
||||
const getPropFromTuple = (
|
||||
value: [number, number] | string,
|
||||
): IProperty | undefined => {
|
||||
if (!constants) return undefined
|
||||
|
||||
if (typeof value === 'string') {
|
||||
value = JSON.parse(value)
|
||||
}
|
||||
const application = ConstantsCtx.constants.applications?.find(
|
||||
const application = constants.applications?.find(
|
||||
(el) => el.id === +value[0],
|
||||
)
|
||||
|
||||
@@ -293,6 +298,11 @@ const RuleInput = (props: IRuleInput) => {
|
||||
})
|
||||
return prop
|
||||
}
|
||||
|
||||
if (!constants || constantsLoading) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full rounded-2xl bg-zinc-800 p-4 text-zinc-100 shadow-lg"
|
||||
@@ -371,7 +381,7 @@ const RuleInput = (props: IRuleInput) => {
|
||||
<option value={undefined} className="text-amber-600">
|
||||
Select First Value...
|
||||
</option>
|
||||
{ConstantsCtx.constants.applications?.map((app) =>
|
||||
{constants.applications?.map((app) =>
|
||||
app.mediaType === MediaType.BOTH ||
|
||||
props.mediaType === app.mediaType ? (
|
||||
<optgroup key={app.id} label={app.name}>
|
||||
@@ -462,7 +472,7 @@ const RuleInput = (props: IRuleInput) => {
|
||||
) : undefined}
|
||||
<MaybeTextListOptions ruleType={ruleType} action={action} />
|
||||
</optgroup>
|
||||
{ConstantsCtx.constants.applications?.map((app) => {
|
||||
{constants.applications?.map((app) => {
|
||||
return (app.mediaType === MediaType.BOTH ||
|
||||
props.mediaType === app.mediaType) &&
|
||||
action != null &&
|
||||
|
||||
@@ -178,7 +178,7 @@ const RuleCreator = (props: iRuleCreator) => {
|
||||
updateRuleAmount([ruleAmount[0], rules])
|
||||
}
|
||||
|
||||
const addSection = (e: any) => {
|
||||
const addSection = () => {
|
||||
const rules = [...ruleAmount[1]]
|
||||
rules.push(1)
|
||||
|
||||
@@ -204,7 +204,7 @@ const RuleCreator = (props: iRuleCreator) => {
|
||||
<div className="rounded-lg bg-zinc-700 px-6 py-0.5 shadow-md">
|
||||
<SectionHeading id={sid} name={'Section'} />
|
||||
<div className="flex flex-col space-y-2">
|
||||
{ruleAmountArr[1][sid - 1].map((id, index) => (
|
||||
{ruleAmountArr[1][sid - 1].map((id) => (
|
||||
<div
|
||||
key={`${sid}-${id}`}
|
||||
className="flex w-full flex-col items-start"
|
||||
|
||||
@@ -11,6 +11,8 @@ interface ArrActionProps {
|
||||
settingId?: number | null // null for when the user has selected 'None', undefined for when this is a new rule
|
||||
options: Option[]
|
||||
onUpdate: (arrAction: number, settingId?: number | null) => void
|
||||
accActionError?: string
|
||||
settingIdError?: string
|
||||
}
|
||||
|
||||
interface Option {
|
||||
@@ -109,6 +111,9 @@ const ArrAction = (props: ArrActionProps) => {
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
{props.settingIdError ? (
|
||||
<p className="mt-1 text-xs text-red-400">{props.settingIdError}</p>
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row items-center">
|
||||
@@ -134,6 +139,9 @@ const ArrAction = (props: ArrActionProps) => {
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
{props.accActionError ? (
|
||||
<p className="mt-1 text-xs text-red-400">{props.accActionError}</p>
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import GetApiHandler from '../../../../../utils/ApiHandler'
|
||||
import Modal from '../../../../Common/Modal'
|
||||
import { AgentConfiguration } from '../../../../Settings/Notifications/CreateNotificationModal'
|
||||
import ToggleItem from '../../../../Common/ToggleButton'
|
||||
import { AgentConfiguration } from '../../../../Settings/Notifications/CreateNotificationModal'
|
||||
|
||||
interface ConfigureNotificationModal {
|
||||
onCancel: () => void
|
||||
@@ -75,6 +75,11 @@ const ConfigureNotificationModal = (props: ConfigureNotificationModal) => {
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && notifications!.length === 0 && (
|
||||
<p className="text-zinc-400">
|
||||
No notification agents configured.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
||||
import EditButton from '../../Common/EditButton'
|
||||
import DeleteButton from '../../Common/DeleteButton'
|
||||
import { IRuleJson } from '../Rule'
|
||||
import { useContext, useState } from 'react'
|
||||
import { DeleteApiHandler } from '../../../utils/ApiHandler'
|
||||
import LibrariesContext from '../../../contexts/libraries-context'
|
||||
import { PencilIcon, TrashIcon } from '@heroicons/react/solid'
|
||||
import { EPlexDataType } from '@maintainerr/contracts'
|
||||
import { useState } from 'react'
|
||||
import { usePlexLibraries } from '../../../api/plex'
|
||||
import { DeleteApiHandler } from '../../../utils/ApiHandler'
|
||||
import { ICollection } from '../../Collection'
|
||||
import DeleteButton from '../../Common/DeleteButton'
|
||||
import EditButton from '../../Common/EditButton'
|
||||
import { AgentConfiguration } from '../../Settings/Notifications/CreateNotificationModal'
|
||||
import { IRuleJson } from '../Rule'
|
||||
|
||||
export interface IRuleGroup {
|
||||
id: number
|
||||
@@ -16,9 +18,9 @@ export interface IRuleGroup {
|
||||
collectionId: number
|
||||
rules: IRuleJson[]
|
||||
useRules: boolean
|
||||
type?: number
|
||||
listExclusions?: boolean
|
||||
dataType: EPlexDataType
|
||||
notifications?: AgentConfiguration[]
|
||||
collection?: ICollection
|
||||
}
|
||||
|
||||
const RuleGroup = (props: {
|
||||
@@ -27,7 +29,7 @@ const RuleGroup = (props: {
|
||||
onEdit: (group: IRuleGroup) => void
|
||||
}) => {
|
||||
const [showsureDelete, setShowSureDelete] = useState<boolean>(false)
|
||||
const LibrariesCtx = useContext(LibrariesContext)
|
||||
const { data: plexLibraries } = usePlexLibraries()
|
||||
|
||||
const onRemove = () => {
|
||||
setShowSureDelete(true)
|
||||
@@ -82,9 +84,8 @@ const RuleGroup = (props: {
|
||||
</div>
|
||||
<div className="flex justify-center text-amber-500">
|
||||
{`${
|
||||
LibrariesCtx.libraries.find(
|
||||
(el) => +el.key === +props.group.libraryId,
|
||||
)?.title ?? ''
|
||||
plexLibraries?.find((el) => +el.key === +props.group.libraryId)
|
||||
?.title ?? ''
|
||||
}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||
import GetApiHandler from '../../../../utils/ApiHandler'
|
||||
import Badge from '../../../Common/Badge'
|
||||
import Button from '../../../Common/Button'
|
||||
@@ -7,9 +6,7 @@ import LoadingSpinner from '../../../Common/LoadingSpinner'
|
||||
import Modal from '../../../Common/Modal'
|
||||
|
||||
// Dynamic import for markdown
|
||||
const ReactMarkdown = dynamic(() => import('react-markdown'), {
|
||||
ssr: false,
|
||||
})
|
||||
const ReactMarkdown = lazy(() => import('react-markdown'))
|
||||
|
||||
const messages = {
|
||||
releases: 'Releases',
|
||||
@@ -78,7 +75,9 @@ const Release = ({ currentVersion, release, isLatest }: ReleaseProps) => {
|
||||
}}
|
||||
>
|
||||
<div className="prose:sm prose">
|
||||
<ReactMarkdown>{release.body}</ReactMarkdown>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<ReactMarkdown>{release.body}</ReactMarkdown>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,6 @@ import GetApiHandler from '../../../utils/ApiHandler'
|
||||
import Releases from './Releases'
|
||||
|
||||
const AboutSettings = () => {
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings - About'
|
||||
}, [])
|
||||
|
||||
// Maintainerr Timezone
|
||||
const [timezone, setTimezone] = useState<string>('')
|
||||
useEffect(() => {
|
||||
@@ -59,225 +55,236 @@ const AboutSettings = () => {
|
||||
// End Maintainerr Community Rules Count
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mt-6 rounded-md border border-amber-600 bg-amber-500 bg-opacity-20 p-4 backdrop-blur">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className="h-5 w-5 text-gray-100" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p className="text-sm leading-5 text-gray-100">
|
||||
This is BETA software. Features may be broken and/or unstable.
|
||||
Please report any issues on GitHub!
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-5 md:ml-6 md:mt-0">
|
||||
<a
|
||||
href="https://github.com/Maintainerr/Maintainerr"
|
||||
className="whitespace-nowrap font-medium text-gray-100 transition duration-150 ease-in-out hover:text-white"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitHub →
|
||||
</a>
|
||||
</p>
|
||||
<>
|
||||
<title>About - Maintainerr</title>
|
||||
<div className="h-full w-full">
|
||||
<div className="mt-6 rounded-md border border-amber-600 bg-amber-500 bg-opacity-20 p-4 backdrop-blur">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className="h-5 w-5 text-gray-100" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p className="text-sm leading-5 text-gray-100">
|
||||
This is BETA software. Features may be broken and/or unstable.
|
||||
Please report any issues on GitHub!
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-5 md:ml-6 md:mt-0">
|
||||
<a
|
||||
href="https://github.com/Maintainerr/Maintainerr"
|
||||
className="whitespace-nowrap font-medium text-gray-100 transition duration-150 ease-in-out hover:text-white"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitHub →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Maintainerr Portion */}
|
||||
<div className="section mb-2 h-full w-full">
|
||||
<h3 className="heading">About Maintainerr</h3>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Version
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>{commitTag === 'local' ? 'local' : <>{version}</>}</code>
|
||||
</span>
|
||||
{/* Maintainerr Portion */}
|
||||
<div className="section mb-2 h-full w-full">
|
||||
<h3 className="heading">About Maintainerr</h3>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Version
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>
|
||||
{commitTag === 'local' ? 'local' : <>{version}</>}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Container Config Path
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>/opt/data</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Time Zone
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>{timezone}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Number Of Rules
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>{ruleCount}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Total Media in Collections
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>{itemCount}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Community Rules
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>{communityCount}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Container Config Path
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>/opt/data</code>
|
||||
</span>
|
||||
{/* End Maintainerr Portion */}
|
||||
{/* Useful Links */}
|
||||
<div className="section mb-2 h-full w-full">
|
||||
<h3 className="heading">Useful Links</h3>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label className="text-label"> Documentation </label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field font-bold text-amber-600 underline">
|
||||
<a
|
||||
className="hover:text-amber-800"
|
||||
href="https://docs.maintainerr.info"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
https://docs.maintainerr.info
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Time Zone
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>{timezone}</code>
|
||||
</span>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label className="text-label"> Discord </label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field font-bold text-amber-600 underline">
|
||||
<a
|
||||
className="hover:text-amber-800"
|
||||
href="https://discord.gg/WP4ZW2QYwk"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
https://discord.gg/WP4ZW2QYwk
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Number Of Rules
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>{ruleCount}</code>
|
||||
</span>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label className="text-label"> Feature Requests </label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field font-bold text-amber-600 underline">
|
||||
<a
|
||||
className="hover:text-amber-800"
|
||||
href="https://features.maintainerr.info"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
https://features.maintainerr.info
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Total Media in Collections
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>{itemCount}</code>
|
||||
</span>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label className="text-label"> Services Status </label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field font-bold text-amber-600 underline">
|
||||
<a
|
||||
className="hover:text-amber-800"
|
||||
href="https://status.maintainerr.info"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
https://status.maintainerr.info
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="form-row my-2">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Community Rules
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<span className="">
|
||||
<code>{communityCount}</code>
|
||||
</span>
|
||||
<div className="section mb-2 h-full w-full">
|
||||
<h3 className="heading">Loving Maintainerr?</h3>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label className="text-label">Donations Welcome</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field font-bold text-amber-600">
|
||||
<a
|
||||
className="pr-2 underline hover:text-amber-800"
|
||||
href="https://github.com/sponsors/jorenn92"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitHub Sponsors
|
||||
</a>
|
||||
<p className="pr-2 !no-underline">or</p>
|
||||
<a
|
||||
className="pr-2 underline hover:text-amber-800"
|
||||
href="https://ko-fi.com/maintainerr_app"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Ko-fi
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
{/* End Maintainerr Portion */}
|
||||
{/* Useful Links */}
|
||||
<div className="section mb-2 h-full w-full">
|
||||
<h3 className="heading">Useful Links</h3>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label className="text-label"> Documentation </label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field font-bold text-amber-600 underline">
|
||||
<a
|
||||
className="hover:text-amber-800"
|
||||
href="https://docs.maintainerr.info"
|
||||
target="_blank"
|
||||
>
|
||||
https://docs.maintainerr.info
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
{/* End Userful Links */}
|
||||
{/* Show Releases */}
|
||||
<div className="section">
|
||||
<Releases currentVersion={version} />
|
||||
</div>
|
||||
{/* End Showing Releases */}
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label className="text-label"> Discord </label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field font-bold text-amber-600 underline">
|
||||
<a
|
||||
className="hover:text-amber-800"
|
||||
href="https://discord.gg/WP4ZW2QYwk"
|
||||
target="_blank"
|
||||
>
|
||||
https://discord.gg/WP4ZW2QYwk
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label className="text-label"> Feature Requests </label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field font-bold text-amber-600 underline">
|
||||
<a
|
||||
className="hover:text-amber-800"
|
||||
href="https://features.maintainerr.info"
|
||||
target="_blank"
|
||||
>
|
||||
https://features.maintainerr.info
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label className="text-label"> Services Status </label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field font-bold text-amber-600 underline">
|
||||
<a
|
||||
className="hover:text-amber-800"
|
||||
href="https://status.maintainerr.info"
|
||||
target="_blank"
|
||||
>
|
||||
https://status.maintainerr.info
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="section mb-2 h-full w-full">
|
||||
<h3 className="heading">Loving Maintainerr?</h3>
|
||||
</div>
|
||||
<hr className="my-2 h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
<div className="section my-2">
|
||||
<div className="form-row my-2">
|
||||
<label className="text-label">Donations Welcome</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field font-bold text-amber-600">
|
||||
<a
|
||||
className="pr-2 underline hover:text-amber-800"
|
||||
href="https://github.com/sponsors/jorenn92"
|
||||
target="_blank"
|
||||
>
|
||||
GitHub Sponsors
|
||||
</a>
|
||||
<p className="pr-2 !no-underline">or</p>
|
||||
<a
|
||||
className="pr-2 underline hover:text-amber-800"
|
||||
href="https://ko-fi.com/maintainerr_app"
|
||||
target="_blank"
|
||||
>
|
||||
Ko-fi
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="h-px border-0 bg-gray-200 dark:bg-gray-700"></hr>
|
||||
{/* End Userful Links */}
|
||||
{/* Show Releases */}
|
||||
<div className="section">
|
||||
<Releases currentVersion={version} />
|
||||
</div>
|
||||
{/* End Showing Releases */}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default AboutSettings
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
JellyseerrSettingDto,
|
||||
jellyseerrSettingSchema,
|
||||
} from '@maintainerr/contracts'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import GetApiHandler, {
|
||||
@@ -46,10 +46,6 @@ const JellyseerrSettings = () => {
|
||||
const [submitError, setSubmitError] = useState<boolean>(false)
|
||||
const [isSubmitSuccessful, setIsSubmitSuccessful] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings - Jellyseerr'
|
||||
}, [])
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -130,7 +126,7 @@ const JellyseerrSettings = () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch(() => {
|
||||
setTestResult({
|
||||
status: false,
|
||||
message: 'Unknown error',
|
||||
@@ -143,6 +139,7 @@ const JellyseerrSettings = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Jellyseerr settings - Maintainerr</title>
|
||||
<div className="mb-6">
|
||||
<h3 className="heading">Jellyseerr Settings</h3>
|
||||
<p className="description">Jellyseerr configuration</p>
|
||||
@@ -242,5 +239,4 @@ const JellyseerrSettings = () => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default JellyseerrSettings
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { SaveIcon } from '@heroicons/react/solid'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import SettingsContext from '../../../contexts/settings-context'
|
||||
import { PostApiHandler } from '../../../utils/ApiHandler'
|
||||
import { isValidCron } from 'cron-validator'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useSettingsOutletContext } from '..'
|
||||
import { usePatchSettings } from '../../../api/settings'
|
||||
import Alert from '../../Common/Alert'
|
||||
import Button from '../../Common/Button'
|
||||
import { isValidCron } from 'cron-validator'
|
||||
|
||||
const JobSettings = () => {
|
||||
const settingsCtx = useContext(SettingsContext)
|
||||
const rulehanderRef = useRef<HTMLInputElement>(null)
|
||||
const collectionHandlerRef = useRef<HTMLInputElement>(null)
|
||||
const [secondCronValid, setSecondCronValid] = useState(true)
|
||||
const [firstCronValid, setFirstCronValid] = useState(true)
|
||||
const [error, setError] = useState<boolean>(false)
|
||||
const [erroMessage, setErrorMessage] = useState<string>('')
|
||||
const [changed, setChanged] = useState<boolean>()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings - Jobs'
|
||||
}, [])
|
||||
const [missingValuesError, setMissingValuesError] = useState<boolean>(false)
|
||||
const {
|
||||
mutateAsync: updateSettings,
|
||||
isError: updateSettingsError,
|
||||
isPending: updateSettingsPending,
|
||||
isSuccess: updateSettingsSuccess,
|
||||
} = usePatchSettings()
|
||||
const { settings } = useSettingsOutletContext()
|
||||
|
||||
const submit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setMissingValuesError(false)
|
||||
|
||||
if (
|
||||
rulehanderRef.current?.value &&
|
||||
collectionHandlerRef.current?.value &&
|
||||
@@ -32,145 +34,136 @@ const JobSettings = () => {
|
||||
collection_handler_job_cron: collectionHandlerRef.current.value,
|
||||
rules_handler_job_cron: rulehanderRef.current.value,
|
||||
}
|
||||
const resp: { code: 0 | 1; message: string } = await PostApiHandler(
|
||||
'/settings',
|
||||
{
|
||||
...settingsCtx.settings,
|
||||
...payload,
|
||||
},
|
||||
)
|
||||
if (Boolean(resp.code)) {
|
||||
setError(false)
|
||||
setChanged(true)
|
||||
|
||||
// set context values
|
||||
settingsCtx.settings.rules_handler_job_cron =
|
||||
payload.rules_handler_job_cron
|
||||
settingsCtx.settings.collection_handler_job_cron =
|
||||
payload.collection_handler_job_cron
|
||||
} else {
|
||||
setError(true)
|
||||
setErrorMessage(resp.message.length > 0 ? resp.message : '')
|
||||
}
|
||||
await updateSettings(payload)
|
||||
} else {
|
||||
setError(true)
|
||||
setErrorMessage('Please make sure all values are valid')
|
||||
setMissingValuesError(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">Job Settings</h3>
|
||||
<p className="description">Job configuration</p>
|
||||
</div>
|
||||
<>
|
||||
<title>Job settings - Maintainerr</title>
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">Job Settings</h3>
|
||||
<p className="description">Job configuration</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
title={
|
||||
erroMessage.length > 0
|
||||
? erroMessage
|
||||
: 'Something went wrong, please check your values'
|
||||
}
|
||||
/>
|
||||
) : changed ? (
|
||||
<Alert type="info" title="Settings successfully updated" />
|
||||
) : undefined}
|
||||
{missingValuesError && (
|
||||
<Alert type="error" title="Please make sure all values are valid" />
|
||||
)}
|
||||
|
||||
<div className="section">
|
||||
<form onSubmit={submit}>
|
||||
<div className="form-row">
|
||||
<label htmlFor="ruleHandler" className="text-label">
|
||||
Rule Handler
|
||||
<p className="text-xs font-normal">
|
||||
Supports all standard{' '}
|
||||
<a href="http://crontab.org/" target="_blank">
|
||||
cron
|
||||
</a>{' '}
|
||||
patterns
|
||||
</p>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div
|
||||
className={`form-input-field' ${
|
||||
!firstCronValid ? 'border-2 border-red-700' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
name="ruleHandler"
|
||||
id="ruleHandler"
|
||||
type="text"
|
||||
onChange={() => {
|
||||
setFirstCronValid(
|
||||
rulehanderRef.current?.value
|
||||
? isValidCron(rulehanderRef.current.value)
|
||||
: false,
|
||||
)
|
||||
}}
|
||||
ref={rulehanderRef}
|
||||
defaultValue={settingsCtx.settings.rules_handler_job_cron}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{updateSettingsError && (
|
||||
<Alert
|
||||
type="error"
|
||||
title="Something went wrong, please check your values"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="collectionHanlder" className="text-label">
|
||||
Collection Handler
|
||||
<p className="text-xs font-normal">
|
||||
Supports all standard{' '}
|
||||
<a href="http://crontab.org/" target="_blank">
|
||||
cron
|
||||
</a>{' '}
|
||||
patterns
|
||||
</p>
|
||||
</label>
|
||||
{updateSettingsSuccess && (
|
||||
<Alert type="info" title="Settings successfully updated" />
|
||||
)}
|
||||
|
||||
<div className="form-input">
|
||||
<div
|
||||
className={`form-input-field' ${
|
||||
!secondCronValid ? 'border-2 border-red-700' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
name="collectionHanlder"
|
||||
id="collectionHanlder"
|
||||
type="text"
|
||||
onChange={() => {
|
||||
setSecondCronValid(
|
||||
collectionHandlerRef.current?.value
|
||||
? isValidCron(collectionHandlerRef.current.value)
|
||||
: false,
|
||||
)
|
||||
}}
|
||||
ref={collectionHandlerRef}
|
||||
defaultValue={
|
||||
settingsCtx.settings.collection_handler_job_cron
|
||||
}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions mt-5 w-full">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
// disabled={isSubmitting || !isValid}
|
||||
<div className="section">
|
||||
<form onSubmit={submit}>
|
||||
<div className="form-row">
|
||||
<label htmlFor="ruleHandler" className="text-label">
|
||||
Rule Handler
|
||||
<p className="text-xs font-normal">
|
||||
Supports all standard{' '}
|
||||
<a
|
||||
href="http://crontab.org/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
cron
|
||||
</a>{' '}
|
||||
patterns
|
||||
</p>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div
|
||||
className={`form-input-field' ${
|
||||
!firstCronValid ? 'border-2 border-red-700' : ''
|
||||
}`}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>Save Changes</span>
|
||||
</Button>
|
||||
</span>
|
||||
<input
|
||||
name="ruleHandler"
|
||||
id="ruleHandler"
|
||||
type="text"
|
||||
onChange={() => {
|
||||
setFirstCronValid(
|
||||
rulehanderRef.current?.value
|
||||
? isValidCron(rulehanderRef.current.value)
|
||||
: false,
|
||||
)
|
||||
}}
|
||||
ref={rulehanderRef}
|
||||
defaultValue={settings.rules_handler_job_cron}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="collectionHanlder" className="text-label">
|
||||
Collection Handler
|
||||
<p className="text-xs font-normal">
|
||||
Supports all standard{' '}
|
||||
<a
|
||||
href="http://crontab.org/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
cron
|
||||
</a>{' '}
|
||||
patterns
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div className="form-input">
|
||||
<div
|
||||
className={`form-input-field' ${
|
||||
!secondCronValid ? 'border-2 border-red-700' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
name="collectionHanlder"
|
||||
id="collectionHanlder"
|
||||
type="text"
|
||||
onChange={() => {
|
||||
setSecondCronValid(
|
||||
collectionHandlerRef.current?.value
|
||||
? isValidCron(collectionHandlerRef.current.value)
|
||||
: false,
|
||||
)
|
||||
}}
|
||||
ref={collectionHandlerRef}
|
||||
defaultValue={settings.collection_handler_job_cron}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions mt-5 w-full">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={updateSettingsPending}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>Save Changes</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default JobSettings
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const SettingsLander = () => {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings'
|
||||
router.push('/settings/main')
|
||||
}, [])
|
||||
|
||||
return <></>
|
||||
}
|
||||
export default SettingsLander
|
||||
@@ -22,16 +22,15 @@ import { InputGroup } from '../../Forms/Input'
|
||||
import { SelectGroup } from '../../Forms/Select'
|
||||
|
||||
const LogSettings = () => {
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings - Logs'
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<LogSettingsForm />
|
||||
<Logs />
|
||||
<LogFiles />
|
||||
</div>
|
||||
<>
|
||||
<title>Logs - Maintainerr</title>
|
||||
<div className="h-full w-full">
|
||||
<LogSettingsForm />
|
||||
<Logs />
|
||||
<LogFiles />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -338,5 +337,4 @@ const LogFiles = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogSettings
|
||||
|
||||
@@ -1,136 +1,129 @@
|
||||
import { RefreshIcon, SaveIcon } from '@heroicons/react/solid'
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react'
|
||||
import SettingsContext from '../../../contexts/settings-context'
|
||||
import GetApiHandler, { PostApiHandler } from '../../../utils/ApiHandler'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useSettingsOutletContext } from '..'
|
||||
import { usePatchSettings } from '../../../api/settings'
|
||||
import GetApiHandler from '../../../utils/ApiHandler'
|
||||
import Alert from '../../Common/Alert'
|
||||
import Button from '../../Common/Button'
|
||||
import DocsButton from '../../Common/DocsButton'
|
||||
|
||||
const MainSettings = () => {
|
||||
const settingsCtx = useContext(SettingsContext)
|
||||
const hostnameRef = useRef<HTMLInputElement>(null)
|
||||
const apiKeyRef = useRef<HTMLInputElement>(null)
|
||||
const [error, setError] = useState<boolean>()
|
||||
const [changed, setChanged] = useState<boolean>()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings - General'
|
||||
}, [])
|
||||
const [missingValuesError, setMissingValuesError] = useState<boolean>()
|
||||
const { settings } = useSettingsOutletContext()
|
||||
const {
|
||||
mutateAsync: updateSettings,
|
||||
isSuccess,
|
||||
isPending,
|
||||
} = usePatchSettings()
|
||||
|
||||
const submit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setMissingValuesError(false)
|
||||
if (hostnameRef.current?.value && apiKeyRef.current?.value) {
|
||||
const payload = {
|
||||
applicationUrl: hostnameRef.current.value,
|
||||
apikey: apiKeyRef.current.value,
|
||||
}
|
||||
const resp: { code: 0 | 1; message: string } = await PostApiHandler(
|
||||
'/settings',
|
||||
{
|
||||
...settingsCtx.settings,
|
||||
...payload,
|
||||
},
|
||||
)
|
||||
if (Boolean(resp.code)) {
|
||||
settingsCtx.addSettings({
|
||||
...settingsCtx.settings,
|
||||
...payload,
|
||||
})
|
||||
setError(false)
|
||||
setChanged(true)
|
||||
} else setError(true)
|
||||
|
||||
await updateSettings(payload)
|
||||
} else {
|
||||
setError(true)
|
||||
setMissingValuesError(true)
|
||||
}
|
||||
}
|
||||
|
||||
const regenerateApi = async () => {
|
||||
const key = await GetApiHandler('/settings/api/generate')
|
||||
|
||||
await PostApiHandler('/settings', {
|
||||
apikey: key,
|
||||
})
|
||||
|
||||
settingsCtx.addSettings({
|
||||
...settingsCtx.settings,
|
||||
await updateSettings({
|
||||
apikey: key,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">General Settings</h3>
|
||||
<p className="description">Configure global settings</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert type="warning" title="Not all fields contain values" />
|
||||
) : changed ? (
|
||||
<Alert type="info" title="Settings successfully updated" />
|
||||
) : undefined}
|
||||
<div className="section">
|
||||
<form onSubmit={submit}>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Hostname
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
name="name"
|
||||
id="name"
|
||||
type="text"
|
||||
ref={hostnameRef}
|
||||
defaultValue={settingsCtx.settings.applicationUrl}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<title>General settings - Maintainerr</title>
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">General Settings</h3>
|
||||
<p className="description">Configure global settings</p>
|
||||
</div>
|
||||
{missingValuesError && (
|
||||
<Alert type="error" title="Not all fields contain values" />
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Api key
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
name="name"
|
||||
id="name"
|
||||
type="text"
|
||||
ref={apiKeyRef}
|
||||
defaultValue={settingsCtx.settings.apikey}
|
||||
></input>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
regenerateApi()
|
||||
}}
|
||||
className="input-action ml-3"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
{isSuccess && (
|
||||
<Alert type="info" title="Settings successfully updated" />
|
||||
)}
|
||||
<div className="section">
|
||||
<form onSubmit={submit}>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Hostname
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
name="name"
|
||||
id="name"
|
||||
type="text"
|
||||
ref={hostnameRef}
|
||||
defaultValue={settings.applicationUrl}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions mt-5 w-full">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex w-full">
|
||||
<span className="mr-auto flex rounded-md shadow-sm">
|
||||
<DocsButton />
|
||||
</span>
|
||||
<span className="ml-auto flex rounded-md shadow-sm">
|
||||
<Button buttonType="primary" type="submit">
|
||||
<SaveIcon />
|
||||
<span>Save Changes</span>
|
||||
</Button>
|
||||
</span>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
API key
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
name="name"
|
||||
id="name"
|
||||
type="text"
|
||||
ref={apiKeyRef}
|
||||
defaultValue={settings.apikey}
|
||||
></input>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
regenerateApi()
|
||||
}}
|
||||
className="input-action ml-3"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="actions mt-5 w-full">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex w-full">
|
||||
<span className="mr-auto flex rounded-md shadow-sm">
|
||||
<DocsButton />
|
||||
</span>
|
||||
<span className="ml-auto flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>Save Changes</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default MainSettings
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
PlusCircleIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/solid'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import GetApiHandler, { DeleteApiHandler } from '../../../utils/ApiHandler'
|
||||
@@ -17,10 +17,9 @@ const NotificationSettings = () => {
|
||||
const [configurations, setConfigurations] = useState<AgentConfiguration[]>()
|
||||
const [editConfig, setEditConfig] = useState<AgentConfiguration>()
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
|
||||
const basePath = import.meta.env.VITE_BASE_PATH ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings - Notifications'
|
||||
GetApiHandler<AgentConfiguration[]>('/notifications/configurations').then(
|
||||
(configs) => setConfigurations(configs),
|
||||
)
|
||||
@@ -47,108 +46,111 @@ const NotificationSettings = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-5 mt-6 h-full w-full text-white">
|
||||
<h3 className="heading flex items-center gap-2">
|
||||
Notification Settings
|
||||
<Image
|
||||
className="h-[1em] w-[2.5em]"
|
||||
width={'0'}
|
||||
height={'0'}
|
||||
src={`${basePath}/beta.svg`}
|
||||
alt="BETA"
|
||||
/>
|
||||
</h3>
|
||||
<p className="description">Notification Agent configuration</p>
|
||||
</div>
|
||||
<>
|
||||
<title>Notification settings - Maintainerr</title>
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-5 mt-6 h-full w-full text-white">
|
||||
<h3 className="heading flex items-center gap-2">
|
||||
Notification Settings
|
||||
<img
|
||||
className="h-[1em] w-[2.5em]"
|
||||
width={'0'}
|
||||
height={'0'}
|
||||
src={`${basePath}/beta.svg`}
|
||||
alt="BETA"
|
||||
/>
|
||||
</h3>
|
||||
<p className="description">Notification Agent configuration</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{configurations?.map((config) => (
|
||||
<li
|
||||
key={config.id}
|
||||
className="h-full rounded-xl bg-zinc-800 p-4 text-zinc-400 shadow ring-1 ring-zinc-700"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-x-3">
|
||||
<div className="text-base font-bold text-white sm:text-lg">
|
||||
{config.name}
|
||||
</div>
|
||||
{!config.enabled && (
|
||||
<div className="rounded bg-amber-600 px-2 py-0.5 text-xs text-zinc-200 shadow-md">
|
||||
Disabled
|
||||
<div>
|
||||
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{configurations?.map((config) => (
|
||||
<li
|
||||
key={config.id}
|
||||
className="h-full rounded-xl bg-zinc-800 p-4 text-zinc-400 shadow ring-1 ring-zinc-700"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-x-3">
|
||||
<div className="text-base font-bold text-white sm:text-lg">
|
||||
{config.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!config.enabled && (
|
||||
<div className="rounded bg-amber-600 px-2 py-0.5 text-xs text-zinc-200 shadow-md">
|
||||
Disabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mb-4 space-x-2 truncate text-gray-300">
|
||||
<span className="font-semibold">{config.agent}</span>
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
buttonType="twin-primary-l"
|
||||
buttonSize="md"
|
||||
className="h-10 w-1/2"
|
||||
onClick={() => {
|
||||
if (config.id) {
|
||||
doEdit(config.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{<DocumentAddIcon className="m-auto" />}{' '}
|
||||
<p className="m-auto font-semibold">Edit</p>
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onDeleteRequested={() => confirmedDelete(config.id)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mb-4 space-x-2 truncate text-gray-300">
|
||||
<span className="font-semibold">{config.agent}</span>
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
buttonType="twin-primary-l"
|
||||
buttonSize="md"
|
||||
className="h-10 w-1/2"
|
||||
onClick={() => {
|
||||
if (config.id) {
|
||||
doEdit(config.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{<DocumentAddIcon className="m-auto" />}{' '}
|
||||
<p className="m-auto font-semibold">Edit</p>
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onDeleteRequested={() => confirmedDelete(config.id)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className="flex h-full items-center justify-center rounded-xl border-2 border-dashed border-gray-400 bg-zinc-800 p-4 text-zinc-400 shadow">
|
||||
<button
|
||||
type="button"
|
||||
className="add-button m-auto flex h-9 rounded bg-amber-600 px-4 text-zinc-200 shadow-md hover:bg-amber-500"
|
||||
onClick={() => updateAddModalActive(!addModalActive)}
|
||||
>
|
||||
{<PlusCircleIcon className="m-auto h-5" />}
|
||||
<p className="m-auto ml-1 font-semibold">Add Agent</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<li className="flex h-full items-center justify-center rounded-xl border-2 border-dashed border-gray-400 bg-zinc-800 p-4 text-zinc-400 shadow">
|
||||
<button
|
||||
type="button"
|
||||
className="add-button m-auto flex h-9 rounded bg-amber-600 px-4 text-zinc-200 shadow-md hover:bg-amber-500"
|
||||
onClick={() => updateAddModalActive(!addModalActive)}
|
||||
>
|
||||
{<PlusCircleIcon className="m-auto h-5" />}
|
||||
<p className="m-auto ml-1 font-semibold">Add Agent</p>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{addModalActive ? (
|
||||
<CreateNotificationModal
|
||||
onCancel={() => {
|
||||
updateAddModalActive(!addModalActive)
|
||||
setEditConfig(undefined)
|
||||
}}
|
||||
onSave={(bool) => {
|
||||
updateAddModalActive(!addModalActive)
|
||||
setEditConfig(undefined)
|
||||
if (bool) {
|
||||
toast.success('Successfully saved notification agent')
|
||||
} else {
|
||||
toast.error("Didn't save incomplete notification agent")
|
||||
}
|
||||
}}
|
||||
onTest={() => {}}
|
||||
{...(editConfig
|
||||
? {
|
||||
selected: {
|
||||
id: editConfig.id!,
|
||||
name: editConfig.name!,
|
||||
enabled: editConfig.enabled!,
|
||||
agent: editConfig.agent!,
|
||||
types: editConfig.types!,
|
||||
options: editConfig.options!,
|
||||
aboutScale: editConfig.aboutScale!,
|
||||
},
|
||||
{addModalActive ? (
|
||||
<CreateNotificationModal
|
||||
onCancel={() => {
|
||||
updateAddModalActive(!addModalActive)
|
||||
setEditConfig(undefined)
|
||||
}}
|
||||
onSave={(bool) => {
|
||||
updateAddModalActive(!addModalActive)
|
||||
setEditConfig(undefined)
|
||||
if (bool) {
|
||||
toast.success('Successfully saved notification agent')
|
||||
} else {
|
||||
toast.error("Didn't save incomplete notification agent")
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
}}
|
||||
onTest={() => {}}
|
||||
{...(editConfig
|
||||
? {
|
||||
selected: {
|
||||
id: editConfig.id!,
|
||||
name: editConfig.name!,
|
||||
enabled: editConfig.enabled!,
|
||||
agent: editConfig.agent!,
|
||||
types: editConfig.types!,
|
||||
options: editConfig.options!,
|
||||
aboutScale: editConfig.aboutScale!,
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
OverseerrSettingDto,
|
||||
overseerrSettingSchema,
|
||||
} from '@maintainerr/contracts'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import GetApiHandler, {
|
||||
@@ -46,10 +46,6 @@ const OverseerrSettings = () => {
|
||||
const [submitError, setSubmitError] = useState<boolean>(false)
|
||||
const [isSubmitSuccessful, setIsSubmitSuccessful] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings - Overseerr'
|
||||
}, [])
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -130,7 +126,7 @@ const OverseerrSettings = () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch(() => {
|
||||
setTestResult({
|
||||
status: false,
|
||||
message: 'Unknown error',
|
||||
@@ -142,105 +138,107 @@ const OverseerrSettings = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">Overseerr Settings</h3>
|
||||
<p className="description">Overseerr configuration</p>
|
||||
</div>
|
||||
{submitError ? (
|
||||
<Alert type="warning" title="Something went wrong" />
|
||||
) : isSubmitSuccessful ? (
|
||||
<Alert type="info" title="Overseerr settings successfully updated" />
|
||||
) : undefined}
|
||||
<>
|
||||
<title>Overseerr settings - Maintainerr</title>
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">Overseerr Settings</h3>
|
||||
<p className="description">Overseerr configuration</p>
|
||||
</div>
|
||||
{submitError ? (
|
||||
<Alert type="warning" title="Something went wrong" />
|
||||
) : isSubmitSuccessful ? (
|
||||
<Alert type="info" title="Overseerr settings successfully updated" />
|
||||
) : undefined}
|
||||
|
||||
{testResult != null &&
|
||||
(testResult?.status ? (
|
||||
<Alert
|
||||
type="info"
|
||||
title={`Successfully connected to Overseerr (${testResult.message})`}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="error" title={testResult.message} />
|
||||
))}
|
||||
{testResult != null &&
|
||||
(testResult?.status ? (
|
||||
<Alert
|
||||
type="info"
|
||||
title={`Successfully connected to Overseerr (${testResult.message})`}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="error" title={testResult.message} />
|
||||
))}
|
||||
|
||||
<div className="section">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name={'url'}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<InputGroup
|
||||
label="URL"
|
||||
value={field.value}
|
||||
placeholder="http://localhost:5055"
|
||||
onChange={field.onChange}
|
||||
onBlur={(event) =>
|
||||
field.onChange(stripLeadingSlashes(event.target.value))
|
||||
}
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
type="text"
|
||||
error={errors.url?.message}
|
||||
helpText={
|
||||
<>
|
||||
Example URL formats:{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
http://localhost:5055
|
||||
</span>
|
||||
,{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
http://192.168.1.5/overseerr
|
||||
</span>
|
||||
,{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
https://overseerr.example.com
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="section">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name={'url'}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<InputGroup
|
||||
label="URL"
|
||||
value={field.value}
|
||||
placeholder="http://localhost:5055"
|
||||
onChange={field.onChange}
|
||||
onBlur={(event) =>
|
||||
field.onChange(stripLeadingSlashes(event.target.value))
|
||||
}
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
type="text"
|
||||
error={errors.url?.message}
|
||||
helpText={
|
||||
<>
|
||||
Example URL formats:{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
http://localhost:5055
|
||||
</span>
|
||||
,{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
http://192.168.1.5/overseerr
|
||||
</span>
|
||||
,{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
https://overseerr.example.com
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<InputGroup
|
||||
label="API key"
|
||||
type="password"
|
||||
{...register('api_key')}
|
||||
error={errors.api_key?.message}
|
||||
/>
|
||||
<InputGroup
|
||||
label="API key"
|
||||
type="password"
|
||||
{...register('api_key')}
|
||||
error={errors.api_key?.message}
|
||||
/>
|
||||
|
||||
<div className="actions mt-5 w-full">
|
||||
<div className="flex w-full flex-wrap sm:flex-nowrap">
|
||||
<span className="m-auto rounded-md shadow-sm sm:ml-3 sm:mr-auto">
|
||||
<DocsButton page="Configuration/#overseerr" />
|
||||
</span>
|
||||
<div className="m-auto mt-3 flex xs:mt-0 sm:m-0 sm:justify-end">
|
||||
<Button
|
||||
buttonType="success"
|
||||
onClick={performTest}
|
||||
className="ml-3"
|
||||
disabled={testing || isGoingToRemoveSetting}
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test'}
|
||||
</Button>
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={!canSaveSettings}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>Save Changes</span>
|
||||
</Button>
|
||||
<div className="actions mt-5 w-full">
|
||||
<div className="flex w-full flex-wrap sm:flex-nowrap">
|
||||
<span className="m-auto rounded-md shadow-sm sm:ml-3 sm:mr-auto">
|
||||
<DocsButton page="Configuration/#overseerr" />
|
||||
</span>
|
||||
<div className="m-auto mt-3 flex xs:mt-0 sm:m-0 sm:justify-end">
|
||||
<Button
|
||||
buttonType="success"
|
||||
onClick={performTest}
|
||||
className="ml-3"
|
||||
disabled={testing || isGoingToRemoveSetting}
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test'}
|
||||
</Button>
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={!canSaveSettings}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>Save Changes</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OverseerrSettings
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { RefreshIcon } from '@heroicons/react/outline'
|
||||
import { SaveIcon } from '@heroicons/react/solid'
|
||||
import axios from 'axios'
|
||||
import { orderBy } from 'lodash'
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { orderBy } from 'lodash-es'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import SettingsContext from '../../../contexts/settings-context'
|
||||
import GetApiHandler, {
|
||||
DeleteApiHandler,
|
||||
PostApiHandler,
|
||||
} from '../../../utils/ApiHandler'
|
||||
import { useSettingsOutletContext } from '..'
|
||||
import {
|
||||
useDeletePlexAuth,
|
||||
usePatchSettings,
|
||||
useUpdatePlexAuth,
|
||||
} from '../../../api/settings'
|
||||
import GetApiHandler from '../../../utils/ApiHandler'
|
||||
import Alert from '../../Common/Alert'
|
||||
import Button from '../../Common/Button'
|
||||
import DocsButton from '../../Common/DocsButton'
|
||||
@@ -65,14 +67,12 @@ export interface PlexDevice {
|
||||
}
|
||||
|
||||
const PlexSettings = () => {
|
||||
const settingsCtx = useContext(SettingsContext)
|
||||
const hostnameRef = useRef<HTMLInputElement>(null)
|
||||
const nameRef = useRef<HTMLInputElement>(null)
|
||||
const portRef = useRef<HTMLInputElement>(null)
|
||||
const sslRef = useRef<HTMLInputElement>(null)
|
||||
const serverPresetRef = useRef<HTMLInputElement>(null)
|
||||
const [error, setError] = useState<boolean>()
|
||||
const [changed, setChanged] = useState<boolean>()
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const [tokenValid, setTokenValid] = useState<boolean>(false)
|
||||
const [clearTokenClicked, setClearTokenClicked] = useState<boolean>(false)
|
||||
const [testBanner, setTestbanner] = useState<{
|
||||
@@ -82,22 +82,31 @@ const PlexSettings = () => {
|
||||
const [availableServers, setAvailableServers] = useState<PlexDevice[]>()
|
||||
const [isRefreshingPresets, setIsRefreshingPresets] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings - Plex'
|
||||
}, [])
|
||||
const {
|
||||
mutateAsync: updateSettings,
|
||||
isPending,
|
||||
isSuccess: updateSettingsSuccess,
|
||||
isError: updateSettingsError,
|
||||
} = usePatchSettings()
|
||||
const {
|
||||
mutateAsync: deletePlexAuth,
|
||||
isSuccess: deletePlexAuthSuccess,
|
||||
isError: deletePlexAuthError,
|
||||
isPending: deletePlexAuthPending,
|
||||
} = useDeletePlexAuth()
|
||||
const { mutateAsync: updatePlexAuth } = useUpdatePlexAuth()
|
||||
const { settings } = useSettingsOutletContext()
|
||||
|
||||
const submit = async (
|
||||
e: React.FormEvent<HTMLFormElement> | undefined,
|
||||
plex_token?: { plex_auth_token: string } | undefined,
|
||||
) => {
|
||||
const submit = async (e: React.FormEvent<HTMLFormElement> | undefined) => {
|
||||
e?.preventDefault()
|
||||
setError(undefined)
|
||||
if (
|
||||
hostnameRef.current?.value &&
|
||||
nameRef.current?.value &&
|
||||
portRef.current?.value &&
|
||||
sslRef.current !== null
|
||||
) {
|
||||
let payload: {
|
||||
const payload: {
|
||||
plex_hostname: string
|
||||
plex_port: number
|
||||
plex_name: string
|
||||
@@ -116,34 +125,14 @@ const PlexSettings = () => {
|
||||
plex_ssl: +sslRef.current.checked, // not used, server derives this from https://
|
||||
}
|
||||
|
||||
if (plex_token) {
|
||||
payload = {
|
||||
...payload,
|
||||
plex_auth_token: plex_token.plex_auth_token,
|
||||
}
|
||||
}
|
||||
|
||||
const resp: { code: 0 | 1; message: string } = await PostApiHandler(
|
||||
'/settings',
|
||||
{
|
||||
...settingsCtx.settings,
|
||||
...payload,
|
||||
},
|
||||
)
|
||||
if (Boolean(resp.code)) {
|
||||
settingsCtx.addSettings({
|
||||
...settingsCtx.settings,
|
||||
...payload,
|
||||
})
|
||||
setError(false)
|
||||
setChanged(true)
|
||||
try {
|
||||
await updateSettings(payload)
|
||||
toast.success('Settings successfully updated!')
|
||||
} else {
|
||||
setError(true)
|
||||
} catch {
|
||||
toast.error('Failed to update settings')
|
||||
}
|
||||
} else {
|
||||
setError(true)
|
||||
setError('Please fill in all required fields.')
|
||||
toast.error('Please fill in all required fields.')
|
||||
}
|
||||
}
|
||||
@@ -152,15 +141,7 @@ const PlexSettings = () => {
|
||||
plex_token?: { plex_auth_token: string } | undefined,
|
||||
) => {
|
||||
if (plex_token) {
|
||||
const resp: { code: 0 | 1; message: string } = await PostApiHandler(
|
||||
'/settings/plex/token',
|
||||
{
|
||||
plex_auth_token: plex_token.plex_auth_token,
|
||||
},
|
||||
)
|
||||
if (resp.code === 1) {
|
||||
settingsCtx.settings.plex_auth_token = plex_token.plex_auth_token
|
||||
}
|
||||
await updatePlexAuth(plex_token.plex_auth_token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,34 +165,24 @@ const PlexSettings = () => {
|
||||
}, [availableServers])
|
||||
|
||||
const authsuccess = (token: string) => {
|
||||
setError(undefined)
|
||||
verifyToken(token)
|
||||
submitPlexToken({ plex_auth_token: token })
|
||||
}
|
||||
|
||||
const authFailed = () => {
|
||||
setError(true)
|
||||
setError('Authentication failed')
|
||||
toast.error('Authentication failed')
|
||||
}
|
||||
|
||||
const deleteToken = async () => {
|
||||
const status = await DeleteApiHandler('/settings/plex/auth')
|
||||
|
||||
if (Boolean(status.code)) {
|
||||
settingsCtx.addSettings({
|
||||
...settingsCtx.settings,
|
||||
plex_auth_token: null,
|
||||
})
|
||||
setError(false)
|
||||
setChanged(true)
|
||||
setTokenValid(false)
|
||||
setClearTokenClicked(false)
|
||||
} else {
|
||||
setError(true)
|
||||
}
|
||||
await deletePlexAuth()
|
||||
setTokenValid(false)
|
||||
setClearTokenClicked(false)
|
||||
}
|
||||
|
||||
const verifyToken = (token?: string) => {
|
||||
const authToken = token || settingsCtx.settings.plex_auth_token
|
||||
const authToken = token || settings?.plex_auth_token
|
||||
if (authToken) {
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -240,8 +211,8 @@ const PlexSettings = () => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsCtx.settings.plex_auth_token) verifyToken()
|
||||
}, [])
|
||||
if (settings?.plex_auth_token) verifyToken()
|
||||
}, [settings?.plex_auth_token])
|
||||
|
||||
const appTest = (result: { status: boolean; message: string }) => {
|
||||
setTestbanner({ status: result.status, version: result.message })
|
||||
@@ -288,245 +259,262 @@ const PlexSettings = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">Plex Settings</h3>
|
||||
<p className="description">Plex configuration</p>
|
||||
</div>
|
||||
<>
|
||||
<title>Plex settings - Maintainerr</title>
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">Plex Settings</h3>
|
||||
<p className="description">Plex configuration</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert type="warning" title="Not all fields contain values" />
|
||||
) : changed ? (
|
||||
<Alert type="info" title="Settings successfully updated" />
|
||||
) : undefined}
|
||||
{error && <Alert type="error" title={error} />}
|
||||
|
||||
{tokenValid ? (
|
||||
''
|
||||
) : (
|
||||
<Alert
|
||||
type="info"
|
||||
title="Plex configuration is required. Other configuration options will become available after configuring Plex."
|
||||
/>
|
||||
)}
|
||||
|
||||
{testBanner.version !== '0' ? (
|
||||
testBanner.status ? (
|
||||
<Alert
|
||||
type="info"
|
||||
title={`Successfully connected to Plex (${testBanner.version})`}
|
||||
/>
|
||||
) : (
|
||||
{deletePlexAuthError && (
|
||||
<Alert
|
||||
type="error"
|
||||
title="Connection failed! Double check your entries and make sure to Save Changes before you Test."
|
||||
title="There was an error clearing Plex authentication."
|
||||
/>
|
||||
)
|
||||
) : undefined}
|
||||
)}
|
||||
|
||||
<div className="section">
|
||||
<form onSubmit={submit}>
|
||||
{/* Load preset server list */}
|
||||
<div className="form-row">
|
||||
<label htmlFor="preset" className="text-label">
|
||||
Server
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<select
|
||||
id="preset"
|
||||
name="preset"
|
||||
value={serverPresetRef?.current?.value}
|
||||
disabled={
|
||||
(!availableServers || isRefreshingPresets) &&
|
||||
tokenValid === true
|
||||
}
|
||||
className="rounded-l-only"
|
||||
onChange={async (e) => {
|
||||
const targPreset = availablePresets[Number(e.target.value)]
|
||||
if (targPreset) {
|
||||
setFieldValue(nameRef, targPreset.name)
|
||||
setFieldValue(hostnameRef, targPreset.address)
|
||||
setFieldValue(portRef, targPreset.port.toString())
|
||||
setFieldValue(sslRef, targPreset.ssl.toString())
|
||||
{updateSettingsError && (
|
||||
<Alert
|
||||
type="error"
|
||||
title="There was an error updating Plex settings."
|
||||
/>
|
||||
)}
|
||||
|
||||
{(deletePlexAuthSuccess || updateSettingsSuccess) && (
|
||||
<Alert type="info" title="Settings successfully updated" />
|
||||
)}
|
||||
|
||||
{tokenValid ? (
|
||||
''
|
||||
) : (
|
||||
<Alert
|
||||
type="info"
|
||||
title="Plex configuration is required. Other configuration options will become available after configuring Plex."
|
||||
/>
|
||||
)}
|
||||
|
||||
{testBanner.version !== '0' ? (
|
||||
testBanner.status ? (
|
||||
<Alert
|
||||
type="info"
|
||||
title={`Successfully connected to Plex (${testBanner.version})`}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="error"
|
||||
title="Connection failed! Double check your entries and make sure to Save Changes before you Test."
|
||||
/>
|
||||
)
|
||||
) : undefined}
|
||||
|
||||
<div className="section">
|
||||
<form onSubmit={submit}>
|
||||
{/* Load preset server list */}
|
||||
<div className="form-row">
|
||||
<label htmlFor="preset" className="text-label">
|
||||
Server
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<select
|
||||
id="preset"
|
||||
name="preset"
|
||||
value={serverPresetRef?.current?.value}
|
||||
disabled={
|
||||
(!availableServers || isRefreshingPresets) &&
|
||||
tokenValid === true
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="manual">
|
||||
{availableServers || isRefreshingPresets
|
||||
? isRefreshingPresets
|
||||
? 'Retrieving servers...'
|
||||
: 'Manual configuration'
|
||||
: tokenValid === true
|
||||
? 'Press the button to load available servers'
|
||||
: 'Authenticate to load servers'}
|
||||
</option>
|
||||
{availablePresets.map((server, index) => (
|
||||
<option key={`preset-server-${index}`} value={index}>
|
||||
{`
|
||||
className="rounded-l-only"
|
||||
onChange={async (e) => {
|
||||
const targPreset =
|
||||
availablePresets[Number(e.target.value)]
|
||||
if (targPreset) {
|
||||
setFieldValue(nameRef, targPreset.name)
|
||||
setFieldValue(hostnameRef, targPreset.address)
|
||||
setFieldValue(portRef, targPreset.port.toString())
|
||||
setFieldValue(sslRef, targPreset.ssl.toString())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="manual">
|
||||
{availableServers || isRefreshingPresets
|
||||
? isRefreshingPresets
|
||||
? 'Retrieving servers...'
|
||||
: 'Manual configuration'
|
||||
: tokenValid === true
|
||||
? 'Press the button to load available servers'
|
||||
: 'Authenticate to load servers'}
|
||||
</option>
|
||||
{availablePresets.map((server, index) => (
|
||||
<option key={`preset-server-${index}`} value={index}>
|
||||
{`
|
||||
${server.name} (${server.address})
|
||||
[${server.local ? 'local' : 'remote'}]${
|
||||
server.ssl ? ` [secure]` : ''
|
||||
}
|
||||
`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
refreshPresetServers()
|
||||
}}
|
||||
disabled={tokenValid !== true}
|
||||
className="input-action"
|
||||
>
|
||||
<RefreshIcon
|
||||
className={isRefreshingPresets ? 'animate-spin' : ''}
|
||||
style={{ animationDirection: 'reverse' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Name */}
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Name
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
name="name"
|
||||
id="name"
|
||||
type="text"
|
||||
ref={nameRef}
|
||||
defaultValue={settingsCtx.settings.plex_name}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="hostname" className="text-label">
|
||||
Hostname or IP
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
name="hostname"
|
||||
id="hostname"
|
||||
type="text"
|
||||
ref={hostnameRef}
|
||||
defaultValue={settingsCtx.settings.plex_hostname
|
||||
?.replace('http://', '')
|
||||
.replace('https://', '')}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="port" className="text-label">
|
||||
Port
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
name="port"
|
||||
id="port"
|
||||
type="number"
|
||||
ref={portRef}
|
||||
defaultValue={settingsCtx.settings.plex_port}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="ssl" className="text-label">
|
||||
SSL
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="ssl"
|
||||
id="ssl"
|
||||
defaultChecked={Boolean(settingsCtx.settings.plex_ssl)}
|
||||
ref={sslRef}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="ssl" className="text-label">
|
||||
Authentication
|
||||
<span className="label-tip">
|
||||
{`Authentication with the server's admin account is required to access the
|
||||
Plex API`}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
{tokenValid ? (
|
||||
clearTokenClicked ? (
|
||||
<Button
|
||||
onClick={(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
deleteToken()
|
||||
}}
|
||||
buttonType="warning"
|
||||
>
|
||||
Clear credentials?
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setClearTokenClicked(true)
|
||||
}}
|
||||
buttonType="success"
|
||||
>
|
||||
Authenticated
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<PlexLoginButton
|
||||
onAuthToken={authsuccess}
|
||||
onError={authFailed}
|
||||
></PlexLoginButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions mt-5 w-full">
|
||||
<div className="flex w-full flex-wrap sm:flex-nowrap">
|
||||
<span className="m-auto rounded-md shadow-sm sm:ml-3 sm:mr-auto">
|
||||
<DocsButton page="Configuration/#plex" />
|
||||
</span>
|
||||
<div className="m-auto mt-3 flex xs:mt-0 sm:m-0 sm:justify-end">
|
||||
<TestButton
|
||||
onTestComplete={appTest}
|
||||
testUrl="/settings/test/plex"
|
||||
/>
|
||||
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
// disabled={isSubmitting || !isValid}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
refreshPresetServers()
|
||||
}}
|
||||
disabled={tokenValid !== true}
|
||||
className="input-action"
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>Save Changes</span>
|
||||
</Button>
|
||||
</span>
|
||||
<RefreshIcon
|
||||
className={isRefreshingPresets ? 'animate-spin' : ''}
|
||||
style={{ animationDirection: 'reverse' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/* Name */}
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
Name
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
name="name"
|
||||
id="name"
|
||||
type="text"
|
||||
ref={nameRef}
|
||||
defaultValue={settings.plex_name}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="hostname" className="text-label">
|
||||
Hostname or IP
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
name="hostname"
|
||||
id="hostname"
|
||||
type="text"
|
||||
ref={hostnameRef}
|
||||
defaultValue={settings.plex_hostname
|
||||
?.replace('http://', '')
|
||||
.replace('https://', '')}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="port" className="text-label">
|
||||
Port
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
name="port"
|
||||
id="port"
|
||||
type="number"
|
||||
ref={portRef}
|
||||
defaultValue={settings.plex_port}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="ssl" className="text-label">
|
||||
SSL
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="ssl"
|
||||
id="ssl"
|
||||
defaultChecked={Boolean(settings.plex_ssl)}
|
||||
ref={sslRef}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="ssl" className="text-label">
|
||||
Authentication
|
||||
<span className="label-tip">
|
||||
{`Authentication with the server's admin account is required to access the
|
||||
Plex API`}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
{tokenValid ? (
|
||||
clearTokenClicked ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={deleteToken}
|
||||
buttonType="warning"
|
||||
disabled={deletePlexAuthPending}
|
||||
>
|
||||
Clear credentials?
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setClearTokenClicked(true)
|
||||
}}
|
||||
buttonType="success"
|
||||
>
|
||||
Authenticated
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<PlexLoginButton
|
||||
onAuthToken={authsuccess}
|
||||
onError={authFailed}
|
||||
></PlexLoginButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions mt-5 w-full">
|
||||
<div className="flex w-full flex-wrap sm:flex-nowrap">
|
||||
<span className="m-auto rounded-md shadow-sm sm:ml-3 sm:mr-auto">
|
||||
<DocsButton page="Configuration/#plex" />
|
||||
</span>
|
||||
<div className="m-auto mt-3 flex xs:mt-0 sm:m-0 sm:justify-end">
|
||||
<TestButton
|
||||
onTestComplete={appTest}
|
||||
testUrl="/settings/test/plex"
|
||||
/>
|
||||
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>Save Changes</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default PlexSettings
|
||||
|
||||
@@ -186,7 +186,7 @@ const RadarrSettingsModal = (props: IRadarrSettingsModal) => {
|
||||
|
||||
setTesting(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch(() => {
|
||||
setTestResult({
|
||||
status: false,
|
||||
version: '0',
|
||||
@@ -300,7 +300,7 @@ const RadarrSettingsModal = (props: IRadarrSettingsModal) => {
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="apikey" className="text-label">
|
||||
Api key
|
||||
API key
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
} from '@heroicons/react/solid'
|
||||
import { useEffect, useState } from 'react'
|
||||
import GetApiHandler, { DeleteApiHandler } from '../../../utils/ApiHandler'
|
||||
import { ICollection } from '../../Collection'
|
||||
import Button from '../../Common/Button'
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner'
|
||||
import RadarrSettingsModal from './SettingsModal'
|
||||
import { ICollection } from '../../Collection'
|
||||
import Modal from '../../Common/Modal'
|
||||
import RadarrSettingsModal from './SettingsModal'
|
||||
|
||||
type DeleteRadarrSettingResponseDto =
|
||||
| {
|
||||
@@ -80,10 +80,6 @@ const RadarrSettings = () => {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings - Radarr'
|
||||
}, [])
|
||||
|
||||
const showAddModal = () => {
|
||||
setSettingsModalActive(true)
|
||||
}
|
||||
@@ -91,6 +87,7 @@ const RadarrSettings = () => {
|
||||
if (!loaded) {
|
||||
return (
|
||||
<>
|
||||
<title>Radarr settings - Maintainerr</title>
|
||||
<div className="mt-6">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
@@ -100,6 +97,7 @@ const RadarrSettings = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Radarr settings - Maintainerr</title>
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">Radarr Settings</h3>
|
||||
|
||||
@@ -186,7 +186,7 @@ const SonarrSettingsModal = (props: ISonarrSettingsModal) => {
|
||||
|
||||
setTesting(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch(() => {
|
||||
setTestResult({
|
||||
status: false,
|
||||
version: '0',
|
||||
@@ -300,7 +300,7 @@ const SonarrSettingsModal = (props: ISonarrSettingsModal) => {
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="apikey" className="text-label">
|
||||
Api key
|
||||
API key
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
} from '@heroicons/react/solid'
|
||||
import { useEffect, useState } from 'react'
|
||||
import GetApiHandler, { DeleteApiHandler } from '../../../utils/ApiHandler'
|
||||
import { ICollection } from '../../Collection'
|
||||
import Button from '../../Common/Button'
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner'
|
||||
import SonarrSettingsModal from './SettingsModal'
|
||||
import { ICollection } from '../../Collection'
|
||||
import Modal from '../../Common/Modal'
|
||||
import SonarrSettingsModal from './SettingsModal'
|
||||
|
||||
type DeleteSonarrSettingResponseDto =
|
||||
| {
|
||||
@@ -80,10 +80,6 @@ const SonarrSettings = () => {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings - Sonarr'
|
||||
}, [])
|
||||
|
||||
const showAddModal = () => {
|
||||
setSettingsModalActive(true)
|
||||
}
|
||||
@@ -91,6 +87,7 @@ const SonarrSettings = () => {
|
||||
if (!loaded) {
|
||||
return (
|
||||
<>
|
||||
<title>Sonarr settings - Maintainerr</title>
|
||||
<div className="mt-6">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
@@ -100,6 +97,7 @@ const SonarrSettings = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Sonarr settings - Maintainerr</title>
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">Sonarr Settings</h3>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
export interface SettingsRoute {
|
||||
text: string
|
||||
@@ -44,7 +43,7 @@ const SettingsLink: React.FC<ISettingsLink> = (props: ISettingsLink) => {
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={props.route}
|
||||
to={props.route}
|
||||
className={`${linkClasses} ${
|
||||
props.currentPath.match(props.regex)
|
||||
? activeLinkColor
|
||||
@@ -62,7 +61,8 @@ const SettingsTabs: React.FC<{
|
||||
settingsRoutes: SettingsRoute[]
|
||||
allEnabled?: boolean
|
||||
}> = ({ tabType = 'default', settingsRoutes, allEnabled = true }) => {
|
||||
const router = useRouter()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
@@ -76,6 +76,10 @@ const SettingsTabs: React.FC<{
|
||||
}
|
||||
}, [allEnabled])
|
||||
|
||||
const currentRoute =
|
||||
settingsRoutes.find((route) => route.regex.test(location.pathname))
|
||||
?.route ?? ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sm:hidden">
|
||||
@@ -83,23 +87,20 @@ const SettingsTabs: React.FC<{
|
||||
Select a Tab
|
||||
</label>
|
||||
<select
|
||||
value={currentRoute}
|
||||
onChange={(e) => {
|
||||
router.push(e.target.value)
|
||||
navigate(e.target.value)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
router.push(e.target.value)
|
||||
navigate(e.target.value)
|
||||
}}
|
||||
defaultValue={
|
||||
settingsRoutes.find((route) => !!router.pathname.match(route.route))
|
||||
?.route
|
||||
}
|
||||
aria-label="Selected Tab"
|
||||
>
|
||||
{settingsRoutes.map((route, index) => (
|
||||
<SettingsLink
|
||||
disabled={!allEnabled}
|
||||
tabType={tabType}
|
||||
currentPath={router.pathname}
|
||||
currentPath={location.pathname}
|
||||
route={route.route}
|
||||
regex={route.regex}
|
||||
isMobile
|
||||
@@ -117,7 +118,7 @@ const SettingsTabs: React.FC<{
|
||||
<SettingsLink
|
||||
disabled={!allEnabled}
|
||||
tabType={tabType}
|
||||
currentPath={router.pathname}
|
||||
currentPath={location.pathname}
|
||||
route={route.route}
|
||||
regex={route.regex}
|
||||
key={`button-settings-link-${index}`}
|
||||
@@ -134,7 +135,7 @@ const SettingsTabs: React.FC<{
|
||||
<SettingsLink
|
||||
disabled={!allEnabled}
|
||||
tabType={tabType}
|
||||
currentPath={router.pathname}
|
||||
currentPath={location.pathname}
|
||||
route={route.route}
|
||||
regex={route.regex}
|
||||
key={`standard-settings-link-${index}`}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
TautulliSettingDto,
|
||||
tautulliSettingSchema,
|
||||
} from '@maintainerr/contracts'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import GetApiHandler, {
|
||||
@@ -46,10 +46,6 @@ const TautulliSettings = () => {
|
||||
const [submitError, setSubmitError] = useState<boolean>(false)
|
||||
const [isSubmitSuccessful, setIsSubmitSuccessful] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Settings - Tautulli'
|
||||
}, [])
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -128,7 +124,7 @@ const TautulliSettings = () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch(() => {
|
||||
setTestResult({
|
||||
status: false,
|
||||
message: 'Unknown error',
|
||||
@@ -140,105 +136,107 @@ const TautulliSettings = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">Tautulli Settings</h3>
|
||||
<p className="description">Tautulli configuration</p>
|
||||
</div>
|
||||
{submitError ? (
|
||||
<Alert type="warning" title="Something went wrong" />
|
||||
) : isSubmitSuccessful ? (
|
||||
<Alert type="info" title="Tautulli settings successfully updated" />
|
||||
) : undefined}
|
||||
<>
|
||||
<title>Tautulli settings - Maintainerr</title>
|
||||
<div className="h-full w-full">
|
||||
<div className="section h-full w-full">
|
||||
<h3 className="heading">Tautulli Settings</h3>
|
||||
<p className="description">Tautulli configuration</p>
|
||||
</div>
|
||||
{submitError ? (
|
||||
<Alert type="warning" title="Something went wrong" />
|
||||
) : isSubmitSuccessful ? (
|
||||
<Alert type="info" title="Tautulli settings successfully updated" />
|
||||
) : undefined}
|
||||
|
||||
{testResult != null &&
|
||||
(testResult?.status ? (
|
||||
<Alert
|
||||
type="info"
|
||||
title={`Successfully connected to Tautulli (${testResult.message})`}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="error" title={testResult.message} />
|
||||
))}
|
||||
{testResult != null &&
|
||||
(testResult?.status ? (
|
||||
<Alert
|
||||
type="info"
|
||||
title={`Successfully connected to Tautulli (${testResult.message})`}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="error" title={testResult.message} />
|
||||
))}
|
||||
|
||||
<div className="section">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name={'url'}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<InputGroup
|
||||
label="URL"
|
||||
value={field.value}
|
||||
placeholder="http://localhost:8181"
|
||||
onChange={field.onChange}
|
||||
onBlur={(event) =>
|
||||
field.onChange(stripLeadingSlashes(event.target.value))
|
||||
}
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
type="text"
|
||||
error={errors.url?.message}
|
||||
helpText={
|
||||
<>
|
||||
Example URL formats:{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
http://localhost:8181
|
||||
</span>
|
||||
,{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
http://192.168.1.5/tautulli
|
||||
</span>
|
||||
,{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
https://tautulli.example.com
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="section">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name={'url'}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<InputGroup
|
||||
label="URL"
|
||||
value={field.value}
|
||||
placeholder="http://localhost:8181"
|
||||
onChange={field.onChange}
|
||||
onBlur={(event) =>
|
||||
field.onChange(stripLeadingSlashes(event.target.value))
|
||||
}
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
type="text"
|
||||
error={errors.url?.message}
|
||||
helpText={
|
||||
<>
|
||||
Example URL formats:{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
http://localhost:8181
|
||||
</span>
|
||||
,{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
http://192.168.1.5/tautulli
|
||||
</span>
|
||||
,{' '}
|
||||
<span className="whitespace-nowrap">
|
||||
https://tautulli.example.com
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<InputGroup
|
||||
label="API key"
|
||||
type="password"
|
||||
{...register('api_key')}
|
||||
error={errors.api_key?.message}
|
||||
/>
|
||||
<InputGroup
|
||||
label="API key"
|
||||
type="password"
|
||||
{...register('api_key')}
|
||||
error={errors.api_key?.message}
|
||||
/>
|
||||
|
||||
<div className="actions mt-5 w-full">
|
||||
<div className="flex w-full flex-wrap sm:flex-nowrap">
|
||||
<span className="m-auto rounded-md shadow-sm sm:ml-3 sm:mr-auto">
|
||||
<DocsButton page="Configuration/#tautulli" />
|
||||
</span>
|
||||
<div className="m-auto mt-3 flex xs:mt-0 sm:m-0 sm:justify-end">
|
||||
<Button
|
||||
buttonType="success"
|
||||
onClick={performTest}
|
||||
className="ml-3"
|
||||
disabled={testing || isGoingToRemoveSetting}
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test'}
|
||||
</Button>
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={!canSaveSettings}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>Save Changes</span>
|
||||
</Button>
|
||||
<div className="actions mt-5 w-full">
|
||||
<div className="flex w-full flex-wrap sm:flex-nowrap">
|
||||
<span className="m-auto rounded-md shadow-sm sm:ml-3 sm:mr-auto">
|
||||
<DocsButton page="Configuration/#tautulli" />
|
||||
</span>
|
||||
<div className="m-auto mt-3 flex xs:mt-0 sm:m-0 sm:justify-end">
|
||||
<Button
|
||||
buttonType="success"
|
||||
onClick={performTest}
|
||||
className="ml-3"
|
||||
disabled={testing || isGoingToRemoveSetting}
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test'}
|
||||
</Button>
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={!canSaveSettings}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>Save Changes</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TautulliSettings
|
||||
|
||||
@@ -1,104 +1,117 @@
|
||||
import { ReactNode, useContext, useEffect, useState } from 'react'
|
||||
import SettingsContext from '../../contexts/settings-context'
|
||||
import GetApiHandler from '../../utils/ApiHandler'
|
||||
import { Outlet, useOutletContext } from 'react-router-dom'
|
||||
import { useSettings, type UseSettingsResult } from '../../api/settings'
|
||||
import Alert from '../Common/Alert'
|
||||
import LoadingSpinner from '../Common/LoadingSpinner'
|
||||
import SettingsTabs, { SettingsRoute } from './Tabs'
|
||||
|
||||
const SettingsWrapper: React.FC<{ children?: ReactNode }> = (props: {
|
||||
children?: ReactNode
|
||||
}) => {
|
||||
const settingsCtx = useContext(SettingsContext)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
export type SettingsOutletContext = {
|
||||
settings: NonNullable<UseSettingsResult['data']>
|
||||
}
|
||||
|
||||
export const useSettingsOutletContext = () =>
|
||||
useOutletContext<SettingsOutletContext>()
|
||||
|
||||
const SettingsWrapper = () => {
|
||||
const { data: settings, isLoading, error } = useSettings()
|
||||
|
||||
const settingsRoutes: SettingsRoute[] = [
|
||||
{
|
||||
text: 'General',
|
||||
route: '/settings/main',
|
||||
regex: /^\/settings(\/main)?$/,
|
||||
regex: /^\/settings\/main$/,
|
||||
},
|
||||
{
|
||||
text: 'Plex',
|
||||
route: '/settings/plex',
|
||||
regex: /^\/settings(\/plex)?$/,
|
||||
regex: /^\/settings\/plex$/,
|
||||
},
|
||||
{
|
||||
text: 'Overseerr',
|
||||
route: '/settings/overseerr',
|
||||
regex: /^\/settings(\/overseerr)?$/,
|
||||
regex: /^\/settings\/overseerr$/,
|
||||
},
|
||||
{
|
||||
text: 'Jellyseerr',
|
||||
route: '/settings/jellyseerr',
|
||||
regex: /^\/settings(\/jellyseerr)?$/,
|
||||
regex: /^\/settings\/jellyseerr$/,
|
||||
},
|
||||
{
|
||||
text: 'Radarr',
|
||||
route: '/settings/radarr',
|
||||
regex: /^\/settings(\/radarr)?$/,
|
||||
regex: /^\/settings\/radarr$/,
|
||||
},
|
||||
{
|
||||
text: 'Sonarr',
|
||||
route: '/settings/sonarr',
|
||||
regex: /^\/settings(\/sonarr)?$/,
|
||||
regex: /^\/settings\/sonarr$/,
|
||||
},
|
||||
{
|
||||
text: 'Tautulli',
|
||||
route: '/settings/tautulli',
|
||||
regex: /^\/settings(\/tautulli)?$/,
|
||||
regex: /^\/settings\/tautulli$/,
|
||||
},
|
||||
{
|
||||
text: 'Notifications',
|
||||
route: '/settings/notifications',
|
||||
regex: /^\/settings(\/notifications)?$/,
|
||||
regex: /^\/settings\/notifications$/,
|
||||
},
|
||||
{
|
||||
text: 'Logs',
|
||||
route: '/settings/logs',
|
||||
regex: /^\/settings(\/logs)?$/,
|
||||
regex: /^\/settings\/logs$/,
|
||||
},
|
||||
{
|
||||
text: 'Jobs',
|
||||
route: '/settings/jobs',
|
||||
regex: /^\/settings(\/jobs)?$/,
|
||||
regex: /^\/settings\/jobs$/,
|
||||
},
|
||||
{
|
||||
text: 'About',
|
||||
route: '/settings/about',
|
||||
regex: /^\/settings(\/about)?$/,
|
||||
regex: /^\/settings\/about$/,
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsCtx.settings?.id === undefined) {
|
||||
GetApiHandler('/settings').then((resp) => {
|
||||
settingsCtx.addSettings(resp)
|
||||
setLoaded(true)
|
||||
})
|
||||
} else {
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<SettingsTabs settingsRoutes={settingsRoutes} allEnabled={false} />
|
||||
</div>
|
||||
<div className="mt-10 flex">
|
||||
<Alert type="error" title="There was a problem loading settings." />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (loaded) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<SettingsTabs settingsRoutes={settingsRoutes} allEnabled={false} />
|
||||
</div>
|
||||
<LoadingSpinner />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (settings) {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<SettingsTabs
|
||||
settingsRoutes={settingsRoutes}
|
||||
allEnabled={settingsCtx.settings.plex_auth_token !== null}
|
||||
allEnabled={settings.plex_auth_token !== null}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-10 text-white">{props.children}</div>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<LoadingSpinner />
|
||||
<div className="mt-10 text-white">
|
||||
<Outlet context={{ settings }} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
export default SettingsWrapper
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
ServerIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import { type VersionResponse } from '@maintainerr/contracts'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import GetApiHandler from '../../utils/ApiHandler'
|
||||
|
||||
enum messages {
|
||||
@@ -47,7 +47,7 @@ const VersionStatus = ({ onClick }: VersionStatusProps) => {
|
||||
<>
|
||||
{!loading ? (
|
||||
<Link
|
||||
href="/settings/about"
|
||||
to="/settings/about"
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && onClick) {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
ReactPortal,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { EPlexDataType } from '../utils/PlexDataType-enum'
|
||||
|
||||
interface Iconstants {
|
||||
export interface IConstants {
|
||||
applications: IApplication[] | null
|
||||
}
|
||||
interface IApplication {
|
||||
@@ -85,48 +78,3 @@ export const enum Application {
|
||||
OVERSEERR,
|
||||
TAUTULLI,
|
||||
}
|
||||
|
||||
const ConstantsContext = createContext({
|
||||
constants: {} as Iconstants,
|
||||
setConstants: (constants: Iconstants) => {},
|
||||
removeConstants: () => {},
|
||||
})
|
||||
|
||||
export function ConstantsContextProvider(props: {
|
||||
children:
|
||||
| boolean
|
||||
| ReactElement<any>
|
||||
| number
|
||||
| string
|
||||
| Iterable<ReactNode>
|
||||
| ReactPortal
|
||||
| null
|
||||
| undefined
|
||||
}) {
|
||||
const [constants, setConstants] = useState<Iconstants>({ applications: null })
|
||||
|
||||
function setConstantsHandler(constants: Iconstants) {
|
||||
setConstants(constants)
|
||||
}
|
||||
function removeConstantsHandler() {
|
||||
setConstants({} as Iconstants)
|
||||
}
|
||||
|
||||
const context: {
|
||||
constants: Iconstants
|
||||
setConstants: (constants: Iconstants) => void
|
||||
removeConstants: () => void
|
||||
} = {
|
||||
constants: constants,
|
||||
setConstants: setConstantsHandler,
|
||||
removeConstants: removeConstantsHandler,
|
||||
}
|
||||
|
||||
return (
|
||||
<ConstantsContext.Provider value={context}>
|
||||
{props.children}
|
||||
</ConstantsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConstantsContext
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
ReactPortal,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
export interface ISettings {
|
||||
id: number
|
||||
clientId: string
|
||||
applicationTitle: string
|
||||
applicationUrl: string
|
||||
apikey: string
|
||||
overseerr_url: string
|
||||
locale: string
|
||||
plex_name: string
|
||||
plex_hostname: string
|
||||
plex_port: number
|
||||
plex_ssl: number
|
||||
plex_auth_token: string | null
|
||||
overseerr_api_key: string
|
||||
tautulli_url: string
|
||||
tautulli_api_key: string
|
||||
jellyseerr_url: string
|
||||
jellyseerr_api_key: string
|
||||
collection_handler_job_cron: string
|
||||
rules_handler_job_cron: string
|
||||
}
|
||||
|
||||
const SettingsContext = createContext({
|
||||
settings: {} as ISettings,
|
||||
addSettings: (settings: ISettings) => {},
|
||||
removeSettings: () => {},
|
||||
})
|
||||
|
||||
export function SettingsContextProvider(props: {
|
||||
children:
|
||||
| boolean
|
||||
| ReactElement<any>
|
||||
| number
|
||||
| string
|
||||
| Iterable<ReactNode>
|
||||
| ReactPortal
|
||||
| null
|
||||
| undefined
|
||||
}) {
|
||||
const [settings, setSettings] = useState<ISettings>({} as ISettings)
|
||||
|
||||
function addSettingsHandler(settings: ISettings) {
|
||||
setSettings(() => {
|
||||
return settings
|
||||
})
|
||||
}
|
||||
function removeSettingsHandler() {
|
||||
setSettings(() => {
|
||||
return {} as ISettings
|
||||
})
|
||||
}
|
||||
|
||||
const context: {
|
||||
settings: ISettings
|
||||
addSettings: (settings: ISettings) => void
|
||||
removeSettings: () => void
|
||||
} = {
|
||||
settings: settings,
|
||||
addSettings: addSettingsHandler,
|
||||
removeSettings: removeSettingsHandler,
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={context}>
|
||||
{props.children}
|
||||
</SettingsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsContext
|
||||
26
ui/src/main.tsx
Normal file
26
ui/src/main.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import 'react-toastify/dist/ReactToastify.css'
|
||||
import '../styles/globals.css'
|
||||
import { EventsProvider } from './contexts/events-context'
|
||||
import { SearchContextProvider } from './contexts/search-context'
|
||||
import { TaskStatusProvider } from './contexts/taskstatus-context'
|
||||
import { router } from './router'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EventsProvider>
|
||||
<TaskStatusProvider>
|
||||
<SearchContextProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SearchContextProvider>
|
||||
</TaskStatusProvider>
|
||||
</EventsProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
122
ui/src/pages/CollectionDetailPage.tsx
Normal file
122
ui/src/pages/CollectionDetailPage.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { PlayIcon } from '@heroicons/react/solid'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ICollection } from '../components/Collection'
|
||||
import TestMediaItem from '../components/Collection/CollectionDetail/TestMediaItem'
|
||||
import LoadingSpinner from '../components/Common/LoadingSpinner'
|
||||
import TabbedLinks, { TabbedRoute } from '../components/Common/TabbedLinks'
|
||||
import GetApiHandler from '../utils/ApiHandler'
|
||||
|
||||
const CollectionDetailPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [collection, setCollection] = useState<ICollection | undefined>()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [mediaTestModalOpen, setMediaTestModalOpen] = useState<boolean>(false)
|
||||
|
||||
// Determine current tab from URL path
|
||||
const getCurrentTab = () => {
|
||||
const path = location.pathname
|
||||
if (path.endsWith('/exclusions')) return 'exclusions'
|
||||
if (path.endsWith('/info')) return 'info'
|
||||
return 'media'
|
||||
}
|
||||
|
||||
const currentTab = getCurrentTab()
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
GetApiHandler(`/collections/collection/${id}`)
|
||||
.then((resp) => {
|
||||
setCollection(resp)
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load collection:', err)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const tabbedRoutes: TabbedRoute[] = [
|
||||
{
|
||||
text: 'Media',
|
||||
route: 'media',
|
||||
},
|
||||
{
|
||||
text: 'Exclusions',
|
||||
route: 'exclusions',
|
||||
},
|
||||
{
|
||||
text: 'Info',
|
||||
route: 'info',
|
||||
},
|
||||
]
|
||||
|
||||
const handleTabChange = (tab: string) => {
|
||||
if (tab === 'media') {
|
||||
navigate(`/collections/${id}`)
|
||||
} else {
|
||||
navigate(`/collections/${id}/${tab}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading || !collection) {
|
||||
return (
|
||||
<>
|
||||
<title>Collection - Maintainerr</title>
|
||||
<LoadingSpinner />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{collection.title} - Maintainerr</title>
|
||||
<div className="w-full">
|
||||
<div className="m-auto mb-3 flex w-full">
|
||||
<h1 className="flex w-full justify-center overflow-hidden overflow-ellipsis whitespace-nowrap text-lg font-bold text-zinc-200 sm:m-0 sm:justify-start xl:m-0">
|
||||
{collection.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="mb-4 mt-0 w-fit sm:w-full">
|
||||
<TabbedLinks
|
||||
onChange={handleTabChange}
|
||||
routes={tabbedRoutes}
|
||||
currentRoute={currentTab}
|
||||
allEnabled={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center sm:justify-start">
|
||||
<button
|
||||
className="edit-button mb-4 flex h-9 rounded text-zinc-200 shadow-md"
|
||||
onClick={() => setMediaTestModalOpen(true)}
|
||||
>
|
||||
{<PlayIcon className="m-auto ml-5 h-5" />}{' '}
|
||||
<p className="rules-button-text m-auto ml-1 mr-5">Test Media</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Outlet context={{ collection }} />
|
||||
</div>
|
||||
|
||||
{mediaTestModalOpen && collection?.id ? (
|
||||
<TestMediaItem
|
||||
collectionId={+collection.id}
|
||||
onCancel={() => {
|
||||
setMediaTestModalOpen(false)
|
||||
}}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CollectionDetailPage
|
||||
20
ui/src/pages/CollectionExclusionsPage.tsx
Normal file
20
ui/src/pages/CollectionExclusionsPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { ICollection } from '../components/Collection'
|
||||
import CollectionExclusions from '../components/Collection/CollectionDetail/Exclusions'
|
||||
|
||||
interface CollectionContextType {
|
||||
collection: ICollection
|
||||
}
|
||||
|
||||
const CollectionExclusionsPage = () => {
|
||||
const { collection } = useOutletContext<CollectionContextType>()
|
||||
|
||||
return (
|
||||
<CollectionExclusions
|
||||
collection={collection}
|
||||
libraryId={collection.libraryId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CollectionExclusionsPage
|
||||
15
ui/src/pages/CollectionInfoPage.tsx
Normal file
15
ui/src/pages/CollectionInfoPage.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { ICollection } from '../components/Collection'
|
||||
import CollectionInfo from '../components/Collection/CollectionDetail/CollectionInfo'
|
||||
|
||||
interface CollectionContextType {
|
||||
collection: ICollection
|
||||
}
|
||||
|
||||
const CollectionInfoPage = () => {
|
||||
const { collection } = useOutletContext<CollectionContextType>()
|
||||
|
||||
return <CollectionInfo collection={collection} />
|
||||
}
|
||||
|
||||
export default CollectionInfoPage
|
||||
127
ui/src/pages/CollectionMediaPage.tsx
Normal file
127
ui/src/pages/CollectionMediaPage.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { debounce } from 'lodash-es'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useOutletContext, useParams } from 'react-router-dom'
|
||||
import { ICollection, ICollectionMedia } from '../components/Collection'
|
||||
import GetApiHandler from '../utils/ApiHandler'
|
||||
import OverviewContent, { IPlexMetadata } from '../components/Overview/Content'
|
||||
|
||||
interface CollectionContextType {
|
||||
collection: ICollection
|
||||
}
|
||||
|
||||
const CollectionMediaPage = () => {
|
||||
const { collection } = useOutletContext<CollectionContextType>()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [data, setData] = useState<IPlexMetadata[]>([])
|
||||
const [media, setMedia] = useState<ICollectionMedia[]>([])
|
||||
// paging
|
||||
const pageData = useRef<number>(0)
|
||||
const fetchAmount = 25
|
||||
const [totalSize, setTotalSize] = useState<number>(999)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const [isLoadingExtra, setIsLoadingExtra] = useState<boolean>(false)
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
const [pageDataCount, setPageDataCount] = useState(0)
|
||||
|
||||
const handleScroll = () => {
|
||||
if (
|
||||
window.innerHeight + document.documentElement.scrollTop >=
|
||||
document.documentElement.scrollHeight * 0.9
|
||||
) {
|
||||
if (
|
||||
!isLoading &&
|
||||
!isLoadingExtra &&
|
||||
!(fetchAmount * (pageData.current - 1) >= totalSize)
|
||||
) {
|
||||
setPage(pageData.current + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== 0) {
|
||||
// Ignore initial page render
|
||||
pageData.current = pageData.current + 1
|
||||
setPageDataCount(pageData.current)
|
||||
fetchData()
|
||||
}
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedScroll = debounce(handleScroll, 200)
|
||||
window.addEventListener('scroll', debouncedScroll)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', debouncedScroll)
|
||||
debouncedScroll.cancel() // Cancel pending debounced calls
|
||||
}
|
||||
}, [isLoading, isLoadingExtra, totalSize])
|
||||
|
||||
useEffect(() => {
|
||||
// Initial first fetch
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!isLoading) {
|
||||
setIsLoadingExtra(true)
|
||||
}
|
||||
const resp: { totalSize: number; items: ICollectionMedia[] } =
|
||||
await GetApiHandler(
|
||||
`/collections/media/${id}/content/${pageData.current}?size=${fetchAmount}`,
|
||||
)
|
||||
|
||||
setTotalSize(resp.totalSize)
|
||||
setMedia((prevMedia) => [...prevMedia, ...resp.items])
|
||||
|
||||
setData((prevData) => [
|
||||
...prevData,
|
||||
...resp.items.map((el) => {
|
||||
el.plexData!.maintainerrIsManual = el.isManual ? el.isManual : false
|
||||
return el.plexData ? el.plexData : ({} as IPlexMetadata)
|
||||
}),
|
||||
])
|
||||
setIsLoading(false)
|
||||
setIsLoadingExtra(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// If page is not filled yet, fetch more
|
||||
if (
|
||||
!isLoading &&
|
||||
!isLoadingExtra &&
|
||||
window.innerHeight + document.documentElement.scrollTop >=
|
||||
document.documentElement.scrollHeight * 0.9 &&
|
||||
!(fetchAmount * (pageData.current - 1) >= totalSize)
|
||||
) {
|
||||
setPage(page + 1)
|
||||
}
|
||||
}, [data, isLoading, isLoadingExtra, totalSize])
|
||||
|
||||
return (
|
||||
<OverviewContent
|
||||
dataFinished={true}
|
||||
fetchData={() => {}}
|
||||
loading={isLoading}
|
||||
data={data}
|
||||
libraryId={collection.libraryId}
|
||||
collectionPage={true}
|
||||
extrasLoading={
|
||||
isLoadingExtra && !isLoading && totalSize >= pageDataCount * fetchAmount
|
||||
}
|
||||
onRemove={(id: string) =>
|
||||
setTimeout(() => {
|
||||
setData((prevData) => prevData.filter((el) => +el.ratingKey !== +id))
|
||||
setMedia((prevMedia) => prevMedia.filter((el) => +el.plexId !== +id))
|
||||
}, 500)
|
||||
}
|
||||
collectionInfo={media.map((el) => {
|
||||
collection.media = []
|
||||
el.collection = collection
|
||||
return el
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CollectionMediaPage
|
||||
76
ui/src/pages/CollectionsListPage.tsx
Normal file
76
ui/src/pages/CollectionsListPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { AxiosError } from 'axios'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { ICollection } from '../components/Collection'
|
||||
import CollectionOverview from '../components/Collection/CollectionOverview'
|
||||
import LoadingSpinner from '../components/Common/LoadingSpinner'
|
||||
import GetApiHandler, { PostApiHandler } from '../utils/ApiHandler'
|
||||
|
||||
const CollectionsListPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [collections, setCollections] = useState<ICollection[]>()
|
||||
|
||||
const getCollections = async (libraryId?: number) => {
|
||||
const colls: ICollection[] = libraryId
|
||||
? await GetApiHandler(`/collections?libraryId=${libraryId}`)
|
||||
: await GetApiHandler('/collections')
|
||||
setCollections(colls)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getCollections()
|
||||
}, [])
|
||||
|
||||
const onSwitchLibrary = (id: number) => {
|
||||
getCollections(id != 9999 ? id : undefined)
|
||||
}
|
||||
|
||||
const doActions = async () => {
|
||||
try {
|
||||
await PostApiHandler(`/collections/handle`, {})
|
||||
|
||||
toast.success('Initiated collection handling in the background.')
|
||||
} catch (e) {
|
||||
if (e instanceof AxiosError) {
|
||||
if (e.response?.status === 409) {
|
||||
toast.error('Collection handling is already running.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
toast.error('Failed to initiate collection handling.')
|
||||
}
|
||||
}
|
||||
|
||||
const openDetail = (collection: ICollection) => {
|
||||
navigate(`/collections/${collection.id}`)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<title>Collections - Maintainerr</title>
|
||||
<LoadingSpinner />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Collections - Maintainerr</title>
|
||||
<div className="w-full">
|
||||
<CollectionOverview
|
||||
onSwitchLibrary={onSwitchLibrary}
|
||||
collections={collections}
|
||||
doActions={doActions}
|
||||
openDetail={openDetail}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CollectionsListPage
|
||||
@@ -1,12 +1,11 @@
|
||||
import { NextPage } from 'next'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const DocsPage: NextPage = () => {
|
||||
const DocsPage = () => {
|
||||
useEffect(() => {
|
||||
window.location.href = 'https://docs.maintainerr.info/latest/Introduction'
|
||||
}, [])
|
||||
|
||||
return <></>
|
||||
return <div className="text-white">Redirecting to documentation...</div>
|
||||
}
|
||||
|
||||
export default DocsPage
|
||||
12
ui/src/pages/PlexLoadingPage.tsx
Normal file
12
ui/src/pages/PlexLoadingPage.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import LoadingSpinner from '../components/Common/LoadingSpinner'
|
||||
|
||||
const PlexLoadingPage: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlexLoadingPage
|
||||
54
ui/src/pages/RuleFormPage.tsx
Normal file
54
ui/src/pages/RuleFormPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useRuleGroup } from '../api/rules'
|
||||
import LoadingSpinner from '../components/Common/LoadingSpinner'
|
||||
import AddModal from '../components/Rules/RuleGroup/AddModal'
|
||||
|
||||
const RuleFormPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { data, isLoading, error } = useRuleGroup(id)
|
||||
|
||||
const handleSuccess = () => {
|
||||
navigate('/rules')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate('/rules')
|
||||
}
|
||||
|
||||
if (id && error) {
|
||||
return (
|
||||
<>
|
||||
<title>Edit rule - Maintainerr</title>
|
||||
<div className="m-4 rounded-md bg-red-500/10 p-4 text-red-300">
|
||||
<h2 className="mb-2 text-lg font-bold">Error loading rule data</h2>
|
||||
<p>{error.message}</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (id && (!data || isLoading)) {
|
||||
return (
|
||||
<>
|
||||
<title>Edit rule - Maintainerr</title>
|
||||
<LoadingSpinner />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const pageTitle = `${id ? 'Edit' : 'New'} rule - Maintainerr`
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{pageTitle}</title>
|
||||
<AddModal
|
||||
onSuccess={handleSuccess}
|
||||
editData={data}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RuleFormPage
|
||||
@@ -1,21 +1,18 @@
|
||||
import { AxiosError } from 'axios'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { ConstantsContextProvider } from '../../contexts/constants-context'
|
||||
import { useTaskStatusContext } from '../../contexts/taskstatus-context'
|
||||
import GetApiHandler, { PostApiHandler } from '../../utils/ApiHandler'
|
||||
import AddButton from '../Common/AddButton'
|
||||
import ExecuteButton from '../Common/ExecuteButton'
|
||||
import LibrarySwitcher from '../Common/LibrarySwitcher'
|
||||
import LoadingSpinner from '../Common/LoadingSpinner'
|
||||
import RuleGroup, { IRuleGroup } from './RuleGroup'
|
||||
import AddModal from './RuleGroup/AddModal'
|
||||
import AddButton from '../components/Common/AddButton'
|
||||
import ExecuteButton from '../components/Common/ExecuteButton'
|
||||
import LibrarySwitcher from '../components/Common/LibrarySwitcher'
|
||||
import LoadingSpinner from '../components/Common/LoadingSpinner'
|
||||
import RuleGroup, { IRuleGroup } from '../components/Rules/RuleGroup'
|
||||
import { useTaskStatusContext } from '../contexts/taskstatus-context'
|
||||
import GetApiHandler, { PostApiHandler } from '../utils/ApiHandler'
|
||||
|
||||
const Rules = () => {
|
||||
const [addModalActive, setAddModal] = useState(false)
|
||||
const [editModalActive, setEditModal] = useState(false)
|
||||
const [data, setData] = useState()
|
||||
const [editData, setEditData] = useState<IRuleGroup>()
|
||||
const RulesListPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<IRuleGroup[]>()
|
||||
const [selectedLibrary, setSelectedLibrary] = useState<number>(9999)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { ruleHandlerRunning } = useTaskStatusContext()
|
||||
@@ -26,7 +23,6 @@ const Rules = () => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Maintainerr - Rules'
|
||||
fetchData().then((resp) => {
|
||||
setData(resp)
|
||||
setIsLoading(false)
|
||||
@@ -37,23 +33,16 @@ const Rules = () => {
|
||||
refreshData()
|
||||
}, [selectedLibrary])
|
||||
|
||||
const showAddModal = () => {
|
||||
setAddModal(!addModalActive)
|
||||
}
|
||||
|
||||
const onSwitchLibrary = (libraryId: number) => {
|
||||
setSelectedLibrary(libraryId)
|
||||
}
|
||||
|
||||
const refreshData = (): void => {
|
||||
fetchData().then((resp) => setData(resp))
|
||||
setAddModal(false)
|
||||
setEditModal(false)
|
||||
}
|
||||
|
||||
const editHandler = (group: IRuleGroup): void => {
|
||||
setEditData(group)
|
||||
setEditModal(true)
|
||||
navigate(`/rules/edit/${group.id}`)
|
||||
}
|
||||
|
||||
const sync = async () => {
|
||||
@@ -85,47 +74,24 @@ const Rules = () => {
|
||||
|
||||
if (!data || isLoading) {
|
||||
return (
|
||||
<span>
|
||||
<LoadingSpinner />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (addModalActive) {
|
||||
return (
|
||||
<ConstantsContextProvider>
|
||||
<AddModal
|
||||
onSuccess={refreshData}
|
||||
onCancel={() => {
|
||||
setAddModal(false)
|
||||
}}
|
||||
/>
|
||||
</ConstantsContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (editModalActive) {
|
||||
return (
|
||||
<ConstantsContextProvider>
|
||||
<AddModal
|
||||
onSuccess={refreshData}
|
||||
editData={editData}
|
||||
onCancel={() => {
|
||||
setEditModal(false)
|
||||
}}
|
||||
/>
|
||||
</ConstantsContextProvider>
|
||||
<>
|
||||
<title>Rules - Maintainerr</title>
|
||||
<span>
|
||||
<LoadingSpinner />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Rules - Maintainerr</title>
|
||||
<div className="w-full">
|
||||
<LibrarySwitcher onSwitch={onSwitchLibrary} />
|
||||
<LibrarySwitcher onLibraryChange={onSwitchLibrary} />
|
||||
|
||||
<div className="m-auto mb-3 flex">
|
||||
<div className="ml-auto sm:ml-0">
|
||||
<AddButton onClick={showAddModal} text="New Rule" />
|
||||
<AddButton onClick={() => navigate('/rules/new')} text="New Rule" />
|
||||
</div>
|
||||
<div className="ml-2 mr-auto sm:mr-0">
|
||||
<ExecuteButton
|
||||
@@ -143,7 +109,7 @@ const Rules = () => {
|
||||
</div>
|
||||
<h1 className="mb-3 text-lg font-bold text-zinc-200">{'Rules'}</h1>
|
||||
<ul className="xs:collection-cards-vertical">
|
||||
{(data as IRuleGroup[]).map((el) => (
|
||||
{data.map((el) => (
|
||||
<li
|
||||
key={el.id}
|
||||
className="collection relative mb-5 flex h-fit transform-gpu flex-col rounded-xl bg-zinc-800 bg-cover bg-center p-4 text-zinc-400 shadow ring-1 ring-zinc-700 xs:w-full sm:mb-0 sm:mr-5"
|
||||
@@ -151,7 +117,7 @@ const Rules = () => {
|
||||
<RuleGroup
|
||||
onDelete={refreshData}
|
||||
onEdit={editHandler}
|
||||
group={el as IRuleGroup}
|
||||
group={el}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -161,4 +127,4 @@ const Rules = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default Rules
|
||||
export default RulesListPage
|
||||
@@ -1,42 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { AppProps } from 'next/app'
|
||||
import { ToastContainer } from 'react-toastify'
|
||||
import '../../styles/globals.css'
|
||||
import Layout from '../components/Layout'
|
||||
import { EventsProvider } from '../contexts/events-context'
|
||||
import { LibrariesContextProvider } from '../contexts/libraries-context'
|
||||
import { SearchContextProvider } from '../contexts/search-context'
|
||||
import { SettingsContextProvider } from '../contexts/settings-context'
|
||||
import { TaskStatusProvider } from '../contexts/taskstatus-context'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function CoreApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EventsProvider>
|
||||
<TaskStatusProvider>
|
||||
<SettingsContextProvider>
|
||||
<SearchContextProvider>
|
||||
<Layout>
|
||||
<LibrariesContextProvider>
|
||||
<ToastContainer
|
||||
stacked
|
||||
position="top-right"
|
||||
autoClose={4500}
|
||||
hideProgressBar={false}
|
||||
theme="dark"
|
||||
closeOnClick
|
||||
/>
|
||||
<Component {...pageProps} />
|
||||
</LibrariesContextProvider>
|
||||
</Layout>
|
||||
</SearchContextProvider>
|
||||
</SettingsContextProvider>
|
||||
</TaskStatusProvider>
|
||||
</EventsProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoreApp
|
||||
@@ -1,8 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import Collection from '../../components/Collection'
|
||||
|
||||
const collections: NextPage = () => {
|
||||
return <Collection />
|
||||
}
|
||||
|
||||
export default collections
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { NextPage } from 'next'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const Home: NextPage = () => {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.push('/overview')
|
||||
}, [router])
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
export default Home
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react'
|
||||
import LoadingSpinner from '../../../../components/Common/LoadingSpinner'
|
||||
|
||||
const PlexLoading: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlexLoading
|
||||
@@ -1,8 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import Overview from '../../components/Overview'
|
||||
|
||||
const overview: NextPage = () => {
|
||||
return <Overview />
|
||||
}
|
||||
|
||||
export default overview
|
||||
@@ -1,8 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import Rules from '../../components/Rules'
|
||||
|
||||
const rules: NextPage = () => {
|
||||
return <Rules />
|
||||
}
|
||||
|
||||
export default rules
|
||||
@@ -1,11 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsWrapper from '../../../components/Settings'
|
||||
import AboutSettings from '../../../components/Settings/About'
|
||||
const SettingsAbout: NextPage = () => {
|
||||
return (
|
||||
<SettingsWrapper>
|
||||
<AboutSettings />
|
||||
</SettingsWrapper>
|
||||
)
|
||||
}
|
||||
export default SettingsAbout
|
||||
@@ -1,8 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsLander from '../../components/Settings/Landing'
|
||||
|
||||
const Settings: NextPage = () => {
|
||||
return <SettingsLander />
|
||||
}
|
||||
|
||||
export default Settings
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsWrapper from '../../../components/Settings'
|
||||
import JellyseerrSettings from '../../../components/Settings/Jellyseerr'
|
||||
|
||||
const SettingsJellyseerr: NextPage = () => {
|
||||
return (
|
||||
<SettingsWrapper>
|
||||
<JellyseerrSettings />
|
||||
</SettingsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsJellyseerr
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsWrapper from '../../../components/Settings'
|
||||
import JobSettings from '../../../components/Settings/Jobs'
|
||||
|
||||
const SettingsJobs: NextPage = () => {
|
||||
return (
|
||||
<SettingsWrapper>
|
||||
<JobSettings />
|
||||
</SettingsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsJobs
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsWrapper from '../../../components/Settings'
|
||||
import LogSettings from '../../../components/Settings/Logs'
|
||||
|
||||
const SettingsLogs: NextPage = () => {
|
||||
return (
|
||||
<SettingsWrapper>
|
||||
<LogSettings />
|
||||
</SettingsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsLogs
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsWrapper from '../../../components/Settings'
|
||||
import MainSettings from '../../../components/Settings/Main'
|
||||
|
||||
const SettingsMain: NextPage = () => {
|
||||
return (
|
||||
<SettingsWrapper>
|
||||
<MainSettings></MainSettings>
|
||||
</SettingsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsMain
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsWrapper from '../../../components/Settings'
|
||||
import NotificationSettings from '../../../components/Settings/Notifications'
|
||||
|
||||
const SettingsOverseerr: NextPage = () => {
|
||||
return (
|
||||
<SettingsWrapper>
|
||||
<NotificationSettings />
|
||||
</SettingsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsOverseerr
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsWrapper from '../../../components/Settings'
|
||||
import OverseerrSettings from '../../../components/Settings/Overseerr'
|
||||
|
||||
const SettingsOverseerr: NextPage = () => {
|
||||
return (
|
||||
<SettingsWrapper>
|
||||
<OverseerrSettings />
|
||||
</SettingsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsOverseerr
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsWrapper from '../../../components/Settings'
|
||||
import PlexSettings from '../../../components/Settings/Plex'
|
||||
|
||||
const SettingsPlex: NextPage = () => {
|
||||
return (
|
||||
<SettingsWrapper>
|
||||
<PlexSettings />
|
||||
</SettingsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPlex
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsWrapper from '../../../components/Settings'
|
||||
import RadarrSettings from '../../../components/Settings/Radarr'
|
||||
|
||||
const SettingsRadarr: NextPage = () => {
|
||||
return (
|
||||
<SettingsWrapper>
|
||||
<RadarrSettings />
|
||||
</SettingsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsRadarr
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsWrapper from '../../../components/Settings'
|
||||
import SonarrSettings from '../../../components/Settings/Sonarr'
|
||||
|
||||
const SettingsSonarr: NextPage = () => {
|
||||
return (
|
||||
<SettingsWrapper>
|
||||
<SonarrSettings />
|
||||
</SettingsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsSonarr
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextPage } from 'next'
|
||||
import SettingsWrapper from '../../../components/Settings'
|
||||
import TautulliSettings from '../../../components/Settings/Tautulli'
|
||||
|
||||
const SettingsTautulli: NextPage = () => {
|
||||
return (
|
||||
<SettingsWrapper>
|
||||
<TautulliSettings />
|
||||
</SettingsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsTautulli
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user