MangaDetail

This commit is contained in:
2025-09-04 20:51:00 +02:00
parent 48b0d216fa
commit 75f66c791d
6 changed files with 141 additions and 91 deletions

View File

@@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'
import { ApiConfig } from './api/http-client.ts' import { ApiConfig } from './api/http-client.ts'
import MangaProvider from './contexts/MangaContext.tsx' import MangaProvider from './contexts/MangaContext.tsx'
import MangaList from './Components/Mangas/MangaList.tsx' import MangaList from './Components/Mangas/MangaList.tsx'
import Search from './Search.tsx' import {Search} from './Search.tsx'
import MangaConnectorProvider from './contexts/MangaConnectorContext.tsx' import MangaConnectorProvider from './contexts/MangaConnectorContext.tsx'
export default function App() { export default function App() {

View File

@@ -6,51 +6,30 @@ import {
Skeleton, Skeleton,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { EventHandler, ReactNode, useContext, useEffect, useState } from 'react' import { EventHandler, ReactNode, useContext } from 'react'
import './MangaCard.css' import './MangaCard.css'
import MangaConnectorIcon from './MangaConnectorIcon.tsx' import MangaConnectorIcon from './MangaConnectorIcon.tsx'
import { Manga, MinimalManga } from '../../api/data-contracts.ts' import { Manga, MinimalManga } from '../../api/data-contracts.ts'
import { ApiContext } from '../../contexts/ApiContext.tsx' import { ApiContext } from '../../contexts/ApiContext.tsx'
export default function MangaCard({ export default function MangaCard(props: MangaCardProps): ReactNode {
mangaDetail, const Api = useContext(ApiContext);
key,
onClick,
}: {
mangaDetail?: Manga | MinimalManga
key?: string
onClick?: EventHandler<any>
}): ReactNode {
const Api = useContext(ApiContext)
const [manga, setManga] = useState<Manga | MinimalManga | undefined>(
mangaDetail
)
useEffect(() => {
if (!key) return
Api.mangaDetail(key).then((data) => {
if (data.ok) {
setManga(data.data)
}
})
}, [Api, key])
return ( return (
<Badge <Badge
badgeContent={manga?.mangaConnectorIds.map((id) => ( badgeContent={props.manga?.mangaConnectorIds.map((id) => (
<MangaConnectorIcon mangaConnectorName={id.mangaConnectorName} /> <MangaConnectorIcon mangaConnectorName={id.mangaConnectorName} />
))} ))}
className={'manga-card-badge'} className={'manga-card-badge'}
> >
<Card className={'manga-card'} onClick={onClick}> <Card className={'manga-card'} onClick={props.onClick}>
<CardCover className={'manga-card-cover'}> <CardCover className={'manga-card-cover'}>
<img src={manga && manga.key != "Search" ? `${Api.baseUrl}/v2/Manga/${manga?.key}/Cover` : '/blahaj.png'} /> <img src={props.manga && props.manga.key != "Search" ? `${Api.baseUrl}/v2/Manga/${props.manga?.key}/Cover` : '/blahaj.png'} />
</CardCover> </CardCover>
<CardCover className={'manga-card-cover-blur'} /> <CardCover className={'manga-card-cover-blur'} />
<CardContent className={'manga-card-content'}> <CardContent className={'manga-card-content'}>
<Typography level={'h4'}> <Typography level={'h4'}>
{manga?.name ?? ( {props.manga?.name ?? (
<Skeleton>{stringWithRandomLength()}</Skeleton> <Skeleton>{stringWithRandomLength()}</Skeleton>
)} )}
</Typography> </Typography>
@@ -60,6 +39,11 @@ export default function MangaCard({
) )
} }
export interface MangaCardProps {
manga?: Manga | MinimalManga
onClick?: EventHandler<any>
}
const stringWithRandomLength = (): string => { const stringWithRandomLength = (): string => {
return 'wow' return 'wow'
} }

View File

@@ -17,7 +17,6 @@ export default function MangaConnectorIcon({
); );
useEffect(() => { useEffect(() => {
console.log(mangaConnector, mangaConnectorName);
if (mangaConnector) { if (mangaConnector) {
setConnector(mangaConnector) setConnector(mangaConnector)
return; return;

View File

@@ -1,6 +1,6 @@
import { Stack } from '@mui/joy' import { Stack } from '@mui/joy'
import './MangaList.css' import './MangaList.css'
import { ReactNode, useContext, useEffect, useState } from 'react' import {Dispatch, ReactNode, useContext, useEffect, useState} from 'react'
import { import {
Manga, Manga,
MangaReleaseStatus, MangaReleaseStatus,
@@ -29,7 +29,7 @@ export default function MangaList({
<MangaCardList manga={downloadingManga}> <MangaCardList manga={downloadingManga}>
<MangaCard <MangaCard
onClick={openSearch} onClick={openSearch}
mangaDetail={{ manga={{
name: 'Search', name: 'Search',
description: 'Search for a new Manga', description: 'Search for a new Manga',
releaseStatus: MangaReleaseStatus.Continuing, releaseStatus: MangaReleaseStatus.Continuing,
@@ -41,13 +41,7 @@ export default function MangaList({
) )
} }
export function MangaCardList({ export function MangaCardList(props: MangaCardListProps): ReactNode {
manga,
children,
}: {
manga: (Manga | MinimalManga)[]
children?: ReactNode
}): ReactNode {
return ( return (
<Stack <Stack
className={'manga-list'} className={'manga-list'}
@@ -60,13 +54,16 @@ export function MangaCardList({
justifyItems: 'space-between', justifyItems: 'space-between',
}} }}
> >
{children} {props.children}
{manga.map((m) => ( {props.manga.map((m) => (
<> <MangaCard key={m.key} manga={m} onClick={() => { if(props.mangaOnClick) props.mangaOnClick(m); } } />
<MangaCard mangaDetail={m} />
<span style={{ flexGrow: 1 }} />
</>
))} ))}
</Stack> </Stack>
) )
} }
export interface MangaCardListProps {
manga: (Manga | MinimalManga)[]
children?: ReactNode
mangaOnClick?: Dispatch<Manga | MinimalManga>
}

View File

@@ -0,0 +1,59 @@
import {Manga} from "./api/data-contracts.ts";
import {Dispatch, ReactNode, useContext, useEffect, useState} from "react";
import {Card, CardCover, Chip, Modal, ModalDialog, Stack, Typography, useTheme} from "@mui/joy";
import ModalClose from "@mui/joy/ModalClose";
import {ApiContext} from "./contexts/ApiContext.tsx";
import {MangaContext} from "./contexts/MangaContext.tsx";
import './Components/Mangas/MangaCard.css'
import MarkdownPreview from "@uiw/react-markdown-preview";
export default function MangaDetail (props: MangaDetailProps) : ReactNode {
const Api = useContext(ApiContext);
const Manga = useContext(MangaContext);
const [manga, setManga] = useState<Manga | undefined>(props.manga)
useEffect(() => {
if (!props.open) return;
if (!props.mangaKey) return;
if (props.manga != undefined) return;
Manga.GetManga(props.mangaKey).then(setManga);
}, [Api, props]);
const theme = useTheme();
return (
<Modal open={props.open} onClose={() => props.setOpen(false)}>
<ModalDialog>
<ModalClose />
<div style={{display: 'flex', flexWrap: 'wrap', flexDirection: 'row'}}>
<Typography level={"h3"} sx={{width: '100%'}}>{manga?.name}</Typography>
<Card className={'manga-card'}>
<CardCover className={'manga-card-cover'}>
<img src={manga ? `${Api.baseUrl}/v2/Manga/${manga.key}/Cover` : '/blahaj.png'} />
</CardCover>
</Card>
<Stack direction={'column'} gap={2} sx={{maxWidth: 'calc(100% - 230px)', margin: '0 5px'}}>
<Stack direction={'row'} gap={0.5} flexWrap={'wrap'}>
{manga?.tags.map(tag => <Chip key={tag} size={"sm"} sx={{backgroundColor: theme.palette.primary.plainColor}}>{tag}</Chip>)}
{manga?.authors.map(author => <Chip key={author.key} size={'sm'} sx={{backgroundColor: theme.palette.success.plainColor}}>{author.name}</Chip> )}
{manga?.links.map(link => <Chip key={link.provider} size={"sm"} sx={{backgroundColor: theme.palette.neutral.plainColor}}><a href={link.url}>{link.provider}</a></Chip>)}
</Stack>
<MarkdownPreview source={manga?.description} style={{backgroundColor: "transparent", color: theme.palette.text.primary, overflowY: "auto"}}/>
</Stack>
<Stack sx={{width: '100%'}} flexWrap={'nowrap'} gap={1}>
{props.actions}
</Stack>
</div>
</ModalDialog>
</Modal>
);
}
export interface MangaDetailProps {
manga?: Manga;
mangaKey?: string;
open: boolean;
setOpen: Dispatch<boolean>;
actions?: ReactNode[];
}

View File

@@ -17,57 +17,61 @@ import TInput from './Components/Inputs/TInput.tsx'
import { ApiContext } from './contexts/ApiContext.tsx' import { ApiContext } from './contexts/ApiContext.tsx'
import { MangaCardList } from './Components/Mangas/MangaList.tsx' import { MangaCardList } from './Components/Mangas/MangaList.tsx'
import {MangaConnector, MinimalManga} from './api/data-contracts.ts' import {MangaConnector, MinimalManga} from './api/data-contracts.ts'
import MangaDetail from "./MangaDetail.tsx";
export default function Search({ export function Search(props: SearchModalProps): ReactNode {
open,
setOpen,
}: {
open: boolean
setOpen: Dispatch<boolean>
}): ReactNode {
const Api = useContext(ApiContext) const Api = useContext(ApiContext)
const MangaConnectors = useContext(MangaConnectorContext) const MangaConnectors = useContext(MangaConnectorContext)
const [selectedConnector, setSelectedConnector] = useState<MangaConnector>()
const [searchResults, setSearchResults] = useState<MinimalManga[]>([])
const startSearch = (
value: string | number | readonly string[] | undefined
): Promise<void> => {
if (typeof value != 'string') return Promise.reject()
setSearchResults([])
if (isUrl(value)) {
return Api.searchUrlCreate(value)
.then((result) => {
if (result.ok) {
setSearchResults([result.data])
return Promise.resolve()
} else return Promise.reject()
})
.catch(Promise.reject)
} else {
if (!selectedConnector) return Promise.reject()
return Api.searchDetail(selectedConnector?.key, value)
.then((result) => {
if (result.ok) {
setSearchResults(result.data)
return Promise.resolve()
} else return Promise.reject()
})
.catch(Promise.reject)
}
}
useEffect(() => { useEffect(() => {
if (open){ if (props.open) {
setSelectedConnector(undefined); setSelectedConnector(undefined);
setSearchResults([]); setSearchResults([]);
} }
}, [open]); }, [open]);
const [selectedConnector, setSelectedConnector] = useState<MangaConnector>()
const [searchResults, setSearchResults] = useState<MinimalManga[]>([])
const startSearch = async (
value: string | number | readonly string[] | undefined
): Promise<void> => {
if (typeof value != 'string') return Promise.reject()
setSearchResults([])
if (isUrl(value)) {
try {
let result = await Api.searchUrlCreate(value);
if (result.ok) {
setSearchResults([result.data])
return Promise.resolve()
} else return Promise.reject()
} catch (reason) {
return await Promise.reject(reason);
}
} else {
if (!selectedConnector) return Promise.reject()
try {
let result2 = await Api.searchDetail(selectedConnector?.key, value);
if (result2.ok) {
setSearchResults(result2.data)
return Promise.resolve()
} else return Promise.reject()
} catch (reason1) {
return await Promise.reject(reason1);
}
}
}
const [selectedManga, setSelectedManga] = useState<MinimalManga | undefined>(undefined);
const [mangaDetailOpen, setMangaDetailOpen] = useState(false);
function openMangaDetail(manga: MinimalManga) {
setSelectedManga(manga);
setMangaDetailOpen(true);
}
return ( return (
<Modal open={open} onClose={() => setOpen(false)}> <Modal open={props.open} onClose={() => props.setOpen(false)}>
<ModalDialog sx={{width: '90vw'}}> <ModalDialog sx={{width: '90vw'}}>
<ModalClose/> <ModalClose/>
<Stepper> <Stepper>
@@ -81,6 +85,7 @@ export default function Search({
<List> <List>
{MangaConnectors.map((c) => ( {MangaConnectors.map((c) => (
<ListItem <ListItem
key={c.key}
onClick={() => setSelectedConnector(c)} onClick={() => setSelectedConnector(c)}
> >
<ListItemDecorator> <ListItemDecorator>
@@ -114,12 +119,18 @@ export default function Search({
/> />
</Step> </Step>
</Stepper> </Stepper>
<MangaCardList manga={searchResults} /> <MangaCardList manga={searchResults} mangaOnClick={openMangaDetail}/>
<MangaDetail mangaKey={selectedManga?.key} open={mangaDetailOpen} setOpen={setMangaDetailOpen} />
</ModalDialog> </ModalDialog>
</Modal> </Modal>
) )
} }
export interface SearchModalProps {
open: boolean;
setOpen: Dispatch<boolean>;
}
function isUrl(str: string): boolean { function isUrl(str: string): boolean {
try { try {
new URL(str) new URL(str)