Add auto-update when adding/removing Manga

Style SearchResults
This commit is contained in:
glax 2024-10-19 19:52:28 +02:00
parent daa05a0b4d
commit 3f26d3bbd6
11 changed files with 318 additions and 77 deletions

View File

@ -1,37 +1,45 @@
import React, {ReactElement, useEffect} from 'react';
import React, {EventHandler, ReactElement, useEffect, useState} from 'react';
import Footer from "./modules/Footer";
import Search from "./modules/Search";
import Header from "./modules/Header";
import MonitorJobsList from "./modules/MonitorJobsList";
import './styles/Manga.css'
import './styles/index.css'
export default function App(){
const [content, setContent] = React.useState<ReactElement>();
function ShowSearch() {
setContent(<>
<Search />
<MonitorJobsList onStartSearch={ShowSearch} />
</>);
}
const [connected, setConnected] = React.useState(false);
const [showSearch, setShowSearch] = React.useState(false);
const [lastMangaListUpdate, setLastMangaListUpdate] = React.useState<Date>(new Date());
useEffect(() => {
setContent(<h1>Testing connection to backend...</h1>)
getData('http://127.0.0.1:6531/v2/Ping').then((result) => {
console.log(result);
if(result === null){
setContent(<h1>No connection to backend</h1>);
setConnected(false);
}else{
setContent(<>
<MonitorJobsList onStartSearch={ShowSearch} />
</>)
setConnected(true);
}
})
}, []);
const JobsChanged : EventHandler<any> = () => {
console.log("Updating Mangalist");
setLastMangaListUpdate(new Date());
}
return(<div>
<Header/>
{content}
{connected
? <>
{showSearch
? <>
<Search onJobsChanged={JobsChanged}/>
<hr/>
</>
: <></>}
<MonitorJobsList onStartSearch={() => setShowSearch(true)} onJobsChanged={JobsChanged}
key={lastMangaListUpdate.getTime()}/>
</>
: <h1>No connection to backend</h1>}
<Footer/>
</div>)
}
@ -66,8 +74,10 @@ export function postData(uri: string, content: object) : Promise<object> {
body: JSON.stringify(content)
})
.then(function(response){
if(!response.ok) throw new Error("Could not fetch data");
return response.json();
if(!response.ok)
throw new Error("Could not fetch data");
let json = response.json();
return json.then((json) => json).catch(() => null);
})
.catch(function(err){
console.error(`Error POSTing Data ${uri}\n${err}`);
@ -75,8 +85,8 @@ export function postData(uri: string, content: object) : Promise<object> {
});
}
export function deleteData(uri: string) {
fetch(uri,
export function deleteData(uri: string) : Promise<void> {
return fetch(uri,
{
method: 'DELETE',
headers : {
@ -84,6 +94,9 @@ export function deleteData(uri: string) {
'Accept': 'application/json'
}
})
.then(() =>{
return Promise.resolve();
})
.catch(function(err){
console.error(`Error DELETEing Data ${uri}\n${err}`);
return Promise.reject();

View File

@ -9,7 +9,9 @@ export class Job
return getData("http://127.0.0.1:6531/v2/Jobs")
.then((json) => {
console.debug("Got all Jobs");
return (json as string[]);
const ret = json as string[];
console.debug(ret);
return (ret);
});
}
@ -18,7 +20,9 @@ export class Job
return getData("http://127.0.0.1:6531/v2/Jobs/Running")
.then((json) => {
console.debug("Got all running Jobs");
return (json as string[]);
const ret = json as string[];
console.debug(ret);
return (ret);
});
}
@ -27,7 +31,9 @@ export class Job
return getData("http://127.0.0.1:6531/v2/Jobs/Waiting")
.then((json) => {
console.debug("Got all waiting Jobs");
return (json as string[]);
const ret = json as string[];
console.debug(ret);
return (ret);
});
}
@ -36,7 +42,9 @@ export class Job
return getData("http://127.0.0.1:6531/v2/Jobs/Monitoring")
.then((json) => {
console.debug("Got all monitoring Jobs");
return (json as string[]);
const ret = json as string[];
console.debug(ret);
return (ret);
});
}
@ -49,7 +57,9 @@ export class Job
return getData(`http://127.0.0.1:6531/v2/Job/${jobId}`)
.then((json) => {
console.debug(`Got Job ${jobId}`);
return (json as IJob);
const ret = json as IJob;
console.debug(ret);
return (ret);
});
}
@ -63,7 +73,9 @@ export class Job
return getData(`http://127.0.0.1:6531/v2/Job?jobIds=${reqStr}`)
.then((json) => {
console.debug(`Got Jobs ${reqStr}`);
return (json as IJob[]);
const ret = json as IJob[];
console.debug(ret);
return (ret);
});
}
@ -72,11 +84,13 @@ export class Job
return getData(`http://127.0.0.1:6531/v2/Job/${jobId}/Progress`)
.then((json) => {
console.debug(`Got Job ${jobId} Progress`);
return (json as IProgressToken);
const ret = json as IProgressToken;
console.debug(ret);
return (ret);
});
}
static async CreateJob(internalId: string, jobType: string, interval: string): Promise<IJob> {
static async CreateJob(internalId: string, jobType: string, interval: string): Promise<null> {
console.debug(`Creating Job for Manga ${internalId} at ${interval} interval`);
let data = {
internalId: internalId,
@ -85,19 +99,19 @@ export class Job
return postData(`http://127.0.0.1:6531/v2/Job/Create/${jobType}`, data)
.then((json) => {
console.debug(`Created Job for Manga ${internalId} at ${interval} interval`);
return (json as IJob);
return null;
});
}
static DeleteJob(jobId: string) {
deleteData(`http://127.0.0.1:6531/v2/Job/${jobId}`);
static DeleteJob(jobId: string) : Promise<void> {
return deleteData(`http://127.0.0.1:6531/v2/Job/${jobId}`);
}
static StartJob(jobId: string) {
postData(`http://127.0.0.1:6531/v2/Job/${jobId}/StartNow`, {});
static StartJob(jobId: string) : Promise<object> {
return postData(`http://127.0.0.1:6531/v2/Job/${jobId}/StartNow`, {});
}
static CancelJob(jobId: string) {
postData(`http://127.0.0.1:6531/v2/Job/${jobId}/Cancel`, {});
static CancelJob(jobId: string) : Promise<object> {
return postData(`http://127.0.0.1:6531/v2/Job/${jobId}/Cancel`, {});
}
}

View File

@ -8,7 +8,9 @@ export class Manga
return getData("http://127.0.0.1:6531/v2/Mangas")
.then((json) => {
console.debug("Got all Manga");
return (json as IManga[]);
const ret = json as IManga[];
console.debug(ret);
return (ret);
});
}
@ -17,7 +19,9 @@ export class Manga
return await getData(`http://127.0.0.1:6531/v2/Manga/Search?title=${name}`)
.then((json) => {
console.debug(`Got Manga ${name}`);
return (json as IManga[]);
const ret = json as IManga[];
console.debug(ret);
return (ret);
});
}
@ -26,7 +30,9 @@ export class Manga
return await getData(`http://127.0.0.1:6531/v2/Manga/${internalId}`)
.then((json) => {
console.debug(`Got Manga ${internalId}`);
return (json as IManga);
const ret = json as IManga;
console.debug(ret);
return (ret);
});
}
@ -35,7 +41,9 @@ export class Manga
return await getData(`http://127.0.0.1:6531/v2/Manga?internalIds=${internalIds.join(",")}`)
.then((json) => {
console.debug(`Got Manga ${internalIds.join(",")}`);
return (json as IManga[]);
const ret = json as IManga[];
console.debug(ret);
return (ret);
});
}

