Compare commits

...

8 Commits

Author SHA1 Message Date
211db3d4d5 Only make 1 request to an endpoint concurrently 2025-03-20 00:58:12 +01:00
a3046680ac Update Job-Footer only every 5 seconds 2025-03-20 00:57:53 +01:00
1ed376ba47 Do not update Queue-preview when not shown 2025-03-20 00:57:23 +01:00
63b10600da README 2025-03-20 00:43:58 +01:00
5333c025f7 Fix loaders 2025-03-20 00:41:35 +01:00
187dd22027 Backend Settings 2025-03-20 00:07:20 +01:00
2092db2ba3 Local Libraries in Settings 2025-03-19 22:44:11 +01:00
ecd76712d8 Add all types of Notification Connectors to settings 2025-03-19 22:01:07 +01:00
29 changed files with 595 additions and 122 deletions

View File

@ -42,9 +42,9 @@
|----------------------------------------------------------------------------|----------------------------------------------------------------------------|----------------------------------------------------------------------------| |----------------------------------------------------------------------------|----------------------------------------------------------------------------|----------------------------------------------------------------------------|
| ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-41-51%20Tranga.png) | ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-42-02%20Tranga.png) | ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-42-12%20Tranga.png) | | ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-41-51%20Tranga.png) | ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-42-02%20Tranga.png) | ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-42-12%20Tranga.png) |
| | Rudimentary Settings | | | | Settings Dialog | |
|-|----------------------------------------------------------------------------|-| |-|----------------------------------------------------------------------------|-|
| | ![Image](Screenshots/Screenshot%202025-03-19%20at%2002-42-21%20Tranga.png) | | | | ![Image](Screenshots/Screenshot%202025-03-20%20at%2000-42-58%20Tranga.png) | |
## About The Project ## About The Project

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View File

