mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-02 10:24:35 -05:00
Manga page Finished
This commit is contained in:
@@ -18,7 +18,8 @@ import NavBar from 'components/navbar/NavBar';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import DarkTheme from 'context/DarkTheme';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import Sources from 'screens/manga/Sources';
|
||||
import MangaSources from 'screens/manga/MangaSources';
|
||||
import AnimeSources from 'screens/anime/AnimeSources';
|
||||
import Settings from 'screens/Settings';
|
||||
import About from 'screens/settings/About';
|
||||
import Categories from 'screens/settings/Categories';
|
||||
@@ -26,8 +27,10 @@ import Backup from 'screens/settings/Backup';
|
||||
import Library from 'screens/manga/Library';
|
||||
import SearchSingle from 'screens/manga/SearchSingle';
|
||||
import Manga from 'screens/manga/Manga';
|
||||
import Anime from 'screens/anime/Anime';
|
||||
import MangaExtensions from 'screens/manga/MangaExtensions';
|
||||
import SourceMangas from 'screens/manga/SourceMangas';
|
||||
import SourceAnimes from 'screens/anime/SourceAnimes';
|
||||
import Reader from 'screens/manga/Reader';
|
||||
import AnimeExtensions from 'screens/anime/AnimeExtensions';
|
||||
|
||||
@@ -118,8 +121,8 @@ export default function App() {
|
||||
<Route path="/sources/:sourceId/latest/">
|
||||
<SourceMangas popular={false} />
|
||||
</Route>
|
||||
<Route path="/sources">
|
||||
<Sources />
|
||||
<Route path="/manga/sources">
|
||||
<MangaSources />
|
||||
</Route>
|
||||
<Route path="/manga/:mangaId/chapter/:chapterNum">
|
||||
<></>
|
||||
@@ -142,6 +145,18 @@ export default function App() {
|
||||
<Route path="/anime/extensions">
|
||||
<AnimeExtensions />
|
||||
</Route>
|
||||
<Route path="/anime/sources/:sourceId/popular/">
|
||||
<SourceAnimes popular />
|
||||
</Route>
|
||||
<Route path="/anime/sources/:sourceId/latest/">
|
||||
<SourceMangas popular={false} />
|
||||
</Route>
|
||||
<Route path="/anime/sources">
|
||||
<AnimeSources />
|
||||
</Route>
|
||||
<Route path="/anime/:id">
|
||||
<Anime />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Container>
|
||||
</NavbarContext.Provider>
|
||||
|
||||
@@ -71,12 +71,20 @@ export default function TemporaryDrawer({ drawerOpen, setDrawerOpen }: IProps) {
|
||||
<ListItemText primary="Anime Extensions" />
|
||||
</ListItem>
|
||||
</Link>
|
||||
<Link to="/sources" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<Link to="/manga/sources" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<ListItem button key="Sources">
|
||||
<ListItemIcon>
|
||||
<ExploreIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Sources" />
|
||||
<ListItemText primary="Manga Sources" />
|
||||
</ListItem>
|
||||
</Link>
|
||||
<Link to="/anime/sources" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<ListItem button key="Sources">
|
||||
<ListItemIcon>
|
||||
<ExploreIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Anime Sources" />
|
||||
</ListItem>
|
||||
</Link>
|
||||
<Link to="/settings" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
|
||||
83
webUI/react/src/components/anime/AnimeCard.tsx
Normal file
83
webUI/react/src/components/anime/AnimeCard.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardActionArea from '@material-ui/core/CardActionArea';
|
||||
import CardMedia from '@material-ui/core/CardMedia';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
},
|
||||
wrapper: {
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
},
|
||||
gradient: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(to bottom, transparent, #000000)',
|
||||
opacity: 0.5,
|
||||
},
|
||||
title: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
padding: '0.5em',
|
||||
color: 'white',
|
||||
},
|
||||
image: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
interface IProps {
|
||||
manga: IMangaCard
|
||||
}
|
||||
const AnimeCard = React.forwardRef((props: IProps, ref) => {
|
||||
const {
|
||||
manga: {
|
||||
id, title, thumbnailUrl,
|
||||
},
|
||||
} = props;
|
||||
const classes = useStyles();
|
||||
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
|
||||
return (
|
||||
<Grid item xs={6} sm={4} md={3} lg={2}>
|
||||
<Link to={`/anime/${id}/`}>
|
||||
<Card className={classes.root} ref={ref}>
|
||||
<CardActionArea>
|
||||
<div className={classes.wrapper}>
|
||||
<CardMedia
|
||||
className={classes.image}
|
||||
component="img"
|
||||
alt={title}
|
||||
image={serverAddress + thumbnailUrl}
|
||||
title={title}
|
||||
/>
|
||||
<div className={classes.gradient} />
|
||||
<Typography className={classes.title} variant="h5" component="h2">{title}</Typography>
|
||||
</div>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Link>
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
|
||||
export default AnimeCard;
|
||||
257
webUI/react/src/components/anime/AnimeDetails.tsx
Normal file
257
webUI/react/src/components/anime/AnimeDetails.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import { Theme } from '@material-ui/core/styles';
|
||||
import FavoriteIcon from '@material-ui/icons/Favorite';
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder';
|
||||
import FilterListIcon from '@material-ui/icons/FilterList';
|
||||
import PublicIcon from '@material-ui/icons/Public';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import CategorySelect from 'components/manga/CategorySelect';
|
||||
|
||||
const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({
|
||||
root: {
|
||||
width: '100%',
|
||||
[theme.breakpoints.up('md')]: {
|
||||
position: 'sticky',
|
||||
top: '64px',
|
||||
left: '0px',
|
||||
width: '50vw',
|
||||
height: 'calc(100vh - 64px)',
|
||||
alignSelf: 'flex-start',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
top: {
|
||||
padding: '10px',
|
||||
// [theme.breakpoints.up('md')]: {
|
||||
// minWidth: '50%',
|
||||
// },
|
||||
},
|
||||
leftRight: {
|
||||
display: 'flex',
|
||||
},
|
||||
leftSide: {
|
||||
'& img': {
|
||||
borderRadius: 4,
|
||||
maxWidth: '100%',
|
||||
minWidth: '100%',
|
||||
height: 'auto',
|
||||
},
|
||||
maxWidth: '50%',
|
||||
// [theme.breakpoints.up('md')]: {
|
||||
// minWidth: '100px',
|
||||
// },
|
||||
},
|
||||
rightSide: {
|
||||
marginLeft: 15,
|
||||
maxWidth: '100%',
|
||||
'& span': {
|
||||
fontWeight: '400',
|
||||
},
|
||||
[theme.breakpoints.up('lg')]: {
|
||||
fontSize: '1.3em',
|
||||
},
|
||||
},
|
||||
buttons: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
'& button': {
|
||||
color: inLibrary === 'In Library' ? '#2196f3' : 'inherit',
|
||||
},
|
||||
'& span': {
|
||||
display: 'block',
|
||||
fontSize: '0.85em',
|
||||
},
|
||||
'& a': {
|
||||
textDecoration: 'none',
|
||||
color: '#858585',
|
||||
'& button': {
|
||||
color: 'inherit',
|
||||
},
|
||||
},
|
||||
},
|
||||
bottom: {
|
||||
paddingLeft: '10px',
|
||||
paddingRight: '10px',
|
||||
[theme.breakpoints.up('md')]: {
|
||||
fontSize: '1.2em',
|
||||
// maxWidth: '50%',
|
||||
},
|
||||
[theme.breakpoints.up('lg')]: {
|
||||
fontSize: '1.3em',
|
||||
},
|
||||
},
|
||||
description: {
|
||||
'& h4': {
|
||||
marginTop: '1em',
|
||||
marginBottom: 0,
|
||||
},
|
||||
'& p': {
|
||||
textAlign: 'justify',
|
||||
textJustify: 'inter-word',
|
||||
},
|
||||
},
|
||||
genre: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
'& h5': {
|
||||
border: '2px solid #2196f3',
|
||||
borderRadius: '1.13em',
|
||||
marginRight: '1em',
|
||||
marginTop: 0,
|
||||
marginBottom: '10px',
|
||||
padding: '0.3em',
|
||||
color: '#2196f3',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
interface IProps{
|
||||
manga: IManga
|
||||
}
|
||||
|
||||
function getSourceName(source: ISource) {
|
||||
if (source.name !== null) {
|
||||
return `${source.name} (${source.lang.toLocaleUpperCase()})`;
|
||||
}
|
||||
return source.id;
|
||||
}
|
||||
|
||||
function getValueOrUnknown(val: string) {
|
||||
return val || 'UNKNOWN';
|
||||
}
|
||||
|
||||
export default function AnimeDetails(props: IProps) {
|
||||
const { setAction } = useContext(NavbarContext);
|
||||
|
||||
const { manga } = props;
|
||||
if (manga.genre == null) {
|
||||
manga.genre = '';
|
||||
}
|
||||
const [inLibrary, setInLibrary] = useState<string>(
|
||||
manga.inLibrary ? 'In Library' : 'Add To Library',
|
||||
);
|
||||
|
||||
const [categoryDialogOpen, setCategoryDialogOpen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (inLibrary === 'In Library') {
|
||||
setAction(
|
||||
<>
|
||||
<IconButton
|
||||
onClick={() => setCategoryDialogOpen(true)}
|
||||
aria-label="display more actions"
|
||||
edge="end"
|
||||
color="inherit"
|
||||
>
|
||||
<FilterListIcon />
|
||||
</IconButton>
|
||||
<CategorySelect
|
||||
open={categoryDialogOpen}
|
||||
setOpen={setCategoryDialogOpen}
|
||||
mangaId={manga.id}
|
||||
/>
|
||||
</>,
|
||||
|
||||
);
|
||||
} else { setAction(<></>); }
|
||||
}, [inLibrary, categoryDialogOpen]);
|
||||
|
||||
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
|
||||
const classes = useStyles(inLibrary)();
|
||||
|
||||
function addToLibrary() {
|
||||
// setInLibrary('adding');
|
||||
client.get(`/api/v1/anime/anime/${manga.id}/library/`).then(() => {
|
||||
setInLibrary('In Library');
|
||||
});
|
||||
}
|
||||
|
||||
function removeFromLibrary() {
|
||||
// setInLibrary('removing');
|
||||
client.delete(`/api/v1/anime/anime/${manga.id}/library/`).then(() => {
|
||||
setInLibrary('Add To Library');
|
||||
});
|
||||
}
|
||||
|
||||
function handleButtonClick() {
|
||||
if (inLibrary === 'Add To Library') {
|
||||
addToLibrary();
|
||||
} else {
|
||||
removeFromLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.top}>
|
||||
<div className={classes.leftRight}>
|
||||
<div className={classes.leftSide}>
|
||||
<img src={`${serverAddress}${manga.thumbnailUrl}?x=${Math.random()}`} alt="Manga Thumbnail" />
|
||||
</div>
|
||||
<div className={classes.rightSide}>
|
||||
<h1>
|
||||
{manga.title}
|
||||
</h1>
|
||||
<h3>
|
||||
Author:
|
||||
{' '}
|
||||
<span>{getValueOrUnknown(manga.author)}</span>
|
||||
</h3>
|
||||
<h3>
|
||||
Artist:
|
||||
{' '}
|
||||
<span>{getValueOrUnknown(manga.artist)}</span>
|
||||
</h3>
|
||||
<h3>
|
||||
Status:
|
||||
{' '}
|
||||
{manga.status}
|
||||
</h3>
|
||||
<h3>
|
||||
Source:
|
||||
{' '}
|
||||
{getSourceName(manga.source)}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.buttons}>
|
||||
<div>
|
||||
<IconButton onClick={() => handleButtonClick()}>
|
||||
{inLibrary === 'In Library' && <FavoriteIcon />}
|
||||
{inLibrary !== 'In Library' && <FavoriteBorderIcon />}
|
||||
<span>{inLibrary}</span>
|
||||
</IconButton>
|
||||
</div>
|
||||
{ /* eslint-disable-next-line react/jsx-no-target-blank */ }
|
||||
<a href={manga.url} target="_blank">
|
||||
<IconButton>
|
||||
<PublicIcon />
|
||||
<span>Open Site</span>
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.bottom}>
|
||||
<div className={classes.description}>
|
||||
<h4>About</h4>
|
||||
<p>{manga.description}</p>
|
||||
</div>
|
||||
<div className={classes.genre}>
|
||||
{manga.genre.split(', ').map((g) => <h5 key={g}>{g}</h5>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
webUI/react/src/components/anime/AnimeGrid.tsx
Normal file
62
webUI/react/src/components/anime/AnimeGrid.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import AnimeCard from './AnimeCard';
|
||||
|
||||
interface IProps{
|
||||
mangas: IMangaCard[]
|
||||
message?: string
|
||||
hasNextPage: boolean
|
||||
lastPageNum: number
|
||||
setLastPageNum: (lastPageNum: number) => void
|
||||
}
|
||||
|
||||
export default function AnimeGrid(props: IProps) {
|
||||
const {
|
||||
mangas, message, hasNextPage, lastPageNum, setLastPageNum,
|
||||
} = props;
|
||||
let mapped;
|
||||
const lastManga = useRef<HTMLInputElement>();
|
||||
|
||||
const scrollHandler = () => {
|
||||
if (lastManga.current) {
|
||||
const rect = lastManga.current.getBoundingClientRect();
|
||||
if (((rect.y + rect.height) / window.innerHeight < 2) && hasNextPage) {
|
||||
setLastPageNum(lastPageNum + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
window.addEventListener('scroll', scrollHandler, true);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', scrollHandler, true);
|
||||
};
|
||||
}, [hasNextPage, mangas]);
|
||||
|
||||
if (mangas.length === 0) {
|
||||
mapped = <h3>{message}</h3>;
|
||||
} else {
|
||||
mapped = mangas.map((it, idx) => {
|
||||
if (idx === mangas.length - 1) {
|
||||
return <AnimeCard manga={it} ref={lastManga} />;
|
||||
}
|
||||
return <AnimeCard manga={it} />;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid container spacing={1} style={{ margin: 0, width: '100%', padding: '5px' }}>
|
||||
{mapped}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
AnimeGrid.defaultProps = {
|
||||
message: 'loading...',
|
||||
};
|
||||
138
webUI/react/src/components/anime/EpisodeCard.tsx
Normal file
138
webUI/react/src/components/anime/EpisodeCard.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles, useTheme } from '@material-ui/core/styles';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import MoreVertIcon from '@material-ui/icons/MoreVert';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Menu from '@material-ui/core/Menu';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import BookmarkIcon from '@material-ui/icons/Bookmark';
|
||||
import client from 'util/client';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
bullet: {
|
||||
display: 'inline-block',
|
||||
margin: '0 2px',
|
||||
transform: 'scale(0.8)',
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
},
|
||||
pos: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
icon: {
|
||||
width: theme.spacing(7),
|
||||
height: theme.spacing(7),
|
||||
flex: '0 0 auto',
|
||||
marginRight: 16,
|
||||
},
|
||||
}));
|
||||
|
||||
interface IProps{
|
||||
episode: IEpisode
|
||||
triggerEpisodesUpdate: () => void
|
||||
}
|
||||
|
||||
export default function EpisodeCard(props: IProps) {
|
||||
const classes = useStyles();
|
||||
const theme = useTheme();
|
||||
const { episode, triggerEpisodesUpdate } = props;
|
||||
|
||||
const dateStr = episode.uploadDate && new Date(episode.uploadDate).toISOString().slice(0, 10);
|
||||
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const sendChange = (key: string, value: any) => {
|
||||
handleClose();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append(key, value);
|
||||
client.patch(`/api/v1/anime/anime/${episode.animeId}/episode/${episode.index}`, formData)
|
||||
.then(() => triggerEpisodesUpdate());
|
||||
};
|
||||
|
||||
const readChapterColor = theme.palette.type === 'dark' ? '#acacac' : '#b0b0b0';
|
||||
return (
|
||||
<>
|
||||
<li>
|
||||
<Card>
|
||||
<CardContent className={classes.root}>
|
||||
<Link
|
||||
to={`/anime/${episode.animeId}/episode/${episode.index}`}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: episode.read ? readChapterColor : theme.palette.text.primary,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h5" component="h2">
|
||||
<span style={{ color: theme.palette.primary.dark }}>
|
||||
{episode.bookmarked && <BookmarkIcon />}
|
||||
</span>
|
||||
{episode.name}
|
||||
{episode.episodeNumber > 0 && ` : ${episode.episodeNumber}`}
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" gutterBottom>
|
||||
{episode.scanlator}
|
||||
{episode.scanlator && ' '}
|
||||
{dateStr}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<IconButton aria-label="more" onClick={handleClick}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
keepMounted
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{/* <MenuItem onClick={handleClose}>Download</MenuItem> */}
|
||||
<MenuItem onClick={() => sendChange('bookmarked', !episode.bookmarked)}>
|
||||
{episode.bookmarked && 'Remove bookmark'}
|
||||
{!episode.bookmarked && 'Bookmark'}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => sendChange('read', !episode.read)}>
|
||||
Mark as
|
||||
{' '}
|
||||
{episode.read && 'unread'}
|
||||
{!episode.read && 'read'}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => sendChange('markPrevRead', true)}>
|
||||
Mark previous as Read
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
webUI/react/src/components/anime/SourceCard.tsx
Normal file
86
webUI/react/src/components/anime/SourceCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import Avatar from '@material-ui/core/Avatar';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import { langCodeToName } from 'util/language';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
bullet: {
|
||||
display: 'inline-block',
|
||||
margin: '0 2px',
|
||||
transform: 'scale(0.8)',
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
},
|
||||
pos: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
icon: {
|
||||
width: theme.spacing(7),
|
||||
height: theme.spacing(7),
|
||||
flex: '0 0 auto',
|
||||
marginRight: 16,
|
||||
},
|
||||
}));
|
||||
|
||||
interface IProps {
|
||||
source: ISource
|
||||
}
|
||||
|
||||
export default function SourceCard(props: IProps) {
|
||||
const {
|
||||
source: {
|
||||
id, name, lang, iconUrl, supportsLatest,
|
||||
},
|
||||
} = props;
|
||||
|
||||
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className={classes.root}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
className={classes.icon}
|
||||
alt={name}
|
||||
src={serverAddress + iconUrl}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h5" component="h2">
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" gutterBottom>
|
||||
{langCodeToName(lang)}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Button variant="outlined" style={{ marginLeft: 20 }} onClick={() => { window.location.href = `/anime/sources/${id}/search/`; }}>Search</Button>
|
||||
{supportsLatest && <Button variant="outlined" style={{ marginLeft: 20 }} onClick={() => { window.location.href = `/anime/sources/${id}/latest/`; }}>Latest</Button>}
|
||||
<Button variant="outlined" style={{ marginLeft: 20 }} onClick={() => { window.location.href = `/anime/sources/${id}/popular/`; }}>Browse</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
118
webUI/react/src/screens/anime/Anime.tsx
Normal file
118
webUI/react/src/screens/anime/Anime.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core/styles';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import EpisodeCard from 'components/anime/EpisodeCard';
|
||||
import AnimeDetails from 'components/anime/AnimeDetails';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
import LoadingPlaceholder from 'components/LoadingPlaceholder';
|
||||
import makeToast from 'components/Toast';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
root: {
|
||||
[theme.breakpoints.up('md')]: {
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
|
||||
chapters: {
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
minHeight: '200px',
|
||||
[theme.breakpoints.up('md')]: {
|
||||
width: '50vw',
|
||||
height: 'calc(100vh - 64px)',
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
|
||||
loading: {
|
||||
margin: '10px 0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
export default function Anime() {
|
||||
const classes = useStyles();
|
||||
|
||||
const { setTitle } = useContext(NavbarContext);
|
||||
useEffect(() => { setTitle('Anime'); }, []); // delegate setting topbar action to MangaDetails
|
||||
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const [manga, setManga] = useState<IManga>();
|
||||
const [episodes, setEpisodes] = useState<IEpisode[]>([]);
|
||||
const [fetchedEpisodes, setFetchedEpisodes] = useState(false);
|
||||
const [noEpisodesFound, setNoEpisodesFound] = useState(false);
|
||||
const [episodeUpdateTriggerer, setEpisodeUpdateTriggerer] = useState(0);
|
||||
|
||||
function triggerEpisodesUpdate() {
|
||||
setEpisodeUpdateTriggerer(episodeUpdateTriggerer + 1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (manga === undefined || !manga.freshData) {
|
||||
client.get(`/api/v1/anime/anime/${id}/?onlineFetch=${manga !== undefined}`)
|
||||
.then((response) => response.data)
|
||||
.then((data: IManga) => {
|
||||
setManga(data);
|
||||
setTitle(data.title);
|
||||
});
|
||||
}
|
||||
}, [manga]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldFetchOnline = fetchedEpisodes && episodeUpdateTriggerer === 0;
|
||||
client.get(`/api/v1/anime/anime/${id}/episodes?onlineFetch=${shouldFetchOnline}`)
|
||||
.then((response) => response.data)
|
||||
.then((data) => {
|
||||
if (data.length === 0 && fetchedEpisodes) {
|
||||
makeToast('No episodes found', 'warning');
|
||||
setNoEpisodesFound(true);
|
||||
}
|
||||
setEpisodes(data);
|
||||
})
|
||||
.then(() => setFetchedEpisodes(true));
|
||||
}, [episodes.length, fetchedEpisodes, episodeUpdateTriggerer]);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<LoadingPlaceholder
|
||||
shouldRender={manga !== undefined}
|
||||
component={AnimeDetails}
|
||||
componentProps={{ manga }}
|
||||
/>
|
||||
|
||||
<LoadingPlaceholder
|
||||
shouldRender={episodes.length > 0 || noEpisodesFound}
|
||||
>
|
||||
<Virtuoso
|
||||
style={{ // override Virtuoso default values and set them with class
|
||||
height: 'undefined',
|
||||
overflowY: window.innerWidth < 960 ? 'visible' : 'auto',
|
||||
}}
|
||||
className={classes.chapters}
|
||||
totalCount={episodes.length}
|
||||
itemContent={(index:number) => (
|
||||
<EpisodeCard
|
||||
episode={episodes[index]}
|
||||
triggerEpisodesUpdate={triggerEpisodesUpdate}
|
||||
/>
|
||||
)}
|
||||
useWindowScroll={window.innerWidth < 960}
|
||||
overscan={window.innerHeight * 0.5}
|
||||
/>
|
||||
</LoadingPlaceholder>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
webUI/react/src/screens/anime/AnimeSources.tsx
Normal file
84
webUI/react/src/screens/anime/AnimeSources.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import ExtensionLangSelect from 'components/manga/ExtensionLangSelect';
|
||||
import SourceCard from 'components/anime/SourceCard';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
import { defualtLangs, langCodeToName, langSortCmp } from 'util/language';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
|
||||
function sourceToLangList(sources: ISource[]) {
|
||||
const result: string[] = [];
|
||||
|
||||
sources.forEach((source) => {
|
||||
if (result.indexOf(source.lang) === -1) { result.push(source.lang); }
|
||||
});
|
||||
|
||||
result.sort(langSortCmp);
|
||||
return result;
|
||||
}
|
||||
|
||||
function groupByLang(sources: ISource[]) {
|
||||
const result = {} as any;
|
||||
sources.forEach((source) => {
|
||||
if (result[source.lang] === undefined) { result[source.lang] = [] as ISource[]; }
|
||||
result[source.lang].push(source);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function AnimeSources() {
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
|
||||
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', defualtLangs());
|
||||
|
||||
const [sources, setSources] = useState<ISource[]>([]);
|
||||
const [fetched, setFetched] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle('Sources');
|
||||
setAction(
|
||||
<ExtensionLangSelect
|
||||
shownLangs={shownLangs}
|
||||
setShownLangs={setShownLangs}
|
||||
allLangs={sourceToLangList(sources)}
|
||||
/>,
|
||||
);
|
||||
}, [shownLangs, sources]);
|
||||
|
||||
useEffect(() => {
|
||||
client.get('/api/v1/anime/source/list')
|
||||
.then((response) => response.data)
|
||||
.then((data) => { setSources(data); setFetched(true); });
|
||||
}, []);
|
||||
|
||||
if (sources.length === 0) {
|
||||
if (fetched) return (<h3>No sources found. Install Some Extensions first.</h3>);
|
||||
return (<h3>loading...</h3>);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line max-len */}
|
||||
{Object.entries(groupByLang(sources)).sort((a, b) => langSortCmp(a[0], b[0])).map(([lang, list]) => (
|
||||
shownLangs.indexOf(lang) !== -1 && (
|
||||
<React.Fragment key={lang}>
|
||||
<h1 key={lang} style={{ marginLeft: 25 }}>{langCodeToName(lang)}</h1>
|
||||
{(list as ISource[]).map((source) => (
|
||||
<SourceCard
|
||||
key={source.id}
|
||||
source={source}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
webUI/react/src/screens/anime/SourceAnimes.tsx
Normal file
51
webUI/react/src/screens/anime/SourceAnimes.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import AnimeGrid from 'components/anime/AnimeGrid';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
|
||||
export default function SourceAnimes(props: { popular: boolean }) {
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
useEffect(() => { setTitle('Source'); setAction(<></>); }, []);
|
||||
|
||||
const { sourceId } = useParams<{ sourceId: string }>();
|
||||
const [mangas, setMangas] = useState<IMangaCard[]>([]);
|
||||
const [hasNextPage, setHasNextPage] = useState<boolean>(false);
|
||||
const [lastPageNum, setLastPageNum] = useState<number>(1);
|
||||
|
||||
useEffect(() => {
|
||||
client.get(`/api/v1/anime/source/${sourceId}`)
|
||||
.then((response) => response.data)
|
||||
.then((data: { name: string }) => setTitle(data.name));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sourceType = props.popular ? 'popular' : 'latest';
|
||||
client.get(`/api/v1/anime/source/${sourceId}/${sourceType}/${lastPageNum}`)
|
||||
.then((response) => response.data)
|
||||
.then((data: { mangaList: IManga[], hasNextPage: boolean }) => {
|
||||
setMangas([
|
||||
...mangas,
|
||||
...data.mangaList.map((it) => ({
|
||||
title: it.title, thumbnailUrl: it.thumbnailUrl, id: it.id,
|
||||
}))]);
|
||||
setHasNextPage(data.hasNextPage);
|
||||
});
|
||||
}, [lastPageNum]);
|
||||
|
||||
return (
|
||||
<AnimeGrid
|
||||
mangas={mangas}
|
||||
hasNextPage={hasNextPage}
|
||||
lastPageNum={lastPageNum}
|
||||
setLastPageNum={setLastPageNum}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const useStyles = makeStyles((theme: Theme) => ({
|
||||
chapters: {
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
minHeight: '50vh',
|
||||
minHeight: '200px',
|
||||
[theme.breakpoints.up('md')]: {
|
||||
width: '50vw',
|
||||
height: 'calc(100vh - 64px)',
|
||||
@@ -98,7 +98,7 @@ export default function Manga() {
|
||||
<Virtuoso
|
||||
style={{ // override Virtuoso default values and set them with class
|
||||
height: 'undefined',
|
||||
overflowY: 'visible',
|
||||
overflowY: window.innerWidth < 960 ? 'visible' : 'auto',
|
||||
}}
|
||||
className={classes.chapters}
|
||||
totalCount={chapters.length}
|
||||
|
||||
@@ -34,7 +34,7 @@ function groupByLang(sources: ISource[]) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function Sources() {
|
||||
export default function MangaSources() {
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
|
||||
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', defualtLangs());
|
||||
16
webUI/react/src/typings.d.ts
vendored
16
webUI/react/src/typings.d.ts
vendored
@@ -70,6 +70,22 @@ interface IChapter {
|
||||
pageCount: number
|
||||
}
|
||||
|
||||
interface IEpisode {
|
||||
id: number
|
||||
url: string
|
||||
name: string
|
||||
uploadDate: number
|
||||
episodeNumber: number
|
||||
scanlator: String
|
||||
animeId: number
|
||||
read: boolean
|
||||
bookmarked: boolean
|
||||
lastPageRead: number
|
||||
index: number
|
||||
episodeCount: number
|
||||
pageCount: number
|
||||
}
|
||||
|
||||
interface IPartialChpter {
|
||||
pageCount: number
|
||||
index: number
|
||||
|
||||
Reference in New Issue
Block a user