diff --git a/.eslintrc.js b/.eslintrc.js index 16b883880..2490d3526 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,9 +3,9 @@ module.exports = { 'mantine', 'plugin:@next/next/recommended', 'plugin:jest/recommended', - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended" + 'eslint:recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', ], plugins: ['testing-library', 'jest', 'react-hooks', 'react', 'unused-imports'], overrides: [ @@ -20,12 +20,13 @@ module.exports = { rules: { 'react/react-in-jsx-scope': 'off', 'react/no-children-prop': 'off', - "unused-imports/no-unused-imports": "warn", - "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/no-unused-imports": "off", - "@typescript-eslint/no-unused-expressions": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-shadow": "off", - "@typescript-eslint/no-use-before-define": "off", + 'unused-imports/no-unused-imports': 'warn', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-unused-imports': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-shadow': 'off', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', }, }; diff --git a/data/configs/default.json b/data/configs/default.json index 3f2acea0d..07dfe37b0 100644 --- a/data/configs/default.json +++ b/data/configs/default.json @@ -17,4 +17,4 @@ "enabled": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index e9750de18..d3519c067 100644 --- a/package.json +++ b/package.json @@ -1,100 +1,102 @@ { - "name": "homarr", - "version": "0.9.2", - "description": "Homarr - A homepage for your server.", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/ajnart/homarr" - }, - "scripts": { - "dev": "next dev", - "build": "next build", - "analyze": "ANALYZE=true next build", - "start": "next start", - "typecheck": "tsc --noEmit", - "export": "next build && next export", - "lint": "next lint", - "jest": "jest", - "jest:watch": "jest --watch", - "prettier:check": "prettier --check \"**/*.{ts,tsx}\"", - "prettier:write": "prettier --write \"**/*.{ts,tsx}\"", - "test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest", - "ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write" - }, - "dependencies": { - "@ctrl/deluge": "^4.1.0", - "@ctrl/qbittorrent": "^4.1.0", - "@ctrl/shared-torrent": "^4.1.1", - "@ctrl/transmission": "^4.1.1", - "@dnd-kit/core": "^6.0.5", - "@dnd-kit/sortable": "^7.0.1", - "@dnd-kit/utilities": "^3.2.0", - "@emotion/react": "^11.10.0", - "@emotion/server": "^11.10.0", - "@mantine/carousel": "^5.1.0", - "@mantine/core": "^5.2.3", - "@mantine/dates": "^5.2.3", - "@mantine/dropzone": "^5.2.3", - "@mantine/form": "^5.2.3", - "@mantine/hooks": "^5.2.3", - "@mantine/modals": "^5.2.3", - "@mantine/next": "^5.2.3", - "@mantine/notifications": "^5.2.3", - "@mantine/prism": "^5.0.0", - "@nivo/core": "^0.79.0", - "@nivo/line": "^0.79.1", - "@tabler/icons": "^1.78.0", - "add": "^2.0.6", - "axios": "^0.27.2", - "consola": "^2.15.3", - "cookies-next": "^2.1.1", - "country-flag-icons": "^1.5.5", - "dayjs": "^1.11.5", - "dockerode": "^3.3.2", - "embla-carousel-react": "^7.0.0", - "framer-motion": "^6.5.1", - "i18next": "^21.9.1", - "i18next-browser-languagedetector": "^6.1.5", - "i18next-http-backend": "^1.4.1", - "js-file-download": "^0.4.12", - "next": "12.1.6", - "next-i18next": "^11.3.0", - "prism-react-renderer": "^1.3.5", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "sharp": "^0.30.7", - "systeminformation": "^5.12.1", - "uuid": "^8.3.2", - "yarn": "^1.22.19" - }, - "devDependencies": { - "@next/bundle-analyzer": "^12.1.4", - "@next/eslint-plugin-next": "^12.1.4", - "@types/dockerode": "^3.3.9", - "@types/node": "17.0.1", - "@types/react": "17.0.1", - "@types/uuid": "^8.3.4", - "@typescript-eslint/eslint-plugin": "^5.30.7", - "@typescript-eslint/parser": "^5.30.7", - "eslint": "^8.20.0", - "eslint-config-airbnb": "^19.0.4", - "eslint-config-airbnb-typescript": "^17.0.0", - "eslint-config-mantine": "^2.0.0", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "^26.6.0", - "eslint-plugin-jsx-a11y": "^6.6.1", - "eslint-plugin-react": "^7.30.1", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-testing-library": "^5.5.1", - "eslint-plugin-unused-imports": "^2.0.0", - "jest": "^28.1.3", - "prettier": "^2.7.1", - "typescript": "^4.7.4" - }, - "resolutions": { - "@types/react": "17.0.2", - "@types/react-dom": "17.0.2" - }, - "packageManager": "yarn@3.2.1" + "name": "homarr", + "version": "0.9.2", + "description": "Homarr - A homepage for your server.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ajnart/homarr" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "analyze": "ANALYZE=true next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "export": "next build && next export", + "lint": "next lint", + "jest": "jest", + "jest:watch": "jest --watch", + "prettier:check": "prettier --check \"**/*.{ts,tsx}\"", + "prettier:write": "prettier --write \"**/*.{ts,tsx}\"", + "test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest", + "ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write" + }, + "dependencies": { + "@ctrl/deluge": "^4.1.0", + "@ctrl/qbittorrent": "^4.1.0", + "@ctrl/shared-torrent": "^4.1.1", + "@ctrl/transmission": "^4.1.1", + "@dnd-kit/core": "^6.0.5", + "@dnd-kit/sortable": "^7.0.1", + "@dnd-kit/utilities": "^3.2.0", + "@emotion/react": "^11.10.0", + "@emotion/server": "^11.10.0", + "@mantine/carousel": "^5.1.0", + "@mantine/core": "^5.2.3", + "@mantine/dates": "^5.2.3", + "@mantine/dropzone": "^5.2.3", + "@mantine/form": "^5.2.3", + "@mantine/hooks": "^5.2.3", + "@mantine/modals": "^5.2.3", + "@mantine/next": "^5.2.3", + "@mantine/notifications": "^5.2.3", + "@mantine/prism": "^5.0.0", + "@nivo/core": "^0.79.0", + "@nivo/line": "^0.79.1", + "@tabler/icons": "^1.78.0", + "@tanstack/react-query": "^4.2.1", + "add": "^2.0.6", + "axios": "^0.27.2", + "consola": "^2.15.3", + "cookies-next": "^2.1.1", + "country-flag-icons": "^1.5.5", + "dayjs": "^1.11.5", + "dockerode": "^3.3.2", + "embla-carousel-react": "^7.0.0", + "framer-motion": "^6.5.1", + "i18next": "^21.9.1", + "i18next-browser-languagedetector": "^6.1.5", + "i18next-http-backend": "^1.4.1", + "js-file-download": "^0.4.12", + "next": "12.1.6", + "next-i18next": "^11.3.0", + "prism-react-renderer": "^1.3.5", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sabnzbd-api": "^1.5.0", + "sharp": "^0.30.7", + "systeminformation": "^5.12.1", + "uuid": "^8.3.2", + "yarn": "^1.22.19" + }, + "devDependencies": { + "@next/bundle-analyzer": "^12.1.4", + "@next/eslint-plugin-next": "^12.1.4", + "@types/dockerode": "^3.3.9", + "@types/node": "17.0.1", + "@types/react": "17.0.1", + "@types/uuid": "^8.3.4", + "@typescript-eslint/eslint-plugin": "^5.30.7", + "@typescript-eslint/parser": "^5.30.7", + "eslint": "^8.20.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-config-mantine": "^2.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jest": "^26.6.0", + "eslint-plugin-jsx-a11y": "^6.6.1", + "eslint-plugin-react": "^7.30.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-testing-library": "^5.5.1", + "eslint-plugin-unused-imports": "^2.0.0", + "jest": "^28.1.3", + "prettier": "^2.7.1", + "typescript": "^4.7.4" + }, + "resolutions": { + "@types/react": "17.0.2", + "@types/react-dom": "17.0.2" + }, + "packageManager": "yarn@3.2.1" } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 9a0b9a9d9..64232fcf5 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -2,5 +2,10 @@ "actions": { "save": "Save" }, - "tip": "Tip: " -} \ No newline at end of file + "tip": "Tip: ", + "time": { + "seconds": "seconds", + "minutes": "minutes", + "hours": "hours" + } +} diff --git a/public/locales/en/modules/usenet.json b/public/locales/en/modules/usenet.json new file mode 100644 index 000000000..efdd2adfb --- /dev/null +++ b/public/locales/en/modules/usenet.json @@ -0,0 +1,49 @@ +{ + "descriptor": { + "name": "Usenet", + "description": "Show the queue and history of supported services" + }, + "card": { + "errors": { + "noDownloadClients": { + "title": "No supported download clients found!", + "text": "Add a download service to view your current downloads" + } + } + }, + "tabs": { + "queue": "Queue", + "history": "History" + }, + "info": { + "sizeLeft": "Size left", + "paused": "Paused" + }, + "queue": { + "header": { + "name": "Name", + "size": "Size", + "eta": "ETA", + "progress": "Progress" + }, + "empty": "Queue is empty.", + "error": { + "title": "Error!", + "message": "Some error has occured while fetching data:" + }, + "paused": "Paused" + }, + "history": { + "header": { + "name": "Name", + "size": "Size", + "duration": "Download Duration" + }, + "empty": "Queue is empty.", + "error": { + "title": "Error!", + "message": "Some error has occured while fetching data:" + }, + "paused": "Paused" + } +} diff --git a/src/components/AppShelf/AddAppShelfItem.tsx b/src/components/AppShelf/AddAppShelfItem.tsx index 3b2cace57..8b5ef87c2 100644 --- a/src/components/AppShelf/AddAppShelfItem.tsx +++ b/src/components/AppShelf/AddAppShelfItem.tsx @@ -276,7 +276,8 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & form.values.type === 'Lidarr' || form.values.type === 'Overseerr' || form.values.type === 'Jellyseerr' || - form.values.type === 'Readarr') && ( + form.values.type === 'Readarr' || + form.values.type === 'Sabnzbd') && ( <> { const { config, setConfig } = useConfig(); @@ -126,7 +135,11 @@ const AppShelf = (props: any) => { const noCategory = config.services.filter( (e) => e.category === undefined || e.category === null ); - const downloadEnabled = config.modules?.[DownloadsModule.id]?.enabled ?? false; + + const torrentEnabled = config.modules?.[TorrentsModule.id]?.enabled ?? false; + const usenetEnabled = config.modules?.[UsenetModule.id]?.enabled ?? false; + + const downloadEnabled = usenetEnabled || torrentEnabled; // Create an item with 0: true, 1: true, 2: true... For each category return ( // TODO: Style accordion so that the bar is transparent to the user settings @@ -159,7 +172,6 @@ const AppShelf = (props: any) => { {t('accordions.downloads.text')} { ${(config.settings.appOpacity || 100) / 100}`, }} > - - + {torrentEnabled && ( + <> + Torrents + + + + )} + {usenetEnabled && ( + <> + {torrentEnabled && } + + Usenet + + + + + )} @@ -183,7 +210,8 @@ const AppShelf = (props: any) => { return ( {getItems()} - + + ); }; diff --git a/src/components/AppShelf/SmallServiceItem.tsx b/src/components/AppShelf/SmallServiceItem.tsx index 46cb29db6..98c8bbf53 100644 --- a/src/components/AppShelf/SmallServiceItem.tsx +++ b/src/components/AppShelf/SmallServiceItem.tsx @@ -1,4 +1,4 @@ -import { Anchor, Avatar, Group, Text } from '@mantine/core'; +import { Avatar, Group, Text } from '@mantine/core'; interface smallServiceItem { label: string; diff --git a/src/modules/index.ts b/src/modules/index.ts index 88cb1ad02..f3b7292a7 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,9 +1,10 @@ export * from './calendar'; export * from './dashdot'; export * from './date'; -export * from './downloads'; +export * from './torrents'; export * from './ping'; export * from './search'; export * from './weather'; export * from './docker'; export * from './overseerr'; +export * from './usenet'; diff --git a/src/modules/downloads/DownloadsModule.tsx b/src/modules/torrents/TorrentsModule.tsx similarity index 95% rename from src/modules/downloads/DownloadsModule.tsx rename to src/modules/torrents/TorrentsModule.tsx index 51afc5301..a8e48a9d6 100644 --- a/src/modules/downloads/DownloadsModule.tsx +++ b/src/modules/torrents/TorrentsModule.tsx @@ -13,9 +13,9 @@ import { import { IconDownload as Download } from '@tabler/icons'; import { useEffect, useState } from 'react'; import axios from 'axios'; -import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { useViewportSize } from '@mantine/hooks'; import { showNotification } from '@mantine/notifications'; +import { NormalizedTorrent } from '@ctrl/shared-torrent'; import { useTranslation } from 'next-i18next'; import { IModule } from '../ModuleTypes'; import { useConfig } from '../../tools/state'; @@ -23,20 +23,20 @@ import { AddItemShelfButton } from '../../components/AppShelf/AddAppShelfItem'; import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval'; import { humanFileSize } from '../../tools/humanFileSize'; -export const DownloadsModule: IModule = { +export const TorrentsModule: IModule = { + id: 'torrents-status', title: 'Torrent', icon: Download, - component: DownloadComponent, + component: TorrentsComponent, options: { hidecomplete: { name: 'descriptor.settings.hideComplete', value: false, }, }, - id: 'torrents-status', }; -export default function DownloadComponent() { +export default function TorrentsComponent() { const { config } = useConfig(); const { height, width } = useViewportSize(); const downloadServices = @@ -46,13 +46,14 @@ export default function DownloadComponent() { service.type === 'Transmission' || service.type === 'Deluge' ) ?? []; + const hideComplete: boolean = - (config?.modules?.[DownloadsModule.id]?.options?.hidecomplete?.value as boolean) ?? false; + (config?.modules?.[TorrentsModule.id]?.options?.hidecomplete?.value as boolean) ?? false; const [torrents, setTorrents] = useState([]); const setSafeInterval = useSetSafeInterval(); const [isLoading, setIsLoading] = useState(true); - const { t } = useTranslation(`modules/${DownloadsModule.id}`); + const { t } = useTranslation(`modules/${TorrentsModule.id}`); useEffect(() => { setIsLoading(true); @@ -60,7 +61,7 @@ export default function DownloadComponent() { const interval = setInterval(() => { // Send one request with each download service inside axios - .post('/api/modules/downloads') + .post('/api/modules/torrents') .then((response) => { setTorrents(response.data); setIsLoading(false); diff --git a/src/modules/downloads/TotalDownloadsModule.tsx b/src/modules/torrents/TotalDownloadsModule.tsx similarity index 98% rename from src/modules/downloads/TotalDownloadsModule.tsx rename to src/modules/torrents/TotalDownloadsModule.tsx index 0addfd712..1dbb44d66 100644 --- a/src/modules/downloads/TotalDownloadsModule.tsx +++ b/src/modules/torrents/TotalDownloadsModule.tsx @@ -37,7 +37,7 @@ export default function TotalDownloadsComponent() { service.type === 'Transmission' || service.type === 'Deluge' ) ?? []; - const { t } = useTranslation(`modules/${TotalDownloadsModule.id}`); + const { t } = useTranslation(`modules/${TotalDownloadsModule.id}`); const [torrentHistory, torrentHistoryHandlers] = useListState([]); const [torrents, setTorrents] = useState([]); diff --git a/src/modules/downloads/index.ts b/src/modules/torrents/index.ts similarity index 54% rename from src/modules/downloads/index.ts rename to src/modules/torrents/index.ts index f2d2c9beb..c5337e5fb 100644 --- a/src/modules/downloads/index.ts +++ b/src/modules/torrents/index.ts @@ -1,2 +1,2 @@ -export { DownloadsModule } from './DownloadsModule'; +export { TorrentsModule } from './TorrentsModule'; export { TotalDownloadsModule } from './TotalDownloadsModule'; diff --git a/src/modules/usenet/UsenetHistoryList.tsx b/src/modules/usenet/UsenetHistoryList.tsx new file mode 100644 index 000000000..f886e5ad3 --- /dev/null +++ b/src/modules/usenet/UsenetHistoryList.tsx @@ -0,0 +1,133 @@ +import { + Alert, + Center, + Code, + Group, + Pagination, + Skeleton, + Table, + Text, + Title, + Tooltip, +} from '@mantine/core'; +import { IconAlertCircle } from '@tabler/icons'; +import { AxiosError } from 'axios'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { useTranslation } from 'next-i18next'; +import { FunctionComponent, useState } from 'react'; +import { useGetUsenetHistory } from '../../tools/hooks/api'; +import { humanFileSize } from '../../tools/humanFileSize'; +import { parseDuration } from '../../tools/parseDuration'; + +dayjs.extend(duration); + +interface UsenetHistoryListProps { + serviceId: string; +} + +const PAGE_SIZE = 10; + +export const UsenetHistoryList: FunctionComponent = ({ serviceId }) => { + const [page, setPage] = useState(1); + const { t } = useTranslation(['modules/usenet', 'common']); + + const { data, isLoading, isError, error } = useGetUsenetHistory({ + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + serviceId, + }); + const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE); + + if (isLoading) { + return ( + <> + + + + + ); + } + + if (isError) { + return ( + + } + my="lg" + title={t('modules/usenet:history.error.title')} + color="red" + radius="md" + > + {t('modules/usenet:history.error.message')} + + {(error as AxiosError)?.response?.data as string} + + + + ); + } + + if (!data || data.items.length <= 0) { + return ( +
+ {t('modules/usenet:history.empty')} +
+ ); + } + + return ( + <> + + + + + + + + + + + + + + + {data.items.map((history) => ( + + + + + + ))} + +
{t('modules/usenet:history.header.name')}{t('modules/usenet:history.header.size')}{t('modules/usenet:history.header.duration')}
+ + + {history.name} + + + + {humanFileSize(history.size)} + + {parseDuration(history.time, t)} +
+ {totalPages > 1 && ( + + )} + + ); +}; diff --git a/src/modules/usenet/UsenetModule.tsx b/src/modules/usenet/UsenetModule.tsx new file mode 100644 index 000000000..f03084e69 --- /dev/null +++ b/src/modules/usenet/UsenetModule.tsx @@ -0,0 +1,102 @@ +import { Badge, Button, Group, Select, Stack, Tabs, Text, Title } from '@mantine/core'; +import { IconDownload, IconPlayerPause, IconPlayerPlay } from '@tabler/icons'; +import { FunctionComponent, useEffect, useState } from 'react'; + +import { useTranslation } from 'next-i18next'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { IModule } from '../ModuleTypes'; +import { UsenetQueueList } from './UsenetQueueList'; +import { UsenetHistoryList } from './UsenetHistoryList'; +import { useGetServiceByType } from '../../tools/hooks/useGetServiceByType'; +import { useGetUsenetInfo, usePauseUsenetQueue, useResumeUsenetQueue } from '../../tools/hooks/api'; +import { humanFileSize } from '../../tools/humanFileSize'; +import { AddItemShelfButton } from '../../components/AppShelf/AddAppShelfItem'; + +dayjs.extend(duration); + +export const UsenetComponent: FunctionComponent = () => { + const downloadServices = useGetServiceByType('Sabnzbd'); + + const { t } = useTranslation('modules/usenet'); + + const [selectedServiceId, setSelectedService] = useState(downloadServices[0]?.id); + const { data } = useGetUsenetInfo({ serviceId: selectedServiceId! }); + + useEffect(() => { + if (!selectedServiceId && downloadServices.length) { + setSelectedService(downloadServices[0].id); + } + }, [downloadServices, selectedServiceId]); + + const { mutate: pause } = usePauseUsenetQueue({ serviceId: selectedServiceId! }); + const { mutate: resume } = useResumeUsenetQueue({ serviceId: selectedServiceId! }); + + if (downloadServices.length === 0) { + return ( + + {t('card.errors.noDownloadClients.title')} + + {t('card.errors.noDownloadClients.text')} + + + + ); + } + + if (!selectedServiceId) { + return null; + } + + return ( + + + + {t('tabs.queue')} + {t('tabs.history')} + {data && ( + + {humanFileSize(data?.speed)}/s + + {t('info.sizeLeft')}: {humanFileSize(data?.sizeLeft)} + + {data.paused ? ( + + ) : ( + + )} + + )} + + {downloadServices.length > 1 && ( +