mirror of
https://github.com/anidl/multi-downloader-nx.git
synced 2026-03-11 17:45:30 +00:00
See todo for changes
This commit is contained in:
parent
f076a7af7e
commit
e9852ade1a
23 changed files with 461 additions and 169 deletions
8
@types/messageHandler.d.ts
vendored
8
@types/messageHandler.d.ts
vendored
|
|
@ -11,7 +11,8 @@ export interface MessageHandler {
|
|||
resolveItems: (data: ResolveItemsData) => Promise<ResponseBase<QueueItem[]>>,
|
||||
listEpisodes: (id: string) => Promise<EpisodeListResponse>,
|
||||
downloadItem: (data) => void,
|
||||
isDownloading: () => boolean
|
||||
isDownloading: () => boolean,
|
||||
writeToClipboard: (text: string) => void
|
||||
}
|
||||
|
||||
export type QueueItem = {
|
||||
|
|
@ -53,7 +54,10 @@ export type Episode = {
|
|||
season: string,
|
||||
seasonTitle: string,
|
||||
episode: string,
|
||||
id: string
|
||||
id: string,
|
||||
img: string,
|
||||
description: string,
|
||||
time: string
|
||||
}
|
||||
|
||||
export type SearchResponse = ResponseBase<SearchResponseItem[]>
|
||||
|
|
|
|||
10
TODO.md
10
TODO.md
|
|
@ -1,4 +1,12 @@
|
|||
- [ ] Hls-Download force yes or no on rewrite promt as well as for mkvmerge/ffmpeg
|
||||
- [x] Pick up if a download is currently in progress
|
||||
- [x] Send more information with the progress event like the title and image to display more information
|
||||
- [ ] Use Click away listener for the search popup
|
||||
- [x] Use Click away listener for the search popup
|
||||
- [x] Quality select button is uncrontrolled/controlled
|
||||
- [ ] Set Options font in divider
|
||||
- [x] Window title
|
||||
- [x] Only open dev tools in test version
|
||||
- [ ] Add help information (version, contributor, documentation...)
|
||||
- [ ] App Icon with electron-forge make
|
||||
- [x] ContextMenu
|
||||
- [x] Better episode listing with selectio via left mouse button
|
||||
|
|
@ -1346,6 +1346,8 @@ export default class Crunchy implements ServiceClass {
|
|||
}
|
||||
|
||||
return { data: episodes, list: Object.entries(episodes).map(([key, value]) => {
|
||||
const images = (value.items[0].images.thumbnail ?? [[ { source: '/notFound.png' } ]])[0];
|
||||
const seconds = Math.floor(value.items[0].duration_ms / 1000);
|
||||
return {
|
||||
e: key.startsWith('E') ? key.slice(1) : key,
|
||||
lang: value.langs.map(a => a.code),
|
||||
|
|
@ -1353,7 +1355,10 @@ export default class Crunchy implements ServiceClass {
|
|||
season: value.items[0].season_number.toString(),
|
||||
seasonTitle: value.items[0].season_title.replace(/\(\w+ Dub\)/g, '').trimEnd(),
|
||||
episode: value.items[0].episode_number?.toString() ?? value.items[0].episode ?? '?',
|
||||
id: value.items[0].season_id
|
||||
id: value.items[0].season_id,
|
||||
img: images[Math.floor(images.length / 2)].source,
|
||||
description: value.items[0].description,
|
||||
time: `${Math.floor(seconds / 60)}:${seconds % 60}`
|
||||
}
|
||||
})};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import fs from 'fs';
|
|||
import dotenv from 'dotenv';
|
||||
import express from "express";
|
||||
import { Console } from 'console';
|
||||
import './menu';
|
||||
|
||||
if (fs.existsSync(path.join(__dirname, '.env')))
|
||||
dotenv.config({ path: path.join(__dirname, '.env'), debug: true });
|
||||
|
|
@ -21,37 +22,12 @@ export { mainWindow };
|
|||
const icon = path.join(__dirname, 'images', `Logo_Inverted.${isWindows ? 'ico' : 'png'}`);
|
||||
|
||||
if (!process.env.TEST) {
|
||||
console = ((oldConsole: Console) => {
|
||||
console = (() => {
|
||||
const logFolder = path.join(__dirname, 'logs');
|
||||
if (!fs.existsSync(logFolder))
|
||||
fs.mkdirSync(logFolder);
|
||||
return new Console(fs.createWriteStream(path.join(logFolder, `${Date.now()}.log`)));
|
||||
|
||||
const writeLogFile = (type: 'log'|'info'|'warn'|'error', args: any[]) => {
|
||||
const file = path.join(logFolder, `${type}.log`);
|
||||
|
||||
args = args.map(a => {
|
||||
const type = typeof a;
|
||||
if (type === 'function' || type === 'symbol')
|
||||
return '';
|
||||
if (type === 'object')
|
||||
return JSON.stringify(a);
|
||||
if (type === 'undefined')
|
||||
return 'undefined';
|
||||
return a;
|
||||
});
|
||||
|
||||
fs.appendFileSync(file, args.join(' ') + '\n');
|
||||
}
|
||||
|
||||
return {
|
||||
...oldConsole,
|
||||
log: (...data: any[]) => writeLogFile('log', data),
|
||||
info: (...data: any[]) => writeLogFile('info', data),
|
||||
warn: (...data: any[]) => writeLogFile('warn', data),
|
||||
error: (...data: any[]) => writeLogFile('error', data),
|
||||
} as Console;
|
||||
})(console);
|
||||
})();
|
||||
}
|
||||
|
||||
const createWindow = async () => {
|
||||
|
|
@ -62,7 +38,7 @@ const createWindow = async () => {
|
|||
width: 800,
|
||||
title: 'AniDL GUI BETA',
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
},
|
||||
icon,
|
||||
|
|
@ -112,7 +88,8 @@ const createWindow = async () => {
|
|||
}
|
||||
|
||||
mainWindow.loadURL('http://localhost:3000');
|
||||
mainWindow.webContents.openDevTools();
|
||||
if (process.env.TEST)
|
||||
mainWindow.webContents.openDevTools();
|
||||
};
|
||||
|
||||
app.on('ready', createWindow);
|
||||
|
|
|
|||
81
gui/electron/src/menu.ts
Normal file
81
gui/electron/src/menu.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { Menu, MenuItem, MenuItemConstructorOptions, shell } from "electron";
|
||||
import path from 'path';
|
||||
import json from '../../../package.json';
|
||||
|
||||
const template: (MenuItemConstructorOptions | MenuItem)[] = [
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{
|
||||
role: 'undo'
|
||||
},
|
||||
{
|
||||
role: 'redo'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
role: 'cut'
|
||||
},
|
||||
{
|
||||
role: 'copy'
|
||||
},
|
||||
{
|
||||
role: 'paste'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Debug',
|
||||
submenu: [
|
||||
{
|
||||
role: 'toggleDevTools'
|
||||
},
|
||||
{
|
||||
label: 'Open log folder',
|
||||
click: () => {
|
||||
shell.openPath(path.join(__dirname, 'logs'))
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Version',
|
||||
sublabel: json.version
|
||||
},
|
||||
{
|
||||
label: 'GitHub',
|
||||
click: () => {
|
||||
shell.openExternal('https://github.com/anidl/multi-downloader-nx')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Report a Bug',
|
||||
click: () => {
|
||||
shell.openExternal(`https://github.com/anidl/multi-downloader-nx/issues/new?assignees=izu-co&labels=bug&template=bug.yml&title=BUG&version=${json.version}`)
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Contributors',
|
||||
click: () => {
|
||||
shell.openExternal('https://github.com/anidl/multi-downloader-nx/graphs/contributors')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Discord',
|
||||
click: () => {
|
||||
shell.openExternal('https://discord.gg/qEpbWen5vq')
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
|
|
@ -22,5 +22,6 @@ export default (window: BrowserWindow) => {
|
|||
ipcMain.handle('resolveItems', async (_, data) => handler?.resolveItems(data));
|
||||
ipcMain.handle('listEpisodes', async (_, data) => handler?.listEpisodes(data));
|
||||
ipcMain.handle('downloadItem', async (_, data) => handler?.downloadItem(data));
|
||||
ipcMain.handle('writeToClipboard', async (_, data) => handler?.writeToClipboard(data));
|
||||
ipcMain.on('isDownloading', (ev) => ev.returnValue = handler?.isDownloading());
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BrowserWindow, dialog } from "electron";
|
||||
import { BrowserWindow, clipboard, dialog } from "electron";
|
||||
import { DownloadInfo, ExtendedProgress, ProgressData } from "../../../../@types/messageHandler";
|
||||
import { RandomEvent, RandomEvents } from "../../../../@types/randomEvents";
|
||||
|
||||
|
|
@ -49,4 +49,9 @@ export default class Base {
|
|||
return this.downloading;
|
||||
}
|
||||
|
||||
async writeToClipboard(text: string) {
|
||||
clipboard.writeText(text, 'clipboard');
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -23,11 +23,14 @@ class FunimationHandler extends Base implements MessageHandler {
|
|||
return { isOk: true, value: request.value.map(item => ({
|
||||
e: item.id_split.join(''),
|
||||
lang: item.audio ?? [],
|
||||
name: item.episodeSlug,
|
||||
season: item.seasonNum,
|
||||
name: item.title,
|
||||
season: item.seasonNum ?? item.seasonTitle ?? item.item.seasonNum ?? item.item.seasonTitle,
|
||||
seasonTitle: item.seasonTitle,
|
||||
episode: item.episodeNum,
|
||||
id: item.id
|
||||
id: item.id,
|
||||
img: item.thumb,
|
||||
description: item.synopsis,
|
||||
time: item.runtime ?? item.item.runtime
|
||||
})) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Hello World!</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="script-src 'self' 'unsafe-eval'"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const AuthButton: React.FC = () => {
|
|||
setAuthed((await messageChannel?.checkToken())?.isOk ?? false);
|
||||
}
|
||||
|
||||
React.useEffect(() => { checkAuth(); return () => {}; }, []);
|
||||
React.useEffect(() => { checkAuth() }, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!messageChannel)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import Queue from "./Queue/Queue";
|
||||
import { Box } from "@mui/material";
|
||||
import React from "react";
|
||||
import EpisodeListing from "./Listing/EpisodeListing";
|
||||
import EpisodeListing from "../Listing/EpisodeListing";
|
||||
|
||||
const Bottom: React.FC = () => {
|
||||
return <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
|
||||
<EpisodeListing />
|
||||
return <Box sx={{ display: 'grid', gridTemplateColumns: '1fr' }}>
|
||||
<Queue />
|
||||
</Box>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
import { Accordion, AccordionSummary, AccordionDetails, Box, List, ListItem, Typography } from "@mui/material";
|
||||
import { ExpandMore } from '@mui/icons-material'
|
||||
import React from "react";
|
||||
import useStore from "../../../../hooks/useStore";
|
||||
import { Episode } from "../../../../../../../@types/messageHandler";
|
||||
|
||||
const EpisodeListing: React.FC = () => {
|
||||
const [store] = useStore();
|
||||
|
||||
const [expended, setExpended] = React.useState('');
|
||||
|
||||
const [data, setData] = React.useState<{
|
||||
[seasonHeader: string]: Episode[]
|
||||
}>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
const map: {
|
||||
[seasonHeader: string]: Episode[]
|
||||
} = {};
|
||||
|
||||
store.episodeListing.forEach(item => {
|
||||
const title = `S${item.season} - ${item.seasonTitle}`;
|
||||
if (Object.prototype.hasOwnProperty.call(map, title)) {
|
||||
map[title].push(item);
|
||||
} else {
|
||||
map[title] = [ item ];
|
||||
}
|
||||
})
|
||||
|
||||
setData(map);
|
||||
}, [store]);
|
||||
|
||||
return <Box>
|
||||
{Object.entries(data).map(([key, items], index) => {
|
||||
return <Accordion key={`Season_${index}`} expanded={expended === key} onChange={() => setExpended(key === expended ? '' : key)}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMore />}
|
||||
aria-controls="panel1bh-content"
|
||||
id="panel1bh-header"
|
||||
>
|
||||
<Typography sx={{ width: '80%', flexShrink: 0 }}>
|
||||
{key}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{items.map((item, index) => {
|
||||
return <Typography key={`Season_Item_${index}`} sx={{ paddingBottom: 1 }}>
|
||||
{`[${item.e}] - ${item.name} ( ${item.lang.join(', ')} ) `}
|
||||
</Typography>
|
||||
})}
|
||||
</AccordionDetails>
|
||||
</Accordion>;
|
||||
})}
|
||||
</Box>
|
||||
}
|
||||
|
||||
export default EpisodeListing;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Box, Button, Checkbox, Chip, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, OutlinedInput, Select, TextField } from "@mui/material";
|
||||
import { Backdrop, Box, Button, Checkbox, Chip, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, OutlinedInput, Select, TextField } from "@mui/material";
|
||||
import useStore from "../../../hooks/useStore";
|
||||
import MultiSelect from "../../reusable/MultiSelect";
|
||||
import { messageChannelContext } from "../../../provider/MessageChannel";
|
||||
|
|
@ -15,13 +15,20 @@ const DownloadSelector: React.FC = () => {
|
|||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
/* If we don't wait the response is undefined? */
|
||||
await new Promise((resolve) => setTimeout(() => resolve(undefined), 100));
|
||||
const dubLang = messageHandler?.handleDefault('dubLang');
|
||||
const q = messageHandler?.handleDefault('q');
|
||||
const fileName = messageHandler?.handleDefault('fileName');
|
||||
const result = await Promise.all([dubLang, q, fileName]);
|
||||
console.log(result);
|
||||
dispatch({
|
||||
type: 'downloadOptions',
|
||||
payload: {
|
||||
...store.downloadOptions,
|
||||
dubLang: await messageHandler?.handleDefault('dubLang'),
|
||||
q: await messageHandler?.handleDefault('q'),
|
||||
fileName: await messageHandler?.handleDefault('fileName')
|
||||
dubLang: result[0],
|
||||
q: result[1],
|
||||
fileName: result[2]
|
||||
}
|
||||
});
|
||||
setAvailableDubs(await messageHandler?.availableDubCodes() ?? []);
|
||||
|
|
@ -69,8 +76,6 @@ const DownloadSelector: React.FC = () => {
|
|||
setLoading(false);
|
||||
}
|
||||
|
||||
console.log(store.queue, store.currentDownload);
|
||||
|
||||
return <Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ m: 2, gap: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<TextField value={store.downloadOptions.id} required onChange={e => {
|
||||
|
|
|
|||
143
gui/react/src/components/MainFrame/Listing/EpisodeListing.tsx
Normal file
143
gui/react/src/components/MainFrame/Listing/EpisodeListing.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { Accordion, AccordionSummary, AccordionDetails, Box, List, ListItem, Typography, Backdrop, Divider, Container, Dialog, Select, MenuItem, FormControl, InputLabel } from "@mui/material";
|
||||
import { ExpandMore } from '@mui/icons-material'
|
||||
import React from "react";
|
||||
import useStore from "../../../hooks/useStore";
|
||||
import { Episode } from "../../../../../../@types/messageHandler";
|
||||
|
||||
const EpisodeListing: React.FC = () => {
|
||||
const [store, dispatch] = useStore();
|
||||
|
||||
const [season, setSeason] = React.useState<'all'|string>('all');
|
||||
|
||||
const seasons = React.useMemo(() => {
|
||||
const s: string[] = [];
|
||||
for (const {season} of store.episodeListing) {
|
||||
if (s.includes(season))
|
||||
continue;
|
||||
s.push(season);
|
||||
}
|
||||
return s;
|
||||
}, [ store.episodeListing ])
|
||||
|
||||
const [selected, setSelected] = React.useState<string[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelected(parseSelect(store.downloadOptions.e));
|
||||
}, [ store.episodeListing ])
|
||||
|
||||
const close = () => {
|
||||
dispatch({
|
||||
type: 'episodeListing',
|
||||
payload: []
|
||||
});
|
||||
dispatch({
|
||||
type: 'downloadOptions',
|
||||
payload: {
|
||||
...store.downloadOptions,
|
||||
e: `${([...new Set([...parseSelect(store.downloadOptions.e), ...selected])]).join(',')}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return <Dialog open={store.episodeListing.length > 0} onClose={close} scroll='paper' maxWidth='xl' sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 200px 20px' }}>
|
||||
<Typography color='text.primary' variant="h5" sx={{ textAlign: 'center', alignItems: 'center', justifyContent: 'center', display: 'flex' }}>
|
||||
Episodes
|
||||
</Typography>
|
||||
<FormControl sx={{ mr: 2, mt: 2 }}>
|
||||
<InputLabel id='seasonSelectLabel'>Season</InputLabel>
|
||||
<Select labelId="seasonSelectLabel" label='Season' value={season} onChange={(e) => setSeason(e.target.value)}>
|
||||
<MenuItem value='all'>Show all Epsiodes</MenuItem>
|
||||
{seasons.map((a, index) => {
|
||||
return <MenuItem value={a} key={`MenuItem_SeasonSelect_${index}`}>
|
||||
{a}
|
||||
</MenuItem>
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<List>
|
||||
{store.episodeListing.filter((a) => season === 'all' ? true : a.season === season).map((item, index, { length }) => {
|
||||
const e = isNaN(parseInt(item.e)) ? item.e : parseInt(item.e);
|
||||
const isSelected = selected.includes(e.toString());
|
||||
return <Box key={`Episode_List_Item_${index}`} sx={{
|
||||
backdropFilter: isSelected ? 'brightness(1.5)' : '',
|
||||
'&:hover': {
|
||||
backdropFilter: 'brightness(1.5)'
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
let arr: string[] = [];
|
||||
if (isSelected) {
|
||||
arr = [...selected.filter(a => a !== e)];
|
||||
} else {
|
||||
arr = [...selected, e.toString()];
|
||||
}
|
||||
setSelected(arr);
|
||||
}}>
|
||||
<ListItem sx={{ display: 'grid', gridTemplateColumns: '50px 1fr 5fr' }}>
|
||||
<Typography color='text.primary' sx={{ textAlign: 'center' }}>
|
||||
{e}
|
||||
</Typography>
|
||||
<img style={{ width: 'inherit', maxHeight: '200px' }} src={item.img}></img>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pl: 1 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr min-content' }}>
|
||||
<Typography color='text.primary' variant="h5">
|
||||
{item.name}
|
||||
</Typography>
|
||||
<Typography color='text.primary'>
|
||||
{item.time.startsWith('00:') ? item.time.slice(3) : item.time}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography color='text.primary'>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItem>
|
||||
{index < length - 1 && <Divider />}
|
||||
</Box>
|
||||
})}
|
||||
</List>
|
||||
</Dialog>
|
||||
}
|
||||
|
||||
const parseSelect = (s: string): string[] => {
|
||||
const ret: string[] = [];
|
||||
s.split(',').forEach(item => {
|
||||
if (item.includes('-')) {
|
||||
let split = item.split('-');
|
||||
if (split.length !== 2)
|
||||
return;
|
||||
const match = split[0].match(/[A-Za-z]+/);
|
||||
if (match && match.length > 0) {
|
||||
if (match.index && match.index !== 0) {
|
||||
return;
|
||||
}
|
||||
const letters = split[0].substring(0, match[0].length);
|
||||
const number = parseInt(split[0].substring(match[0].length));
|
||||
const b = parseInt(split[1]);
|
||||
if (isNaN(number) || isNaN(b)) {
|
||||
return;
|
||||
}
|
||||
for (let i = number; i <= b; i++) {
|
||||
ret.push(`${letters}${i}`);
|
||||
}
|
||||
|
||||
} else {
|
||||
const a = parseInt(split[0]);
|
||||
const b = parseInt(split[1]);
|
||||
if (isNaN(a) || isNaN(b)) {
|
||||
return;
|
||||
}
|
||||
for (let i = a; i <= b; i++) {
|
||||
ret.push(`${i}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ret.push(item);
|
||||
}
|
||||
})
|
||||
return [...new Set(ret)];
|
||||
}
|
||||
|
||||
export default EpisodeListing;
|
||||
|
|
@ -2,6 +2,7 @@ import { Box, Divider } from "@mui/material";
|
|||
import React from "react";
|
||||
import Bottom from "./Bottom/Bottom";
|
||||
import DownloadSelector from "./DownloadSelector/DownloadSelector";
|
||||
import EpisodeListing from "./Listing/EpisodeListing";
|
||||
import './MainFrame.css';
|
||||
import Progress from "./Progress/Progress";
|
||||
import SearchBox from "./SearchBox/SearchBox";
|
||||
|
|
@ -18,6 +19,7 @@ const MainFrame: React.FC = () => {
|
|||
<Box sx={{ marginLeft: 1 }}>
|
||||
<Progress />
|
||||
</Box>
|
||||
<EpisodeListing />
|
||||
</Box>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import useDownloadManager from "../DownloadManager/DownloadManager";
|
|||
const Progress: React.FC = () => {
|
||||
const data = useDownloadManager();
|
||||
|
||||
return data ? <Box sx={{ display: 'grid', gridTemplateRows: '1fr 2fr', height: '100%' }}>
|
||||
return data ? <Box sx={{ display: 'grid', gridTemplateRows: '1fr 2fr', height: 'auto' }}>
|
||||
<img style={{ maxWidth: '100%', maxHeight: '100%', border: '2px solid white', padding: 8 }} src={data.downloadInfo.image}></img>
|
||||
<Box sx={{ display: 'grid', gridTemplateRows: '1ft fit-content', gap: 1 }}>
|
||||
<table>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React from "react";
|
||||
import { Box, Divider, List, ListItem, Paper, TextField, Typography } from "@mui/material";
|
||||
import { Box, ClickAwayListener, Divider, List, ListItem, Paper, TextField, Typography } from "@mui/material";
|
||||
import { SearchResponse } from "../../../../../../@types/messageHandler";
|
||||
import useStore from "../../../hooks/useStore";
|
||||
import { messageChannelContext } from "../../../provider/MessageChannel";
|
||||
import './SearchBox.css';
|
||||
import ContextMenu from "../../reusable/ContextMenu";
|
||||
import { useSnackbar } from "notistack";
|
||||
|
||||
const SearchBox: React.FC = () => {
|
||||
const messageHandler = React.useContext(messageChannelContext);
|
||||
|
|
@ -15,6 +17,8 @@ const SearchBox: React.FC = () => {
|
|||
const [searchResult, setSearchResult] = React.useState<undefined|SearchResponse>();
|
||||
const anchor = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const selectItem = (id: string) => {
|
||||
console.log(id);
|
||||
dispatch({
|
||||
|
|
@ -39,44 +43,55 @@ const SearchBox: React.FC = () => {
|
|||
|
||||
const anchorBounding = anchor.current?.getBoundingClientRect();
|
||||
|
||||
|
||||
return <Box onBlurCapture={() => /* Delay to capture onclick */setTimeout(() => setFocus(false), 100) } onFocusCapture={() => setFocus(true)} sx={{ m: 2 }}>
|
||||
<TextField ref={anchor} value={search} onChange={e => setSearch(e.target.value)} variant='outlined' label='Search' fullWidth />
|
||||
{searchResult !== undefined && searchResult.isOk && searchResult.value.length > 0 && focus &&
|
||||
<Paper sx={{ position: 'absolute', maxHeight: '50%', width: `${anchorBounding?.width}px`,
|
||||
left: anchorBounding?.x, top: (anchorBounding?.y ?? 0) + (anchorBounding?.height ?? 0), zIndex: 99, overflowY: 'scroll'}}>
|
||||
<List>
|
||||
{searchResult && searchResult.isOk ?
|
||||
searchResult.value.map((a, ind, arr) => {
|
||||
return <Box key={a.id}>
|
||||
<ListItem className='listitem-hover' onClick={() => selectItem(a.id)}>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Box sx={{ width: '20%', height: '100%', pr: 2 }}>
|
||||
<img src={a.image} style={{ width: '100%', height: '100%' }}/>
|
||||
return <ClickAwayListener onClickAway={() => setFocus(false)}>
|
||||
<Box sx={{ m: 2 }}>
|
||||
<TextField ref={anchor} value={search} onClick={() => setFocus(true)} onChange={e => setSearch(e.target.value)} variant='outlined' label='Search' fullWidth />
|
||||
{searchResult !== undefined && searchResult.isOk && searchResult.value.length > 0 && focus &&
|
||||
<Paper sx={{ position: 'absolute', maxHeight: '50%', width: `${anchorBounding?.width}px`,
|
||||
left: anchorBounding?.x, top: (anchorBounding?.y ?? 0) + (anchorBounding?.height ?? 0), zIndex: 99, overflowY: 'scroll'}}>
|
||||
<List>
|
||||
{searchResult && searchResult.isOk ?
|
||||
searchResult.value.map((a, ind, arr) => {
|
||||
const imageRef = React.createRef<HTMLImageElement>();
|
||||
return <Box key={a.id}>
|
||||
<ListItem className='listitem-hover' onClick={(e) => {
|
||||
selectItem(a.id);
|
||||
setFocus(false);
|
||||
}}>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Box sx={{ width: '20%', height: '100%', pr: 2 }}>
|
||||
<img ref={imageRef} src={a.image} style={{ width: '100%', height: '100%' }}/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', maxWidth: '70%' }}>
|
||||
<Typography variant='h6' component='h6' color='text.primary' sx={{ }}>
|
||||
{a.name}
|
||||
</Typography>
|
||||
{a.desc && <Typography variant='caption' component='p' color='text.primary' sx={{ pt: 1, pb: 1 }}>
|
||||
{a.desc}
|
||||
</Typography>}
|
||||
{a.lang && <Typography variant='caption' component='p' color='text.primary' sx={{ }}>
|
||||
Languages: {a.lang.join(', ')}
|
||||
</Typography>}
|
||||
<Typography variant='caption' component='p' color='text.primary' sx={{ }}>
|
||||
ID: {a.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', maxWidth: '70%' }}>
|
||||
<Typography variant='h6' component='h6' color='text.primary' sx={{ }}>
|
||||
{a.name}
|
||||
</Typography>
|
||||
{a.desc && <Typography variant='caption' component='p' color='text.primary' sx={{ pt: 1, pb: 1 }}>
|
||||
{a.desc}
|
||||
</Typography>}
|
||||
{a.lang && <Typography variant='caption' component='p' color='text.primary' sx={{ }}>
|
||||
Languages: {a.lang.join(', ')}
|
||||
</Typography>}
|
||||
<Typography variant='caption' component='p' color='text.primary' sx={{ }}>
|
||||
ID: {a.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
{(ind < arr.length - 1) && <Divider />}
|
||||
</Box>
|
||||
})
|
||||
: <></>}
|
||||
</List>
|
||||
</Paper>}
|
||||
</Box>
|
||||
</ListItem>
|
||||
<ContextMenu options={[ { text: 'Copy image URL', onClick: () => {
|
||||
messageHandler?.writeToClipboard(a.image);
|
||||
enqueueSnackbar('Copied URL to clipboard', {
|
||||
variant: 'info'
|
||||
});
|
||||
}} ]} popupItem={imageRef} />
|
||||
{(ind < arr.length - 1) && <Divider />}
|
||||
</Box>
|
||||
})
|
||||
: <></>}
|
||||
</List>
|
||||
</Paper>}
|
||||
</Box>
|
||||
</ClickAwayListener>
|
||||
}
|
||||
|
||||
export default SearchBox;
|
||||
66
gui/react/src/components/reusable/ContextMenu.tsx
Normal file
66
gui/react/src/components/reusable/ContextMenu.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { StyledOptions } from "@emotion/styled";
|
||||
import { Box, Button, Card, Divider, List, Typography, SxProps } from "@mui/material";
|
||||
import React from "react";
|
||||
|
||||
export type Option = {
|
||||
text: string,
|
||||
onClick: () => unknown
|
||||
}
|
||||
|
||||
export type ContextMenuProps<T extends HTMLElement> = {
|
||||
options: ('divider'|Option)[],
|
||||
popupItem: React.RefObject<T>
|
||||
}
|
||||
|
||||
const buttonSx: SxProps = {
|
||||
'&:hover': {
|
||||
background: 'rgb(0, 30, 60)'
|
||||
},
|
||||
fontSize: '0.7rem',
|
||||
minHeight: '30px',
|
||||
justifyContent: 'center',
|
||||
p: 0
|
||||
};
|
||||
|
||||
function ContextMenu<T extends HTMLElement, >(props: ContextMenuProps<T>) {
|
||||
const [anchor, setAnchor] = React.useState( { x: 0, y: 0 } );
|
||||
|
||||
const [show, setShow] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const { popupItem: ref } = props;
|
||||
if (ref.current === null)
|
||||
return;
|
||||
const listener = (ev: MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
setAnchor({ x: ev.x + 10, y: ev.y + 10 });
|
||||
setShow(true);
|
||||
}
|
||||
ref.current.addEventListener('contextmenu', listener);
|
||||
|
||||
return () => {
|
||||
if (ref.current)
|
||||
ref.current.removeEventListener('contextmenu', listener)
|
||||
};
|
||||
}, [ props.popupItem ])
|
||||
|
||||
return show ? <Box sx={{ p: 1, background: 'rgba(0, 0, 0, 0.75)', backdropFilter: 'blur(5px)', position: 'fixed', left: anchor.x, top: anchor.y }}>
|
||||
<List sx={{ p: 0, m: 0 }}>
|
||||
{props.options.map((item, i) => {
|
||||
return item === 'divider' ? <Divider key={`ContextMenu_Divider_${i}_${item}`}/> :
|
||||
<Button color='inherit' key={`ContextMenu_Value_${i}_${item}`} onClick={() => {
|
||||
item.onClick();
|
||||
setShow(false);
|
||||
}} sx={buttonSx}>
|
||||
{item.text}
|
||||
</Button>
|
||||
})}
|
||||
<Divider />
|
||||
<Button fullWidth color='inherit' onClick={() => setShow(false)} sx={buttonSx} >
|
||||
Close
|
||||
</Button>
|
||||
</List>
|
||||
</Box> : <></>
|
||||
}
|
||||
|
||||
export default ContextMenu;
|
||||
|
|
@ -53,11 +53,7 @@ const MultiSelect: React.FC<MultiSelectProps> = (props) => {
|
|||
}}
|
||||
input={<OutlinedInput id="select-multiple-chip" label={props.title} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} />
|
||||
))}
|
||||
</Box>
|
||||
selected.join(', ')
|
||||
)}
|
||||
MenuProps={MenuProps}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { IconButton } from "@mui/material";
|
|||
import { CloseOutlined } from "@mui/icons-material";
|
||||
import { SnackbarProvider, SnackbarKey } from 'notistack';
|
||||
import Store from './provider/Store';
|
||||
import ErrorHandler from './provider/ErrorHandler';
|
||||
|
||||
const notistackRef = React.createRef<SnackbarProvider>();
|
||||
const onClickDismiss = (key: SnackbarKey | undefined) => () => {
|
||||
|
|
@ -17,24 +18,26 @@ const onClickDismiss = (key: SnackbarKey | undefined) => () => {
|
|||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<Store>
|
||||
<SnackbarProvider
|
||||
ref={notistackRef}
|
||||
action={(key) => (
|
||||
<IconButton onClick={onClickDismiss(key)} color="inherit">
|
||||
<CloseOutlined />
|
||||
</IconButton>
|
||||
)}
|
||||
>
|
||||
<Style>
|
||||
<MessageChannel>
|
||||
<ServiceProvider>
|
||||
<App />
|
||||
</ServiceProvider>
|
||||
</MessageChannel>
|
||||
</Style>
|
||||
</SnackbarProvider>
|
||||
</Store>
|
||||
<ErrorHandler>
|
||||
<Store>
|
||||
<SnackbarProvider
|
||||
ref={notistackRef}
|
||||
action={(key) => (
|
||||
<IconButton onClick={onClickDismiss(key)} color="inherit">
|
||||
<CloseOutlined />
|
||||
</IconButton>
|
||||
)}
|
||||
>
|
||||
<Style>
|
||||
<MessageChannel>
|
||||
<ServiceProvider>
|
||||
<App />
|
||||
</ServiceProvider>
|
||||
</MessageChannel>
|
||||
</Style>
|
||||
</SnackbarProvider>
|
||||
</Store>
|
||||
</ErrorHandler>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
35
gui/react/src/provider/ErrorHandler.tsx
Normal file
35
gui/react/src/provider/ErrorHandler.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Backdrop, Box, Typography } from "@mui/material";
|
||||
import React from "react";
|
||||
|
||||
export default class ErrorHandler extends React.Component<{}, {
|
||||
error?: {
|
||||
er: Error,
|
||||
stack: React.ErrorInfo
|
||||
}
|
||||
}> {
|
||||
|
||||
constructor(props: React.PropsWithChildren<{}>) {
|
||||
super(props);
|
||||
this.state = { error: undefined }
|
||||
}
|
||||
|
||||
componentDidCatch(er: Error, stack: React.ErrorInfo) {
|
||||
this.setState({ error: { er, stack } })
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
return this.state.error ?
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', p: 2 }}>
|
||||
<Typography variant='body1' color='red'>
|
||||
{`${this.state.error.er.name}: ${this.state.error.er.message}`}
|
||||
<br/>
|
||||
{this.state.error.stack.componentStack.split('\n').map(a => {
|
||||
return <>
|
||||
{a}
|
||||
<br/>
|
||||
</>
|
||||
})}
|
||||
</Typography>
|
||||
</Box> : this.props.children;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type { IpcRenderer, IpcRendererEvent } from "electron";
|
|||
import useStore from '../hooks/useStore';
|
||||
|
||||
import type { Handler, RandomEvent, RandomEvents } from '../../../../@types/randomEvents';
|
||||
import { Backdrop, Typography } from '@mui/material';
|
||||
|
||||
|
||||
export type FrontEndMessanges = (MessageHandler & { randomEvents: RandomEventHandler });
|
||||
|
|
@ -77,7 +78,8 @@ const MessageChannelProvider: React.FC = ({ children }) => {
|
|||
listEpisodes: async (data) => await ipcRenderer.invoke('listEpisodes', data),
|
||||
randomEvents: randomEventHandler,
|
||||
downloadItem: (data) => ipcRenderer.invoke('downloadItem', data),
|
||||
isDownloading: () => ipcRenderer.sendSync('isDownloading')
|
||||
isDownloading: () => ipcRenderer.sendSync('isDownloading'),
|
||||
writeToClipboard: async (data) => await ipcRenderer.invoke('writeToClipboard', data)
|
||||
}
|
||||
|
||||
return <messageChannelContext.Provider value={messageHandler}>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const initialState: StoreState = {
|
|||
q: 0,
|
||||
e: '',
|
||||
dubLang: [ 'jpn' ],
|
||||
fileName: '[${service}] ${showTitle} - S${season}E${episode} [${height}p]',
|
||||
fileName: '',
|
||||
all: false,
|
||||
but: false
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue