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 Settings from "./Settings.tsx";
import Header from "./Header.tsx";
import {Button} from "@mui/joy";
import {Badge, Button} from "@mui/joy";
import {useState} from "react";
import {ApiUriContext} from "./api/fetchApi.tsx";
import Search from './Components/Search.tsx';
import MangaList from "./Components/MangaList.tsx";
export default function App () {
const [showSettings, setShowSettings] = 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("/")));
@ -18,12 +20,15 @@ export default function App () {
<ApiUriContext.Provider value={apiUri}>
<Sheet className={"app"}>
<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>
</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"}>
<Search open={showSearch} setOpen={setShowSearch} />
<MangaList />
</Sheet>
</Sheet>
</ApiUriContext.Provider>

View File

@ -13,7 +13,7 @@ import {
} from "@mui/joy";
import IManga, {DefaultManga} from "../api/types/IManga.ts";
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 AuthorTag from "./AuthorTag.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 {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;
loading = loading ?? false;
const apiUri = useContext(ApiUriContext);
@ -59,7 +78,7 @@ export function Manga({manga, children} : { manga: IManga | undefined, children?
position: "relative",
}
const interactiveElements = ["button", "input", "textarea", "a"];
const interactiveElements = ["button", "input", "textarea", "a", "select", "option", "li"];
return (
<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"}}>
<Box sx={sideSx}>
<Link href={useManga.websiteUrl} level={"h1"} sx={{height:"min-content",width:"fit-content",color:"white",margin:"0 0 0 10px"}}>
{useManga.name}
</Link>
<Skeleton loading={loading}>
<Link href={useManga.websiteUrl} level={"h1"} sx={{height:"min-content",width:"fit-content",color:"white",margin:"0 0 0 10px"}}>
{useManga.name}
</Link>
</Skeleton>
</Box>
{
expanded ?
<Box sx={sideSx}>
<Stack direction={"row"} flexWrap={"wrap"} spacing={0.5}>
{useManga.authorIds.map(authorId => <AuthorTag key={authorId} authorId={authorId} color={"success"} />)}
{useManga.tags.map(tag => <Chip key={tag} variant={"outlined"} size={"md"} color={"primary"}>{tag}</Chip>)}
{useManga.linkIds.map(linkId => <LinkTag key={linkId} linkId={linkId} color={"danger"} />)}
</Stack>
<MarkdownPreview source={useManga.description} style={{backgroundColor: "transparent", color: "black"}} />
<Skeleton loading={loading} variant={"text"} level={"title-lg"}>
<Stack direction={"row"} flexWrap={"wrap"} spacing={0.5}>
{useManga.authorIds.map(authorId => <AuthorTag key={authorId} authorId={authorId} color={"success"} />)}
{useManga.tags.map(tag => <Chip key={tag} variant={"outlined"} size={"md"} color={"primary"}>{tag}</Chip>)}
{useManga.linkIds.map(linkId => <LinkTag key={linkId} linkId={linkId} color={"danger"} />)}
</Stack>
</Skeleton>
<Skeleton loading={loading} sx={{maxHeight:"300px"}}>
<MarkdownPreview source={useManga.description} style={{backgroundColor: "transparent", color: "black"}} />
</Skeleton>
</Box>
: null
}
@ -99,30 +124,32 @@ export function Manga({manga, children} : { manga: IManga | undefined, children?
{
expanded ?
<CardActions sx={{justifyContent:"space-between"}}>
<Input
type={"number"}
placeholder={"0.0"}
startDecorator={
<>
{
updatingThreshold ?
<CircularProgress color={"primary"} size={"sm"} />
: <Typography>Ch.</Typography>
<Skeleton loading={loading} sx={{maxHeight: "30px", maxWidth:"calc(100% - 40px)"}}>
<Input
type={"number"}
placeholder={"0.0"}
startDecorator={
<>
{
updatingThreshold ?
<CircularProgress color={"primary"} size={"sm"} />
: <Typography>Ch.</Typography>
}
</>
}
</>
}
endDecorator={
<Typography>
<Skeleton loading={maxChapterLoading}>
/{mangaMaxChapter?.chapterNumber??"Load Failed"}
</Skeleton>
</Typography>
}
sx={{width:"min-content"}}
size={"md"}
onChange={(e) => updateIgnoreThreshhold(e.currentTarget.valueAsNumber)}
/>
{children}
endDecorator={
<Typography>
<Skeleton loading={maxChapterLoading}>
/{mangaMaxChapter?.chapterNumber??"Load Failed"}
</Skeleton>
</Typography>
}
sx={{width:"min-content"}}
size={"md"}
onChange={(e) => updateIgnoreThreshhold(e.currentTarget.valueAsNumber)}
/>
{children}
</Skeleton>
</CardActions>
: 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 {
Avatar, Button, Chip,
Avatar,
Button,
Chip,
CircularProgress,
Drawer,
Input,
ListItemDecorator,
Option,
Select,
SelectOption,
Skeleton, Stack,
Select, SelectOption,
Skeleton,
Stack,
Step,
StepIndicator,
Stepper, Typography
Stepper,
Typography
} from "@mui/joy";
import ModalClose from "@mui/joy/ModalClose";
import IMangaConnector from "../api/types/IMangaConnector";
@ -22,6 +26,9 @@ import {Manga} from "./Manga.tsx";
import Add from "@mui/icons-material/Add";
import React from "react";
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>>}){
@ -32,10 +39,10 @@ export default function Search({open, setOpen}:{open:boolean, setOpen:React.Disp
const [mangaConnectorsLoading, setMangaConnectorsLoading] = useState<boolean>(true);
const [selectedMangaConnector, setSelectedMangaConnector] = useState<IMangaConnector>();
useEffect(() => {
const loadMangaConnectors = useCallback(() => {
setMangaConnectorsLoading(true);
GetAllConnectors(apiUri).then(setMangaConnectors).finally(() => setMangaConnectorsLoading(false));
},[apiUri])
}, [apiUri]);
const [results, setResults] = useState<IManga[]>([]);
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));
},[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) {
if (!option) {
return null;
@ -63,6 +89,7 @@ export default function Search({open, setOpen}:{open:boolean, setOpen:React.Disp
);
}
// @ts-ignore
return (
<Drawer size={"lg"} anchor={"right"} open={open} onClose={() => {
setStep(2);
@ -113,13 +140,28 @@ export default function Search({open, setOpen}:{open:boolean, setOpen:React.Disp
<StepIndicator variant="solid" color="primary">
3
</StepIndicator>}>
<Typography>Results</Typography>
<Typography endDecorator={<Chip size={"sm"} color={"primary"}>{results.length}</Chip>}>Results</Typography>
<Skeleton loading={resultsLoading}>
<Stack direction={"row"} spacing={1}>
{results.map((result) =>
<Manga key={result.mangaId} manga={result}>
<Button onClick={() => {
CreateDownloadAvailableChaptersJob(apiUri, result.mangaId, {localLibraryId: "",recurrenceTimeMs: 1000 * 60 * 60 * 3})
<Select
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>
</Manga>)}
</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";
import './Settings.css';
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 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> =>{
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);
@ -53,6 +56,7 @@ export default function Settings({open, setOpen, setApiUri}:{open:boolean, setOp
setChecking(true);
checkConnection(uri)
.then((result) => {
setConnected(result);
setApiUriAccordionOpen(!result);
setApiUriColor(result ? "success" : "danger");
if(result)
@ -61,6 +65,16 @@ export default function Settings({open, setOpen, setApiUri}:{open:boolean, setOp
.finally(() => setChecking(false));
}
const [backendSettings, setBackendSettings] = useState<IBackendSettings>();
const getBackendSettings = useCallback(() => {
GetSettings(apiUri).then(setBackendSettings);
}, [apiUri]);
useEffect(() => {
getBackendSettings();
}, [checking]);
return (
<Drawer size={"md"} open={open} onClose={() => setOpen(false)}>
<ModalClose />
@ -86,6 +100,7 @@ export default function Settings({open, setOpen, setApiUri}:{open:boolean, setOp
endDecorator={(checking ? <CircularProgress color={apiUriColor} size={"sm"} /> : null)} />
</AccordionDetails>
</Accordion>
<UserAgent backendSettings={backendSettings} />
</AccordionGroup>
</DialogContent>
</Drawer>

View File

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