Add ChaptersSection to Manga, add TCheckbox and rework other custom components

This commit is contained in:
2025-09-05 17:07:42 +02:00
parent e2926cddbc
commit c625e3dede
14 changed files with 186 additions and 76 deletions

View File

@@ -70,7 +70,7 @@ export function App() {
setOpen={setDownloadDrawerOpen} setOpen={setDownloadDrawerOpen}
mangaKey={selectedMangaKey} mangaKey={selectedMangaKey}
downloadOpen={downloadSectionOpen}> downloadOpen={downloadSectionOpen}>
<TButton completionAction={() => removeManga(selectedMangaKey)}> <TButton onClick={() => removeManga(selectedMangaKey)}>
Remove Remove
</TButton> </TButton>
</MangaDetail> </MangaDetail>

View File

@@ -8,9 +8,9 @@ export default function TButton(props: TButtonProps) {
const clicked: MouseEventHandler<HTMLAnchorElement> = (e) => { const clicked: MouseEventHandler<HTMLAnchorElement> = (e) => {
setState(TState.busy); setState(TState.busy);
e.preventDefault(); e.preventDefault();
if (props.completionAction) if (props.onClick)
props props
.completionAction(undefined) .onClick()
.then(() => setState(TState.success)) .then(() => setState(TState.success))
.catch(() => setState(TState.failure)); .catch(() => setState(TState.failure));
}; };
@@ -29,4 +29,5 @@ export default function TButton(props: TButtonProps) {
export interface TButtonProps extends TProps { export interface TButtonProps extends TProps {
children?: ReactNode; children?: ReactNode;
onClick?: () => Promise<void>;
} }

View File

@@ -0,0 +1,35 @@
import { Checkbox } from '@mui/joy';
import TProps, { TColor, TDisabled, TState } from './TProps.ts';
import { ChangeEvent, ReactNode, useState } from 'react';
export default function TCheckbox(props: TCheckboxProps) {
const [state, setState] = useState<TState>(TState.clean);
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
setState(TState.busy);
e.preventDefault();
if (props.onCheckChanged)
props
.onCheckChanged(e.target.checked)
.then(() => setState(TState.success))
.catch(() => setState(TState.failure));
};
return (
<Checkbox
color={TColor(state)}
disabled={props.disabled ?? TDisabled(state)}
aria-disabled={props.disabled ?? TDisabled(state)}
onChange={onChange}
className={'t-loadable'}
defaultChecked={props.defaultChecked}
label={props.label}
/>
);
}
export interface TCheckboxProps extends TProps {
label?: ReactNode;
defaultChecked?: boolean;
onCheckChanged?: (value: boolean) => Promise<void>;
}

View File

@@ -6,12 +6,10 @@ import './loadingBorder.css';
export default function TInput(props: TInputProps) { export default function TInput(props: TInputProps) {
const [state, setState] = useState<TState>(TState.clean); const [state, setState] = useState<TState>(TState.clean);
const [value, setValue] = useState<string | number | readonly string[] | undefined>( const [value, setValue] = useState<string | number | undefined>(props.defaultValue);
const [initialValue, setInitialValue] = useState<string | number | undefined>(
props.defaultValue props.defaultValue
); );
const [initialValue, setInitialValue] = useState<
string | number | readonly string[] | undefined
>(props.defaultValue);
const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined); const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined);
@@ -39,9 +37,9 @@ export default function TInput(props: TInputProps) {
const submit = () => { const submit = () => {
setState(TState.busy); setState(TState.busy);
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
if (props.completionAction) if (props.onSubmit)
props props
.completionAction(value) .onSubmit(value)
.then(() => { .then(() => {
setState(TState.success); setState(TState.success);
setInitialValue(value); setInitialValue(value);
@@ -82,9 +80,10 @@ export default function TInput(props: TInputProps) {
export interface TInputProps extends TProps { export interface TInputProps extends TProps {
placeholder?: string; placeholder?: string;
defaultValue?: string | number | readonly string[]; defaultValue?: string | number;
actionDelay?: number; actionDelay?: number;
autoSubmit?: boolean; autoSubmit?: boolean;
submitButtonHidden?: boolean; submitButtonHidden?: boolean;
submitButtonText?: string; submitButtonText?: string;
onSubmit?: (value?: string | number) => Promise<void>;
} }

View File

@@ -34,5 +34,4 @@ export const TColor = (state: TState): ColorPaletteProp => {
export default interface TProps { export default interface TProps {
disabled?: boolean; disabled?: boolean;
completionAction?: (value?: string | number | readonly string[]) => Promise<void>;
} }

View File

@@ -0,0 +1,78 @@
import { ReactNode, useContext, useEffect, useState } from 'react';
import { Chapter, Manga, MangaConnector, MangaConnectorId } from '../../../api/data-contracts.ts';
import { Accordion, AccordionDetails, AccordionSummary, Table, Typography } from '@mui/joy';
import { ApiContext } from '../../../contexts/ApiContext.tsx';
import { MangaConnectorContext } from '../../../contexts/MangaConnectorContext.tsx';
import MangaConnectorIcon from '../MangaConnectorIcon.tsx';
import TCheckbox from '../../Inputs/TCheckbox.tsx';
export default function ChaptersSection(props: ChaptersSectionProps): ReactNode {
const Api = useContext(ApiContext);
const MangaConnectors = useContext(MangaConnectorContext);
const [chapters, setChapters] = useState<Chapter[]>([]);
useEffect(() => {
if (!props.manga) return;
Api.mangaChaptersList(props.manga.key).then((data) => {
if (!data.ok) return;
setChapters(data.data);
});
}, [props]);
const chapterConnectorCheckbox = (chapter: Chapter, connector: MangaConnector): ReactNode => {
const id = chapter.mangaConnectorIds.find((id) => id.mangaConnectorName == connector.key);
if (!id) return null;
return (
<TCheckbox
onCheckChanged={(value) => setDownloadingFrom(id, value)}
defaultChecked={id.useForDownload}
/>
);
};
const setDownloadingFrom = (id: MangaConnectorId, value: boolean): Promise<void> => {
return Promise.reject();
};
return (
<Accordion sx={{ maxHeight: '50vh' }}>
<AccordionSummary>
<Typography level={'h3'}>Chapters</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography level={'body-md'}>Set source for chapter</Typography>
<Table>
<thead>
<tr>
<th>Vol</th>
<th>Ch</th>
<th>Title</th>
{MangaConnectors.map((con) => (
<th>
<MangaConnectorIcon mangaConnector={con} />
{con.name}
</th>
))}
</tr>
</thead>
<tbody>
{chapters.map((ch) => (
<tr>
<td>{ch.volume}</td>
<td>{ch.chapterNumber}</td>
<td>{ch.title}</td>
{MangaConnectors.map((con) => (
<td>{chapterConnectorCheckbox(ch, con)}</td>
))}
</tr>
))}
</tbody>
</Table>
</AccordionDetails>
</Accordion>
);
}
export interface ChaptersSectionProps {
manga?: Manga;
}

View File

@@ -11,15 +11,61 @@ import {
Stack, Stack,
Typography, Typography,
} from '@mui/joy'; } from '@mui/joy';
import { ReactNode, useContext } from 'react'; import { ReactNode, useContext, useEffect, useState } from 'react';
import TButton from '../../Inputs/TButton.tsx'; import TButton from '../../Inputs/TButton.tsx';
import MangaConnectorIcon from '../MangaConnectorIcon.tsx'; import MangaConnectorIcon from '../MangaConnectorIcon.tsx';
import { FileLibrary, Manga, MangaConnectorId } from '../../../api/data-contracts.ts'; import { FileLibrary, Manga, MangaConnectorId } from '../../../api/data-contracts.ts';
import { FileLibraryContext } from '../../../contexts/FileLibraryContext.tsx'; import { FileLibraryContext } from '../../../contexts/FileLibraryContext.tsx';
import { ApiContext } from '../../../contexts/ApiContext.tsx';
export default function DownloadSection(props: DownloadSectionProps): ReactNode { export function DownloadSection(props: DownloadSectionProps): ReactNode {
const Api = useContext(ApiContext);
const Libraries = useContext(FileLibraryContext); const Libraries = useContext(FileLibraryContext);
const [manga, setManga] = useState<Manga>();
const [library, setLibrary] = useState<FileLibrary | undefined>();
const [downloadFromMap, setDownloadFromMap] = useState<Map<MangaConnectorId, boolean>>(
new Map()
);
useEffect(() => {
const newMap = new Map();
setLibrary(Libraries.find((library) => library.key == manga?.fileLibraryId));
manga?.mangaConnectorIds.forEach((id) => {
newMap.set(id, id.useForDownload);
});
setDownloadFromMap(newMap);
}, [manga, Libraries]);
useEffect(() => {
setManga(props.manga);
}, [props]);
const onLibraryChange = (_: any, value: string | null) => {
setLibrary(Libraries.find((library) => library.key == value));
};
const setDownload = async (): Promise<void> => {
if (!manga) return Promise.reject();
if (library) {
const s = await Api.mangaChangeLibraryCreate(manga.key, library?.key)
.then((result) => result.ok)
.catch(() => false);
if (!s) return Promise.reject();
}
for (const kv of downloadFromMap) {
const s = await Api.mangaSetAsDownloadFromCreate(
manga?.key,
kv[0].mangaConnectorName,
kv[1]
)
.then((result) => result.ok)
.catch(() => false);
if (!s) return Promise.reject();
}
return Promise.resolve();
};
return ( return (
<Accordion defaultExpanded={props.downloadOpen}> <Accordion defaultExpanded={props.downloadOpen}>
<AccordionSummary> <AccordionSummary>
@@ -34,8 +80,8 @@ export default function DownloadSection(props: DownloadSectionProps): ReactNode
<Typography>Select a Library to Download to:</Typography> <Typography>Select a Library to Download to:</Typography>
<Select <Select
placeholder={'Select a Library'} placeholder={'Select a Library'}
value={props.library?.key} value={library?.key}
onChange={props.onLibraryChange}> onChange={onLibraryChange}>
{Libraries.map((l) => ( {Libraries.map((l) => (
<Option <Option
key={l.key} key={l.key}
@@ -54,9 +100,7 @@ export default function DownloadSection(props: DownloadSectionProps): ReactNode
<ListItem key={id.key}> <ListItem key={id.key}>
<Checkbox <Checkbox
defaultChecked={id.useForDownload} defaultChecked={id.useForDownload}
onChange={(c) => onChange={(c) => downloadFromMap.set(id, c.target.checked)}
props.downloadFromMap.set(id, c.target.checked)
}
label={ label={
<div <div
style={{ style={{
@@ -75,7 +119,7 @@ export default function DownloadSection(props: DownloadSectionProps): ReactNode
))} ))}
</List> </List>
</Box> </Box>
<TButton completionAction={props.setDownload}>Download All</TButton> <TButton onClick={setDownload}>Download All</TButton>
</Stack> </Stack>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
@@ -84,8 +128,4 @@ export default function DownloadSection(props: DownloadSectionProps): ReactNode
export interface DownloadSectionProps { export interface DownloadSectionProps {
manga?: Manga; manga?: Manga;
downloadOpen: boolean; downloadOpen: boolean;
library?: FileLibrary;
onLibraryChange: (_: any, value: string | null) => void;
downloadFromMap: Map<MangaConnectorId, boolean>;
setDownload: () => Promise<void>;
} }

View File

@@ -1,67 +1,27 @@
import { Dispatch, ReactNode, useContext, useEffect, useState } from 'react'; import { Dispatch, ReactNode, useContext, useEffect, useState } from 'react';
import { Card, CardCover, Chip, Modal, ModalDialog, Stack, Typography, useTheme } from '@mui/joy'; import { Card, CardCover, Chip, Modal, ModalDialog, Stack, Typography, useTheme } from '@mui/joy';
import ModalClose from '@mui/joy/ModalClose'; import ModalClose from '@mui/joy/ModalClose';
import { FileLibrary, Manga, MangaConnectorId } from '../../../api/data-contracts.ts'; import { Manga } from '../../../api/data-contracts.ts';
import { ApiContext } from '../../../contexts/ApiContext.tsx'; import { ApiContext } from '../../../contexts/ApiContext.tsx';
import { MangaContext } from '../../../contexts/MangaContext.tsx'; import { MangaContext } from '../../../contexts/MangaContext.tsx';
import { FileLibraryContext } from '../../../contexts/FileLibraryContext.tsx';
import MarkdownPreview from '@uiw/react-markdown-preview'; import MarkdownPreview from '@uiw/react-markdown-preview';
import DownloadSection from './DownloadSection.tsx'; import { DownloadSection } from './DownloadSection.tsx';
import ChaptersSection from './ChaptersSection.tsx';
export default function MangaDetail(props: MangaDetailProps): ReactNode { export default function MangaDetail(props: MangaDetailProps): ReactNode {
const Api = useContext(ApiContext); const Api = useContext(ApiContext);
const Manga = useContext(MangaContext); const Manga = useContext(MangaContext);
const Libraries = useContext(FileLibraryContext);
const theme = useTheme(); const theme = useTheme();
const [manga, setManga] = useState<Manga | undefined>(props.manga); const [manga, setManga] = useState<Manga | undefined>(props.manga);
const [library, setLibrary] = useState<FileLibrary | undefined>();
const [downloadFromMap, setDownloadFromMap] = useState<Map<MangaConnectorId, boolean>>(
new Map()
);
useEffect(() => { useEffect(() => {
if (!props.open) return; if (!props.open) return;
if (!props.mangaKey) return; if (!props.mangaKey) return;
if (props.manga != undefined) return; if (props.manga != undefined) return;
setLibrary(undefined);
Manga.GetManga(props.mangaKey).then(setManga); Manga.GetManga(props.mangaKey).then(setManga);
}, [Api, Manga, props]); }, [Api, Manga, props]);
useEffect(() => {
const newMap = new Map();
setLibrary(Libraries.find((library) => library.key == manga?.fileLibraryId));
manga?.mangaConnectorIds.forEach((id) => {
newMap.set(id, id.useForDownload);
});
setDownloadFromMap(newMap);
}, [manga, Libraries]);
const setDownload = async (): Promise<void> => {
if (!manga) return Promise.reject();
if (library) {
const s = await Api.mangaChangeLibraryCreate(manga.key, library?.key)
.then((result) => result.ok)
.catch(() => false);
if (!s) return Promise.reject();
}
for (const kv of downloadFromMap) {
const s = await Api.mangaSetAsDownloadFromCreate(
manga?.key,
kv[0].mangaConnectorName,
kv[1]
)
.then((result) => result.ok)
.catch(() => false);
if (!s) return Promise.reject();
}
return Promise.resolve();
};
const onLibraryChange = (_: any, value: string | null) => {
setLibrary(Libraries.find((library) => library.key == value));
};
return ( return (
<Modal <Modal
open={props.open} open={props.open}
@@ -139,11 +99,9 @@ export default function MangaDetail(props: MangaDetailProps): ReactNode {
</Stack> </Stack>
<DownloadSection <DownloadSection
downloadOpen={props.downloadOpen ?? false} downloadOpen={props.downloadOpen ?? false}
library={library} manga={manga}
onLibraryChange={onLibraryChange}
downloadFromMap={downloadFromMap}
setDownload={setDownload}
/> />
<ChaptersSection manga={manga} />
</ModalDialog> </ModalDialog>
</Modal> </Modal>
); );

View File

@@ -30,7 +30,7 @@ export default function ChapterNamingScheme(): ReactNode {
<TInput <TInput
defaultValue={settings?.chapterNamingScheme as string} defaultValue={settings?.chapterNamingScheme as string}
placeholder={'Scheme'} placeholder={'Scheme'}
completionAction={schemeChanged} onSubmit={schemeChanged}
actionDelay={5000} actionDelay={5000}
/> />
</SettingsItem> </SettingsItem>

View File

@@ -23,7 +23,7 @@ export default function DownloadLanguage(): ReactNode {
<TInput <TInput
defaultValue={settings?.downloadLanguage as string} defaultValue={settings?.downloadLanguage as string}
placeholder={"Language code (f.e. 'en')"} placeholder={"Language code (f.e. 'en')"}
completionAction={languageChanged} onSubmit={languageChanged}
/> />
</SettingsItem> </SettingsItem>
); );

View File

@@ -23,7 +23,7 @@ export default function FlareSolverr(): ReactNode {
<TInput <TInput
placeholder={'FlareSolverr URL'} placeholder={'FlareSolverr URL'}
defaultValue={settings?.flareSolverrUrl as string} defaultValue={settings?.flareSolverrUrl as string}
completionAction={uriChanged} onSubmit={uriChanged}
/> />
</SettingsItem> </SettingsItem>
); );

View File

@@ -18,7 +18,7 @@ export default function Maintenance() {
return ( return (
<SettingsItem title={'Maintenance'}> <SettingsItem title={'Maintenance'}>
<TButton completionAction={cleanUnusedManga}>Cleanup unused Manga</TButton> <TButton onClick={cleanUnusedManga}>Cleanup unused Manga</TButton>
</SettingsItem> </SettingsItem>
); );
} }

View File

@@ -60,7 +60,7 @@ export default function Settings({ setApiUri }: { setApiUri: (uri: string) => vo
<TInput <TInput
placeholder={'http(s)://'} placeholder={'http(s)://'}
defaultValue={Api.baseUrl} defaultValue={Api.baseUrl}
completionAction={apiUriChanged} onSubmit={apiUriChanged}
/> />
</SettingsItem> </SettingsItem>
<ImageCompression /> <ImageCompression />

View File

@@ -98,7 +98,7 @@ export function Search(props: SearchModalProps): ReactNode {
<Typography level={'title-lg'}>Enter a search term or URL</Typography> <Typography level={'title-lg'}>Enter a search term or URL</Typography>
<TInput <TInput
placeholder={'Manga-name or URL'} placeholder={'Manga-name or URL'}
completionAction={startSearch} onSubmit={startSearch}
/> />
</Step> </Step>
</Stepper> </Stepper>