@ -16,18 +16,16 @@ export default function App(){
const [updateInterval, setUpdateInterval] = React.useState<number | undefined>(undefined); const [updateInterval, setUpdateInterval] = React.useState<number | undefined>(undefined);
const checkConnectedInterval = 1000; const checkConnectedInterval = 1000;
const apiUri = frontendSettings.apiUri;
useEffect(() => { useEffect(() => {
setCookie('apiUri', frontendSettings.apiUri); setCookie('apiUri', frontendSettings.apiUri);
setCookie('jobInterval', frontendSettings.jobInterval); setCookie('jobInterval', frontendSettings.jobInterval);
updateConnected(apiUri, connected, setConnected); updateConnected(frontendSettings.apiUri, connected, setConnected);
}, [frontendSettings]); }, [frontendSettings]);
useEffect(() => { useEffect(() => {
if(updateInterval === undefined){ if(updateInterval === undefined){
setUpdateInterval(setInterval(() => { setUpdateInterval(setInterval(() => {
updateConnected(apiUri, connected, setConnected); updateConnected(frontendSettings.apiUri, connected, setConnected);
}, checkConnectedInterval)); }, checkConnectedInterval));
}else{ }else{
clearInterval(updateInterval); clearInterval(updateInterval);
@ -36,23 +34,23 @@ export default function App(){
}, [connected]); }, [connected]);
return(<div> return(<div>
<Header apiUri={apiUri} backendConnected={connected} settings={frontendSettings} setFrontendSettings={setFrontendSettings} /> <Header apiUri={frontendSettings.apiUri} backendConnected={connected} settings={frontendSettings} setFrontendSettings={setFrontendSettings} />
{connected {connected
? <> ? <>
{showSearch {showSearch
? <> ? <>
<Search apiUri={apiUri} jobInterval={frontendSettings.jobInterval} closeSearch={() => setShowSearch(false)} /> <Search apiUri={frontendSettings.apiUri} jobInterval={frontendSettings.jobInterval} closeSearch={() => setShowSearch(false)} />
<hr/> <hr/>
</> </>
: <></>} : <></>}
<MonitorJobsList apiUri={apiUri} onStartSearch={() => setShowSearch(true)} connectedToBackend={connected} checkConnectedInterval={checkConnectedInterval} /> <MonitorJobsList apiUri={frontendSettings.apiUri} onStartSearch={() => setShowSearch(true)} connectedToBackend={connected} checkConnectedInterval={checkConnectedInterval} />
</> </>
: <> : <>
<h1>No connection to the Backend.</h1> <h1>No connection to the Backend.</h1>
<h3>Check the Settings ApiUri.</h3> <h3>Check the Settings ApiUri.</h3>
<Loader loading={true} /> <Loader loading={true} />
</>} </>}
<Footer apiUri={apiUri} connectedToBackend={connected} checkConnectedInterval={checkConnectedInterval} /> <Footer apiUri={frontendSettings.apiUri} connectedToBackend={connected} checkConnectedInterval={checkConnectedInterval} />
</div>) </div>)
} }
@ -60,7 +58,7 @@ export function getData(uri: string) : Promise<object> {
return makeRequest("GET", uri, null) as Promise<object>; return makeRequest("GET", uri, null) as Promise<object>;
} }
export function postData(uri: string, content: object | string | number) : Promise<object> { export function postData(uri: string, content: object | string | number | boolean) : Promise<object> {
return makeRequest("POST", uri, content) as Promise<object>; return makeRequest("POST", uri, content) as Promise<object>;
} }
@ -68,15 +66,20 @@ export function deleteData(uri: string) : Promise<void> {
return makeRequest("DELETE", uri, null) as Promise<void>; return makeRequest("DELETE", uri, null) as Promise<void>;
} }
export function patchData(uri: string, content: object | string | number) : Promise<object> { export function patchData(uri: string, content: object | string | number | boolean) : Promise<object> {
return makeRequest("patch", uri, content) as Promise<object>; return makeRequest("patch", uri, content) as Promise<object>;
} }
export function putData(uri: string, content: object | string | number) : Promise<object> { export function putData(uri: string, content: object | string | number | boolean) : Promise<object> {
return makeRequest("PUT", uri, content) as Promise<object>; return makeRequest("PUT", uri, content) as Promise<object>;
} }
function makeRequest(method: string, uri: string, content: object | string | number | null) : Promise<object | void> { let currentlyRequestedEndpoints: string[] = [];
function makeRequest(method: string, uri: string, content: object | string | number | null | boolean) : Promise<object | void> {
const id = method + uri;
if(currentlyRequestedEndpoints.find(x => x == id) != undefined)
return Promise.reject(`Already requested: ${method} ${uri}`);
currentlyRequestedEndpoints.push(id);
return fetch(uri, return fetch(uri,
{ {
method: method, method: method,
@ -115,7 +118,7 @@ function makeRequest(method: string, uri: string, content: object | string | num
.catch(function(err : Error){ .catch(function(err : Error){
console.error(`Error ${method}ing Data ${uri}\n${err}`); console.error(`Error ${method}ing Data ${uri}\n${err}`);
return Promise.reject(); return Promise.reject();
}); }).finally(() => currentlyRequestedEndpoints.splice(currentlyRequestedEndpoints.indexOf(id), 1));
} }
export function isValidUri(uri: string) : boolean{ export function isValidUri(uri: string) : boolean{
@ -141,8 +144,8 @@ export const checkConnection = async (apiUri: string): Promise<boolean> =>{
{ {
method: 'GET', method: 'GET',
}) })
.then((response) =>{ .then((response) => {
return response.type != "error"; return response.ok;
}) })
.catch(() => { .catch(() => {
return Promise.reject(); return Promise.reject();

View File

@ -0,0 +1,61 @@
import {deleteData, getData, patchData} from "../App";
import IRequestLimits, {RequestType} from "./interfaces/IRequestLimits";
import IBackendSettings from "./interfaces/IBackendSettings";
export default class BackendSettings {
static async GetSettings(apiUri: string) : Promise<IBackendSettings> {
return getData(`${apiUri}/v2/Settings`).then((s) => s as IBackendSettings);
}
static async GetUserAgent(apiUri: string) : Promise<string> {
return getData(`${apiUri}/v2/Settings/UserAgent`).then((text) => text as unknown as string);
}
static async UpdateUserAgent(apiUri: string, userAgent: string) {
return patchData(`${apiUri}/v2/Settings/UserAgent`, userAgent);
}
static async ResetUserAgent(apiUri: string) {
return deleteData(`${apiUri}/v2/Settings/UserAgent`);
}
static async GetRequestLimits(apiUri: string) : Promise<IRequestLimits> {
return getData(`${apiUri}/v2/Settings/RequestLimits`).then((limits) => limits as IRequestLimits);
}
static async ResetRequestLimits(apiUri: string) {
return deleteData(`${apiUri}/v2/Settings/RequestLimits`);
}
static async UpdateRequestLimit(apiUri: string, requestType: RequestType, value: number) {
return patchData(`${apiUri}/v2/Settings/RequestLimits/${requestType}`, value);
}
static async ResetRequestLimit(apiUri: string, requestType: RequestType) {
return deleteData(`${apiUri}/v2/Settings/RequestLimits/${requestType}`);
}
static async GetImageCompressionValue(apiUri: string) : Promise<number> {
return getData(`${apiUri}/v2/Settings/ImageCompression`).then((n) => n as unknown as number);
}
static async UpdateImageCompressionValue(apiUri: string, value: number) {
return patchData(`${apiUri}/v2/Settings/ImageCompression`, value);
}
static async GetBWImageToggle(apiUri: string) : Promise<boolean> {
return getData(`${apiUri}/v2/Settings/BWImages`).then((state) => state as unknown as boolean);
}
static async UpdateBWImageToggle(apiUri: string, value: boolean) {
return patchData(`${apiUri}/v2/Settings/BWImages`, value);
}
static async GetAprilFoolsToggle(apiUri: string) : Promise<boolean> {
return getData(`${apiUri}/v2/Settings/AprilFoolsMode`).then((state) => state as unknown as boolean);
}
static async UpdateAprilFoolsToggle(apiUri: string, value: boolean) {
return patchData(`${apiUri}/v2/Settings/AprilFoolsMode`, value);
}
}

View File

@ -26,7 +26,7 @@ export default function Footer({connectedToBackend, apiUri, checkConnectedInterv
if(countUpdateInterval === undefined){ if(countUpdateInterval === undefined){
setCountUpdateInterval(setInterval(() => { setCountUpdateInterval(setInterval(() => {
UpdateBackendState(); UpdateBackendState();
}, checkConnectedInterval)); }, checkConnectedInterval * 5));
} }
}else{ }else{
clearInterval(countUpdateInterval); clearInterval(countUpdateInterval);

View File

@ -47,4 +47,11 @@ export default class LocalLibraryFunctions
return Promise.resolve() return Promise.resolve()
}); });
} }
static async UpdateLibrary(apiUri: string, libraryId: string, record: INewLibraryRecord): Promise<void> {
return patchData(`${apiUri}/v2/LocalLibraries/${libraryId}`, record)
.then(()=> {
return Promise.resolve()
});
}
} }

View File

@ -1,4 +1,4 @@
import React, {EventHandler, ReactElement, useEffect, useState} from 'react'; import React, {ReactElement, useEffect, useState} from 'react';
import JobFunctions from './JobFunctions'; import JobFunctions from './JobFunctions';
import '../styles/monitorMangaList.css'; import '../styles/monitorMangaList.css';
import {JobType} from "./interfaces/Jobs/IJob"; import {JobType} from "./interfaces/Jobs/IJob";

View File

@ -14,7 +14,7 @@ export default function QueuePopUp({connectedToBackend, children, apiUri, checkC
const [queueListInterval, setQueueListInterval] = React.useState<number | undefined>(undefined); const [queueListInterval, setQueueListInterval] = React.useState<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
if(connectedToBackend) { if(connectedToBackend && showQueuePopup) {
UpdateMonitoringJobsList(); UpdateMonitoringJobsList();
if(queueListInterval === undefined){ if(queueListInterval === undefined){
setQueueListInterval(setInterval(() => { setQueueListInterval(setInterval(() => {
@ -25,7 +25,7 @@ export default function QueuePopUp({connectedToBackend, children, apiUri, checkC
clearInterval(queueListInterval); clearInterval(queueListInterval);
setQueueListInterval(undefined); setQueueListInterval(undefined);
} }
}, [connectedToBackend]); }, [connectedToBackend, showQueuePopup]);
function UpdateMonitoringJobsList(){ function UpdateMonitoringJobsList(){
JobFunctions.GetJobsInState(apiUri, JobState.Waiting) JobFunctions.GetJobsInState(apiUri, JobState.Waiting)

View File

@ -127,7 +127,7 @@ export default function Search({apiUri, jobInterval, closeSearch} : {apiUri: str
: selectedConnector.supportedLanguages.map(language => <option value={language} key={language}>{language}</option>)} : selectedConnector.supportedLanguages.map(language => <option value={language} key={language}>{language}</option>)}
</select> </select>
<button id="Searchbox-button" type="submit" onClick={ExecuteSearch} disabled={loading}>Search</button> <button id="Searchbox-button" type="submit" onClick={ExecuteSearch} disabled={loading}>Search</button>
<Loader loading={loading} style={{width:"40px", height:"40px"}}/> <Loader loading={loading} style={{width:"40px", height:"40px", zIndex: 50}}/>
</div> </div>
<img alt="Close Search" id="closeSearch" src="../media/close-x.svg" onClick={closeSearch} /> <img alt="Close Search" id="closeSearch" src="../media/close-x.svg" onClick={closeSearch} />
<div id="SearchResults"> <div id="SearchResults">

View File

@ -1,20 +1,53 @@
import IFrontendSettings from "./interfaces/IFrontendSettings"; import IFrontendSettings from "./interfaces/IFrontendSettings";
import '../styles/settings.css'; import '../styles/settings.css';
import '../styles/react-toggle.css'; import '../styles/react-toggle.css';
import React, {useEffect, useState} from "react"; import React, {useEffect, useRef, useState} from "react";
import INotificationConnector, {NotificationConnectorItem} from "./interfaces/INotificationConnector"; import INotificationConnector, {NotificationConnectorItem} from "./interfaces/INotificationConnector";
import NotificationConnectorFunctions from "./NotificationConnectorFunctions"; import NotificationConnectorFunctions from "./NotificationConnectorFunctions";
import ILocalLibrary, {LocalLibraryItem} from "./interfaces/ILocalLibrary";
import LocalLibraryFunctions from "./LocalLibraryFunctions";
import IBackendSettings from "./interfaces/IBackendSettings";
import BackendSettings from "./BackendSettingsFunctions";
import Toggle from "react-toggle";
import Loader from "./Loader";
import {RequestType} from "./interfaces/IRequestLimits";
export default function Settings({backendConnected, apiUri, frontendSettings, setFrontendSettings} : {backendConnected: boolean, apiUri: string, frontendSettings: IFrontendSettings, setFrontendSettings: (settings: IFrontendSettings) => void}) { export default function Settings({ backendConnected, apiUri, frontendSettings, setFrontendSettings } : {
let [showSettings, setShowSettings] = useState<boolean>(false); backendConnected: boolean,
let [notificationConnectors, setNotificationConnectors] = useState<INotificationConnector[]>([]); apiUri: string,
frontendSettings: IFrontendSettings,
setFrontendSettings: (settings: IFrontendSettings) => void
}) {
const [showSettings, setShowSettings] = useState<boolean>(false);
const [loadingBackend, setLoadingBackend] = useState(false);
const [backendSettings, setBackendSettings] = useState<IBackendSettings|null>(null);
const [notificationConnectors, setNotificationConnectors] = useState<INotificationConnector[]>([]);
const [localLibraries, setLocalLibraries] = useState<ILocalLibrary[]>([]);
useEffect(() => { useEffect(() => {
if(!backendConnected) if(!backendConnected)
return; return;
NotificationConnectorFunctions.GetNotificationConnectors(apiUri).then(setNotificationConnectors); NotificationConnectorFunctions.GetNotificationConnectors(apiUri).then(setNotificationConnectors);
}, []); LocalLibraryFunctions.GetLibraries(apiUri).then(setLocalLibraries);
BackendSettings.GetSettings(apiUri).then(setBackendSettings);
}, [backendConnected, showSettings]);
const dateToStr = (x: Date) => {
const ret = (x.getHours() < 10 ? "0" + x.getHours() : x.getHours())
+ ":" +
(x.getMinutes() < 10 ? "0" + x.getMinutes() : x.getMinutes());
return ret;
}
const ChangeRequestLimit = (requestType: RequestType, limit: number) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateRequestLimit(apiUri, requestType, limit)
.then(() => setBackendSettings({...backendSettings, [requestType]: requestType}))
.finally(() => setLoadingBackend(false));
}
const ref : React.LegacyRef<HTMLInputElement> | undefined = useRef<HTMLInputElement>(null);
return ( return (
<div id="Settings"> <div id="Settings">
@ -28,25 +61,109 @@ export default function Settings({backendConnected, apiUri, frontendSettings, se
<img alt="Close Settings" className="close" src="../media/close-x.svg" onClick={() => setShowSettings(false)}/> <img alt="Close Settings" className="close" src="../media/close-x.svg" onClick={() => setShowSettings(false)}/>
</div> </div>
<div id="SettingsPopUpBody" className="popupBody"> <div id="SettingsPopUpBody" className="popupBody">
<Loader loading={loadingBackend} style={{width: "64px", height: "64px", margin: "25vh calc(sin(70)*(50% - 40px))", zIndex: 100, padding: 0, borderRadius: "50%", border: 0, minWidth: "initial", maxWidth: "initial"}}/>
<div className="settings-apiuri"> <div className="settings-apiuri">
<label>ApiUri</label> <h3>ApiUri</h3>
<input type="url" defaultValue={frontendSettings.apiUri} onChange={(e) => { <input type="url" defaultValue={frontendSettings.apiUri} onChange={(e) => setFrontendSettings({...frontendSettings, apiUri:e.currentTarget.value})} id="ApiUri" />
let newSettings = frontendSettings;
newSettings.apiUri = e.currentTarget.value;
setFrontendSettings(newSettings);
}} id="ApiUri" />
</div> </div>
<div className="settings-apiuri"> <div className="settings-jobinterval">
<label>Default Job-Interval</label> <h3>Default Job-Interval</h3>
<input type="time" defaultValue={new Date(frontendSettings.jobInterval).getTime()} onChange={(e) => { <input type="time" min="00:30" max="23:59" defaultValue={dateToStr(new Date(frontendSettings.jobInterval))} onChange={(e) => setFrontendSettings({...frontendSettings, jobInterval: new Date(e.currentTarget.valueAsNumber-60*60*1000) ?? frontendSettings.jobInterval})}/>
let newSettings = frontendSettings; </div>
newSettings.jobInterval = e.currentTarget.valueAsDate ?? frontendSettings.jobInterval; <div className="settings-bwimages">
setFrontendSettings(newSettings); <h3>B/W Images</h3>
console.log(frontendSettings); <Toggle defaultChecked={backendSettings ? backendSettings.bwImages : false} disabled={backendSettings ? false : !loadingBackend}
}}/> onChange={(e) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateBWImageToggle(apiUri, e.target.checked)
.then(() => setBackendSettings({...backendSettings, bwImages: e.target.checked}))
.finally(() => setLoadingBackend(false));
}} />
</div>
<div className="settings-aprilfools">
<h3>April Fools Mode</h3>
<Toggle defaultChecked={backendSettings ? backendSettings.aprilFoolsMode : false} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateAprilFoolsToggle(apiUri, e.target.checked)
.then(() => setBackendSettings({...backendSettings, aprilFoolsMode: e.target.checked}))
.finally(() => setLoadingBackend(false));
}} />
</div>
<div className="settings-imagecompression">
<h3>Image Compression</h3>
<Toggle defaultChecked={backendSettings ? backendSettings.compression < 100 : false} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateImageCompressionValue(apiUri, e.target.checked ? 40 : 100)
.then(() => setBackendSettings({...backendSettings, compression: e.target.checked ? 40 : 100}))
.then(() => {
if(ref.current != null){
ref.current.value = e.target.checked ? "40" : "100";
ref.current.disabled = !e.target.checked;
}
})
.finally(() => setLoadingBackend(false));
}} />
<input ref={ref} type="number" min={0} max={100} defaultValue={backendSettings ? backendSettings.compression : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateImageCompressionValue(apiUri, e.currentTarget.valueAsNumber)
.then(() => setBackendSettings({...backendSettings, compression: e.currentTarget.valueAsNumber}))
.finally(() => setLoadingBackend(false));
}} />
</div>
<div className="settings-useragent">
<h3>User Agent</h3>
<input type="text" defaultValue={backendSettings ? backendSettings.userAgent : ""}
onChange={(e) => {
if(backendSettings === null)
return;
setLoadingBackend(true);
BackendSettings.UpdateUserAgent(apiUri, e.currentTarget.value)
.then(() => setBackendSettings({...backendSettings, userAgent: e.currentTarget.value}))
.finally(() => setLoadingBackend(false));
}} />
</div>
<div className="settings-requestLimits">
<h3>Request Limits:</h3>
<label htmlFor="Default">Default</label>
<input id="Default" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.Default : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.Default, e.currentTarget.valueAsNumber)} />
<label htmlFor="MangaInfo">MangaInfo</label>
<input id="MangaInfo" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.MangaInfo : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.MangaInfo, e.currentTarget.valueAsNumber)} />
<label htmlFor="MangaDexFeed">MangaDexFeed</label>
<input id="MangaDexFeed" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.MangaDexFeed : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.MangaDexFeed, e.currentTarget.valueAsNumber)} />
<label htmlFor="MangaDexImage">MangaDexImage</label>
<input id="MangaDexImage" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.MangaDexImage : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.MangaDexImage, e.currentTarget.valueAsNumber)} />
<label htmlFor="MangaImage">MangaImage</label>
<input id="MangaImage" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.MangaImage : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.MangaImage, e.currentTarget.valueAsNumber)} />
<label htmlFor="MangaCover">MangaCover</label>
<input id="MangaCover" type="number" defaultValue={backendSettings ? backendSettings.requestLimits.MangaCover : 0} disabled={backendSettings ? false : !loadingBackend}
onChange={(e) => ChangeRequestLimit(RequestType.MangaCover, e.currentTarget.valueAsNumber)} />
</div>
<div>
<h3>Notification Connectors:</h3>
{notificationConnectors.map(c => <NotificationConnectorItem apiUri={apiUri} notificationConnector={c} key={c.name} />)}
<NotificationConnectorItem apiUri={apiUri} notificationConnector={null} key="New Notification Connector" />
</div>
<div>
<h3>Local Libraries:</h3>
{localLibraries.map(l => <LocalLibraryItem apiUri={apiUri} library={l} key={l.localLibraryId} />)}
<LocalLibraryItem apiUri={apiUri} library={null} key="New Local Library" />
</div> </div>
{notificationConnectors.map(c => <NotificationConnectorItem apiUri={apiUri} notificationConnector={c} />)}
<NotificationConnectorItem apiUri={apiUri} notificationConnector={null} />
</div> </div>
</div> </div>
: null : null

View File

@ -1,15 +0,0 @@
export default interface IBackendSettings {
"downloadLocation": string;
"userAgent": string;
"aprilFoolsMode": boolean;
"compression": number;
"bwImages": boolean;
"requestLimits": {
"MangaInfo": number;
"MangaDexFeed": number;
"MangaDexImage": number;
"MangaImage": number;
"MangaCover": number;
"Default": number
}
}

View File

@ -0,0 +1,17 @@
export default interface IBackendSettings {
downloadLocation: string;
workingDirectory: string;
userAgent: string;
aprilFoolsMode: boolean;
requestLimits: {
Default: number,
MangaInfo: number,
MangaDexFeed: number,
MangaDexImage: number,
MangaImage: number,
MangaCover: number,
};
compression: number;
bwImages: boolean;
startNewJobTimeoutMs: number;
}

View File

@ -1,4 +1,8 @@
import {ReactElement} from "react"; import {ReactElement, useState} from "react";
import INewLibraryRecord, {Validate} from "./records/INewLibraryRecord";
import Loader from "../Loader";
import LocalLibraryFunctions from "../LocalLibraryFunctions";
import "../../styles/localLibrary.css";
export default interface ILocalLibrary { export default interface ILocalLibrary {
localLibraryId: string; localLibraryId: string;
@ -6,9 +10,36 @@ export default interface ILocalLibrary {
libraryName: string; libraryName: string;
} }
export function LocalLibrary(library: ILocalLibrary) : ReactElement { export function LocalLibraryItem({apiUri, library} : {apiUri: string, library: ILocalLibrary | null}) : ReactElement {
return (<div key={library.localLibraryId}> const [loading, setLoading] = useState<boolean>(false);
<p className={"LocalLibraryFunctions-Name"}>{library.libraryName}</p> const [record, setRecord] = useState<INewLibraryRecord>({
<p className={"LocalLibraryFunctions-Path"}>{library.basePath}</p> path: library?.basePath ?? "",
name: library?.libraryName ?? ""
});
return (<div className="LocalLibraryFunctions">
<label htmlFor="LocalLibraryFunctions-Name">Library Name</label>
<input id="LocalLibraryFunctions-Name" className="LocalLibraryFunctions-Name" placeholder="Library Name" defaultValue={library ? library.libraryName : "New Library"}
onChange={(e) => setRecord({...record, name: e.currentTarget.value})}/>
<label htmlFor="LocalLibraryFunctions-Path">Library Path</label>
<input id="LocalLibraryFunctions-Path" className="LocalLibraryFunctions-Path" placeholder="Library Path" defaultValue={library ? library.basePath : ""}
onChange={(e) => setRecord({...record, path: e.currentTarget.value})}/>
{library
? <button className="LocalLibraryFunctions-Action" onClick={() => {
if(record === null || Validate(record) === false)
return;
setLoading(true);
LocalLibraryFunctions.UpdateLibrary(apiUri, library.localLibraryId, record)
.finally(() => setLoading(false));
}}>Edit</button>
: <button className="LocalLibraryFunctions-Action" onClick={() => {
if(record === null || Validate(record) === false)
return;
setLoading(true);
LocalLibraryFunctions.CreateLibrary(apiUri, record)
.finally(() => setLoading(false));
}}>Add</button>
}
<Loader loading={loading} style={{width:"40px",height:"40px"}}/>
</div>); </div>);
} }

View File

@ -115,8 +115,8 @@ export function MangaItem({apiUri, mangaId, children} : {apiUri: string, mangaId
setSettingThreshold(true); setSettingThreshold(true);
MangaFunctions.SetIgnoreThreshold(apiUri, mangaId, e.currentTarget.valueAsNumber).finally(()=>setSettingThreshold(false)); MangaFunctions.SetIgnoreThreshold(apiUri, mangaId, e.currentTarget.valueAsNumber).finally(()=>setSettingThreshold(false));
}} /> }} />
<Loader loading={settingThreshold} /> <Loader loading={settingThreshold} style={{margin: "-10px -45px"}}/>
out of <span className="MangaItem-Props-Threshold-Available">{latestChapterAvailable ? latestChapterAvailable.chapterNumber : <Loader loading={loadingChapterStats}/>}</span> out of <span className="MangaItem-Props-Threshold-Available">{latestChapterAvailable ? latestChapterAvailable.chapterNumber : <Loader loading={loadingChapterStats} style={{margin: "-10px -35px"}} />}</span>
</div> </div>
{children ? children.map(c => { {children ? children.map(c => {
if(c instanceof Element) if(c instanceof Element)

View File

@ -2,6 +2,9 @@ import {ReactElement, ReactEventHandler, useState} from "react";
import "../../styles/notificationConnector.css"; import "../../styles/notificationConnector.css";
import Loader from "../Loader"; import Loader from "../Loader";
import NotificationConnectorFunctions from "../NotificationConnectorFunctions"; import NotificationConnectorFunctions from "../NotificationConnectorFunctions";
import {LunaseaItem} from "./records/ILunaseaRecord";
import {GotifyItem} from "./records/IGotifyRecord";
import {NtfyItem} from "./records/INtfyRecord";
export default interface INotificationConnector { export default interface INotificationConnector {
name: string; name: string;
@ -12,7 +15,33 @@ export default interface INotificationConnector {
} }
export function NotificationConnectorItem({apiUri, notificationConnector} : {apiUri: string, notificationConnector: INotificationConnector | null}) : ReactElement { export function NotificationConnectorItem({apiUri, notificationConnector} : {apiUri: string, notificationConnector: INotificationConnector | null}) : ReactElement {
const AddHeader : ReactEventHandler<HTMLButtonElement> = (e) => { if(notificationConnector != null)
return <DefaultItem apiUri={apiUri} notificationConnector={notificationConnector} />
const [selectedConnectorElement, setSelectedConnectorElement] = useState<ReactElement>(<DefaultItem apiUri={apiUri} notificationConnector={null} />);
return <div>
<div>New Notification Connector</div>
<label>Type</label>
<select defaultValue="default" onChange={(e) => {
switch (e.currentTarget.value){
case "default": setSelectedConnectorElement(<DefaultItem apiUri={apiUri} notificationConnector={null} />); break;
case "gotify": setSelectedConnectorElement(<GotifyItem apiUri={apiUri} />); break;
case "ntfy": setSelectedConnectorElement(<NtfyItem apiUri={apiUri} />); break;
case "lunasea": setSelectedConnectorElement(<LunaseaItem apiUri={apiUri} />); break;
}
}}>
<option value="default">Generic REST</option>
<option value="gotify">Gotify</option>
<option value="ntfy">Ntfy</option>
<option value="lunasea">Lunasea</option>
</select>
{selectedConnectorElement}
</div>;
}
function DefaultItem({apiUri, notificationConnector}:{apiUri: string, notificationConnector: INotificationConnector | null}) : ReactElement {
const AddHeader : ReactEventHandler<HTMLButtonElement> = () => {
let header : Record<string, string> = {}; let header : Record<string, string> = {};
let x = info; let x = info;
x.headers = [header, ...x.headers]; x.headers = [header, ...x.headers];
@ -20,7 +49,6 @@ export function NotificationConnectorItem({apiUri, notificationConnector} : {api
setHeaderElements([...headerElements, <HeaderElement record={header} />]) setHeaderElements([...headerElements, <HeaderElement record={header} />])
} }
const [headerElements, setHeaderElements] = useState<ReactElement[]>([]); const [headerElements, setHeaderElements] = useState<ReactElement[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [info, setInfo] = useState<INotificationConnector>({ const [info, setInfo] = useState<INotificationConnector>({
name: "", name: "",
url: "", url: "",
@ -28,33 +56,25 @@ export function NotificationConnectorItem({apiUri, notificationConnector} : {api
httpMethod: "", httpMethod: "",
body: "" body: ""
}); });
const [loading, setLoading] = useState<boolean>(false);
return (<div className="NotificationConnectorItem" key={notificationConnector ? notificationConnector.name : "new"}> return <div className="NotificationConnectorItem">
<p className="NotificationConnectorItem-Name">{notificationConnector ? notificationConnector.name : "New Notification Connector"}</p> <input className="NotificationConnectorItem-Name" placeholder="Name" defaultValue={notificationConnector ? notificationConnector.name : ""}
disabled={notificationConnector != null} onChange={(e) => setInfo({...info, name: e.currentTarget.value})} />
<div className="NotificationConnectorItem-Url"> <div className="NotificationConnectorItem-Url">
<select className="NotificationConnectorItem-RequestMethod" defaultValue={notificationConnector ? notificationConnector.httpMethod : ""} disabled={notificationConnector != null} onChange={(e) => { <select className="NotificationConnectorItem-RequestMethod" defaultValue={notificationConnector ? notificationConnector.httpMethod : ""}
let x = info; disabled={notificationConnector != null} onChange={(e)=> setInfo({...info, httpMethod: e.currentTarget.value})} >
x.httpMethod = e.currentTarget.value;
setInfo(x);
}}>
<option value="" disabled hidden>Request Method</option> <option value="" disabled hidden>Request Method</option>
<option value="GET">GET</option> <option value="GET">GET</option>
<option value="POST">POST</option> <option value="POST">POST</option>
</select> </select>
<input type="url" className="NotificationConnectorItem-RequestUrl" placeholder="URL" defaultValue={notificationConnector ? notificationConnector.url : ""} disabled={notificationConnector != null} onChange={(e) => { <input type="url" className="NotificationConnectorItem-RequestUrl" placeholder="URL" defaultValue={notificationConnector ? notificationConnector.url : ""}
let x = info; disabled={notificationConnector != null} onChange={(e) => setInfo({...info, url: e.currentTarget.value})} />
x.url = e.currentTarget.value;
setInfo(x);
}} />
</div> </div>
<textarea className="NotificationConnectorItem-Body" placeholder="Request-Body" defaultValue={notificationConnector ? notificationConnector.body : ""} disabled={notificationConnector != null} onChange={(e) => { <textarea className="NotificationConnectorItem-Body" placeholder="Request-Body" defaultValue={notificationConnector ? notificationConnector.body : ""}
let x = info; disabled={notificationConnector != null} onChange={(e)=> setInfo({...info, body: e.currentTarget.value})} />
x.body = e.currentTarget.value;
setInfo(x);
}} />
{notificationConnector != null ? null : {notificationConnector != null ? null :
( (
<p className="NotificationConnectorItem-Explanation">Explanation Text</p> <p className="NotificationConnectorItem-Explanation">Formatting placeholders: "%title" and "%text" can be placed in url, header-values and body and will be replaced when notifications are sent</p>
)} )}
<div className="NotificationConnectorItem-Headers"> <div className="NotificationConnectorItem-Headers">
{headerElements} {headerElements}
@ -67,17 +87,15 @@ export function NotificationConnectorItem({apiUri, notificationConnector} : {api
) )
} }
</div> </div>
{notificationConnector != null ? null : ( <>
<> <button className="NotificationConnectorItem-Save" onClick={(e) => {
<button className="NotificationConnectorItem-Save" onClick={(e) => { setLoading(true);
setLoading(true); NotificationConnectorFunctions.CreateNotificationConnector(apiUri, info)
NotificationConnectorFunctions.CreateNotificationConnector(apiUri, info) .finally(() => setLoading(false));
.finally(() => setLoading(false)); }}>Add</button>
}}>Add</button> <Loader loading={loading} style={{width:"40px",height:"40px",margin:"25vh calc(sin(70)*(50% - 40px))"}}/>
<Loader loading={loading} style={{width:"40px",height:"40px",margin:"calc(sin(70)*(50% - 40px))"}}/> </>
</> </div>
)}
</div>);
} }
function HeaderElement({record, disabled} : {record: Record<string, string>, disabled?: boolean | null}) : ReactElement { function HeaderElement({record, disabled} : {record: Record<string, string>, disabled?: boolean | null}) : ReactElement {

View File

@ -0,0 +1,17 @@
export default interface IRequestLimits {
Default: number;
MangaDexFeed: number;
MangaImage: number;
MangaCover: number;
MangaDexImage: number;
MangaInfo: number;
}
export enum RequestType {
Default = "Default",
MangaDexFeed = "MangaDexFeed",
MangaImage = "MangaImage",
MangaCover = "MangaCover",
MangaDexImage = "MangaDexImage",
MangaInfo = "MangaInfo"
}

View File

@ -1,5 +0,0 @@
export default interface IGotifyRecord {
endpoint: string;
appToken: string;
priority: number;
}

View File

@ -0,0 +1,51 @@
import {ReactElement, useState} from "react";
import NotificationConnectorFunctions from "../../NotificationConnectorFunctions";
import Loader from "../../Loader";
import "../../../styles/notificationConnector.css";
import {isValidUri} from "../../../App";
export default interface IGotifyRecord {
endpoint: string;
appToken: string;
priority: number;
}
function Validate(record: IGotifyRecord) : boolean {
if(!isValidUri(record.endpoint))
return false;
if(record.appToken.length < 1)
return false;
if(record.priority < 1 || record.priority > 5)
return false;
return true;
}
export function GotifyItem ({apiUri} : {apiUri: string}) : ReactElement{
const [record, setRecord] = useState<IGotifyRecord>({
endpoint: "",
appToken: "",
priority: 3
});
const [loading, setLoading] = useState(false);
return <div className="NotificationConnectorItem">
<input className="NotificationConnectorItem-Name" value="Gotify" disabled={true} />
<div className="NotificationConnectorItem-Url">
<input type="text" className="NotificationConnectorItem-RequestUrl" placeholder="URL" onChange={(e) => setRecord({...record, endpoint: e.currentTarget.value})} />
<input type="text" className="NotificationConnectorItem-AppToken" placeholder="Apptoken" onChange={(e) => setRecord({...record, appToken: e.currentTarget.value})} />
</div>
<div className="NotificationConnectorItem-Priority">
<label htmlFor="NotificationConnectorItem-Priority">Priority</label>
<input id="NotificationConnectorItem-Priority-Value" type="number" className="NotificationConnectorItem-Priority-Value" min={1} max={5} defaultValue={3} onChange={(e) => setRecord({...record, priority: e.currentTarget.valueAsNumber})} />
</div>
<>
<button className="NotificationConnectorItem-Save" onClick={(e) => {
if(record === null || Validate(record) === false)
return;
setLoading(true);
NotificationConnectorFunctions.CreateGotify(apiUri, record)
.finally(() => setLoading(false));
}}>Add</button>
<Loader loading={loading} style={{width:"40px",height:"40px"}}/>
</>
</div>;
}

View File

@ -1,3 +0,0 @@
export default interface ILunaseaRecord {
id: string;
}

View File

@ -0,0 +1,36 @@
import {ReactElement, useState} from "react";
import NotificationConnectorFunctions from "../../NotificationConnectorFunctions";
import Loader from "../../Loader";
import "../../../styles/notificationConnector.css";
export default interface ILunaseaRecord {
id: string;
}
const regex = new RegExp("(?:device|user)\/[0-9a-zA-Z\-]+");
function Validate(record: ILunaseaRecord) : boolean {
return regex.test(record.id);
}
export function LunaseaItem ({apiUri} : {apiUri: string}) : ReactElement{
const [record, setRecord] = useState<ILunaseaRecord>({
id: ""
});
const [loading, setLoading] = useState(false);
return <div className="NotificationConnectorItem">
<input className="NotificationConnectorItem-Name" value="LunaSea" disabled={true} />
<div className="NotificationConnectorItem-Url">
<input type="text" className="NotificationConnectorItem-RequestUrl" placeholder="device/:device_id or user/:user_id" onChange={(e) => setRecord({...record, id: e.currentTarget.value})} />
</div>
<>
<button className="NotificationConnectorItem-Save" onClick={(e) => {
if(record === null || Validate(record) === false)
return;
setLoading(true);
NotificationConnectorFunctions.CreateLunasea(apiUri, record)
.finally(() => setLoading(false));
}}>Add</button>
<Loader loading={loading} style={{width:"40px",height:"40px",margin:"25vh calc(sin(70)*(50% - 40px))"}}/>
</>
</div>;
}

View File

@ -1,4 +1,12 @@
export default interface INewLibraryRecord { export default interface INewLibraryRecord {
path: string; path: string;
name: string; name: string;
}
export function Validate(record: INewLibraryRecord) : boolean {
if(record.path.length < 1)
return false;
if(record.name.length < 1)
return false;
return true;
} }

View File

@ -1,7 +0,0 @@
export default interface INtfyRecord {
endpoint: string;
username: string;
password: string;
topic: string;
priority: number;
}

View File

@ -0,0 +1,62 @@
import {ReactElement, useState} from "react";
import NotificationConnectorFunctions from "../../NotificationConnectorFunctions";
import Loader from "../../Loader";
import "../../../styles/notificationConnector.css";
import {isValidUri} from "../../../App";
export default interface INtfyRecord {
endpoint: string;
username: string;
password: string;
topic: string;
priority: number;
}
function Validate(record: INtfyRecord) : boolean {
if(!isValidUri(record.endpoint))
return false;
if(record.username.length < 1)
return false;
if(record.password.length < 1)
return false;
if(record.topic.length < 1)
return false;
if(record.priority < 1 || record.priority > 5)
return false;
return true;
}
export function NtfyItem ({apiUri} : {apiUri: string}) : ReactElement{
const [info, setInfo] = useState<INtfyRecord>({
endpoint: "",
username: "",
password: "",
topic: "",
priority: 0
});
const [loading, setLoading] = useState(false);
return <div className="NotificationConnectorItem">
<input className="NotificationConnectorItem-Name" value="Ntfy" disabled={true} />
<div className="NotificationConnectorItem-Url">
<input type="text" className="NotificationConnectorItem-RequestUrl" placeholder="URL" onChange={(e) => setInfo({...info, endpoint: e.currentTarget.value})} />
<input type="text" className="NotificationConnectorItem-Topic" placeholder="Topic" onChange={(e) => setInfo({...info, topic: e.currentTarget.value})} />
</div>
<div className="NotificationConnectorItem-Ident">
<input type="text" className="NotificationConnectorItem-Username" placeholder="Username" onChange={(e) => setInfo({...info, username: e.currentTarget.value})} />
<input type="password" className="NotificationConnectorItem-Password" placeholder="***" onChange={(e) => setInfo({...info, password: e.currentTarget.value})} />
</div>
<div className="NotificationConnectorItem-Priority">
<label htmlFor="NotificationConnectorItem-Priority">Priority</label>
<input id="NotificationConnectorItem-Priority-Value" type="number" className="NotificationConnectorItem-Priority-Value" min={1} max={5} defaultValue={3} onChange={(e) => setInfo({...info, priority: e.currentTarget.valueAsNumber})} />
</div><>
<button className="NotificationConnectorItem-Save" onClick={(e) => {
if(info === null || Validate(info) === false)
return;
setLoading(true);
NotificationConnectorFunctions.CreateNtfy(apiUri, info)
.finally(() => setLoading(false));
}}>Add</button>
<Loader loading={loading} style={{width:"40px",height:"40px",margin:"25vh calc(sin(70)*(50% - 40px))"}}/>
</>
</div>;
}

View File

@ -12,6 +12,7 @@ span[is-loading="loading"] {
position: fixed; position: fixed;
background-color: var(--secondary-color); background-color: var(--secondary-color);
background-blend-mode: lighten; background-blend-mode: lighten;
margin: 25vh calc(sin(70)*(50% - 40px));
} }
span[is-loading="loading"]:before, span[is-loading="loading"]:before,

View File

@ -0,0 +1,13 @@
.LocalLibraryFunctions {
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.LocalLibraryFunctions input{
width: min-content;
}
.LocalLibraryFunctions-Action {
margin-left: auto;
}

View File

@ -1,12 +1,8 @@
.NotificationConnectorItem{ .NotificationConnectorItem{
position: relative; position: relative;
display: grid; display: grid;
width: calc(100% - 10px);
grid-template-columns: 40% calc(60% - 10px); grid-template-columns: 40% calc(60% - 10px);
margin: 0 auto; grid-template-rows: 30px auto auto 30px;
padding: 5px;
border-radius: 5px;
grid-template-rows: 30px 30px auto 30px;
column-gap: 4px; column-gap: 4px;
row-gap: 4px; row-gap: 4px;
grid-template-areas: grid-template-areas:
@ -15,7 +11,6 @@
"headers body" "headers body"
"footer footer"; "footer footer";
align-items: center; align-items: center;
border: 1px solid var(--primary-color);
} }
.NotificationConnectorItem p{ .NotificationConnectorItem p{
@ -25,6 +20,13 @@
.NotificationConnectorItem-Name{ .NotificationConnectorItem-Name{
grid-area: name; grid-area: name;
justify-self: flex-start; justify-self: flex-start;
width: fit-content;
}
.NotificationConnectorItem-Name::before {
content: "Connector-Name";
position: absolute;
display: block;
} }
.NotificationConnectorItem-Url{ .NotificationConnectorItem-Url{
@ -46,6 +48,14 @@
align-self: flex-end; align-self: flex-end;
} }
.NotificationConnectorItem-Priority {
grid-area: explanation;
}
.NotificationConnectorItem-Ident {
grid-area: body;
}
.NotificationConnectorItem-Headers{ .NotificationConnectorItem-Headers{
grid-area: headers; grid-area: headers;
justify-self: flex-end; justify-self: flex-end;
@ -65,5 +75,4 @@
.NotificationConnectorItem-Save{ .NotificationConnectorItem-Save{
grid-area: footer; grid-area: footer;
justify-self: flex-end; justify-self: flex-end;
padding: 0 15px;
} }

View File

@ -40,5 +40,8 @@
left: 0; left: 0;
width: calc(100% - 30px); width: calc(100% - 30px);
height: calc(100% - 50px); height: calc(100% - 50px);
margin: 5px 15px; padding: 5px 15px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
} }

View File

@ -16,10 +16,59 @@
#SettingsPopUpBody { #SettingsPopUpBody {
display: flex; display: flex;
flex-direction: column; flex-flow: row wrap;
justify-content: flex-start; justify-content: flex-start;
} }
#SettingsPopUpBody > * { #SettingsPopUpBody > * {
margin: 5px 0; margin: 5px 2px;
border: 1px solid var(--primary-color);
border-radius: 5px;
padding: 0 5px;
max-width: calc(100% - 10px);
min-width: calc(30% - 10px);
flex-grow: 1;
flex-basis: 0;
}
#SettingsPopUpBody > * > .LocalLibraryFunctions, #SettingsPopUpBody > * > div > .NotificationConnectorItem {
border: 1px solid var(--primary-color);
border-left: 0;
border-right: 0;
border-radius: 5px;
margin: 5px -5px -1px -5px;
padding: 5px;
}
#SettingsPopUpBody > *:has(.NotificationConnectorItem) {
width: 100%;
flex-basis: 100%;
}
#SettingsPopUpBody label {
width: max-content;
margin-right: 5px;
}
#SettingsPopUpBody label::after {
content: ':';
}
#SettingsPopUpBody button {
padding: 0 15px;
}
#SettingsPopUpBody h1, #SettingsPopUpBody h2, #SettingsPopUpBody h3 {
border: 0;
margin: 5px 0 2px 0;
padding: 0;
}
.settings-requestLimits {
display: flex;
flex-direction: column;
}
.settings-requestLimits input {
width: min-content;
} }