diff --git a/.env.example b/.env.example index f89e0dd..88d97ae 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,3 @@ DATABASE_URL=postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST} # Redis REDIS_HOST=localhost REDIS_PORT=6379 - -# Notification -TELEGRAM_TOKEN= -TELEGRAM_CHAT_ID= -TELEGRAM_SEND_SILENTLY=0 \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 90ad9b4..b83b9b8 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -29,6 +29,7 @@ "@typescript-eslint/no-unused-vars": [ "error", { + "ignoreRestSiblings": true, "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" diff --git a/README.md b/README.md index 48cc152..9f53439 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,6 @@ services: - KAIZOKU_PORT=3000 - REDIS_HOST=redis - REDIS_PORT=6379 - - TELEGRAM_TOKEN= # Don't set if you don't want telegram notifications. - - TELEGRAM_CHAT_ID= - - TELEGRAM_SEND_SILENTLY=0 - PUID= - PGID= - TZ=Europe/Istanbul diff --git a/docker-compose.yml b/docker-compose.yml index e2d288a..f144618 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,3 +49,7 @@ services: - db:/var/lib/postgresql/data ports: - "${DATABASE_PORT:-5432}:5432" + apprise: + image: caronc/apprise:latest + ports: + - "9292:8000" \ No newline at end of file diff --git a/public/brand/apprise.png b/public/brand/apprise.png new file mode 100644 index 0000000..b27d3b1 Binary files /dev/null and b/public/brand/apprise.png differ diff --git a/public/brand/komga.png b/public/brand/komga.png new file mode 100644 index 0000000..68183eb Binary files /dev/null and b/public/brand/komga.png differ diff --git a/public/brand/telegram.png b/public/brand/telegram.png new file mode 100644 index 0000000..1f07ab2 Binary files /dev/null and b/public/brand/telegram.png differ diff --git a/src/components/settings/integration.tsx b/src/components/settings/integration.tsx new file mode 100644 index 0000000..f0088cc --- /dev/null +++ b/src/components/settings/integration.tsx @@ -0,0 +1,176 @@ +import { Accordion, Box, Breadcrumbs, createStyles, Group, Image, Text } from '@mantine/core'; +import { trpc } from '../../utils/trpc'; +import { SwitchItem, TextItem } from './mangal'; + +const useStyles = createStyles((theme) => ({ + item: { + '&': { + paddingTop: theme.spacing.sm, + marginTop: theme.spacing.sm, + borderTop: `1px solid ${theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2]}`, + }, + }, + + switch: { + '& *': { + cursor: 'pointer', + }, + }, + + numberInput: { + maxWidth: 60, + }, + + textInput: { + maxWidth: 120, + }, + + title: { + lineHeight: 1, + }, +})); + +export function IntegrationSettings() { + const { classes } = useStyles(); + const update = trpc.settings.update.useMutation(); + const settings = trpc.settings.query.useQuery(); + + const handleUpdate = async (key: string, value: boolean | string | number) => { + await update.mutateAsync({ + key, + value, + updateType: 'app', + }); + await settings.refetch(); + }; + + if (settings.isLoading || !settings.data) { + return null; + } + + return ( + + + }>Komga + + + + + Enabled + + + Enable Komga integration to trigger library scan and metadata refresh tasks + + + + + + + + Host + + + Komga host or ip + + + + + + + + Email + + + Komga user + + + + + + + + Password + + + Komga user password + + + + + + + + ); +} diff --git a/src/components/settings/mangal.tsx b/src/components/settings/mangal.tsx new file mode 100644 index 0000000..c0b90e9 --- /dev/null +++ b/src/components/settings/mangal.tsx @@ -0,0 +1,273 @@ +import { + ActionIcon, + Box, + Breadcrumbs, + Button, + createStyles, + Group, + NumberInput, + Switch, + Text, + TextInput, +} from '@mantine/core'; +import { IconCheck, IconX } from '@tabler/icons'; +import { nanoid } from 'nanoid'; +import { useEffect, useState } from 'react'; +import { logger } from '../../utils/logging'; +import { trpc } from '../../utils/trpc'; + +const useStyles = createStyles((theme) => ({ + card: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.white, + }, + + item: { + '& + &': { + paddingTop: theme.spacing.sm, + marginTop: theme.spacing.sm, + borderTop: `1px solid ${theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2]}`, + }, + }, + + switch: { + '& *': { + cursor: 'pointer', + }, + }, + + numberInput: { + maxWidth: 60, + }, + + textInput: { + minWidth: 180, + width: 180, + }, + + title: { + lineHeight: 1, + }, +})); + +export function SwitchItem({ + configKey, + initialValue, + onUpdate, +}: { + configKey: string; + initialValue: boolean; + onUpdate: (configKey: string, value: boolean) => void; +}) { + const { classes } = useStyles(); + return ( + onUpdate(configKey, event.currentTarget.checked)} + onLabel={} + defaultChecked={initialValue} + offLabel={} + className={classes.switch} + size="lg" + /> + ); +} + +export function NumberItem({ + configKey, + initialValue, + onUpdate, +}: { + configKey: string; + initialValue: number; + onUpdate: (configKey: string, value: number) => void; +}) { + const { classes } = useStyles(); + const [value, setValue] = useState(initialValue); + + return ( + newValue !== undefined && setValue(newValue)} + onBlur={() => { + if (value !== initialValue) { + onUpdate(configKey, value); + } + }} + /> + ); +} + +export function TextItem({ + configKey, + initialValue, + onUpdate, +}: { + configKey: string; + initialValue: string | null; + onUpdate: (configKey: string, value: string) => void; +}) { + const { classes } = useStyles(); + const [value, setValue] = useState(initialValue || ''); + + return ( + event.currentTarget.value !== undefined && setValue(event.currentTarget.value)} + onBlur={() => { + if (value !== initialValue) { + onUpdate(configKey, value); + } + }} + /> + ); +} + +function ArrayTextItem({ + initialValue, + onUpdate, + onRemove, +}: { + initialValue: string | null; + onUpdate: (value: string) => void; + onRemove: () => void; +}) { + const { classes } = useStyles(); + const [value, setValue] = useState(initialValue || ''); + + return ( + onRemove()}> + + + } + onChange={(event) => setValue(event.currentTarget.value)} + onBlur={() => { + if (value !== initialValue && value) { + logger.info(`blur: ${value}`); + onUpdate(value); + } + }} + /> + ); +} + +export function ArrayItem({ + configKey, + initialValue, + onUpdate, +}: { + configKey: string; + initialValue: string[]; + onUpdate: (configKey: string, value: string[]) => void; +}) { + const [value, setValue] = useState<{ [key: string]: string }>( + initialValue.reduce((acc, v) => ({ ...acc, [nanoid()]: v }), {}), + ); + + useEffect(() => { + logger.info(value); + onUpdate( + configKey, + Object.values(value).filter((i) => !!i), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [configKey, value]); + + return ( + + {Object.entries(value).map(([key, item]) => { + return ( + + setValue({ + ...value, + [key]: updated, + }) + } + onRemove={() => { + const { [key]: removed, ...rest } = value; + + setValue(rest); + }} + /> + ); + })} + + + ); +} + +export function MangalSettings() { + const { classes } = useStyles(); + const settings = trpc.settings.query.useQuery(); + const mangalUpdate = trpc.settings.update.useMutation(); + + const handleUpdate = async (key: string, value: boolean | string | number) => { + await mangalUpdate.mutateAsync({ + key, + value, + updateType: 'mangal', + }); + settings.refetch(); + }; + + if (settings.isLoading) { + return null; + } + + return ( + + {settings.data?.mangalConfig.map((item) => ( + +
+ + {item.key.split('.').map((val) => val.split('_').join(' '))} + + + {item.description} + +
+ {item.type === 'bool' && ( + + )} + {item.type === 'int' && ( + + )} + {item.type === 'string' && ( + + )} +
+ ))} +
+ ); +} diff --git a/src/components/settings/notification.tsx b/src/components/settings/notification.tsx new file mode 100644 index 0000000..11e4cbe --- /dev/null +++ b/src/components/settings/notification.tsx @@ -0,0 +1,287 @@ +import { Accordion, Box, Breadcrumbs, createStyles, Group, Text } from '@mantine/core'; +import Image from 'next/image'; +import { trpc } from '../../utils/trpc'; +import { ArrayItem, SwitchItem, TextItem } from './mangal'; + +const useStyles = createStyles((theme) => ({ + item: { + '&': { + paddingTop: theme.spacing.sm, + marginTop: theme.spacing.sm, + borderTop: `1px solid ${theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2]}`, + }, + }, + + switch: { + '& *': { + cursor: 'pointer', + }, + }, + + numberInput: { + maxWidth: 60, + }, + + textInput: { + maxWidth: 120, + }, + + title: { + lineHeight: 1, + }, +})); + +export function NotificationSettings() { + const { classes } = useStyles(); + const update = trpc.settings.update.useMutation(); + const settings = trpc.settings.query.useQuery(); + + const handleUpdate = async (key: string, value: boolean | string | number | string[]) => { + await update.mutateAsync({ + key, + value, + updateType: 'app', + }); + await settings.refetch(); + }; + + if (settings.isLoading || !settings.data) { + return null; + } + + return ( + + + }> + Telegram + + + + + + Enabled + + + Enable Telegram notifications + + + + + + + + Token + + + Telegram token + + + + + + + + Chat Id + + + Telegram chat id + + + + + + + + Send Silently + + + Send Telegram notifications silently + + + + + + + + + }>Apprise + + + + + Enabled + + + Enable Apprise notifications + + + + + + + + Host + + + Apprise host name or ip + + + + + + + + + Urls + + + Apprise urls + + + + + + + + ); +} diff --git a/src/components/settings/switchTheme.tsx b/src/components/settings/switchTheme.tsx new file mode 100644 index 0000000..352585d --- /dev/null +++ b/src/components/settings/switchTheme.tsx @@ -0,0 +1,62 @@ +import { Box, Center, SegmentedControl, useMantineColorScheme } from '@mantine/core'; +import { useColorScheme } from '@mantine/hooks'; +import { IconMoon, IconPalette, IconSun } from '@tabler/icons'; +import { getCookie, setCookie } from 'cookies-next'; +import { useEffect, useState } from 'react'; + +export function SwitchTheme() { + const { colorScheme, toggleColorScheme } = useMantineColorScheme(); + const preferredColorScheme = useColorScheme(); + const [value, setValue] = useState('auto'); + + useEffect(() => { + const followSystem = getCookie('follow-system') === '1'; + if (followSystem) { + setValue('auto'); + } else { + setValue(colorScheme); + } + }, [colorScheme]); + + return ( + { + setValue(val); + setCookie('follow-system', val === 'auto' ? '1' : '0'); + toggleColorScheme(val === 'auto' ? preferredColorScheme : val); + }} + data={[ + { + value: 'auto', + label: ( +
+ + Auto +
+ ), + }, + { + value: 'light', + label: ( +
+ + Light +
+ ), + }, + { + value: 'dark', + label: ( +
+ + Dark +
+ ), + }, + ]} + /> + ); +} diff --git a/src/components/settingsMenu.tsx b/src/components/settingsMenu.tsx new file mode 100644 index 0000000..ab68979 --- /dev/null +++ b/src/components/settingsMenu.tsx @@ -0,0 +1,68 @@ +import { ActionIcon, Box, Divider, Drawer, ScrollArea, Title } from '@mantine/core'; +import { IconSettings } from '@tabler/icons'; +import { useState } from 'react'; +import { IntegrationSettings } from './settings/integration'; +import { MangalSettings } from './settings/mangal'; +import { NotificationSettings } from './settings/notification'; +import { SwitchTheme } from './settings/switchTheme'; + +function SettingsMenu() { + return ( + + Theme} /> + + Notification} + /> + + Integration} + /> + + Mangal} /> + + + ); +} + +export function SettingsMenuButton() { + const [opened, setOpened] = useState(false); + return ( + <> + setOpened(false)} + title={Settings} + padding="md" + size="xl" + position="right" + > + + + + + ({ + backgroundColor: theme.white, + color: theme.black, + '&:hover': { + backgroundColor: theme.colors.gray[0], + }, + })} + onClick={() => setOpened(true)} + size="lg" + > + + + + ); +} diff --git a/src/server/utils/mangal.ts b/src/server/utils/mangal.ts index 5605fa6..43a9aac 100644 --- a/src/server/utils/mangal.ts +++ b/src/server/utils/mangal.ts @@ -89,6 +89,58 @@ export const getAvailableSources = async () => { return []; }; +interface MangalConfig { + key: string; + value: string[] | boolean | number | string; + default: string[] | boolean | number | string; + description: string; + type: MangalConfigType; +} + +enum MangalConfigType { + Bool = 'bool', + Int = 'int', + String = 'string', + StringArray = '[]string', +} + +const excludedConfigs = [ + 'downloader.chapter_name_template', + 'downloader.redownload_existing', + 'downloader.download_cover', + 'downloader.create_manga_dir', + 'downloader.create_volume_dir', + 'downloader.default_sources', + 'downloader.path', + 'metadata.comic_info_xml', + 'metadata.fetch_anilist', + 'metadata.series_json', + 'formats.use', +]; + +export const getMangalConfig = async (): Promise => { + try { + const { stdout, command } = await execa('mangal', ['config', 'info', '-j']); + logger.info(`Getting mangal config with following command: ${command}`); + const result = JSON.parse(stdout) as MangalConfig[]; + + return result.filter((item) => !excludedConfigs.includes(item.key) && item.type !== '[]string'); + } catch (err) { + logger.error(`Failed to get mangal config. err: ${err}`); + } + + return []; +}; + +export const setMangalConfig = async (key: string, value: string | boolean | number | string[]) => { + try { + const { command } = await execa('mangal', ['config', 'set', '--key', key, '--value', `${value}`]); + logger.info(`set mangal config with following command: ${command}`); + } catch (err) { + logger.error(`Failed to set mangal config. err: ${err}`); + } +}; + export const bindTitleToAnilistId = async (title: string, anilistId: string) => { try { const { command } = await execa('mangal', ['inline', 'anilist', 'set', '--name', title, '--id', anilistId]);