Compare commits

...

5 Commits

Author SHA1 Message Date
6d402357e7 UserAgent Setting 2025-04-01 19:17:46 +02:00
9e0eb0262e MangaList 2025-04-01 19:05:57 +02:00
63b220bd79 Add Badge to Search Results to show number of results 2025-04-01 18:29:27 +02:00
d480f62e51 Warning icon if API not connected 2025-04-01 18:16:57 +02:00
44755675e5 LocalLibrary Select in Manga Search 2025-04-01 18:10:30 +02:00
7 changed files with 226 additions and 63 deletions

View File

@ -2,15 +2,17 @@ import Sheet from '@mui/joy/Sheet';
import './App.css' import './App.css'
import Settings from "./Settings.tsx"; import Settings from "./Settings.tsx";
import Header from "./Header.tsx"; import Header from "./Header.tsx";
import {Button} from "@mui/joy"; import {Badge, Button} from "@mui/joy";
import {useState} from "react"; import {useState} from "react";
import {ApiUriContext} from "./api/fetchApi.tsx"; import {ApiUriContext} from "./api/fetchApi.tsx";
import Search from './Components/Search.tsx'; import Search from './Components/Search.tsx';
import MangaList from "./Components/MangaList.tsx";
export default function App () { export default function App () {
const [showSettings, setShowSettings] = useState<boolean>(false); const [showSettings, setShowSettings] = useState<boolean>(false);
const [showSearch, setShowSearch] = useState<boolean>(false); const [showSearch, setShowSearch] = useState<boolean>(false);
const [apiConnected, setApiConnected] = useState<boolean>(false);
const [apiUri, setApiUri] = useState<string>(window.location.href.substring(0, window.location.href.lastIndexOf("/"))); const [apiUri, setApiUri] = useState<string>(window.location.href.substring(0, window.location.href.lastIndexOf("/")));
@ -18,12 +20,15 @@ export default function App () {
<ApiUriContext.Provider value={apiUri}> <ApiUriContext.Provider value={apiUri}>
<Sheet className={"app"}> <Sheet className={"app"}>
<Header> <Header>
<Button onClick={() => setShowSettings(true)}>Settings</Button> <Badge color={"danger"} invisible={apiConnected} badgeContent={"!"}>
<Button onClick={() => setShowSettings(true)}>Settings</Button>
</Badge>
<Button onClick={() => setShowSearch(true)}>Search</Button> <Button onClick={() => setShowSearch(true)}>Search</Button>
</Header> </Header>
<Settings open={showSettings} setOpen={setShowSettings} setApiUri={setApiUri}/> <Settings open={showSettings} setOpen={setShowSettings} setApiUri={setApiUri} setConnected={setApiConnected} />
<Search open={showSearch} setOpen={setShowSearch} />
<Sheet className={"app-content"}> <Sheet className={"app-content"}>
<Search open={showSearch} setOpen={setShowSearch} /> <MangaList />
</Sheet> </Sheet>
</Sheet> </Sheet>
</ApiUriContext.Provider> </ApiUriContext.Provider>

View File

@ -13,7 +13,7 @@ import {
} from "@mui/joy"; } from "@mui/joy";
import IManga, {DefaultManga} from "../api/types/IManga.ts"; import IManga, {DefaultManga} from "../api/types/IManga.ts";
import {ReactElement, useCallback, useContext, useEffect, useState} from "react"; import {ReactElement, useCallback, useContext, useEffect, useState} from "react";
import {GetLatestChapterAvailable, GetMangaCoverImageUrl, SetIgnoreThreshold} from "../api/Manga.tsx"; import {GetLatestChapterAvailable, GetMangaById, GetMangaCoverImageUrl, SetIgnoreThreshold} from "../api/Manga.tsx";
import {ApiUriContext} from "../api/fetchApi.tsx"; import {ApiUriContext} from "../api/fetchApi.tsx";
import AuthorTag from "./AuthorTag.tsx"; import AuthorTag from "./AuthorTag.tsx";
import LinkTag from "./LinkTag.tsx"; import LinkTag from "./LinkTag.tsx";
@ -22,8 +22,27 @@ import IChapter from "../api/types/IChapter.ts";
import MarkdownPreview from "@uiw/react-markdown-preview"; import MarkdownPreview from "@uiw/react-markdown-preview";
import {SxProps} from "@mui/joy/styles/types"; import {SxProps} from "@mui/joy/styles/types";
export function Manga({manga, children} : { manga: IManga | undefined, children?: ReactElement<any, any> | ReactElement<any, any>[] | undefined }) { export function MangaFromId({mangaId, children} : { mangaId: string, children?: ReactElement<any, any> | ReactElement<any, any>[] | undefined }){
const [manga, setManga] = useState(DefaultManga);
const [loading, setLoading] = useState(true);
const apiUri = useContext(ApiUriContext);
const loadManga = useCallback(() => {
setLoading(true);
GetMangaById(apiUri, mangaId).then(setManga).finally(() => setLoading(false));
},[apiUri, mangaId]);
useEffect(() => {
loadManga();
}, []);
return <Manga manga={manga} loading={loading} children={children} />
}
export function Manga({manga, children, loading} : { manga: IManga | undefined, children?: ReactElement<any, any> | ReactElement<any, any>[] | undefined, loading?: boolean}) {
const useManga = manga ?? DefaultManga; const useManga = manga ?? DefaultManga;
loading = loading ?? false;
const apiUri = useContext(ApiUriContext); const apiUri = useContext(ApiUriContext);
@ -59,7 +78,7 @@ export function Manga({manga, children} : { manga: IManga | undefined, children?
position: "relative", position: "relative",
} }
const interactiveElements = ["button", "input", "textarea", "a"]; const interactiveElements = ["button", "input", "textarea", "a", "select", "option", "li"];
return ( return (
<Badge badgeContent={useManga.mangaConnectorId} color={ReleaseStatusToPalette(useManga.releaseStatus)} size={"lg"}> <Badge badgeContent={useManga.mangaConnectorId} color={ReleaseStatusToPalette(useManga.releaseStatus)} size={"lg"}>
@ -79,19 +98,25 @@ export function Manga({manga, children} : { manga: IManga | undefined, children?
}}/> }}/>
<CardContent sx={{display: "flex", alignItems: "center", flexFlow: "row nowrap"}}> <CardContent sx={{display: "flex", alignItems: "center", flexFlow: "row nowrap"}}>
<Box sx={sideSx}> <Box sx={sideSx}>
<Link href={useManga.websiteUrl} level={"h1"} sx={{height:"min-content",width:"fit-content",color:"white",margin:"0 0 0 10px"}}> <Skeleton loading={loading}>
{useManga.name} <Link href={useManga.websiteUrl} level={"h1"} sx={{height:"min-content",width:"fit-content",color:"white",margin:"0 0 0 10px"}}>
</Link> {useManga.name}
</Link>
</Skeleton>
</Box> </Box>
{ {
expanded ? expanded ?
<Box sx={sideSx}> <Box sx={sideSx}>
<Stack direction={"row"} flexWrap={"wrap"} spacing={0.5}> <Skeleton loading={loading} variant={"text"} level={"title-lg"}>
{useManga.authorIds.map(authorId => <AuthorTag key={authorId} authorId={authorId} color={"success"} />)} <Stack direction={"row"} flexWrap={"wrap"} spacing={0.5}>
{useManga.tags.map(tag => <Chip key={tag} variant={"outlined"} size={"md"} color={"primary"}>{tag}</Chip>)} {useManga.authorIds.map(authorId => <AuthorTag key={authorId} authorId={authorId} color={"success"} />)}
{useManga.linkIds.map(linkId => <LinkTag key={linkId} linkId={linkId} color={"danger"} />)} {useManga.tags.map(tag => <Chip key={tag} variant={"outlined"} size={"md"} color={"primary"}>{tag}</Chip>)}
</Stack> {useManga.linkIds.map(linkId => <LinkTag key={linkId} linkId={linkId} color={"danger"} />)}
<MarkdownPreview source={useManga.description} style={{backgroundColor: "transparent", color: "black"}} /> </Stack>
</Skeleton>
<Skeleton loading={loading} sx={{maxHeight:"300px"}}>
<MarkdownPreview source={useManga.description} style={{backgroundColor: "transparent", color: "black"}} />
</Skeleton>
</Box> </Box>
: null : null
} }
@ -99,30 +124,32 @@ export function Manga({manga, children} : { manga: IManga | undefined, children?
{ {
expanded ? expanded ?
<CardActions sx={{justifyContent:"space-between"}}> <CardActions sx={{justifyContent:"space-between"}}>
<Input <Skeleton loading={loading} sx={{maxHeight: "30px", maxWidth:"calc(100% - 40px)"}}>
type={"number"} <Input
placeholder={"0.0"} type={"number"}
startDecorator={ placeholder={"0.0"}
<> startDecorator={
{ <>
updatingThreshold ? {
<CircularProgress color={"primary"} size={"sm"} /> updatingThreshold ?
: <Typography>Ch.</Typography> <CircularProgress color={"primary"} size={"sm"} />
: <Typography>Ch.</Typography>
}
</>
} }
</> endDecorator={
} <Typography>
endDecorator={ <Skeleton loading={maxChapterLoading}>
<Typography> /{mangaMaxChapter?.chapterNumber??"Load Failed"}
<Skeleton loading={maxChapterLoading}> </Skeleton>
/{mangaMaxChapter?.chapterNumber??"Load Failed"} </Typography>
</Skeleton> }
</Typography> sx={{width:"min-content"}}
} size={"md"}
sx={{width:"min-content"}} onChange={(e) => updateIgnoreThreshhold(e.currentTarget.valueAsNumber)}
size={"md"} />
onChange={(e) => updateIgnoreThreshhold(e.currentTarget.valueAsNumber)} {children}
/> </Skeleton>
{children}
</CardActions> </CardActions>
: null : null
} }

View File

@ -0,0 +1,36 @@
import {Button, Stack} from "@mui/joy";
import {useCallback, useContext, useEffect, useState} from "react";
import {ApiUriContext} from "../api/fetchApi.tsx";
import {DeleteJob, GetJobsWithType} from "../api/Job.tsx";
import {JobType} from "../api/types/Jobs/IJob.ts";
import IDownloadAvailableChaptersJob from "../api/types/Jobs/IDownloadAvailableChaptersJob.ts";
import {MangaFromId} from "./Manga.tsx";
import { Remove } from "@mui/icons-material";
export default function MangaList(){
const apiUri = useContext(ApiUriContext);
const [jobList, setJobList] = useState<IDownloadAvailableChaptersJob[]>([]);
const getJobList = useCallback(() => {
GetJobsWithType(apiUri, JobType.DownloadAvailableChaptersJob).then((jl) => setJobList(jl as IDownloadAvailableChaptersJob[]));
},[apiUri]);
const deleteJob = useCallback((jobId: string) => {
DeleteJob(apiUri, jobId).finally(() => getJobList());
},[apiUri]);
useEffect(() => {
getJobList();
}, [apiUri]);
return(
<Stack direction="row" spacing={1}>
{jobList.map((job) => (
<MangaFromId key={job.mangaId} mangaId={job.mangaId}>
<Button color={"danger"} endDecorator={<Remove />} onClick={() => deleteJob(job.jobId)}>Delete</Button>
</MangaFromId>
))}
</Stack>
);
}

View File

@ -1,15 +1,19 @@
import { import {
Avatar, Button, Chip, Avatar,
Button,
Chip,
CircularProgress,
Drawer, Drawer,
Input, Input,
ListItemDecorator, ListItemDecorator,
Option, Option,
Select, Select, SelectOption,
SelectOption, Skeleton,
Skeleton, Stack, Stack,
Step, Step,
StepIndicator, StepIndicator,
Stepper, Typography Stepper,
Typography
} from "@mui/joy"; } from "@mui/joy";
import ModalClose from "@mui/joy/ModalClose"; import ModalClose from "@mui/joy/ModalClose";
import IMangaConnector from "../api/types/IMangaConnector"; import IMangaConnector from "../api/types/IMangaConnector";
@ -22,6 +26,9 @@ import {Manga} from "./Manga.tsx";
import Add from "@mui/icons-material/Add"; import Add from "@mui/icons-material/Add";
import React from "react"; import React from "react";
import {CreateDownloadAvailableChaptersJob} from "../api/Job.tsx"; import {CreateDownloadAvailableChaptersJob} from "../api/Job.tsx";
import ILocalLibrary from "../api/types/ILocalLibrary.ts";
import {GetLibraries} from "../api/LocalLibrary.tsx";
import { LibraryBooks } from "@mui/icons-material";
export default function Search({open, setOpen}:{open:boolean, setOpen:React.Dispatch<React.SetStateAction<boolean>>}){ export default function Search({open, setOpen}:{open:boolean, setOpen:React.Dispatch<React.SetStateAction<boolean>>}){
@ -32,10 +39,10 @@ export default function Search({open, setOpen}:{open:boolean, setOpen:React.Disp
const [mangaConnectorsLoading, setMangaConnectorsLoading] = useState<boolean>(true); const [mangaConnectorsLoading, setMangaConnectorsLoading] = useState<boolean>(true);
const [selectedMangaConnector, setSelectedMangaConnector] = useState<IMangaConnector>(); const [selectedMangaConnector, setSelectedMangaConnector] = useState<IMangaConnector>();
useEffect(() => { const loadMangaConnectors = useCallback(() => {
setMangaConnectorsLoading(true); setMangaConnectorsLoading(true);
GetAllConnectors(apiUri).then(setMangaConnectors).finally(() => setMangaConnectorsLoading(false)); GetAllConnectors(apiUri).then(setMangaConnectors).finally(() => setMangaConnectorsLoading(false));
},[apiUri]) }, [apiUri]);
const [results, setResults] = useState<IManga[]>([]); const [results, setResults] = useState<IManga[]>([]);
const [resultsLoading, setResultsLoading] = useState<boolean>(false); const [resultsLoading, setResultsLoading] = useState<boolean>(false);
@ -48,6 +55,25 @@ export default function Search({open, setOpen}:{open:boolean, setOpen:React.Disp
SearchNameOnConnector(apiUri, mangaConnector.name, value).then(setResults).finally(() => setResultsLoading(false)); SearchNameOnConnector(apiUri, mangaConnector.name, value).then(setResults).finally(() => setResultsLoading(false));
},[apiUri]) },[apiUri])
const [localLibraries, setLocalLibraries] = useState<ILocalLibrary[]>();
const [localLibrariesLoading, setLocalLibrariesLoading] = useState<boolean>(true);
const [selectedLibraryId, setSelectedLibraryId] = useState<string>();
const loadLocalLibraries = useCallback(() => {
setLocalLibrariesLoading(true);
GetLibraries(apiUri).then(setLocalLibraries).finally(() => setLocalLibrariesLoading(false));
}, [apiUri]);
useEffect(() => {
loadMangaConnectors();
loadLocalLibraries();
},[apiUri]);
useEffect(() => {
loadMangaConnectors();
loadLocalLibraries();
}, []);
function renderValue(option: SelectOption<string> | null) { function renderValue(option: SelectOption<string> | null) {
if (!option) { if (!option) {
return null; return null;
@ -63,6 +89,7 @@ export default function Search({open, setOpen}:{open:boolean, setOpen:React.Disp
); );
} }
// @ts-ignore
return ( return (
<Drawer size={"lg"} anchor={"right"} open={open} onClose={() => { <Drawer size={"lg"} anchor={"right"} open={open} onClose={() => {
setStep(2); setStep(2);
@ -113,13 +140,28 @@ export default function Search({open, setOpen}:{open:boolean, setOpen:React.Disp
<StepIndicator variant="solid" color="primary"> <StepIndicator variant="solid" color="primary">
3 3
</StepIndicator>}> </StepIndicator>}>
<Typography>Results</Typography> <Typography endDecorator={<Chip size={"sm"} color={"primary"}>{results.length}</Chip>}>Results</Typography>
<Skeleton loading={resultsLoading}> <Skeleton loading={resultsLoading}>
<Stack direction={"row"} spacing={1}> <Stack direction={"row"} spacing={1}>
{results.map((result) => {results.map((result) =>
<Manga key={result.mangaId} manga={result}> <Manga key={result.mangaId} manga={result}>
<Button onClick={() => { <Select
CreateDownloadAvailableChaptersJob(apiUri, result.mangaId, {localLibraryId: "",recurrenceTimeMs: 1000 * 60 * 60 * 3}) placeholder={"Select Library"}
defaultValue={""}
startDecorator={<LibraryBooks />}
value={selectedLibraryId}
onChange={(_e, newValue) => setSelectedLibraryId(newValue!)}>
{localLibrariesLoading ?
<Option value={""} disabled>Loading <CircularProgress color={"primary"} size={"sm"} /></Option>
:
(localLibraries??[]).map(library => {
return (
<Option value={library.localLibraryId}>{library.libraryName} ({library.basePath})</Option>
);
})}
</Select>
<Button disabled={localLibrariesLoading || selectedLibraryId === undefined} onClick={() => {
CreateDownloadAvailableChaptersJob(apiUri, result.mangaId, {localLibraryId: selectedLibraryId!,recurrenceTimeMs: 1000 * 60 * 60 * 3})
}} endDecorator={<Add />}>Watch</Button> }} endDecorator={<Add />}>Watch</Button>
</Manga>)} </Manga>)}
</Stack> </Stack>

View File

@ -0,0 +1,38 @@
import IBackendSettings from "../../api/types/IBackendSettings";
import {Accordion, AccordionDetails, AccordionSummary, CircularProgress, ColorPaletteProp, Input} from "@mui/joy";
import {KeyboardEventHandler, useCallback, useContext, useState} from "react";
import {ApiUriContext} from "../../api/fetchApi.tsx";
import {UpdateUserAgent} from "../../api/BackendSettings.tsx";
export default function UserAgent({backendSettings}: {backendSettings?: IBackendSettings}) {
const apiUri = useContext(ApiUriContext);
const [loading, setLoading] = useState<boolean>(false);
const [value, setValue] = useState<string>("");
const [color, setColor] = useState<ColorPaletteProp>("neutral");
const keyDown : KeyboardEventHandler<HTMLInputElement> = useCallback((e) => {
if(e.key === "Enter") {
setLoading(true);
UpdateUserAgent(apiUri, value)
.then(() => setColor("success"))
.catch(() => setColor("danger"))
.finally(() => setLoading(false));
}
}, [apiUri])
return (
<Accordion>
<AccordionSummary>UserAgent</AccordionSummary>
<AccordionDetails>
<Input disabled={backendSettings === undefined || loading}
placeholder={"UserAgent"}
defaultValue={backendSettings?.userAgent}
onKeyDown={keyDown}
onChange={e => setValue(e.target.value)}
color={color}
endDecorator={(loading ? <CircularProgress color={"primary"} size={"sm"} /> : null)}
/>
</AccordionDetails>
</Accordion>
);
}

View File

@ -10,8 +10,11 @@ import {
} from "@mui/joy"; } from "@mui/joy";
import './Settings.css'; import './Settings.css';
import * as React from "react"; import * as React from "react";
import {useContext, useEffect, useState} from "react"; import {useCallback, useContext, useEffect, useState} from "react";
import {ApiUriContext} from "./api/fetchApi.tsx"; import {ApiUriContext} from "./api/fetchApi.tsx";
import IBackendSettings from "./api/types/IBackendSettings.ts";
import { GetSettings } from './api/BackendSettings.tsx';
import UserAgent from "./Components/Settings/UserAgent.tsx";
const checkConnection = async (apiUri: string): Promise<boolean> =>{ const checkConnection = async (apiUri: string): Promise<boolean> =>{
return fetch(`${apiUri}/swagger/v2/swagger.json`, return fetch(`${apiUri}/swagger/v2/swagger.json`,
@ -28,7 +31,7 @@ const checkConnection = async (apiUri: string): Promise<boolean> =>{
}); });
} }
export default function Settings({open, setOpen, setApiUri}:{open:boolean, setOpen:React.Dispatch<React.SetStateAction<boolean>>, setApiUri:React.Dispatch<React.SetStateAction<string>>}) { export default function Settings({open, setOpen, setApiUri, setConnected}:{open:boolean, setOpen:React.Dispatch<React.SetStateAction<boolean>>, setApiUri:React.Dispatch<React.SetStateAction<string>>, setConnected:React.Dispatch<React.SetStateAction<boolean>>}) {
const apiUri = useContext(ApiUriContext); const apiUri = useContext(ApiUriContext);
@ -53,6 +56,7 @@ export default function Settings({open, setOpen, setApiUri}:{open:boolean, setOp
setChecking(true); setChecking(true);
checkConnection(uri) checkConnection(uri)
.then((result) => { .then((result) => {
setConnected(result);
setApiUriAccordionOpen(!result); setApiUriAccordionOpen(!result);
setApiUriColor(result ? "success" : "danger"); setApiUriColor(result ? "success" : "danger");
if(result) if(result)
@ -61,6 +65,16 @@ export default function Settings({open, setOpen, setApiUri}:{open:boolean, setOp
.finally(() => setChecking(false)); .finally(() => setChecking(false));
} }
const [backendSettings, setBackendSettings] = useState<IBackendSettings>();
const getBackendSettings = useCallback(() => {
GetSettings(apiUri).then(setBackendSettings);
}, [apiUri]);
useEffect(() => {
getBackendSettings();
}, [checking]);
return ( return (
<Drawer size={"md"} open={open} onClose={() => setOpen(false)}> <Drawer size={"md"} open={open} onClose={() => setOpen(false)}>
<ModalClose /> <ModalClose />
@ -86,6 +100,7 @@ export default function Settings({open, setOpen, setApiUri}:{open:boolean, setOp
endDecorator={(checking ? <CircularProgress color={apiUriColor} size={"sm"} /> : null)} /> endDecorator={(checking ? <CircularProgress color={apiUriColor} size={"sm"} /> : null)} />
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
<UserAgent backendSettings={backendSettings} />
</AccordionGroup> </AccordionGroup>
</DialogContent> </DialogContent>
</Drawer> </Drawer>

View File

@ -19,19 +19,19 @@ export default interface IManga{
} }
export const DefaultManga : IManga = { export const DefaultManga : IManga = {
mangaId: "MangaId", mangaId: "Loading",
idOnConnectorSite: "ID", idOnConnectorSite: "Loading",
name: "TestManga", name: "Loading",
description: "Wow so much text, very cool", description: "Loading",
websiteUrl: "https://realsite.realdomain", websiteUrl: "",
year: 1999, year: 1999,
originalLanguage: "lindtChoccy", originalLanguage: "en",
releaseStatus: MangaReleaseStatus.Continuing, releaseStatus: MangaReleaseStatus.Continuing,
folderName: "uhhh", folderName: "Loading",
ignoreChapterBefore: 0, ignoreChapterBefore: 0,
mangaConnectorId: "MangaDex", mangaConnectorId: "Loading",
authorIds: ["We got", "Authors"], authorIds: ["Loading"],
tags: ["And we", "got Tags"], tags: ["Loading"],
linkIds: ["And most", "definitely", "links"], linkIds: ["Loading"],
altTitleIds: ["But not alt-titles."], altTitleIds: ["Loading"],
} }