From 2ae4a5da3d758820ec80fa1d5183b9391d59f1d9 Mon Sep 17 00:00:00 2001 From: Marco <35533749+marcopeocchi@users.noreply.github.com> Date: Fri, 23 Jun 2023 10:48:38 +0200 Subject: [PATCH] New home view layout (#58) * Home layout refactor, moved new download to dialog * sort downloads by date --- frontend/src/components/DownloadDialog.tsx | 335 ++++++++++++++++++ frontend/src/components/DownloadsListView.tsx | 2 +- frontend/src/components/Logout.tsx | 2 +- frontend/src/components/Splash.tsx | 34 ++ .../formatSelection/formatSelectionSlice.ts | 7 + frontend/src/types/index.d.ts | 35 +- frontend/src/utils.ts | 4 +- frontend/src/views/Home.tsx | 308 ++-------------- server/process.go | 5 +- server/types.go | 19 +- 10 files changed, 451 insertions(+), 300 deletions(-) create mode 100644 frontend/src/components/DownloadDialog.tsx create mode 100644 frontend/src/components/Splash.tsx create mode 100644 frontend/src/features/formatSelection/formatSelectionSlice.ts diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx new file mode 100644 index 0000000..c489104 --- /dev/null +++ b/frontend/src/components/DownloadDialog.tsx @@ -0,0 +1,335 @@ +import { FileUpload } from '@mui/icons-material' +import CloseIcon from '@mui/icons-material/Close' +import { + Button, + Container, + FormControl, + Grid, + IconButton, + InputAdornment, + InputLabel, + MenuItem, + Paper, + Select, + styled, + TextField +} from '@mui/material' +import AppBar from '@mui/material/AppBar' +import Dialog from '@mui/material/Dialog' +import Slide from '@mui/material/Slide' +import Toolbar from '@mui/material/Toolbar' +import { TransitionProps } from '@mui/material/transitions' +import Typography from '@mui/material/Typography' +import { Buffer } from 'buffer' +import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import FormatsGrid from '../components/FormatsGrid' +import { CliArguments } from '../lib/argsParser' +import I18nBuilder from '../lib/intl' +import { RPCClient } from '../lib/rpcClient' +import { RootState } from '../stores/store' +import type { DLMetadata } from '../types' +import { isValidURL, toFormatArgs } from '../utils' + +const Transition = forwardRef(function Transition( + props: TransitionProps & { + children: React.ReactElement + }, + ref: React.Ref, +) { + return +}) + +type Props = { + open: boolean + onClose: () => void +} + +export default function DownloadDialog({ open, onClose }: Props) { + // redux state + const settings = useSelector((state: RootState) => state.settings) + const status = useSelector((state: RootState) => state.status) + const dispatch = useDispatch() + + // ephemeral state + const [downloadFormats, setDownloadFormats] = useState() + const [pickedVideoFormat, setPickedVideoFormat] = useState('') + const [pickedAudioFormat, setPickedAudioFormat] = useState('') + const [pickedBestFormat, setPickedBestFormat] = useState('') + + const [customArgs, setCustomArgs] = useState('') + const [downloadPath, setDownloadPath] = useState(0) + const [availableDownloadPaths, setAvailableDownloadPaths] = useState([]) + + const [fileNameOverride, setFilenameOverride] = useState('') + + const [url, setUrl] = useState('') + const [workingUrl, setWorkingUrl] = useState('') + + // memos + const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language]) + const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort]) + const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]) + + // refs + const urlInputRef = useRef(null) + const customFilenameInputRef = useRef(null) + + // effects + useEffect(() => { + client.directoryTree() + .then(data => { + setAvailableDownloadPaths(data.result) + }) + }, []) + + useEffect(() => { + setCustomArgs(localStorage.getItem('last-input-args') ?? '') + setFilenameOverride(localStorage.getItem('last-filename-override') ?? '') + }, []) + + /** + * Retrive url from input, cli-arguments from checkboxes and emits via WebSocket + */ + const sendUrl = (immediate?: string) => { + const codes = new Array() + if (pickedVideoFormat !== '') codes.push(pickedVideoFormat) + if (pickedAudioFormat !== '') codes.push(pickedAudioFormat) + if (pickedBestFormat !== '') codes.push(pickedBestFormat) + + client.download( + immediate || url || workingUrl, + `${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`, + availableDownloadPaths[downloadPath] ?? '', + fileNameOverride + ) + + setUrl('') + setWorkingUrl('') + + setTimeout(() => { + resetInput() + setDownloadFormats(undefined) + onClose() + }, 250) + } + + /** + * Retrive url from input and display the formats selection view + */ + const sendUrlFormatSelection = () => { + setWorkingUrl(url) + setUrl('') + setPickedAudioFormat('') + setPickedVideoFormat('') + setPickedBestFormat('') + + client.formats(url) + ?.then(formats => { + setDownloadFormats(formats.result) + resetInput() + }) + } + + /** + * Update the url state whenever the input value changes + * @param e Input change event + */ + const handleUrlChange = (e: React.ChangeEvent) => { + setUrl(e.target.value) + } + + /** + * Update the filename override state whenever the input value changes + * @param e Input change event + */ + const handleFilenameOverrideChange = (e: React.ChangeEvent) => { + setFilenameOverride(e.target.value) + localStorage.setItem('last-filename-override', e.target.value) + } + + /** + * Update the custom args state whenever the input value changes + * @param e Input change event + */ + const handleCustomArgsChange = (e: React.ChangeEvent) => { + setCustomArgs(e.target.value) + localStorage.setItem("last-input-args", e.target.value) + } + + const parseUrlListFile = (event: any) => { + const urlList = event.target.files + const reader = new FileReader() + reader.addEventListener('load', $event => { + const base64 = $event.target?.result!.toString().split(',')[1] + Buffer.from(base64!, 'base64') + .toString() + .trimEnd() + .split('\n') + .filter(_url => isValidURL(_url)) + .forEach(_url => sendUrl(_url)) + }) + reader.readAsDataURL(urlList[0]) + } + + const resetInput = () => { + urlInputRef.current!.value = '' + if (customFilenameInputRef.current) { + customFilenameInputRef.current!.value = '' + } + } + + /* -------------------- styled components -------------------- */ + + const Input = styled('input')({ + display: 'none', + }) + + return ( +
+ + + + + + + + Download + + + + + + + + + + + + ), + }} + /> + + + { + settings.enableCustomArgs && + + + + } + { + settings.fileRenaming && + + + + } + { + settings.pathOverriding && + + + {i18n.t('customPath')} + + + + } + + + + + + + + + + {/* Format Selection grid */} + {downloadFormats && { + setPickedBestFormat(id) + setPickedVideoFormat('') + setPickedAudioFormat('') + }} + onVideoSelected={(id) => { + setPickedVideoFormat(id) + setPickedBestFormat('') + }} + onAudioSelected={(id) => { + setPickedAudioFormat(id) + setPickedBestFormat('') + }} + onClear={() => { + setPickedAudioFormat('') + setPickedVideoFormat('') + setPickedBestFormat('') + }} + onSubmit={sendUrl} + pickedBestFormat={pickedBestFormat} + pickedVideoFormat={pickedVideoFormat} + pickedAudioFormat={pickedAudioFormat} + />} + + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/DownloadsListView.tsx b/frontend/src/components/DownloadsListView.tsx index 95235ae..88aa556 100644 --- a/frontend/src/components/DownloadsListView.tsx +++ b/frontend/src/components/DownloadsListView.tsx @@ -23,7 +23,7 @@ export function DownloadsListView({ downloads, abortFunction }: Props) { return ( - + diff --git a/frontend/src/components/Logout.tsx b/frontend/src/components/Logout.tsx index beb312b..4953a07 100644 --- a/frontend/src/components/Logout.tsx +++ b/frontend/src/components/Logout.tsx @@ -18,7 +18,7 @@ export default function Logout() { - + ) } \ No newline at end of file diff --git a/frontend/src/components/Splash.tsx b/frontend/src/components/Splash.tsx new file mode 100644 index 0000000..62cee4a --- /dev/null +++ b/frontend/src/components/Splash.tsx @@ -0,0 +1,34 @@ +import CloudDownloadIcon from '@mui/icons-material/CloudDownload' +import { Container, SvgIcon, Typography, styled } from '@mui/material' + +const FlexContainer = styled(Container)({ + display: 'flex', + minWidth: '100%', + minHeight: '80vh', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column' +}) + +const Title = styled(Typography)({ + display: 'flex', + width: '100%', + alignItems: 'center', + justifyContent: 'center', + paddingBottom: '0.5rem' +}) + +export default function Splash() { + return ( + + + <SvgIcon sx={{ fontSize: '200px' }}> + <CloudDownloadIcon /> + </SvgIcon> + + + No active downloads + + + ) +} \ No newline at end of file diff --git a/frontend/src/features/formatSelection/formatSelectionSlice.ts b/frontend/src/features/formatSelection/formatSelectionSlice.ts new file mode 100644 index 0000000..1398bfc --- /dev/null +++ b/frontend/src/features/formatSelection/formatSelectionSlice.ts @@ -0,0 +1,7 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +export interface FormatSelectionState { + bestFormat: string + audioFormat: string + videoFormat: string +} \ No newline at end of file diff --git a/frontend/src/types/index.d.ts b/frontend/src/types/index.d.ts index a2ca267..e812f72 100644 --- a/frontend/src/types/index.d.ts +++ b/frontend/src/types/index.d.ts @@ -21,23 +21,28 @@ export type RPCResponse = { id?: string } +type DownloadInfo = { + url: string + filesize_approx?: number + resolution?: string + thumbnail: string + title: string + vcodec?: string + acodec?: string + ext?: string + created_at: string +} + +type DownloadProgress = { + speed: number + eta: number + percentage: string +} + export type RPCResult = { id: string - progress: { - speed: number - eta: number - percentage: string - } - info: { - url: string - filesize_approx?: number - resolution?: string - thumbnail: string - title: string - vcodec?: string - acodec?: string - ext?: string - } + progress: DownloadProgress + info: DownloadInfo } export type RPCParams = { diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 9b06aff..8f6d925 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -91,4 +91,6 @@ export function formatGiB(bytes: number) { } export const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB` -export const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s` \ No newline at end of file +export const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s` + +export const dateTimeComparatorFunc = (a: string, b: string) => new Date(a).getTime() - new Date(b).getTime() diff --git a/frontend/src/views/Home.tsx b/frontend/src/views/Home.tsx index 1cbd2f4..598b3ee 100644 --- a/frontend/src/views/Home.tsx +++ b/frontend/src/views/Home.tsx @@ -1,40 +1,30 @@ -import { FileUpload } from '@mui/icons-material' +import AddCircleIcon from '@mui/icons-material/AddCircle' +import DeleteForeverIcon from '@mui/icons-material/DeleteForever' import FormatListBulleted from '@mui/icons-material/FormatListBulleted' import { Alert, Backdrop, - Button, CircularProgress, Container, - FormControl, - Grid, - IconButton, - InputAdornment, - InputLabel, - MenuItem, - Paper, - Select, Snackbar, SpeedDial, SpeedDialAction, SpeedDialIcon, - styled, - TextField + styled } from '@mui/material' -import { Buffer } from 'buffer' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' +import DownloadDialog from '../components/DownloadDialog' import { DownloadsCardView } from '../components/DownloadsCardView' import { DownloadsListView } from '../components/DownloadsListView' -import FormatsGrid from '../components/FormatsGrid' -import { CliArguments } from '../lib/argsParser' -import I18nBuilder from '../lib/intl' -import { RPCClient, socket$ } from '../lib/rpcClient' +import Splash from '../components/Splash' import { toggleListView } from '../features/settings/settingsSlice' import { connected, setFreeSpace } from '../features/status/statusSlice' +import I18nBuilder from '../lib/intl' +import { RPCClient, socket$ } from '../lib/rpcClient' import { RootState } from '../stores/store' -import type { DLMetadata, RPCResponse, RPCResult } from '../types' -import { isValidURL, toFormatArgs } from '../utils' +import type { RPCResponse, RPCResult } from '../types' +import { dateTimeComparatorFunc } from '../utils' export default function Home() { // redux state @@ -43,34 +33,17 @@ export default function Home() { const dispatch = useDispatch() // ephemeral state - const [activeDownloads, setActiveDownloads] = useState>() - const [downloadFormats, setDownloadFormats] = useState() - const [pickedVideoFormat, setPickedVideoFormat] = useState('') - const [pickedAudioFormat, setPickedAudioFormat] = useState('') - const [pickedBestFormat, setPickedBestFormat] = useState('') - - const [customArgs, setCustomArgs] = useState('') - const [downloadPath, setDownloadPath] = useState(0) - const [availableDownloadPaths, setAvailableDownloadPaths] = useState([]) - - const [fileNameOverride, setFilenameOverride] = useState('') - - const [url, setUrl] = useState('') - const [workingUrl, setWorkingUrl] = useState('') + const [activeDownloads, setActiveDownloads] = useState() const [showBackdrop, setShowBackdrop] = useState(true) const [showToast, setShowToast] = useState(true) + const [openDialog, setOpenDialog] = useState(false) const [socketHasError, setSocketHasError] = useState(false) // memos const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language]) const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort]) - const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]) - - // refs - const urlInputRef = useRef(null) - const customFilenameInputRef = useRef(null) /* -------------------- Effects -------------------- */ @@ -81,8 +54,6 @@ export default function Home() { const sub = socket$.subscribe({ next: () => { dispatch(connected()) - setCustomArgs(localStorage.getItem('last-input-args') ?? '') - setFilenameOverride(localStorage.getItem('last-filename-override') ?? '') }, error: () => { setSocketHasError(true) @@ -117,7 +88,10 @@ export default function Home() { setActiveDownloads( (event.result ?? []) .filter((r) => !!r.info.url) - .sort((a, b) => a.info.title.localeCompare(b.info.title)) + .sort((a, b) => dateTimeComparatorFunc( + b.info.created_at, + a.info.created_at, + )) ) break default: @@ -133,88 +107,6 @@ export default function Home() { } }, [activeDownloads?.length]) - useEffect(() => { - client.directoryTree() - .then(data => { - setAvailableDownloadPaths(data.result) - }) - }, []) - - /* -------------------- callbacks-------------------- */ - - /** - * Retrive url from input, cli-arguments from checkboxes and emits via WebSocket - */ - const sendUrl = (immediate?: string) => { - const codes = new Array(); - if (pickedVideoFormat !== '') codes.push(pickedVideoFormat); - if (pickedAudioFormat !== '') codes.push(pickedAudioFormat); - if (pickedBestFormat !== '') codes.push(pickedBestFormat); - - client.download( - immediate || url || workingUrl, - `${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`, - availableDownloadPaths[downloadPath] ?? '', - fileNameOverride - ) - - setUrl('') - setWorkingUrl('') - setShowBackdrop(true) - - setTimeout(() => { - resetInput() - setShowBackdrop(true) - setDownloadFormats(undefined) - }, 250) - } - - /** - * Retrive url from input and display the formats selection view - */ - const sendUrlFormatSelection = () => { - setWorkingUrl(url) - setUrl('') - setPickedAudioFormat('') - setPickedVideoFormat('') - setPickedBestFormat('') - - setShowBackdrop(true) - - client.formats(url) - ?.then(formats => { - setDownloadFormats(formats.result) - setShowBackdrop(false) - resetInput() - }) - } - - /** - * Update the url state whenever the input value changes - * @param e Input change event - */ - const handleUrlChange = (e: React.ChangeEvent) => { - setUrl(e.target.value) - } - - /** - * Update the filename override state whenever the input value changes - * @param e Input change event - */ - const handleFilenameOverrideChange = (e: React.ChangeEvent) => { - setFilenameOverride(e.target.value) - localStorage.setItem('last-filename-override', e.target.value) - } - - /** - * Update the custom args state whenever the input value changes - * @param e Input change event - */ - const handleCustomArgsChange = (e: React.ChangeEvent) => { - setCustomArgs(e.target.value) - localStorage.setItem("last-input-args", e.target.value) - } - /** * Abort a specific download if id's provided, other wise abort all running ones. * @param id The download id / pid @@ -228,27 +120,6 @@ export default function Home() { client.killAll() } - const parseUrlListFile = (event: any) => { - const urlList = event.target.files - const reader = new FileReader() - reader.addEventListener('load', $event => { - const base64 = $event.target?.result!.toString().split(',')[1] - Buffer.from(base64!, 'base64') - .toString() - .trimEnd() - .split('\n') - .filter(_url => isValidURL(_url)) - .forEach(_url => sendUrl(_url)) - }) - reader.readAsDataURL(urlList[0]) - } - - const resetInput = () => { - urlInputRef.current!.value = ''; - if (customFilenameInputRef.current) { - customFilenameInputRef.current!.value = '' - } - } /* -------------------- styled components -------------------- */ @@ -264,133 +135,9 @@ export default function Home() { > - - - - - - - - ), - }} - /> - - - { - settings.enableCustomArgs && - - - - } - { - settings.fileRenaming && - - - - } - { - settings.pathOverriding && - - - {i18n.t('customPath')} - - - - } - - - - - - - - - - - - - {/* Format Selection grid */} - {downloadFormats && { - setPickedBestFormat(id) - setPickedVideoFormat('') - setPickedAudioFormat('') - }} - onVideoSelected={(id) => { - setPickedVideoFormat(id) - setPickedBestFormat('') - }} - onAudioSelected={(id) => { - setPickedAudioFormat(id) - setPickedBestFormat('') - }} - onClear={() => { - setPickedAudioFormat(''); - setPickedVideoFormat(''); - setPickedBestFormat(''); - }} - onSubmit={sendUrl} - pickedBestFormat={pickedBestFormat} - pickedVideoFormat={pickedVideoFormat} - pickedAudioFormat={pickedAudioFormat} - />} + {activeDownloads?.length === 0 && + + } { settings.listView ? : @@ -418,10 +165,25 @@ export default function Home() { } tooltipTitle={`Table view`} - tooltipOpen onClick={() => dispatch(toggleListView())} /> + } + tooltipTitle={i18n.t('abortAllButton')} + onClick={() => abort()} + /> + } + tooltipTitle={`New download`} + onClick={() => setOpenDialog(true)} + /> + { + setOpenDialog(false) + activeDownloads?.length === 0 + ? setShowBackdrop(false) + : setShowBackdrop(true) + }} /> ) } diff --git a/server/process.go b/server/process.go index d348319..4df3b39 100644 --- a/server/process.go +++ b/server/process.go @@ -116,7 +116,10 @@ func (p *Process) Start(path, filename string) { if err != nil { log.Println("Cannot retrieve info for", p.url) } - info := DownloadInfo{URL: p.url} + info := DownloadInfo{ + URL: p.url, + CreatedAt: time.Now(), + } json.Unmarshal(stdout, &info) p.mem.UpdateInfo(p.id, info) }() diff --git a/server/types.go b/server/types.go index ade2d2d..043441a 100644 --- a/server/types.go +++ b/server/types.go @@ -1,5 +1,7 @@ package server +import "time" + // Progress for the Running call type DownloadProgress struct { Percentage string `json:"percentage"` @@ -9,14 +11,15 @@ type DownloadProgress struct { // Used to deser the yt-dlp -J output type DownloadInfo struct { - URL string `json:"url"` - Title string `json:"title"` - Thumbnail string `json:"thumbnail"` - Resolution string `json:"resolution"` - Size int32 `json:"filesize_approx"` - VCodec string `json:"vcodec"` - ACodec string `json:"acodec"` - Extension string `json:"ext"` + URL string `json:"url"` + Title string `json:"title"` + Thumbnail string `json:"thumbnail"` + Resolution string `json:"resolution"` + Size int32 `json:"filesize_approx"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + Extension string `json:"ext"` + CreatedAt time.Time `json:"created_at"` } // Used to deser the formats in the -J output