feat: Migrate UI from Next.js to Vite + React Router (#2100)

This commit is contained in:
Ben Scobie
2025-12-01 23:26:42 +00:00
committed by GitHub
parent f318a66174
commit a9474d4569
108 changed files with 4409 additions and 3907 deletions

View File

@@ -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

View File

@@ -14,10 +14,6 @@ updates:
- "react-dom"
- "@types/react"
- "@types/react-dom"
next:
patterns:
- "next"
- "eslint-config-next"
eslint:
patterns:
- "@eslint/js"

View File

@@ -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

View File

@@ -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'

View File

@@ -0,0 +1 @@
export * from './types'

View File

@@ -0,0 +1,6 @@
export enum EPlexDataType {
MOVIES = 1,
SHOWS = 2,
SEASONS = 3,
EPISODES = 4,
}

View 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.',
);
}
}

View File

@@ -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 (

View File

@@ -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 {}

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { SettingDto } from './setting.dto';
export class UpdateSettingDto extends PartialType(SettingDto) {}

View File

@@ -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);

View File

@@ -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://') ||

View File

@@ -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
View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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
View File

@@ -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.

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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>

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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',

View File

@@ -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 ?? <>&nbsp;</>}
</p>

View File

@@ -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">

View File

@@ -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

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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}

View File

@@ -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">

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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`}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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.')
})
}

View File

@@ -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 &&

View File

@@ -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"

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 &rarr;
</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 &rarr;
</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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
</>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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}`}

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
View 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>,
)

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -1,8 +0,0 @@
import { NextPage } from 'next'
import Collection from '../../components/Collection'
const collections: NextPage = () => {
return <Collection />
}
export default collections

View File

@@ -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

View File

@@ -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

View File

@@ -1,8 +0,0 @@
import { NextPage } from 'next'
import Overview from '../../components/Overview'
const overview: NextPage = () => {
return <Overview />
}
export default overview

View File

@@ -1,8 +0,0 @@
import { NextPage } from 'next'
import Rules from '../../components/Rules'
const rules: NextPage = () => {
return <Rules />
}
export default rules

View File

@@ -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

View File

@@ -1,8 +0,0 @@
import { NextPage } from 'next'
import SettingsLander from '../../components/Settings/Landing'
const Settings: NextPage = () => {
return <SettingsLander />
}
export default Settings

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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