move webUI out of Tachidesk
This commit is contained in:
134
src/screens/Settings.tsx
Normal file
134
src/screens/Settings.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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 List from '@material-ui/core/List';
|
||||
import ListAltIcon from '@material-ui/icons/ListAlt';
|
||||
import BackupIcon from '@material-ui/icons/Backup';
|
||||
import Brightness6Icon from '@material-ui/icons/Brightness6';
|
||||
import DnsIcon from '@material-ui/icons/Dns';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import InfoIcon from '@material-ui/icons/Info';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
|
||||
import Switch from '@material-ui/core/Switch';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import NavbarContext from '../context/NavbarContext';
|
||||
import DarkTheme from '../context/DarkTheme';
|
||||
import useLocalStorage from '../util/useLocalStorage';
|
||||
import ListItemLink from '../util/ListItemLink';
|
||||
|
||||
export default function Settings() {
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
useEffect(() => { setTitle('Settings'); setAction(<></>); }, []);
|
||||
|
||||
const { darkTheme, setDarkTheme } = useContext(DarkTheme);
|
||||
const [serverAddress, setServerAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogValue, setDialogValue] = useState(serverAddress);
|
||||
|
||||
const handleDialogOpen = () => {
|
||||
setDialogValue(serverAddress);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogCancel = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDialogSubmit = () => {
|
||||
setDialogOpen(false);
|
||||
setServerAddress(dialogValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<List style={{ padding: 0 }}>
|
||||
<ListItemLink href="/settings/categories">
|
||||
<ListItemIcon>
|
||||
<ListAltIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Categories" />
|
||||
</ListItemLink>
|
||||
<ListItemLink href="/settings/backup">
|
||||
<ListItemIcon>
|
||||
<BackupIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Backup" />
|
||||
</ListItemLink>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Brightness6Icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Dark Theme" />
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
edge="end"
|
||||
checked={darkTheme}
|
||||
onChange={() => setDarkTheme(!darkTheme)}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<DnsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Server Address" secondary={serverAddress} />
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handleDialogOpen();
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<ListItemLink href="/settings/about">
|
||||
<ListItemIcon>
|
||||
<InfoIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="About" />
|
||||
</ListItemLink>
|
||||
</List>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={handleDialogCancel}>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Enter new category name.
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="name"
|
||||
label="Category Name"
|
||||
type="text"
|
||||
fullWidth
|
||||
value={dialogValue}
|
||||
onChange={(e) => setDialogValue(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDialogCancel} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDialogSubmit} color="primary">
|
||||
Set
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
118
src/screens/anime/Anime.tsx
Normal file
118
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>
|
||||
);
|
||||
}
|
||||
112
src/screens/anime/AnimeExtensions.tsx
Normal file
112
src/screens/anime/AnimeExtensions.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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 ExtensionCard from 'components/anime/ExtensionCard';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import ExtensionLangSelect from 'components/manga/ExtensionLangSelect';
|
||||
import { defualtLangs, langCodeToName, langSortCmp } from 'util/language';
|
||||
|
||||
const allLangs: string[] = [];
|
||||
|
||||
function groupExtensions(extensions: IExtension[]) {
|
||||
allLangs.length = 0; // empty the array
|
||||
const result = { installed: [], 'updates pending': [] } as any;
|
||||
extensions.sort((a, b) => ((a.apkName > b.apkName) ? 1 : -1));
|
||||
|
||||
extensions.forEach((extension) => {
|
||||
if (result[extension.lang] === undefined) {
|
||||
result[extension.lang] = [];
|
||||
if (extension.lang !== 'all') { allLangs.push(extension.lang); }
|
||||
}
|
||||
if (extension.installed) {
|
||||
if (extension.hasUpdate) {
|
||||
result['updates pending'].push(extension);
|
||||
} else {
|
||||
result.installed.push(extension);
|
||||
}
|
||||
} else {
|
||||
result[extension.lang].push(extension);
|
||||
}
|
||||
});
|
||||
|
||||
// put english first for convience
|
||||
allLangs.sort(langSortCmp);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function extensionDefaultLangs() {
|
||||
return [...defualtLangs(), 'all'];
|
||||
}
|
||||
|
||||
export default function AnimeExtensions() {
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs());
|
||||
|
||||
useEffect(() => {
|
||||
setTitle('Extensions');
|
||||
setAction(
|
||||
<ExtensionLangSelect
|
||||
shownLangs={shownLangs}
|
||||
setShownLangs={setShownLangs}
|
||||
allLangs={allLangs}
|
||||
/>,
|
||||
);
|
||||
}, [shownLangs]);
|
||||
|
||||
const [extensionsRaw, setExtensionsRaw] = useState<IExtension[]>([]);
|
||||
const [extensions, setExtensions] = useState<any>({});
|
||||
|
||||
const [updateTriggerHolder, setUpdateTriggerHolder] = useState(0); // just a hack
|
||||
const triggerUpdate = () => setUpdateTriggerHolder(updateTriggerHolder + 1); // just a hack
|
||||
|
||||
useEffect(() => {
|
||||
client.get('/api/v1/anime/extension/list')
|
||||
.then((response) => response.data)
|
||||
.then((data) => setExtensionsRaw(data));
|
||||
}, [updateTriggerHolder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (extensionsRaw.length > 0) {
|
||||
const groupedExtension = groupExtensions(extensionsRaw);
|
||||
setExtensions(groupedExtension);
|
||||
}
|
||||
}, [extensionsRaw]);
|
||||
|
||||
if (Object.entries(extensions).length === 0) {
|
||||
return <h3>loading...</h3>;
|
||||
}
|
||||
const groupsToShow = ['updates pending', 'installed', ...shownLangs];
|
||||
return (
|
||||
<>
|
||||
{
|
||||
Object.entries(extensions).map(([lang, list]) => (
|
||||
((groupsToShow.indexOf(lang) !== -1 && (list as []).length > 0)
|
||||
&& (
|
||||
<React.Fragment key={lang}>
|
||||
<h1 key={lang} style={{ marginLeft: 25 }}>
|
||||
{langCodeToName(lang)}
|
||||
</h1>
|
||||
{(list as IExtension[]).map((it) => (
|
||||
<ExtensionCard
|
||||
key={it.apkName}
|
||||
extension={it}
|
||||
notifyInstall={() => {
|
||||
triggerUpdate();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
))
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/screens/anime/AnimeSources.tsx
Normal file
84
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>
|
||||
)
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/screens/anime/Player.tsx
Normal file
77
src/screens/anime/Player.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 CircularProgress from '@material-ui/core/CircularProgress';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
width: 'calc(100vw - 10px)',
|
||||
height: 'calc(100vh - 64px)',
|
||||
},
|
||||
|
||||
loading: {
|
||||
margin: '50px auto',
|
||||
},
|
||||
|
||||
video: {
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
const initialEpisode = () => ({ linkUrl: '', index: -1, episodeCount: 0 });
|
||||
|
||||
export default function Player() {
|
||||
const classes = useStyles();
|
||||
|
||||
const { episodeIndex, animeId } = useParams<{ episodeIndex: string, animeId: string }>();
|
||||
const [episode, setEpisode] = useState<IEpisode | IPartialEpisode>(initialEpisode());
|
||||
const [episodeLink, setEpisodeLink] = useState<string>();
|
||||
const { setTitle } = useContext(NavbarContext);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle('Reader');
|
||||
client.get(`/api/v1/anime/anime/${animeId}/`)
|
||||
.then((response) => response.data)
|
||||
.then((data: IManga) => {
|
||||
setTitle(data.title);
|
||||
});
|
||||
}, [episodeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setEpisode(initialEpisode);
|
||||
client.get(`/api/v1/anime/anime/${animeId}/episode/${episodeIndex}`)
|
||||
.then((response) => response.data)
|
||||
.then((data:IEpisode) => {
|
||||
setEpisode(data);
|
||||
setEpisodeLink(data.linkUrl);
|
||||
});
|
||||
}, [episodeIndex]);
|
||||
|
||||
// return spinner while chpater data is loading
|
||||
if (episode.linkUrl === '') {
|
||||
return (
|
||||
<div className={classes.loading}>
|
||||
<CircularProgress thickness={5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video className={classes.video} controls>
|
||||
<source src={episodeLink} />
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/screens/anime/SourceAnimes.tsx
Normal file
51
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
153
src/screens/manga/DownloadQueue.tsx
Normal file
153
src/screens/manga/DownloadQueue.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/* eslint-disable @typescript-eslint/no-shadow */
|
||||
/* eslint-disable react/destructuring-assignment */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/*
|
||||
* 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 NavbarContext from 'context/NavbarContext';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
|
||||
import PauseIcon from '@material-ui/icons/Pause';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import client from 'util/client';
|
||||
import {
|
||||
DragDropContext, Draggable, DraggingStyle, Droppable, DropResult, NotDraggingStyle,
|
||||
} from 'react-beautiful-dnd';
|
||||
import { useTheme } from '@material-ui/core/styles';
|
||||
import { Palette } from '@material-ui/core/styles/createPalette';
|
||||
import List from '@material-ui/core/List';
|
||||
import DragHandleIcon from '@material-ui/icons/DragHandle';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import { ListItemIcon } from '@material-ui/core';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
|
||||
const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws');
|
||||
|
||||
const getItemStyle = (isDragging: boolean,
|
||||
draggableStyle: DraggingStyle | NotDraggingStyle | undefined, palette: Palette) => ({
|
||||
// styles we need to apply on draggables
|
||||
...draggableStyle,
|
||||
|
||||
...(isDragging && {
|
||||
background: palette.type === 'dark' ? '#424242' : 'rgb(235,235,235)',
|
||||
}),
|
||||
});
|
||||
|
||||
const initialQueue = {
|
||||
status: 'Stopped',
|
||||
queue: [],
|
||||
} as IQueue;
|
||||
|
||||
export default function DownloadQueue() {
|
||||
const [, setWsClient] = useState<WebSocket>();
|
||||
const [queueState, setQueueState] = useState<IQueue>(initialQueue);
|
||||
const { queue, status } = queueState;
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
|
||||
const toggleQueueStatus = () => {
|
||||
if (status === 'Stopped') {
|
||||
client.get('/api/v1/downloads/start');
|
||||
} else {
|
||||
client.get('/api/v1/downloads/stop');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTitle('Download Queue');
|
||||
|
||||
setAction(() => {
|
||||
if (status === 'Stopped') {
|
||||
return (
|
||||
<IconButton onClick={toggleQueueStatus}>
|
||||
<PlayArrowIcon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<IconButton onClick={toggleQueueStatus}>
|
||||
<PauseIcon />
|
||||
</IconButton>
|
||||
);
|
||||
});
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
const wsc = new WebSocket(`${baseWebsocketUrl}/api/v1/downloads`);
|
||||
wsc.onmessage = (e) => {
|
||||
setQueueState(JSON.parse(e.data));
|
||||
};
|
||||
|
||||
setWsClient(wsc);
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided) => (
|
||||
<List ref={provided.innerRef}>
|
||||
{queue.map((item, index) => (
|
||||
<Draggable
|
||||
key={`${item.mangaId}-${item.chapterIndex}`}
|
||||
draggableId={`${item.mangaId}-${item.chapterIndex}`}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<ListItem
|
||||
ContainerProps={{ ref: provided.innerRef } as any}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={getItemStyle(
|
||||
snapshot.isDragging,
|
||||
provided.draggableProps.style,
|
||||
theme.palette,
|
||||
)}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<DragHandleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
`${item.chapter.name} | `
|
||||
+ ` (${item.progress * 100}%)`
|
||||
+ ` => state: ${item.state}`
|
||||
}
|
||||
/>
|
||||
{/* <IconButton
|
||||
onClick={() => {
|
||||
handleEditDialogOpen(index);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
deleteCategory(index);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton> */}
|
||||
</ListItem>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</List>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
}
|
||||
161
src/screens/manga/Library.tsx
Normal file
161
src/screens/manga/Library.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* 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 { Tab, Tabs } from '@material-ui/core';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import MangaGrid from 'components/manga/MangaGrid';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
import cloneObject from 'util/cloneObject';
|
||||
|
||||
interface IMangaCategory {
|
||||
category: ICategory
|
||||
mangas: IManga[]
|
||||
isFetched: boolean
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children: React.ReactNode;
|
||||
index: any;
|
||||
value: any;
|
||||
}
|
||||
|
||||
function TabPanel(props: TabPanelProps) {
|
||||
const {
|
||||
children, value, index,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`simple-tabpanel-${index}`}
|
||||
>
|
||||
{value === index && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Library() {
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
useEffect(() => { setTitle('Library'); setAction(<></>); }, []);
|
||||
|
||||
const [tabs, setTabs] = useState<IMangaCategory[]>([]);
|
||||
const [tabNum, setTabNum] = useState<number>(0);
|
||||
|
||||
// a hack so MangaGrid doesn't stop working. I won't change it in case
|
||||
// if I do manga pagination for library..
|
||||
const [lastPageNum, setLastPageNum] = useState<number>(1);
|
||||
|
||||
const handleTabChange = (newTab: number) => {
|
||||
setTabNum(newTab);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all<IManga[], ICategory[]>([
|
||||
client.get('/api/v1/library').then((response) => response.data),
|
||||
client.get('/api/v1/category').then((response) => response.data),
|
||||
])
|
||||
.then(
|
||||
([libraryMangas, categories]) => {
|
||||
const categoryTabs = categories.map((category) => ({
|
||||
category,
|
||||
mangas: [] as IManga[],
|
||||
isFetched: false,
|
||||
}));
|
||||
|
||||
if (libraryMangas.length > 0 || categoryTabs.length === 0) {
|
||||
const defaultCategoryTab = {
|
||||
category: {
|
||||
name: 'Default',
|
||||
default: true,
|
||||
order: 0,
|
||||
id: -1,
|
||||
},
|
||||
mangas: libraryMangas,
|
||||
isFetched: true,
|
||||
};
|
||||
setTabs(
|
||||
[defaultCategoryTab, ...categoryTabs],
|
||||
);
|
||||
} else {
|
||||
setTabs(categoryTabs);
|
||||
setTabNum(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
// console.log(client.defaults.baseURL);
|
||||
// fetch the current tab
|
||||
useEffect(() => {
|
||||
tabs.forEach((tab, index) => {
|
||||
if (tab.category.order === tabNum && !tab.isFetched) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
client.get(`/api/v1/category/${tab.category.id}`)
|
||||
.then((response) => response.data)
|
||||
.then((data: IManga[]) => {
|
||||
const tabsClone = cloneObject(tabs);
|
||||
tabsClone[index].mangas = data;
|
||||
tabsClone[index].isFetched = true;
|
||||
|
||||
setTabs(tabsClone); // clone the object
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [tabNum]);
|
||||
|
||||
let toRender;
|
||||
if (tabs.length > 1) {
|
||||
// eslint-disable-next-line max-len
|
||||
const tabDefines = tabs.map((tab) => (<Tab label={tab.category.name} value={tab.category.order} />));
|
||||
|
||||
const tabBodies = tabs.map((tab) => (
|
||||
<TabPanel value={tabNum} index={tab.category.order}>
|
||||
<MangaGrid
|
||||
mangas={tab.mangas}
|
||||
hasNextPage={false}
|
||||
lastPageNum={lastPageNum}
|
||||
setLastPageNum={setLastPageNum}
|
||||
message={tab.isFetched ? 'Category is Empty' : 'Loading...'}
|
||||
/>
|
||||
</TabPanel>
|
||||
));
|
||||
|
||||
// Visual Hack: 160px is min-width for viewport width of >600
|
||||
const scrollableTabs = window.innerWidth < tabs.length * 160;
|
||||
toRender = (
|
||||
<>
|
||||
<Tabs
|
||||
value={tabNum}
|
||||
onChange={(e, newTab) => handleTabChange(newTab)}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
centered={!scrollableTabs}
|
||||
variant={scrollableTabs ? 'scrollable' : 'fullWidth'}
|
||||
scrollButtons="on"
|
||||
>
|
||||
{tabDefines}
|
||||
</Tabs>
|
||||
{tabBodies}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
const mangas = tabs.length === 1 ? tabs[0].mangas : [];
|
||||
toRender = (
|
||||
<MangaGrid
|
||||
mangas={mangas}
|
||||
hasNextPage={false}
|
||||
lastPageNum={lastPageNum}
|
||||
setLastPageNum={setLastPageNum}
|
||||
message={tabs.length > 0 ? 'Library is Empty' : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return toRender;
|
||||
}
|
||||
163
src/screens/manga/Manga.tsx
Normal file
163
src/screens/manga/Manga.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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 ChapterCard from 'components/manga/ChapterCard';
|
||||
import MangaDetails from 'components/manga/MangaDetails';
|
||||
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',
|
||||
},
|
||||
}));
|
||||
|
||||
const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws');
|
||||
const initialQueue = {
|
||||
status: 'Stopped',
|
||||
queue: [],
|
||||
} as IQueue;
|
||||
|
||||
export default function Manga() {
|
||||
const classes = useStyles();
|
||||
|
||||
const { setTitle } = useContext(NavbarContext);
|
||||
useEffect(() => { setTitle('Manga'); }, []); // delegate setting topbar action to MangaDetails
|
||||
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const [manga, setManga] = useState<IManga>();
|
||||
const [chapters, setChapters] = useState<IChapter[]>([]);
|
||||
const [fetchedChapters, setFetchedChapters] = useState(false);
|
||||
const [noChaptersFound, setNoChaptersFound] = useState(false);
|
||||
const [chapterUpdateTriggerer, setChapterUpdateTriggerer] = useState(0);
|
||||
|
||||
const [, setWsClient] = useState<WebSocket>();
|
||||
const [{ queue }, setQueueState] = useState<IQueue>(initialQueue);
|
||||
|
||||
function triggerChaptersUpdate() {
|
||||
setChapterUpdateTriggerer(chapterUpdateTriggerer + 1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const wsc = new WebSocket(`${baseWebsocketUrl}/api/v1/downloads`);
|
||||
wsc.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data) as IQueue;
|
||||
setQueueState(data);
|
||||
|
||||
let shouldUpdate = false;
|
||||
data.queue.forEach((q) => {
|
||||
if (q.mangaId === manga?.id && q.state === 'Finished') {
|
||||
shouldUpdate = true;
|
||||
}
|
||||
});
|
||||
if (shouldUpdate) {
|
||||
triggerChaptersUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
setWsClient(wsc);
|
||||
|
||||
return () => wsc.close();
|
||||
}, [queue.length]);
|
||||
|
||||
const downloadingStringFor = (chapter: IChapter) => {
|
||||
let rtn = '';
|
||||
if (chapter.downloaded) {
|
||||
rtn = ' • Downloaded';
|
||||
}
|
||||
queue.forEach((q) => {
|
||||
if (chapter.index === q.chapterIndex && chapter.mangaId === q.mangaId) {
|
||||
rtn = ` • Downloading (${q.progress * 100}%)`;
|
||||
}
|
||||
});
|
||||
return rtn;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (manga === undefined || !manga.freshData) {
|
||||
client.get(`/api/v1/manga/${id}/?onlineFetch=${manga !== undefined}`)
|
||||
.then((response) => response.data)
|
||||
.then((data: IManga) => {
|
||||
setManga(data);
|
||||
setTitle(data.title);
|
||||
});
|
||||
}
|
||||
}, [manga]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldFetchOnline = fetchedChapters && chapterUpdateTriggerer === 0;
|
||||
client.get(`/api/v1/manga/${id}/chapters?onlineFetch=${shouldFetchOnline}`)
|
||||
.then((response) => response.data)
|
||||
.then((data) => {
|
||||
if (data.length === 0 && fetchedChapters) {
|
||||
makeToast('No chapters found', 'warning');
|
||||
setNoChaptersFound(true);
|
||||
}
|
||||
setChapters(data);
|
||||
})
|
||||
.then(() => setFetchedChapters(true));
|
||||
}, [chapters.length, fetchedChapters, chapterUpdateTriggerer]);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<LoadingPlaceholder
|
||||
shouldRender={manga !== undefined}
|
||||
component={MangaDetails}
|
||||
componentProps={{ manga }}
|
||||
/>
|
||||
|
||||
<LoadingPlaceholder
|
||||
shouldRender={chapters.length > 0 || noChaptersFound}
|
||||
>
|
||||
<Virtuoso
|
||||
style={{ // override Virtuoso default values and set them with class
|
||||
height: 'undefined',
|
||||
overflowY: window.innerWidth < 960 ? 'visible' : 'auto',
|
||||
}}
|
||||
className={classes.chapters}
|
||||
totalCount={chapters.length}
|
||||
itemContent={(index:number) => (
|
||||
<ChapterCard
|
||||
chapter={chapters[index]}
|
||||
downloadingString={downloadingStringFor(chapters[index])}
|
||||
triggerChaptersUpdate={triggerChaptersUpdate}
|
||||
/>
|
||||
)}
|
||||
useWindowScroll={window.innerWidth < 960}
|
||||
overscan={window.innerHeight * 0.5}
|
||||
/>
|
||||
</LoadingPlaceholder>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/screens/manga/MangaExtensions.tsx
Normal file
112
src/screens/manga/MangaExtensions.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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 ExtensionCard from 'components/manga/ExtensionCard';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import ExtensionLangSelect from 'components/manga/ExtensionLangSelect';
|
||||
import { defualtLangs, langCodeToName, langSortCmp } from 'util/language';
|
||||
|
||||
const allLangs: string[] = [];
|
||||
|
||||
function groupExtensions(extensions: IExtension[]) {
|
||||
allLangs.length = 0; // empty the array
|
||||
const result = { installed: [], 'updates pending': [] } as any;
|
||||
extensions.sort((a, b) => ((a.apkName > b.apkName) ? 1 : -1));
|
||||
|
||||
extensions.forEach((extension) => {
|
||||
if (result[extension.lang] === undefined) {
|
||||
result[extension.lang] = [];
|
||||
if (extension.lang !== 'all') { allLangs.push(extension.lang); }
|
||||
}
|
||||
if (extension.installed) {
|
||||
if (extension.hasUpdate) {
|
||||
result['updates pending'].push(extension);
|
||||
} else {
|
||||
result.installed.push(extension);
|
||||
}
|
||||
} else {
|
||||
result[extension.lang].push(extension);
|
||||
}
|
||||
});
|
||||
|
||||
// put english first for convience
|
||||
allLangs.sort(langSortCmp);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function extensionDefaultLangs() {
|
||||
return [...defualtLangs(), 'all'];
|
||||
}
|
||||
|
||||
export default function MangaExtensions() {
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs());
|
||||
|
||||
useEffect(() => {
|
||||
setTitle('Extensions');
|
||||
setAction(
|
||||
<ExtensionLangSelect
|
||||
shownLangs={shownLangs}
|
||||
setShownLangs={setShownLangs}
|
||||
allLangs={allLangs}
|
||||
/>,
|
||||
);
|
||||
}, [shownLangs]);
|
||||
|
||||
const [extensionsRaw, setExtensionsRaw] = useState<IExtension[]>([]);
|
||||
const [extensions, setExtensions] = useState<any>({});
|
||||
|
||||
const [updateTriggerHolder, setUpdateTriggerHolder] = useState(0); // just a hack
|
||||
const triggerUpdate = () => setUpdateTriggerHolder(updateTriggerHolder + 1); // just a hack
|
||||
|
||||
useEffect(() => {
|
||||
client.get('/api/v1/extension/list')
|
||||
.then((response) => response.data)
|
||||
.then((data) => setExtensionsRaw(data));
|
||||
}, [updateTriggerHolder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (extensionsRaw.length > 0) {
|
||||
const groupedExtension = groupExtensions(extensionsRaw);
|
||||
setExtensions(groupedExtension);
|
||||
}
|
||||
}, [extensionsRaw]);
|
||||
|
||||
if (Object.entries(extensions).length === 0) {
|
||||
return <h3>loading...</h3>;
|
||||
}
|
||||
const groupsToShow = ['updates pending', 'installed', ...shownLangs];
|
||||
return (
|
||||
<>
|
||||
{
|
||||
Object.entries(extensions).map(([lang, list]) => (
|
||||
((groupsToShow.indexOf(lang) !== -1 && (list as []).length > 0)
|
||||
&& (
|
||||
<React.Fragment key={lang}>
|
||||
<h1 key={lang} style={{ marginLeft: 25 }}>
|
||||
{langCodeToName(lang)}
|
||||
</h1>
|
||||
{(list as IExtension[]).map((it) => (
|
||||
<ExtensionCard
|
||||
key={it.apkName}
|
||||
extension={it}
|
||||
notifyInstall={() => {
|
||||
triggerUpdate();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
))
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/screens/manga/MangaSources.tsx
Normal file
84
src/screens/manga/MangaSources.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/manga/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 MangaSources() {
|
||||
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/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>
|
||||
)
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
196
src/screens/manga/Reader.tsx
Normal file
196
src/screens/manga/Reader.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* 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 CircularProgress from '@material-ui/core/CircularProgress';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import HorizontalPager from 'components/manga/reader/pager/HorizontalPager';
|
||||
import PageNumber from 'components/manga/reader/PageNumber';
|
||||
import PagedPager from 'components/manga/reader/pager/PagedPager';
|
||||
import DoublePagedPager from 'components/manga/reader/pager/DoublePagedPager';
|
||||
import VerticalPager from 'components/manga/reader/pager/VerticalPager';
|
||||
import ReaderNavBar, { defaultReaderSettings } from 'components/navbar/ReaderNavBar';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
import useLocalStorage from 'util/useLocalStorage';
|
||||
import cloneObject from 'util/cloneObject';
|
||||
|
||||
const useStyles = (settings: IReaderSettings) => makeStyles({
|
||||
root: {
|
||||
width: settings.staticNav ? 'calc(100vw - 300px)' : '100vw',
|
||||
},
|
||||
|
||||
loading: {
|
||||
margin: '50px auto',
|
||||
},
|
||||
});
|
||||
|
||||
const getReaderComponent = (readerType: ReaderType) => {
|
||||
switch (readerType) {
|
||||
case 'ContinuesVertical':
|
||||
case 'Webtoon':
|
||||
return VerticalPager;
|
||||
break;
|
||||
case 'SingleVertical':
|
||||
case 'SingleRTL':
|
||||
case 'SingleLTR':
|
||||
return PagedPager;
|
||||
break;
|
||||
case 'DoubleVertical':
|
||||
case 'DoubleRTL':
|
||||
case 'DoubleLTR':
|
||||
return DoublePagedPager;
|
||||
break;
|
||||
case 'ContinuesHorizontalLTR':
|
||||
case 'ContinuesHorizontalRTL':
|
||||
return HorizontalPager;
|
||||
default:
|
||||
return VerticalPager;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const range = (n:number) => Array.from({ length: n }, (value, key) => key);
|
||||
const initialChapter = () => ({ pageCount: -1, index: -1, chapterCount: 0 });
|
||||
|
||||
export default function Reader() {
|
||||
const [settings, setSettings] = useLocalStorage<IReaderSettings>('readerSettings', defaultReaderSettings);
|
||||
|
||||
const classes = useStyles(settings)();
|
||||
const history = useHistory();
|
||||
|
||||
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
|
||||
|
||||
const { chapterIndex, mangaId } = useParams<{ chapterIndex: string, mangaId: string }>();
|
||||
const [manga, setManga] = useState<IMangaCard | IManga>({ id: +mangaId, title: '', thumbnailUrl: '' });
|
||||
const [chapter, setChapter] = useState<IChapter | IPartialChpter>(initialChapter());
|
||||
const [curPage, setCurPage] = useState<number>(0);
|
||||
const { setOverride, setTitle } = useContext(NavbarContext);
|
||||
|
||||
useEffect(() => {
|
||||
// make sure settings has all the keys
|
||||
const settingsClone = cloneObject(settings) as any;
|
||||
const defualtSettings = defaultReaderSettings();
|
||||
let shouldUpdateSettings = false;
|
||||
Object.keys(defualtSettings).forEach((key) => {
|
||||
const keyOf = key as keyof IReaderSettings;
|
||||
if (settings[keyOf] === undefined) {
|
||||
settingsClone[keyOf] = defualtSettings[keyOf];
|
||||
shouldUpdateSettings = true;
|
||||
}
|
||||
});
|
||||
if (shouldUpdateSettings) { setSettings(settingsClone); }
|
||||
|
||||
// set the custom navbar
|
||||
setOverride(
|
||||
{
|
||||
status: true,
|
||||
value: (
|
||||
<ReaderNavBar
|
||||
settings={settings}
|
||||
setSettings={setSettings}
|
||||
manga={manga}
|
||||
chapter={chapter}
|
||||
curPage={curPage}
|
||||
/>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
// clean up for when we leave the reader
|
||||
return () => setOverride({ status: false, value: <div /> });
|
||||
}, [manga, chapter, settings, curPage, chapterIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle('Reader');
|
||||
client.get(`/api/v1/manga/${mangaId}/`)
|
||||
.then((response) => response.data)
|
||||
.then((data: IManga) => {
|
||||
setManga(data);
|
||||
setTitle(data.title);
|
||||
});
|
||||
}, [chapterIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setChapter(initialChapter);
|
||||
client.get(`/api/v1/manga/${mangaId}/chapter/${chapterIndex}`)
|
||||
.then((response) => response.data)
|
||||
.then((data:IChapter) => {
|
||||
setChapter(data);
|
||||
setCurPage(data.lastPageRead);
|
||||
});
|
||||
}, [chapterIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (curPage !== -1) {
|
||||
const formData = new FormData();
|
||||
formData.append('lastPageRead', curPage.toString());
|
||||
client.patch(`/api/v1/manga/${manga.id}/chapter/${chapter.index}`, formData);
|
||||
}
|
||||
|
||||
if (curPage === chapter.pageCount - 1) {
|
||||
const formDataRead = new FormData();
|
||||
formDataRead.append('read', 'true');
|
||||
client.patch(`/api/v1/manga/${manga.id}/chapter/${chapter.index}`, formDataRead);
|
||||
}
|
||||
}, [curPage]);
|
||||
|
||||
// return spinner while chpater data is loading
|
||||
if (chapter.pageCount === -1) {
|
||||
return (
|
||||
<div className={classes.loading}>
|
||||
<CircularProgress thickness={5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nextChapter = () => {
|
||||
if (chapter.index < chapter.chapterCount) {
|
||||
const formData = new FormData();
|
||||
formData.append('lastPageRead', `${chapter.pageCount - 1}`);
|
||||
formData.append('read', 'true');
|
||||
client.patch(`/api/v1/manga/${manga.id}/chapter/${chapter.index}`, formData);
|
||||
|
||||
history.push(`/manga/${manga.id}/chapter/${chapter.index + 1}`);
|
||||
}
|
||||
};
|
||||
|
||||
const prevChapter = () => {
|
||||
if (chapter.index > 1) {
|
||||
history.push(`/manga/${manga.id}/chapter/${chapter.index - 1}`);
|
||||
}
|
||||
};
|
||||
|
||||
const pages = range(chapter.pageCount).map((index) => ({
|
||||
index,
|
||||
src: `${serverAddress}/api/v1/manga/${mangaId}/chapter/${chapterIndex}/page/${index}`,
|
||||
}));
|
||||
|
||||
const ReaderComponent = getReaderComponent(settings.readerType);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<PageNumber
|
||||
settings={settings}
|
||||
curPage={curPage}
|
||||
pageCount={chapter.pageCount}
|
||||
/>
|
||||
<ReaderComponent
|
||||
pages={pages}
|
||||
pageCount={chapter.pageCount}
|
||||
setCurPage={setCurPage}
|
||||
curPage={curPage}
|
||||
settings={settings}
|
||||
manga={manga}
|
||||
chapter={chapter}
|
||||
nextChapter={nextChapter}
|
||||
prevChapter={prevChapter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/screens/manga/SearchSingle.tsx
Normal file
109
src/screens/manga/SearchSingle.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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 { makeStyles } from '@material-ui/core/styles';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import MangaGrid from 'components/manga/MangaGrid';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
TextField: {
|
||||
margin: theme.spacing(1),
|
||||
width: '25ch',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default function SearchSingle() {
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
useEffect(() => { setTitle('Search'); setAction(<></>); }, []);
|
||||
|
||||
const { sourceId } = useParams<{ sourceId: string }>();
|
||||
const classes = useStyles();
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
const [mangas, setMangas] = useState<IMangaCard[]>([]);
|
||||
const [message, setMessage] = useState<string>('');
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [hasNextPage, setHasNextPage] = useState<boolean>(false);
|
||||
const [lastPageNum, setLastPageNum] = useState<number>(1);
|
||||
|
||||
const textInput = React.createRef<HTMLInputElement>();
|
||||
|
||||
useEffect(() => {
|
||||
client.get(`/api/v1/source/${sourceId}`)
|
||||
.then((response) => response.data)
|
||||
.then((data: { name: string }) => setTitle(`Search: ${data.name}`));
|
||||
}, []);
|
||||
|
||||
function processInput() {
|
||||
if (textInput.current) {
|
||||
const { value } = textInput.current;
|
||||
if (value === '') {
|
||||
setError(true);
|
||||
setMessage('Type something to search');
|
||||
} else {
|
||||
setError(false);
|
||||
setSearchTerm(value);
|
||||
setMangas([]);
|
||||
setMessage('loading...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm.length > 0) {
|
||||
client.get(`/api/v1/source/${sourceId}/search/${searchTerm}/${lastPageNum}`)
|
||||
.then((response) => response.data)
|
||||
.then((data: { mangaList: IManga[], hasNextPage: boolean }) => {
|
||||
setMessage('');
|
||||
if (data.mangaList.length > 0) {
|
||||
setMangas([
|
||||
...mangas,
|
||||
...data.mangaList.map((it) => ({
|
||||
title: it.title, thumbnailUrl: it.thumbnailUrl, id: it.id,
|
||||
}))]);
|
||||
setHasNextPage(data.hasNextPage);
|
||||
} else {
|
||||
setMessage('search query returned nothing.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
const mangaGrid = (
|
||||
<MangaGrid
|
||||
mangas={mangas}
|
||||
message={message}
|
||||
hasNextPage={hasNextPage}
|
||||
lastPageNum={lastPageNum}
|
||||
setLastPageNum={setLastPageNum}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.root}>
|
||||
<TextField
|
||||
inputRef={textInput}
|
||||
error={error}
|
||||
id="standard-basic"
|
||||
label="Search text.."
|
||||
onKeyDown={(e) => e.key === 'Enter' && processInput()}
|
||||
/>
|
||||
<Button variant="contained" color="primary" onClick={() => processInput()}>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
{mangaGrid}
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
src/screens/manga/SourceConfigure.tsx
Normal file
49
src/screens/manga/SourceConfigure.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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 NavbarContext from 'context/NavbarContext';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import client from 'util/client';
|
||||
import CheckBoxPreference from 'components/manga/sourceConfiguration/CheckBoxPreference';
|
||||
import List from '@material-ui/core/List';
|
||||
|
||||
function getPrefComponent(type: string) {
|
||||
switch (type) {
|
||||
case 'CheckBoxPreference':
|
||||
return CheckBoxPreference;
|
||||
default:
|
||||
return CheckBoxPreference;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SourceConfigure() {
|
||||
const [sourcePreferences, setSourcePreferences] = useState<SourcePreferences[]>([]);
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
|
||||
useEffect(() => { setTitle('Source Configuration'); setAction(<></>); }, []);
|
||||
|
||||
const { sourceId } = useParams<{ sourceId: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
client.get(`/api/v1/source/${sourceId}/preferences`)
|
||||
.then((response) => response.data)
|
||||
.then((data) => setSourcePreferences(data));
|
||||
}, []);
|
||||
|
||||
console.log(sourcePreferences);
|
||||
return (
|
||||
<>
|
||||
<List style={{ padding: 0 }}>
|
||||
|
||||
{sourcePreferences.map(
|
||||
(it) => React.createElement(getPrefComponent(it.type), it.props),
|
||||
)}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
src/screens/manga/SourceMangas.tsx
Normal file
51
src/screens/manga/SourceMangas.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 MangaGrid from 'components/manga/MangaGrid';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
|
||||
export default function SourceMangas(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/source/${sourceId}`)
|
||||
.then((response) => response.data)
|
||||
.then((data: { name: string }) => setTitle(data.name));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sourceType = props.popular ? 'popular' : 'latest';
|
||||
client.get(`/api/v1/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 (
|
||||
<MangaGrid
|
||||
mangas={mangas}
|
||||
hasNextPage={hasNextPage}
|
||||
lastPageNum={lastPageNum}
|
||||
setLastPageNum={setLastPageNum}
|
||||
/>
|
||||
);
|
||||
}
|
||||
70
src/screens/settings/About.tsx
Normal file
70
src/screens/settings/About.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { CircularProgress, makeStyles } from '@material-ui/core';
|
||||
import List from '@material-ui/core/List';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import client from '../../util/client';
|
||||
import ListItemLink from '../../util/ListItemLink';
|
||||
import NavbarContext from '../../context/NavbarContext';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
loading: {
|
||||
width: '100vw',
|
||||
'& div': {
|
||||
margin: '50px auto',
|
||||
display: 'block',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function About() {
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
const classes = useStyles();
|
||||
|
||||
const [about, setAbout] = useState<IAbout>();
|
||||
|
||||
useEffect(() => { setTitle('About'); setAction(<></>); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
client.get('/api/v1/settings/about')
|
||||
.then((response) => response.data)
|
||||
.then((data:IAbout) => {
|
||||
setAbout(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (about === undefined) {
|
||||
return (
|
||||
<div className={classes.loading}>
|
||||
<CircularProgress thickness={5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const version = () => {
|
||||
if (about.buildType === 'Stable') return `${about.version}`;
|
||||
return `${about.version}-${about.revision}`;
|
||||
};
|
||||
|
||||
const buildTime = () => new Date(about.buildTime * 1000).toUTCString();
|
||||
|
||||
return (
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemText primary="Server" secondary={`${about.name} ${about.buildType}`} />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText primary="Server version" secondary={version()} />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText primary="Build time" secondary={buildTime()} />
|
||||
</ListItem>
|
||||
<ListItemLink href={about.github}>
|
||||
<ListItemText primary="Github" secondary={about.github} />
|
||||
</ListItemLink>
|
||||
<ListItemLink href={about.discord}>
|
||||
<ListItemText primary="Discord" secondary={about.discord} />
|
||||
</ListItemLink>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
91
src/screens/settings/Backup.tsx
Normal file
91
src/screens/settings/Backup.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
import { ListItemIcon } from '@material-ui/core';
|
||||
import List from '@material-ui/core/List';
|
||||
import InboxIcon from '@material-ui/icons/Inbox';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import { fromEvent } from 'file-selector';
|
||||
import ListItemLink from '../../util/ListItemLink';
|
||||
import NavbarContext from '../../context/NavbarContext';
|
||||
import client from '../../util/client';
|
||||
|
||||
export default function Backup() {
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
useEffect(() => { setTitle('Backup'); setAction(<></>); }, []);
|
||||
|
||||
const { baseURL } = client.defaults;
|
||||
|
||||
const submitBackup = (file: File) => {
|
||||
file.text()
|
||||
.then(
|
||||
(fileContent: string) => {
|
||||
client.post('/api/v1/backup/legacy/import',
|
||||
fileContent, { headers: { 'Content-Type': 'application/json' } });
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const dropHandler = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
const files = await fromEvent(e);
|
||||
|
||||
submitBackup(files[0] as File);
|
||||
};
|
||||
|
||||
const dragOverHandler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('drop', dropHandler);
|
||||
document.addEventListener('dragover', dragOverHandler);
|
||||
|
||||
const input = document.getElementById('backup-file');
|
||||
input?.addEventListener('change', async (evt) => {
|
||||
const files = await fromEvent(evt);
|
||||
submitBackup(files[0] as File);
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('drop', dropHandler);
|
||||
document.removeEventListener('dragover', dragOverHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<List style={{ padding: 0 }}>
|
||||
<ListItemLink href={`${baseURL}/api/v1/backup/legacy/export/file`}>
|
||||
<ListItemIcon>
|
||||
<InboxIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Create Legacy Backup"
|
||||
secondary="Backup library as a Tachiyomi legacy backup"
|
||||
/>
|
||||
</ListItemLink>
|
||||
<ListItem button onClick={() => document.getElementById('backup-file')?.click()}>
|
||||
<ListItemIcon>
|
||||
<InboxIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Restore Legacy Backup"
|
||||
secondary="You can also drop the backup file anywhere to restore"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
name="backup.json"
|
||||
id="backup-file"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
);
|
||||
}
|
||||
249
src/screens/settings/Categories.tsx
Normal file
249
src/screens/settings/Categories.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
/* eslint-disable @typescript-eslint/no-shadow */
|
||||
/* eslint-disable react/destructuring-assignment */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/*
|
||||
* 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, { useState, useContext, useEffect } from 'react';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
IconButton,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
DragDropContext, Droppable, Draggable, DropResult, DraggingStyle, NotDraggingStyle,
|
||||
} from 'react-beautiful-dnd';
|
||||
import DragHandleIcon from '@material-ui/icons/DragHandle';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import { useTheme } from '@material-ui/core/styles';
|
||||
import Fab from '@material-ui/core/Fab';
|
||||
import AddIcon from '@material-ui/icons/Add';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import Checkbox from '@material-ui/core/Checkbox';
|
||||
import FormControlLabel from '@material-ui/core/FormControlLabel';
|
||||
import NavbarContext from 'context/NavbarContext';
|
||||
import client from 'util/client';
|
||||
import { Palette } from '@material-ui/core/styles/createPalette';
|
||||
|
||||
const getItemStyle = (isDragging: boolean,
|
||||
draggableStyle: DraggingStyle | NotDraggingStyle | undefined, palette: Palette) => ({
|
||||
// styles we need to apply on draggables
|
||||
...draggableStyle,
|
||||
|
||||
...(isDragging && {
|
||||
background: palette.type === 'dark' ? '#424242' : 'rgb(235,235,235)',
|
||||
}),
|
||||
});
|
||||
|
||||
export default function Categories() {
|
||||
const { setTitle, setAction } = useContext(NavbarContext);
|
||||
useEffect(() => { setTitle('Categories'); setAction(<></>); }, []);
|
||||
|
||||
const [categories, setCategories] = useState<ICategory[]>([]);
|
||||
const [categoryToEdit, setCategoryToEdit] = useState<number>(-1); // -1 means new category
|
||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [dialogName, setDialogName] = useState<string>('');
|
||||
const [dialogDefault, setDialogDefault] = useState<boolean>(false);
|
||||
const theme = useTheme();
|
||||
|
||||
const [updateTriggerHolder, setUpdateTriggerHolder] = useState<number>(0); // just a hack
|
||||
const triggerUpdate = () => setUpdateTriggerHolder(updateTriggerHolder + 1); // just a hack
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
client.get('/api/v1/category/')
|
||||
.then((response) => response.data)
|
||||
.then((data) => setCategories(data));
|
||||
}
|
||||
}, [updateTriggerHolder]);
|
||||
|
||||
const categoryReorder = (list: ICategory[], from: number, to: number) => {
|
||||
const category = list[from];
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('from', `${from + 1}`);
|
||||
formData.append('to', `${to + 1}`);
|
||||
client.post(`/api/v1/category/${category.id}/reorder`, formData)
|
||||
.finally(() => triggerUpdate());
|
||||
|
||||
// also move it in local state to avoid jarring moving behviour...
|
||||
const result = Array.from(list);
|
||||
const [removed] = result.splice(from, 1);
|
||||
result.splice(to, 0, removed);
|
||||
return result;
|
||||
};
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
// dropped outside the list?
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCategories(categoryReorder(
|
||||
categories,
|
||||
result.source.index,
|
||||
result.destination.index,
|
||||
));
|
||||
};
|
||||
|
||||
const resetDialog = () => {
|
||||
setDialogName('');
|
||||
setDialogDefault(false);
|
||||
setCategoryToEdit(-1);
|
||||
};
|
||||
|
||||
const handleDialogOpen = () => {
|
||||
resetDialog();
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditDialogOpen = (index:number) => {
|
||||
setDialogName(categories[index].name);
|
||||
setDialogDefault(categories[index].default);
|
||||
setCategoryToEdit(index);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogCancel = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDialogSubmit = () => {
|
||||
setDialogOpen(false);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('name', dialogName);
|
||||
formData.append('default', dialogDefault.toString());
|
||||
|
||||
if (categoryToEdit === -1) {
|
||||
client.post('/api/v1/category/', formData)
|
||||
.finally(() => triggerUpdate());
|
||||
} else {
|
||||
const category = categories[categoryToEdit];
|
||||
client.patch(`/api/v1/category/${category.id}`, formData)
|
||||
.finally(() => triggerUpdate());
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCategory = (index:number) => {
|
||||
const category = categories[index];
|
||||
client.delete(`/api/v1/category/${category.id}`)
|
||||
.finally(() => triggerUpdate());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided) => (
|
||||
<List ref={provided.innerRef}>
|
||||
{categories.map((item, index) => (
|
||||
<Draggable
|
||||
key={item.id}
|
||||
draggableId={item.id.toString()}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<ListItem
|
||||
ContainerProps={{ ref: provided.innerRef } as any}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={getItemStyle(
|
||||
snapshot.isDragging,
|
||||
provided.draggableProps.style,
|
||||
theme.palette,
|
||||
)}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<DragHandleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.name}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handleEditDialogOpen(index);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
deleteCategory(index);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</List>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label="add"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: theme.spacing(2),
|
||||
right: theme.spacing(2),
|
||||
}}
|
||||
onClick={handleDialogOpen}
|
||||
>
|
||||
<AddIcon />
|
||||
</Fab>
|
||||
<Dialog open={dialogOpen} onClose={handleDialogCancel}>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
{categoryToEdit === -1 ? 'New Catalog' : 'Edit Catalog'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="name"
|
||||
label="Category Name"
|
||||
type="text"
|
||||
fullWidth
|
||||
value={dialogName}
|
||||
onChange={(e) => setDialogName(e.target.value)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={(
|
||||
<Checkbox
|
||||
checked={dialogDefault}
|
||||
onChange={(e) => setDialogDefault(e.target.checked)}
|
||||
color="default"
|
||||
/>
|
||||
)}
|
||||
label="Default category when adding new manga to library"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDialogCancel} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDialogSubmit} color="primary">
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user