View File

@ -1,25 +1,17 @@
import React, {MouseEventHandler, ReactElement, useEffect} from 'react';
import React, {EventHandler, MouseEventHandler, ReactElement, useEffect, useState} from 'react';
import {Job} from './Job';
import '../styles/monitorMangaList.css';
import IJob from "./interfaces/IJob";
import IManga, {HTMLFromIManga} from "./interfaces/IManga";
import IManga, {CoverCard} from "./interfaces/IManga";
import {Manga} from './Manga';
import '../styles/MangaCoverCard.css'
export default function MonitorJobsList({onStartSearch} : {onStartSearch() : void}){
const [MonitoringJobs, setMonitoringJobs] = React.useState<IJob[]>([]);
const [AllManga, setAllManga] = React.useState<IManga[]>([]);
function UpdateMonitoringJobsList(){
Job.GetMonitoringJobs()
.then((jobs) => {
if(jobs.length > 0)
return Job.GetJobs(jobs)
return [];
})
.then((jobs) => setMonitoringJobs(jobs));
}
export default function MonitorJobsList({onStartSearch, onJobsChanged} : {onStartSearch() : void, onJobsChanged: EventHandler<any>}) {
const [MonitoringJobs, setMonitoringJobs] = useState<IJob[]>([]);
const [AllManga, setAllManga] = useState<IManga[]>([]);
useEffect(() => {
console.debug("Updating display list.");
//Remove all Manga that are not associated with a Job
setAllManga(AllManga.filter(manga => MonitoringJobs.find(job => job.mangaInternalId == manga.internalId)));
//Fetch Manga that are missing (from Jobs)
@ -38,7 +30,18 @@ export default function MonitorJobsList({onStartSearch} : {onStartSearch() : voi
const DeleteJob : MouseEventHandler = (e) => {
const jobId = e.currentTarget.id;
Job.DeleteJob(jobId);
Job.DeleteJob(jobId).then(() => onJobsChanged(jobId));
}
function UpdateMonitoringJobsList(){
console.debug("Updating MonitoringJobsList");
Job.GetMonitoringJobs()
.then((jobs) => {
if(jobs.length > 0)
return Job.GetJobs(jobs)
return [];
})
.then((jobs) => setMonitoringJobs(jobs));
}
function StartSearchMangaEntry() : ReactElement {
@ -61,7 +64,7 @@ export default function MonitorJobsList({onStartSearch} : {onStartSearch() : voi
if (job === undefined || job == null)
return <div>Error. Could not find matching job for {manga.internalId}</div>
return <div key={"monitorMangaEntry." + manga.internalId} className="monitorMangaEntry">
{HTMLFromIManga(manga)}
{CoverCard(manga)}
{job.id}
<button id={job.id} onClick={DeleteJob}>Delete</button>
</div>;

View File

@ -1,12 +1,13 @@
import React, { ChangeEventHandler, MouseEventHandler, useEffect, useState} from 'react';
import React, {ChangeEventHandler, EventHandler, MouseEventHandler, useEffect, useState} from 'react';
import {MangaConnector} from "./MangaConnector";
import {Job} from "./Job";
import IMangaConnector from "./interfaces/IMangaConnector";
import {isValidUri} from "../App";
import IManga, {HTMLFromIManga} from "./interfaces/IManga";
import IManga, {SearchResult} from "./interfaces/IManga";
import '../styles/search.css';
import '../styles/MangaSearchResult.css'
export default function Search(){
export default function Search({onJobsChanged} : {onJobsChanged: EventHandler<any>}) {
const [mangaConnectors, setConnectors] = useState<IMangaConnector[]>();
const [selectedConnector, setSelectedConnector] = useState<IMangaConnector>();
const [selectedLanguage, setSelectedLanguage] = useState<string>();
@ -88,28 +89,25 @@ export default function Search(){
return (<div>
<div id="SearchBox">
<input type="text" placeholder="Manganame" onChange={searchBoxValueChanged}></input>
<select value={selectedConnector === undefined ? "" : selectedConnector.name} onChange={selectedConnectorChanged}>
<input type="text" placeholder="Manganame" id="Searchbox-Manganame" onChange={searchBoxValueChanged}></input>
<select id="Searchbox-Connector" value={selectedConnector === undefined ? "" : selectedConnector.name} onChange={selectedConnectorChanged}>
<option value="" disabled hidden>Select</option>
{mangaConnectors === undefined
? <option value="Loading">Loading</option>
: mangaConnectors.map(con => <option value={con.name} key={con.name}>{con.name}</option>)}
</select>
<select onChange={changeSelectedLanguage} value={selectedLanguage === null ? "" : selectedLanguage}>
<select id="Searchbox-language" onChange={changeSelectedLanguage} value={selectedLanguage === null ? "" : selectedLanguage}>
{selectedConnector === undefined
? <option value="" disabled hidden>Select Connector</option>
: selectedConnector.SupportedLanguages.map(language => <option value={language}
key={language}>{language}</option>)}
</select>
<button type="submit" onClick={ExecuteSearch}>Search</button>
<button id="Searchbox-button" type="submit" onClick={ExecuteSearch}>Search</button>
</div>
<div>
<div id="SearchResults">
{searchResults === undefined
? <p>No Results yet</p>
: searchResults.map(result => <div key={"searchResult."+result.internalId} className="searchResult">
{HTMLFromIManga(result)}
<button onClick={(e) => {Job.CreateJob(result.internalId, "MonitorManga", "03:00:00")}}>Monitor</button>
</div>)}
? <p></p>
: searchResults.map(result => SearchResult(result, onJobsChanged))}
</div>
</div>)
}

View File

@ -1,7 +1,10 @@
import IMangaConnector from "./IMangaConnector";
import KeyValuePair from "./KeyValuePair";
import {Manga} from "../Manga";
import {ReactElement} from "react";
import React, {ChangeEventHandler, EventHandler, ReactElement} from "react";
import {Job} from "../Job";
import Icon from '@mdi/react';
import { mdiTagTextOutline, mdiAccountEdit } from '@mdi/js';
export default interface IManga{
"sortName": string,
@ -36,7 +39,7 @@ function ReleaseStatusFromNumber(n: number): string {
return "";
}
export function HTMLFromIManga(manga: IManga) : ReactElement {
export function CoverCard(manga: IManga) : ReactElement {
return(
<div className="Manga" key={manga.internalId}>
<img src={Manga.GetMangaCoverUrl(manga.internalId)}></img>
@ -47,3 +50,22 @@ export function HTMLFromIManga(manga: IManga) : ReactElement {
</div>
</div>);
}
export function SearchResult(manga: IManga, jobsChanged: EventHandler<any>) : ReactElement {
return(
<div className="SearchResult" key={manga.internalId}>
<img src={Manga.GetMangaCoverUrl(manga.internalId)}></img>
<p className="connector-name">{manga.mangaConnector.name}</p>
<div className="Manga-status" release-status={ReleaseStatusFromNumber(manga.releaseStatus)}></div>
<p className="Manga-name">{manga.sortName}</p>
<ul className="Manga-tags">
{manga.authors.map(author => <li className="Manga-author" key={manga.internalId + "-author-" + author}> <Icon path={mdiAccountEdit} size={0.5} /> {author}</li>)}
{manga.tags.map(tag => <li className="Manga-tag" key={manga.internalId + "-tag-" + tag}><Icon path={mdiTagTextOutline} size={0.5} /> {tag}</li>)}
</ul>
<p className="Manga-description">{manga.description}</p>
<button className="Manga-AddButton" onClick={(e) => {
Job.CreateJob(manga.internalId, "MonitorManga", "03:00:00").then(() => jobsChanged(manga.internalId));
}}>Monitor
</button>
</div>);
}

View File

@ -0,0 +1,88 @@
.SearchResult {
background-color: var(--second-background-color);
border-radius: 2px;
padding: 5px 5px 9px 5px;
position: relative;
max-width: 100%;
width: fit-content;
height: 328px;
display: grid;
grid-template-columns: 220px 600px 50px;
grid-template-rows: 40px 40px 200px auto;
column-gap: 10px;
grid-template-areas:
"cover header header"
"cover alltags alltags"
"cover description description"
"cover footer button";
}
.SearchResult p {
margin: 2px 0;
}
.SearchResult > img {
grid-area: cover;
position: relative;
height: 100%;
width: 100%;
z-index: 0;
border: 2px solid var(--primary-color);
border-radius: 4px;
}
.SearchResult > .connector-name {
grid-area: cover;
position: absolute;
z-index: 1;
left: 2px;
top: 2px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
width: 100%;
background-color: var(--accent-color);
margin: 0;
padding: 2px 0;
text-align: center;
color: var(--secondary-color);
}
.SearchResult > .Manga-status {
grid-area: header;
}
.SearchResult > .Manga-name {
grid-area: header;
color: black;
}
.SearchResult > .Manga-tags {
grid-area: alltags;
color: white;
padding: 0;
margin: 0;
}
.SearchResult > ul > li {
display: inline;
margin: 0 2px;
padding: 5px;
font-size: 10pt;
}
.SearchResult .Manga-author {
background-color: green;
}
.SearchResult .Manga-tag {
background-color: blue;
}
.SearchResult > .Manga-description {
grid-area: description;
color: black;
}
.SearchResult > .Manga-AddButton {
grid-area: button;
}

View File

@ -1,3 +1,48 @@
.searchResult{
background-color: var(--second-background-color);
#SearchBox{
display: flex;
align-content: center;
justify-content: center;
margin: 10px 0;
}
#SearchResults {
max-width: 100vw;
}
#SearchBox select, #SearchBox button, #SearchBox input {
border-color: var(--accent-color);
border-style: solid;
border-width: 0;
border-bottom-width: 2px;
border-top-width: 2px;
padding: 2px 5px;
font-size: 12pt;
}
#Searchbox-Manganame {
border-bottom-left-radius: 2px;
border-top-left-radius: 2px;
border-left-width: 2px;
border-right-width: 0;
width: 300px;
}
#Searchbox-connector {
border-left-width: 0;
border-right-width: 0;
width: max-content;
}
#Searchbox-language {
border-left-width: 0;
border-right-width: 0;
width: 90px;
}
#Searchbox-button {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
border-left-width: 0;
border-right-width: 2px;
width: 90px;
}

48
package-lock.json generated
View File

@ -5,6 +5,8 @@
"packages": {
"": {
"devDependencies": {
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@types/react": "^18.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@ -403,6 +405,23 @@
"node": ">=12"
}
},
"node_modules/@mdi/js": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@mdi/react": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@mdi/react/-/react-1.6.1.tgz",
"integrity": "sha512-4qZeDcluDFGFTWkHs86VOlHkm6gnKaMql13/gpIcUQ8kzxHgpj31NuCkD8abECVfbULJ3shc7Yt4HJ6Wu6SN4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz",
@ -760,6 +779,16 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -796,6 +825,18 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@ -823,6 +864,13 @@
"react": "^18.3.1"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz",

View File

@ -1,5 +1,7 @@
{
"devDependencies": {
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@types/react": "^18.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",