mirror of
https://github.com/ajnart/homarr.git
synced 2026-01-29 18:59:20 +01:00
🚀 v0.7.0 : Theming, Password protection, Autocompletion, Transmission, Mobile responsiveness! This is a big upgrade 👀
This commit is contained in:
7
.github/pull_request_template.md
vendored
7
.github/pull_request_template.md
vendored
@@ -14,10 +14,3 @@
|
||||
|
||||
### Screenshot _(if applicable)_
|
||||
> If you've introduced any significant UI changes, please include a screenshot.
|
||||
|
||||
### Code Quality Checklist _(Please complete)_
|
||||
- [ ] All changes are backwards compatible
|
||||
- [ ] There are no (new) build warnings or errors
|
||||
- [ ] _(If a new config option is added)_ Attribute is outlined in the schema and documented
|
||||
- [ ] _(If a new dependency is added)_ Package is essential, and has been checked out for security or performance
|
||||
- [ ] Bumps version, if new feature added
|
||||
|
||||
2
.github/workflows/docker_dev.yml
vendored
2
.github/workflows/docker_dev.yml
vendored
@@ -16,7 +16,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
requierd: true
|
||||
required: true
|
||||
description: 'Tags to deploy to'
|
||||
|
||||
env:
|
||||
|
||||
@@ -9,5 +9,6 @@ COPY /.next/standalone ./
|
||||
COPY /.next/static ./.next/static
|
||||
EXPOSE 7575
|
||||
ENV PORT 7575
|
||||
RUN apk add tzdata
|
||||
VOLUME /app/data/configs
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -198,7 +198,4 @@ SOFTWARE.
|
||||
<i>Thank you for visiting! <b>For more information <a href="https://github.com/ajnart/homarr/wiki">read the wiki!</a></b></i>
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://trackgit.com">
|
||||
<img src="https://us-central1-trackgit-analytics.cloudfunctions.net/token/ping/l3khzc3a3pexzw5w5whl" alt="trackgit-views" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const REPO_URL = 'ajnart/homarr';
|
||||
export const CURRENT_VERSION = 'v0.6.0';
|
||||
export const CURRENT_VERSION = 'v0.7.0';
|
||||
|
||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Homarr - A homepage for your server.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -24,26 +24,27 @@
|
||||
"ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ctrl/deluge": "^4.0.0",
|
||||
"@ctrl/deluge": "^4.1.0",
|
||||
"@ctrl/qbittorrent": "^4.0.0",
|
||||
"@ctrl/shared-torrent": "^4.1.0",
|
||||
"@ctrl/transmission": "^4.1.1",
|
||||
"@dnd-kit/core": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.0",
|
||||
"@mantine/core": "^4.2.6",
|
||||
"@mantine/dates": "^4.2.6",
|
||||
"@mantine/dropzone": "^4.2.6",
|
||||
"@mantine/form": "^4.2.6",
|
||||
"@mantine/hooks": "^4.2.6",
|
||||
"@mantine/next": "^4.2.6",
|
||||
"@mantine/notifications": "^4.2.6",
|
||||
"@mantine/prism": "^4.2.6",
|
||||
"@mantine/core": "^4.2.8",
|
||||
"@mantine/dates": "^4.2.8",
|
||||
"@mantine/dropzone": "^4.2.8",
|
||||
"@mantine/form": "^4.2.8",
|
||||
"@mantine/hooks": "^4.2.8",
|
||||
"@mantine/next": "^4.2.8",
|
||||
"@mantine/notifications": "^4.2.8",
|
||||
"@mantine/prism": "^4.2.8",
|
||||
"@nivo/core": "^0.79.0",
|
||||
"@nivo/line": "^0.79.1",
|
||||
"@tabler/icons": "^1.68.0",
|
||||
"axios": "^0.27.2",
|
||||
"cookies-next": "^2.0.4",
|
||||
"dayjs": "^1.11.2",
|
||||
"dayjs": "^1.11.3",
|
||||
"framer-motion": "^6.3.1",
|
||||
"js-file-download": "^0.4.12",
|
||||
"next": "12.1.6",
|
||||
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconApps as Apps } from '@tabler/icons';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { ServiceTypeList } from '../../tools/types';
|
||||
|
||||
@@ -64,7 +65,7 @@ function MatchIcon(name: string, form: any) {
|
||||
}
|
||||
|
||||
function MatchService(name: string, form: any) {
|
||||
const service = ServiceTypeList.find((s) => s === name);
|
||||
const service = ServiceTypeList.find((s) => s.toLowerCase() === name.toLowerCase());
|
||||
if (service) {
|
||||
form.setFieldValue('type', service);
|
||||
}
|
||||
@@ -72,16 +73,16 @@ function MatchService(name: string, form: any) {
|
||||
|
||||
function MatchPort(name: string, form: any) {
|
||||
const portmap = [
|
||||
{ name: 'qBittorrent', value: '8080' },
|
||||
{ name: 'Sonarr', value: '8989' },
|
||||
{ name: 'Radarr', value: '7878' },
|
||||
{ name: 'Lidarr', value: '8686' },
|
||||
{ name: 'Readarr', value: '8686' },
|
||||
{ name: 'Deluge', value: '8112' },
|
||||
{ name: 'Transmission', value: '9091' },
|
||||
{ name: 'qbittorrent', value: '8080' },
|
||||
{ name: 'sonarr', value: '8989' },
|
||||
{ name: 'radarr', value: '7878' },
|
||||
{ name: 'lidarr', value: '8686' },
|
||||
{ name: 'readarr', value: '8686' },
|
||||
{ name: 'deluge', value: '8112' },
|
||||
{ name: 'transmission', value: '9091' },
|
||||
];
|
||||
// Match name with portmap key
|
||||
const port = portmap.find((p) => p.name === name);
|
||||
const port = portmap.find((p) => p.name === name.toLowerCase());
|
||||
if (port) {
|
||||
form.setFieldValue('url', `http://localhost:${port.value}`);
|
||||
}
|
||||
@@ -111,6 +112,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
apiKey: props.apiKey ?? (undefined as unknown as string),
|
||||
username: props.username ?? (undefined as unknown as string),
|
||||
password: props.password ?? (undefined as unknown as string),
|
||||
openedUrl: props.openedUrl ?? (undefined as unknown as string),
|
||||
},
|
||||
validate: {
|
||||
apiKey: () => null,
|
||||
@@ -134,6 +136,14 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
},
|
||||
});
|
||||
|
||||
const [debounced, cancel] = useDebouncedValue(form.values.name, 250);
|
||||
useEffect(() => {
|
||||
if (form.values.name !== debounced || props.name || props.type) return;
|
||||
MatchIcon(form.values.name, form);
|
||||
MatchService(form.values.name, form);
|
||||
MatchPort(form.values.name, form);
|
||||
}, [debounced]);
|
||||
|
||||
// Try to set const hostname to new URL(form.values.url).hostname)
|
||||
// If it fails, set it to the form.values.url
|
||||
let hostname = form.values.url;
|
||||
@@ -186,28 +196,26 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
required
|
||||
label="Service name"
|
||||
placeholder="Plex"
|
||||
value={form.values.name}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('name', event.currentTarget.value);
|
||||
MatchIcon(event.currentTarget.value, form);
|
||||
MatchService(event.currentTarget.value, form);
|
||||
MatchPort(event.currentTarget.value, form);
|
||||
}}
|
||||
error={form.errors.name && 'Invalid icon url'}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label="Icon url"
|
||||
placeholder="https://i.gifer.com/ANPC.gif"
|
||||
label="Icon URL"
|
||||
placeholder="/favicon.svg"
|
||||
{...form.getInputProps('icon')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label="Service url"
|
||||
label="Service URL"
|
||||
placeholder="http://localhost:7575"
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
<TextInput
|
||||
label="New tab URL"
|
||||
placeholder="http://sonarr.example.com"
|
||||
{...form.getInputProps('openedUrl')}
|
||||
/>
|
||||
<Select
|
||||
label="Service type"
|
||||
defaultValue="Other"
|
||||
@@ -292,12 +300,12 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{form.values.type === 'Deluge' && (
|
||||
{(form.values.type === 'Deluge' || form.values.type === 'Transmission') && (
|
||||
<>
|
||||
<TextInput
|
||||
required
|
||||
label="Password"
|
||||
placeholder="deluge"
|
||||
placeholder="password"
|
||||
value={form.values.password}
|
||||
onChange={(event) => {
|
||||
form.setFieldValue('password', event.currentTarget.value);
|
||||
|
||||
@@ -1,24 +1,50 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Grid, Group, Title } from '@mantine/core';
|
||||
import { Accordion, createStyles, Grid, Group, Paper, useMantineColorScheme } from '@mantine/core';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
import { SortableAppShelfItem, AppShelfItem } from './AppShelfItem';
|
||||
import { ModuleWrapper } from '../modules/moduleWrapper';
|
||||
import { ModuleMenu, ModuleWrapper } from '../modules/moduleWrapper';
|
||||
import { DownloadsModule } from '../modules';
|
||||
import DownloadComponent from '../modules/downloads/DownloadsModule';
|
||||
|
||||
const useStyles = createStyles((theme, _params) => ({
|
||||
item: {
|
||||
borderBottom: 0,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: theme.radius.lg,
|
||||
marginTop: theme.spacing.md,
|
||||
},
|
||||
|
||||
itemOpened: {
|
||||
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[3],
|
||||
},
|
||||
}));
|
||||
|
||||
const AppShelf = (props: any) => {
|
||||
const { classes, cx } = useStyles(props);
|
||||
const [toggledCategories, settoggledCategories] = useLocalStorage({
|
||||
key: 'app-shelf-toggled',
|
||||
// This is a bit of a hack to get the 5 first categories to be toggled on by default
|
||||
defaultValue: { 0: true, 1: true, 2: true, 3: true, 4: true } as Record<string, boolean>,
|
||||
});
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
const { config, setConfig } = useConfig();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(MouseSensor, {
|
||||
// Require the mouse to move by 10 pixels before activating
|
||||
activationConstraint: {
|
||||
@@ -99,26 +125,51 @@ const AppShelf = (props: any) => {
|
||||
const noCategory = config.services.filter(
|
||||
(e) => e.category === undefined || e.category === null
|
||||
);
|
||||
|
||||
// Create an item with 0: true, 1: true, 2: true... For each category
|
||||
return (
|
||||
// Return one item for each category
|
||||
<Group grow direction="column">
|
||||
{categoryList.map((category) => (
|
||||
<>
|
||||
<Title order={3} key={category}>
|
||||
{category}
|
||||
</Title>
|
||||
{item(category)}
|
||||
</>
|
||||
))}
|
||||
{/* Return the item for all services without category */}
|
||||
{noCategory && noCategory.length > 0 ? (
|
||||
<>
|
||||
<Title order={3}>Other</Title>
|
||||
{item()}
|
||||
</>
|
||||
) : null}
|
||||
<ModuleWrapper mt="xl" module={DownloadsModule} />
|
||||
<Accordion
|
||||
disableIconRotation
|
||||
classNames={classes}
|
||||
order={2}
|
||||
iconPosition="right"
|
||||
multiple
|
||||
styles={{
|
||||
item: {
|
||||
borderRadius: '20px',
|
||||
},
|
||||
}}
|
||||
initialState={toggledCategories}
|
||||
onChange={(idx) => settoggledCategories(idx)}
|
||||
>
|
||||
{categoryList.map((category, idx) => (
|
||||
<Accordion.Item key={category} label={category}>
|
||||
{item(category)}
|
||||
</Accordion.Item>
|
||||
))}
|
||||
{/* Return the item for all services without category */}
|
||||
{noCategory && noCategory.length > 0 ? (
|
||||
<Accordion.Item key="Other" label="Other">
|
||||
{item()}
|
||||
</Accordion.Item>
|
||||
) : null}
|
||||
<Accordion.Item key="Downloads" label="Your downloads">
|
||||
<Paper
|
||||
p="lg"
|
||||
radius="lg"
|
||||
style={{
|
||||
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
borderColor: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
}}
|
||||
>
|
||||
<ModuleMenu module={DownloadsModule} />
|
||||
<DownloadComponent />
|
||||
</Paper>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Text, Card, Anchor, AspectRatio, Image, Center, createStyles } from '@mantine/core';
|
||||
import {
|
||||
Text,
|
||||
Card,
|
||||
Anchor,
|
||||
AspectRatio,
|
||||
Image,
|
||||
Center,
|
||||
createStyles,
|
||||
useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
@@ -6,6 +15,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { serviceItem } from '../../tools/types';
|
||||
import PingComponent from '../modules/ping/PingModule';
|
||||
import AppShelfMenu from './AppShelfMenu';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
item: {
|
||||
@@ -15,6 +25,9 @@ const useStyles = createStyles((theme) => ({
|
||||
boxShadow: `${theme.shadows.md} !important`,
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
WebkitUserSelect: 'none',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -38,7 +51,9 @@ export function SortableAppShelfItem(props: any) {
|
||||
export function AppShelfItem(props: any) {
|
||||
const { service }: { service: serviceItem } = props;
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const { classes, theme } = useStyles();
|
||||
const { config } = useConfig();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { classes } = useStyles();
|
||||
return (
|
||||
<motion.div
|
||||
animate={{
|
||||
@@ -54,7 +69,18 @@ export function AppShelfItem(props: any) {
|
||||
setHovering(false);
|
||||
}}
|
||||
>
|
||||
<Card withBorder radius="lg" shadow="md" className={classes.item}>
|
||||
<Card
|
||||
withBorder
|
||||
radius="lg"
|
||||
shadow="md"
|
||||
className={classes.item}
|
||||
style={{
|
||||
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
borderColor: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
}}
|
||||
>
|
||||
<Card.Section>
|
||||
<Anchor
|
||||
target="_blank"
|
||||
@@ -101,7 +127,8 @@ export function AppShelfItem(props: any) {
|
||||
src={service.icon}
|
||||
fit="contain"
|
||||
onClick={() => {
|
||||
window.open(service.url);
|
||||
if (service.openedUrl) window.open(service.openedUrl, '_blank');
|
||||
else window.open(service.url);
|
||||
}}
|
||||
/>
|
||||
</motion.i>
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function AppShelfMenu(props: any) {
|
||||
apiKey={service.apiKey}
|
||||
username={service.username}
|
||||
password={service.password}
|
||||
openedUrl={service.openedUrl}
|
||||
message="Save service"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createStyles, Switch, Group, useMantineColorScheme, Kbd } from '@mantine/core';
|
||||
import { IconSun as Sun, IconMoonStars as MoonStars } from '@tabler/icons';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
@@ -29,6 +30,7 @@ const useStyles = createStyles((theme) => ({
|
||||
}));
|
||||
|
||||
export function ColorSchemeSwitch() {
|
||||
const { config } = useConfig();
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ export default function ConfigChanger() {
|
||||
label="Config loader"
|
||||
onChange={(e) => {
|
||||
loadConfig(e ?? 'default');
|
||||
setCookies('config-name', e ?? 'default', { maxAge: 60 * 60 * 24 * 30 });
|
||||
setCookies('config-name', e ?? 'default', {
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
}}
|
||||
data={
|
||||
// If config list is empty, return the current config
|
||||
|
||||
@@ -90,7 +90,10 @@ export default function LoadConfigComponent(props: any) {
|
||||
icon: <Check />,
|
||||
message: undefined,
|
||||
});
|
||||
setCookies('config-name', newConfig.name, { maxAge: 60 * 60 * 24 * 30 });
|
||||
setCookies('config-name', newConfig.name, {
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
const migratedConfig = migrateToIdConfig(newConfig);
|
||||
setConfig(migratedConfig);
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function SaveConfigComponent(props: any) {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Group>
|
||||
<Group spacing="xs">
|
||||
<Modal
|
||||
radius="md"
|
||||
opened={opened}
|
||||
@@ -59,10 +59,11 @@ export default function SaveConfigComponent(props: any) {
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
<Button leftIcon={<Download />} variant="outline" onClick={onClick}>
|
||||
<Button size="xs" leftIcon={<Download />} variant="outline" onClick={onClick}>
|
||||
Download config
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
leftIcon={<Trash />}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
@@ -93,7 +94,7 @@ export default function SaveConfigComponent(props: any) {
|
||||
>
|
||||
Delete config
|
||||
</Button>
|
||||
<Button leftIcon={<Plus />} variant="outline" onClick={() => setOpened(true)}>
|
||||
<Button size="xs" leftIcon={<Plus />} variant="outline" onClick={() => setOpened(true)}>
|
||||
Save a copy
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
63
src/components/Settings/AdvancedSettings.tsx
Normal file
63
src/components/Settings/AdvancedSettings.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { TextInput, Group, Button } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { ColorSelector } from './ColorSelector';
|
||||
import { OpacitySelector } from './OpacitySelector';
|
||||
import { ShadeSelector } from './ShadeSelector';
|
||||
|
||||
export default function TitleChanger() {
|
||||
const { config, setConfig } = useConfig();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
title: config.settings.title,
|
||||
logo: config.settings.logo,
|
||||
favicon: config.settings.favicon,
|
||||
background: config.settings.background,
|
||||
},
|
||||
});
|
||||
|
||||
const saveChanges = (values: {
|
||||
title?: string;
|
||||
logo?: string;
|
||||
favicon?: string;
|
||||
background?: string;
|
||||
}) => {
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
title: values.title,
|
||||
logo: values.logo,
|
||||
favicon: values.favicon,
|
||||
background: values.background,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Group direction="column" grow>
|
||||
<form onSubmit={form.onSubmit((values) => saveChanges(values))}>
|
||||
<Group grow direction="column">
|
||||
<TextInput label="Page title" placeholder="Homarr 🦞" {...form.getInputProps('title')} />
|
||||
<TextInput label="Logo" placeholder="/img/logo.png" {...form.getInputProps('logo')} />
|
||||
<TextInput
|
||||
label="Favicon"
|
||||
placeholder="/favicon.svg"
|
||||
{...form.getInputProps('favicon')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Background"
|
||||
placeholder="/img/background.png"
|
||||
{...form.getInputProps('background')}
|
||||
/>
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</form>
|
||||
<ColorSelector type="primary" />
|
||||
<ColorSelector type="secondary" />
|
||||
<ShadeSelector />
|
||||
<OpacitySelector />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
96
src/components/Settings/ColorSelector.tsx
Normal file
96
src/components/Settings/ColorSelector.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ColorSwatch, Group, Popover, Text, useMantineTheme } from '@mantine/core';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { useColorTheme } from '../../tools/color';
|
||||
|
||||
interface ColorControlProps {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function ColorSelector({ type }: ColorControlProps) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const { primaryColor, secondaryColor, setPrimaryColor, setSecondaryColor } = useColorTheme();
|
||||
|
||||
const theme = useMantineTheme();
|
||||
const colors = Object.keys(theme.colors).map((color) => ({
|
||||
swatch: theme.colors[color][6],
|
||||
color,
|
||||
}));
|
||||
|
||||
const configColor = type === 'primary' ? primaryColor : secondaryColor;
|
||||
|
||||
const setConfigColor = (color: string) => {
|
||||
if (type === 'primary') {
|
||||
setPrimaryColor(color);
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
primaryColor: color,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setSecondaryColor(color);
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
secondaryColor: color,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const swatches = colors.map(({ color, swatch }) => (
|
||||
<ColorSwatch
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => setConfigColor(color)}
|
||||
key={color}
|
||||
color={swatch}
|
||||
size={22}
|
||||
style={{ color: theme.white, cursor: 'pointer' }}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<Group direction="row" spacing={3}>
|
||||
<Popover
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
transitionDuration={0}
|
||||
target={
|
||||
<ColorSwatch
|
||||
component="button"
|
||||
type="button"
|
||||
color={theme.colors[configColor][6]}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
size={22}
|
||||
style={{ display: 'block', cursor: 'pointer' }}
|
||||
/>
|
||||
}
|
||||
styles={{
|
||||
root: {
|
||||
marginRight: theme.spacing.xs,
|
||||
},
|
||||
body: {
|
||||
width: 152,
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
|
||||
},
|
||||
arrow: {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
|
||||
},
|
||||
}}
|
||||
position="bottom"
|
||||
placement="end"
|
||||
withArrow
|
||||
arrowSize={3}
|
||||
>
|
||||
<Group spacing="xs">{swatches}</Group>
|
||||
</Popover>
|
||||
<Text>{type[0].toUpperCase() + type.slice(1)} color</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
119
src/components/Settings/CommonSettings.tsx
Normal file
119
src/components/Settings/CommonSettings.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ActionIcon, Group, Text, SegmentedControl, TextInput, Anchor } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { IconBrandGithub as BrandGithub } from '@tabler/icons';
|
||||
import { CURRENT_VERSION } from '../../../data/constants';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
|
||||
import { WidgetsPositionSwitch } from '../WidgetsPositionSwitch/WidgetsPositionSwitch';
|
||||
import ConfigChanger from '../Config/ConfigChanger';
|
||||
import SaveConfigComponent from '../Config/SaveConfig';
|
||||
import ModuleEnabler from './ModuleEnabler';
|
||||
|
||||
export default function CommonSettings(args: any) {
|
||||
const { config, setConfig } = useConfig();
|
||||
|
||||
const matches = [
|
||||
{ label: 'Google', value: 'https://google.com/search?q=' },
|
||||
{ label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=' },
|
||||
{ label: 'Bing', value: 'https://bing.com/search?q=' },
|
||||
{ label: 'Custom', value: 'Custom' },
|
||||
];
|
||||
|
||||
const [customSearchUrl, setCustomSearchUrl] = useState(config.settings.searchUrl);
|
||||
const [searchUrl, setSearchUrl] = useState(
|
||||
matches.find((match) => match.value === config.settings.searchUrl)?.value ?? 'Custom'
|
||||
);
|
||||
|
||||
return (
|
||||
<Group direction="column" grow>
|
||||
<Group grow direction="column" spacing={0}>
|
||||
<Text>Search engine</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
title="Search engine"
|
||||
value={
|
||||
// Match config.settings.searchUrl with a key in the matches array
|
||||
searchUrl
|
||||
}
|
||||
onChange={
|
||||
// Set config.settings.searchUrl to the value of the selected item
|
||||
(e) => {
|
||||
setSearchUrl(e);
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
searchUrl: e,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
data={matches}
|
||||
/>
|
||||
{searchUrl === 'Custom' && (
|
||||
<TextInput
|
||||
label="Query URL"
|
||||
placeholder="Custom query url"
|
||||
value={customSearchUrl}
|
||||
onChange={(event) => {
|
||||
setCustomSearchUrl(event.currentTarget.value);
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
searchUrl: event.currentTarget.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<ColorSchemeSwitch />
|
||||
<WidgetsPositionSwitch />
|
||||
<ModuleEnabler />
|
||||
<ConfigChanger />
|
||||
<SaveConfigComponent />
|
||||
<Text
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
Tip: You can upload your config file by dragging and dropping it onto the page!
|
||||
</Text>
|
||||
<Group position="center" direction="row" mr="xs">
|
||||
<Group spacing={0}>
|
||||
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
|
||||
<BrandGithub size={18} />
|
||||
</ActionIcon>
|
||||
<Text
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: '0.90rem',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
{CURRENT_VERSION}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '0.90rem',
|
||||
textAlign: 'center',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
Made with ❤️ by @
|
||||
<Anchor
|
||||
href="https://github.com/ajnart"
|
||||
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
|
||||
>
|
||||
ajnart
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Group, Switch } from '@mantine/core';
|
||||
import { Checkbox, Group, SimpleGrid, Title } from '@mantine/core';
|
||||
import * as Modules from '../modules';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
@@ -7,26 +7,29 @@ export default function ModuleEnabler(props: any) {
|
||||
const modules = Object.values(Modules).map((module) => module);
|
||||
return (
|
||||
<Group direction="column">
|
||||
{modules.map((module) => (
|
||||
<Switch
|
||||
key={module.title}
|
||||
size="md"
|
||||
checked={config.modules?.[module.title]?.enabled ?? false}
|
||||
label={`Enable ${module.title}`}
|
||||
onChange={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
modules: {
|
||||
...config.modules,
|
||||
[module.title]: {
|
||||
...config.modules?.[module.title],
|
||||
enabled: e.currentTarget.checked,
|
||||
<Title order={4}>Module enabler</Title>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
{modules.map((module) => (
|
||||
<Checkbox
|
||||
key={module.title}
|
||||
size="md"
|
||||
checked={config.modules?.[module.title]?.enabled ?? false}
|
||||
label={`${module.title}`}
|
||||
onChange={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
modules: {
|
||||
...config.modules,
|
||||
[module.title]: {
|
||||
...config.modules?.[module.title],
|
||||
enabled: e.currentTarget.checked,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
44
src/components/Settings/OpacitySelector.tsx
Normal file
44
src/components/Settings/OpacitySelector.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Group, Text, Slider } from '@mantine/core';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
export function OpacitySelector() {
|
||||
const { config, setConfig } = useConfig();
|
||||
|
||||
const MARKS = [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 20, label: '20' },
|
||||
{ value: 30, label: '30' },
|
||||
{ value: 40, label: '40' },
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 60, label: '60' },
|
||||
{ value: 70, label: '70' },
|
||||
{ value: 80, label: '80' },
|
||||
{ value: 90, label: '90' },
|
||||
{ value: 100, label: '100' },
|
||||
];
|
||||
|
||||
const setConfigOpacity = (opacity: number) => {
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
appOpacity: opacity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Group direction="column" spacing="xs" grow>
|
||||
<Text>App Opacity</Text>
|
||||
<Slider
|
||||
defaultValue={config.settings.appOpacity || 100}
|
||||
step={10}
|
||||
min={10}
|
||||
marks={MARKS}
|
||||
styles={{ markLabel: { fontSize: 'xx-small' } }}
|
||||
onChange={(value) => setConfigOpacity(value)}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +1,20 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Title,
|
||||
Text,
|
||||
Tooltip,
|
||||
SegmentedControl,
|
||||
TextInput,
|
||||
Drawer,
|
||||
Anchor,
|
||||
} from '@mantine/core';
|
||||
import { useColorScheme, useHotkeys } from '@mantine/hooks';
|
||||
import { ActionIcon, Title, Tooltip, Drawer, Tabs } from '@mantine/core';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { useState } from 'react';
|
||||
import { IconBrandGithub as BrandGithub, IconSettings } from '@tabler/icons';
|
||||
import { CURRENT_VERSION } from '../../../data/constants';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
|
||||
import ConfigChanger from '../Config/ConfigChanger';
|
||||
import SaveConfigComponent from '../Config/SaveConfig';
|
||||
import ModuleEnabler from './ModuleEnabler';
|
||||
import { IconSettings } from '@tabler/icons';
|
||||
import AdvancedSettings from './AdvancedSettings';
|
||||
import CommonSettings from './CommonSettings';
|
||||
|
||||
function SettingsMenu(props: any) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const colorScheme = useColorScheme();
|
||||
const { current, latest } = props;
|
||||
|
||||
const matches = [
|
||||
{ label: 'Google', value: 'https://google.com/search?q=' },
|
||||
{ label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=' },
|
||||
{ label: 'Bing', value: 'https://bing.com/search?q=' },
|
||||
{ label: 'Custom', value: 'Custom' },
|
||||
];
|
||||
|
||||
const [customSearchUrl, setCustomSearchUrl] = useState(config.settings.searchUrl);
|
||||
const [searchUrl, setSearchUrl] = useState(
|
||||
matches.find((match) => match.value === config.settings.searchUrl)?.value ?? 'Custom'
|
||||
);
|
||||
|
||||
return (
|
||||
<Group direction="column" grow>
|
||||
<Group grow direction="column" spacing={0}>
|
||||
<Text>Search engine</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
title="Search engine"
|
||||
value={
|
||||
// Match config.settings.searchUrl with a key in the matches array
|
||||
searchUrl
|
||||
}
|
||||
onChange={
|
||||
// Set config.settings.searchUrl to the value of the selected item
|
||||
(e) => {
|
||||
setSearchUrl(e);
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
searchUrl: e,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
data={matches}
|
||||
/>
|
||||
{searchUrl === 'Custom' && (
|
||||
<TextInput
|
||||
label="Query URL"
|
||||
placeholder="Custom query url"
|
||||
value={customSearchUrl}
|
||||
onChange={(event) => {
|
||||
setCustomSearchUrl(event.currentTarget.value);
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
searchUrl: event.currentTarget.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<ModuleEnabler />
|
||||
<ColorSchemeSwitch />
|
||||
<ConfigChanger />
|
||||
<SaveConfigComponent />
|
||||
<Text
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
Tip: You can upload your config file by dragging and dropping it onto the page!
|
||||
</Text>
|
||||
<Group position="center" direction="row" mr="xs">
|
||||
<Group spacing={0}>
|
||||
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
|
||||
<BrandGithub size={18} />
|
||||
</ActionIcon>
|
||||
<Text
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: '0.90rem',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
{CURRENT_VERSION}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '0.90rem',
|
||||
textAlign: 'center',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
Made with ❤️ by @
|
||||
<Anchor
|
||||
href="https://github.com/ajnart"
|
||||
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
|
||||
>
|
||||
ajnart
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Tabs grow>
|
||||
<Tabs.Tab data-autofocus label="Common">
|
||||
<CommonSettings />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab label="Customizations">
|
||||
<AdvancedSettings />
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +25,7 @@ export function SettingsMenuButton(props: any) {
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
size="auto"
|
||||
size="xl"
|
||||
padding="xl"
|
||||
position="right"
|
||||
title={<Title order={3}>Settings</Title>}
|
||||
|
||||
97
src/components/Settings/ShadeSelector.tsx
Normal file
97
src/components/Settings/ShadeSelector.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ColorSwatch, Group, Popover, Text, useMantineTheme, MantineTheme } from '@mantine/core';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { useColorTheme } from '../../tools/color';
|
||||
|
||||
export function ShadeSelector() {
|
||||
const { config, setConfig } = useConfig();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const { primaryColor, secondaryColor, primaryShade, setPrimaryShade } = useColorTheme();
|
||||
|
||||
const theme = useMantineTheme();
|
||||
const primaryShades = theme.colors[primaryColor].map((s, i) => ({
|
||||
swatch: theme.colors[primaryColor][i],
|
||||
shade: i as MantineTheme['primaryShade'],
|
||||
}));
|
||||
const secondaryShades = theme.colors[secondaryColor].map((s, i) => ({
|
||||
swatch: theme.colors[secondaryColor][i],
|
||||
shade: i as MantineTheme['primaryShade'],
|
||||
}));
|
||||
|
||||
const setConfigShade = (shade: MantineTheme['primaryShade']) => {
|
||||
setPrimaryShade(shade);
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
primaryShade: shade,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const primarySwatches = primaryShades.map(({ swatch, shade }) => (
|
||||
<ColorSwatch
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => setConfigShade(shade)}
|
||||
key={Number(shade)}
|
||||
color={swatch}
|
||||
size={22}
|
||||
style={{ color: theme.white, cursor: 'pointer' }}
|
||||
/>
|
||||
));
|
||||
|
||||
const secondarySwatches = secondaryShades.map(({ swatch, shade }) => (
|
||||
<ColorSwatch
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => setConfigShade(shade)}
|
||||
key={Number(shade)}
|
||||
color={swatch}
|
||||
size={22}
|
||||
style={{ color: theme.white, cursor: 'pointer' }}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<Group direction="row" spacing={3}>
|
||||
<Popover
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
transitionDuration={0}
|
||||
target={
|
||||
<ColorSwatch
|
||||
component="button"
|
||||
type="button"
|
||||
color={theme.colors[primaryColor][Number(primaryShade)]}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
size={22}
|
||||
style={{ display: 'block', cursor: 'pointer' }}
|
||||
/>
|
||||
}
|
||||
styles={{
|
||||
root: {
|
||||
marginRight: theme.spacing.xs,
|
||||
},
|
||||
body: {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
|
||||
},
|
||||
arrow: {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
|
||||
},
|
||||
}}
|
||||
position="bottom"
|
||||
placement="end"
|
||||
withArrow
|
||||
arrowSize={3}
|
||||
>
|
||||
<Group direction="column" spacing="xs">
|
||||
<Group spacing="xs">{primarySwatches}</Group>
|
||||
<Group spacing="xs">{secondarySwatches}</Group>
|
||||
</Group>
|
||||
</Popover>
|
||||
<Text>Shade</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useState } from 'react';
|
||||
import { createStyles, Switch, Group } from '@mantine/core';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
position: 'relative',
|
||||
'& *': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
|
||||
icon: {
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
top: 3,
|
||||
},
|
||||
|
||||
iconLight: {
|
||||
left: 4,
|
||||
color: theme.white,
|
||||
},
|
||||
|
||||
iconDark: {
|
||||
right: 4,
|
||||
color: theme.colors.gray[6],
|
||||
},
|
||||
}));
|
||||
|
||||
export function WidgetsPositionSwitch() {
|
||||
const { config, setConfig } = useConfig();
|
||||
const { classes, cx } = useStyles();
|
||||
const defaultPosition = config?.settings?.widgetPosition || 'right';
|
||||
const [widgetPosition, setWidgetPosition] = useState(defaultPosition);
|
||||
const toggleWidgetPosition = () => {
|
||||
const position = widgetPosition === 'right' ? 'left' : 'right';
|
||||
setWidgetPosition(position);
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
widgetPosition: position,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<div className={classes.root}>
|
||||
<Switch
|
||||
checked={widgetPosition === 'left'}
|
||||
onChange={() => toggleWidgetPosition()}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
Position widgets on left
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,36 @@
|
||||
import { Aside as MantineAside, Group } from '@mantine/core';
|
||||
import {
|
||||
WeatherModule,
|
||||
DateModule,
|
||||
CalendarModule,
|
||||
TotalDownloadsModule,
|
||||
SystemModule,
|
||||
} from '../modules';
|
||||
import { ModuleWrapper } from '../modules/moduleWrapper';
|
||||
import { Aside as MantineAside, createStyles } from '@mantine/core';
|
||||
import Widgets from './Widgets';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
hide: {
|
||||
[theme.fn.smallerThan('xs')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
burger: {
|
||||
[theme.fn.largerThan('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default function Aside(props: any) {
|
||||
const { classes, cx } = useStyles();
|
||||
return (
|
||||
<MantineAside
|
||||
pr="md"
|
||||
hiddenBreakpoint="md"
|
||||
hiddenBreakpoint="sm"
|
||||
hidden
|
||||
className={cx(classes.hide)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
}}
|
||||
width={{
|
||||
base: 'auto',
|
||||
}}
|
||||
>
|
||||
<Group my="sm" grow direction="column" style={{ width: 300 }}>
|
||||
<ModuleWrapper module={CalendarModule} />
|
||||
<ModuleWrapper module={TotalDownloadsModule} />
|
||||
<ModuleWrapper module={WeatherModule} />
|
||||
<ModuleWrapper module={DateModule} />
|
||||
<ModuleWrapper module={SystemModule} />
|
||||
</Group>
|
||||
<Widgets />
|
||||
</MantineAside>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/components/layout/Background.tsx
Normal file
20
src/components/layout/Background.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Global } from '@mantine/core';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
export function Background() {
|
||||
const { config } = useConfig();
|
||||
|
||||
return (
|
||||
<Global
|
||||
styles={{
|
||||
body: {
|
||||
minHeight: '100vh',
|
||||
backgroundImage: `url('${config.settings.background}')` || '',
|
||||
backgroundPosition: 'center center',
|
||||
backgroundSize: 'cover',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createStyles, Footer as FooterComponent } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
|
||||
import { IconAlertCircle as AlertCircle } from '@tabler/icons';
|
||||
import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
footer: {
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import React from 'react';
|
||||
import { createStyles, Header as Head, Group, Box } from '@mantine/core';
|
||||
import {
|
||||
createStyles,
|
||||
Header as Head,
|
||||
Group,
|
||||
Box,
|
||||
Burger,
|
||||
Drawer,
|
||||
Title,
|
||||
ScrollArea,
|
||||
ActionIcon,
|
||||
Transition,
|
||||
} from '@mantine/core';
|
||||
import { useBooleanToggle } from '@mantine/hooks';
|
||||
import { Logo } from './Logo';
|
||||
import SearchBar from '../modules/search/SearchModule';
|
||||
import { AddItemShelfButton } from '../AppShelf/AddAppShelfItem';
|
||||
import { SettingsMenuButton } from '../Settings/SettingsMenu';
|
||||
import { ModuleWrapper } from '../modules/moduleWrapper';
|
||||
import { CalendarModule, TotalDownloadsModule, WeatherModule, DateModule } from '../modules';
|
||||
|
||||
const HEADER_HEIGHT = 60;
|
||||
|
||||
@@ -13,14 +27,21 @@ const useStyles = createStyles((theme) => ({
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
burger: {
|
||||
[theme.fn.largerThan('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export function Header(props: any) {
|
||||
const [opened, toggleOpened] = useBooleanToggle(false);
|
||||
const { classes, cx } = useStyles();
|
||||
const [hidden, toggleHidden] = useBooleanToggle(true);
|
||||
|
||||
return (
|
||||
<Head height="auto">
|
||||
<Group m="xs" position="apart">
|
||||
<Group p="xs" position="apart">
|
||||
<Box className={classes.hide}>
|
||||
<Logo style={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
@@ -28,6 +49,47 @@ export function Header(props: any) {
|
||||
<SearchBar />
|
||||
<SettingsMenuButton />
|
||||
<AddItemShelfButton />
|
||||
<ActionIcon className={classes.burger} variant="default" radius="md" size="xl">
|
||||
<Burger
|
||||
opened={!hidden}
|
||||
onClick={(_) => {
|
||||
toggleHidden();
|
||||
toggleOpened();
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<Drawer
|
||||
size="auto"
|
||||
padding="xl"
|
||||
position="right"
|
||||
hidden={hidden}
|
||||
title={<Title order={3}>Modules</Title>}
|
||||
opened
|
||||
onClose={() => {
|
||||
toggleHidden();
|
||||
}}
|
||||
>
|
||||
<Transition
|
||||
mounted={opened}
|
||||
transition="pop-top-right"
|
||||
duration={300}
|
||||
timingFunction="ease"
|
||||
onExit={() => toggleOpened()}
|
||||
>
|
||||
{(styles) => (
|
||||
<div style={styles}>
|
||||
<ScrollArea style={{ height: '90vh' }}>
|
||||
<Group my="sm" grow direction="column" style={{ width: 300 }}>
|
||||
<ModuleWrapper module={CalendarModule} />
|
||||
<ModuleWrapper module={TotalDownloadsModule} />
|
||||
<ModuleWrapper module={WeatherModule} />
|
||||
<ModuleWrapper module={DateModule} />
|
||||
</Group>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
</Drawer>
|
||||
</Group>
|
||||
</Group>
|
||||
</Head>
|
||||
|
||||
14
src/components/layout/HeaderConfig.tsx
Normal file
14
src/components/layout/HeaderConfig.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
export function HeaderConfig(props: any) {
|
||||
const { config } = useConfig();
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<title>{config.settings.title || 'Homarr 🦞'}</title>
|
||||
<link rel="shortcut icon" href={config.settings.favicon || '/favicon.svg'} />
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { AppShell, createStyles } from '@mantine/core';
|
||||
import { Header } from './Header';
|
||||
import { Footer } from './Footer';
|
||||
import Aside from './Aside';
|
||||
import Navbar from './Navbar';
|
||||
import { HeaderConfig } from './HeaderConfig';
|
||||
import { Background } from './Background';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
main: {},
|
||||
@@ -9,8 +13,18 @@ const useStyles = createStyles((theme) => ({
|
||||
|
||||
export default function Layout({ children, style }: any) {
|
||||
const { classes, cx } = useStyles();
|
||||
const { config } = useConfig();
|
||||
const widgetPosition = config?.settings?.widgetPosition === 'left';
|
||||
|
||||
return (
|
||||
<AppShell aside={<Aside />} header={<Header />} footer={<Footer links={[]} />}>
|
||||
<AppShell
|
||||
header={<Header />}
|
||||
navbar={widgetPosition ? <Navbar /> : <></>}
|
||||
aside={widgetPosition ? <></> : <Aside />}
|
||||
footer={<Footer links={[]} />}
|
||||
>
|
||||
<HeaderConfig />
|
||||
<Background />
|
||||
<main
|
||||
className={cx(classes.main)}
|
||||
style={{
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Group, Image, Text } from '@mantine/core';
|
||||
import { NextLink } from '@mantine/next';
|
||||
import * as React from 'react';
|
||||
import { useColorTheme } from '../../tools/color';
|
||||
import { useConfig } from '../../tools/state';
|
||||
|
||||
export function Logo({ style }: any) {
|
||||
const { config } = useConfig();
|
||||
const { primaryColor, secondaryColor } = useColorTheme();
|
||||
|
||||
return (
|
||||
<Group spacing="xs">
|
||||
<Image
|
||||
width={50}
|
||||
src="/imgs/logo.png"
|
||||
src={config.settings.logo || '/imgs/logo.png'}
|
||||
style={{
|
||||
position: 'relative',
|
||||
}}
|
||||
@@ -23,9 +28,13 @@ export function Logo({ style }: any) {
|
||||
sx={style}
|
||||
weight="bold"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'red', to: 'orange', deg: 145 }}
|
||||
gradient={{
|
||||
from: primaryColor,
|
||||
to: secondaryColor,
|
||||
deg: 145,
|
||||
}}
|
||||
>
|
||||
Homarr
|
||||
{config.settings.title || 'Homarr'}
|
||||
</Text>
|
||||
</NextLink>
|
||||
</Group>
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
import { Group, Navbar as MantineNavbar } from '@mantine/core';
|
||||
import { WeatherModule, DateModule } from '../modules';
|
||||
import { ModuleWrapper } from '../modules/moduleWrapper';
|
||||
import { createStyles, Navbar as MantineNavbar } from '@mantine/core';
|
||||
import Widgets from './Widgets';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
hide: {
|
||||
[theme.fn.smallerThan('xs')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
burger: {
|
||||
[theme.fn.largerThan('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default function Navbar() {
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
return (
|
||||
<MantineNavbar
|
||||
hiddenBreakpoint="lg"
|
||||
pl="md"
|
||||
hiddenBreakpoint="sm"
|
||||
hidden
|
||||
className={cx(classes.hide)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
}}
|
||||
width={{
|
||||
base: 'auto',
|
||||
}}
|
||||
>
|
||||
<Group mt="sm" direction="column" align="center">
|
||||
<ModuleWrapper module={DateModule} />
|
||||
<ModuleWrapper module={WeatherModule} />
|
||||
<ModuleWrapper module={WeatherModule} />
|
||||
</Group>
|
||||
<Widgets />
|
||||
</MantineNavbar>
|
||||
);
|
||||
}
|
||||
|
||||
21
src/components/layout/Widgets.tsx
Normal file
21
src/components/layout/Widgets.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Group } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { CalendarModule, DateModule, TotalDownloadsModule, WeatherModule } from '../modules';
|
||||
import { ModuleWrapper } from '../modules/moduleWrapper';
|
||||
|
||||
export default function Widgets(props: any) {
|
||||
const matches = useMediaQuery('(min-width: 800px)');
|
||||
|
||||
return (
|
||||
<>
|
||||
{matches && (
|
||||
<Group my="sm" grow direction="column" style={{ width: 300 }}>
|
||||
<ModuleWrapper module={CalendarModule} />
|
||||
<ModuleWrapper module={TotalDownloadsModule} />
|
||||
<ModuleWrapper module={WeatherModule} />
|
||||
<ModuleWrapper module={DateModule} />
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
/* eslint-disable react/no-children-prop */
|
||||
import { Box, Divider, Indicator, Popover, ScrollArea } from '@mantine/core';
|
||||
import {
|
||||
Box,
|
||||
Divider,
|
||||
Indicator,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
createStyles,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Calendar } from '@mantine/dates';
|
||||
import { IconCalendar as CalendarIcon } from '@tabler/icons';
|
||||
@@ -13,6 +21,7 @@ import {
|
||||
ReadarrMediaDisplay,
|
||||
} from '../common';
|
||||
import { serviceItem } from '../../../tools/types';
|
||||
import { useColorTheme } from '../../../tools/color';
|
||||
|
||||
export const CalendarModule: IModule = {
|
||||
title: 'Calendar',
|
||||
@@ -24,14 +33,25 @@ export const CalendarModule: IModule = {
|
||||
|
||||
export default function CalendarComponent(props: any) {
|
||||
const { config } = useConfig();
|
||||
const theme = useMantineTheme();
|
||||
const { secondaryColor } = useColorTheme();
|
||||
const useStyles = createStyles((theme) => ({
|
||||
weekend: {
|
||||
color: `${secondaryColor} !important`,
|
||||
},
|
||||
}));
|
||||
|
||||
const [sonarrMedias, setSonarrMedias] = useState([] as any);
|
||||
const [lidarrMedias, setLidarrMedias] = useState([] as any);
|
||||
const [radarrMedias, setRadarrMedias] = useState([] as any);
|
||||
const [readarrMedias, setReadarrMedias] = useState([] as any);
|
||||
const sonarrService = config.services.filter((service) => service.type === 'Sonarr').at(0);
|
||||
const radarrService = config.services.filter((service) => service.type === 'Radarr').at(0);
|
||||
const lidarrService = config.services.filter((service) => service.type === 'Lidarr').at(0);
|
||||
const readarrService = config.services.filter((service) => service.type === 'Readarr').at(0);
|
||||
const sonarrServices = config.services.filter((service) => service.type === 'Sonarr');
|
||||
const radarrServices = config.services.filter((service) => service.type === 'Radarr');
|
||||
const lidarrServices = config.services.filter((service) => service.type === 'Lidarr');
|
||||
const readarrServices = config.services.filter((service) => service.type === 'Readarr');
|
||||
const today = new Date();
|
||||
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
function getMedias(service: serviceItem | undefined, type: string) {
|
||||
if (!service || !service.apiKey) {
|
||||
@@ -41,18 +61,61 @@ export default function CalendarComponent(props: any) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Filter only sonarr and radarr services
|
||||
|
||||
// Get the url and apiKey for all Sonarr and Radarr services
|
||||
getMedias(sonarrService, 'sonarr').then((res) => setSonarrMedias(res.data));
|
||||
getMedias(radarrService, 'radarr').then((res) => setRadarrMedias(res.data));
|
||||
getMedias(lidarrService, 'lidarr').then((res) => setLidarrMedias(res.data));
|
||||
getMedias(readarrService, 'readarr').then((res) => setReadarrMedias(res.data));
|
||||
// Create each Sonarr service and get the medias
|
||||
const currentSonarrMedias: any[] = [...sonarrMedias];
|
||||
Promise.all(
|
||||
sonarrServices.map((service) =>
|
||||
getMedias(service, 'sonarr').then((res) => {
|
||||
currentSonarrMedias.push(...res.data);
|
||||
})
|
||||
)
|
||||
).then(() => {
|
||||
setSonarrMedias(currentSonarrMedias);
|
||||
});
|
||||
const currentRadarrMedias: any[] = [...radarrMedias];
|
||||
Promise.all(
|
||||
radarrServices.map((service) =>
|
||||
getMedias(service, 'radarr').then((res) => {
|
||||
currentRadarrMedias.push(...res.data);
|
||||
})
|
||||
)
|
||||
).then(() => {
|
||||
setRadarrMedias(currentRadarrMedias);
|
||||
});
|
||||
const currentLidarrMedias: any[] = [...lidarrMedias];
|
||||
Promise.all(
|
||||
lidarrServices.map((service) =>
|
||||
getMedias(service, 'lidarr').then((res) => {
|
||||
currentLidarrMedias.push(...res.data);
|
||||
})
|
||||
)
|
||||
).then(() => {
|
||||
setLidarrMedias(currentLidarrMedias);
|
||||
});
|
||||
const currentReadarrMedias: any[] = [...readarrMedias];
|
||||
Promise.all(
|
||||
readarrServices.map((service) =>
|
||||
getMedias(service, 'readarr').then((res) => {
|
||||
currentReadarrMedias.push(...res.data);
|
||||
})
|
||||
)
|
||||
).then(() => {
|
||||
setReadarrMedias(currentReadarrMedias);
|
||||
});
|
||||
}, [config.services]);
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
onChange={(day: any) => {}}
|
||||
dayStyle={(date) =>
|
||||
date.getDay() === today.getDay() && date.getDate() === today.getDate()
|
||||
? {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[0],
|
||||
}
|
||||
: {}
|
||||
}
|
||||
dayClassName={(date, modifiers) => cx({ [classes.weekend]: modifiers.weekend })}
|
||||
renderDay={(renderdate) => (
|
||||
<DayComponent
|
||||
renderdate={renderdate}
|
||||
@@ -81,23 +144,20 @@ function DayComponent(props: any) {
|
||||
|
||||
const readarrFiltered = readarrmedias.filter((media: any) => {
|
||||
const date = new Date(media.releaseDate);
|
||||
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
|
||||
return date.toDateString() === renderdate.toDateString();
|
||||
});
|
||||
|
||||
const lidarrFiltered = lidarrmedias.filter((media: any) => {
|
||||
const date = new Date(media.releaseDate);
|
||||
// Return true if the date is renerdate without counting hours and minutes
|
||||
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
|
||||
return date.toDateString() === renderdate.toDateString();
|
||||
});
|
||||
const sonarrFiltered = sonarrmedias.filter((media: any) => {
|
||||
const date = new Date(media.airDate);
|
||||
// Return true if the date is renerdate without counting hours and minutes
|
||||
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
|
||||
const date = new Date(media.airDateUtc);
|
||||
return date.toDateString() === renderdate.toDateString();
|
||||
});
|
||||
const radarrFiltered = radarrmedias.filter((media: any) => {
|
||||
const date = new Date(media.inCinemas);
|
||||
// Return true if the date is renerdate without counting hours and minutes
|
||||
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
|
||||
return date.toDateString() === renderdate.toDateString();
|
||||
});
|
||||
if (
|
||||
sonarrFiltered.length === 0 &&
|
||||
@@ -167,7 +227,7 @@ function DayComponent(props: any) {
|
||||
/>
|
||||
)}
|
||||
<Popover
|
||||
position="left"
|
||||
position="bottom"
|
||||
radius="lg"
|
||||
shadow="xl"
|
||||
transition="pop"
|
||||
@@ -176,7 +236,7 @@ function DayComponent(props: any) {
|
||||
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.1), 0 14px 11px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}}
|
||||
width={700}
|
||||
width="auto"
|
||||
onClose={() => setOpened(false)}
|
||||
opened={opened}
|
||||
target={day}
|
||||
@@ -197,12 +257,18 @@ function DayComponent(props: any) {
|
||||
{index < radarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{sonarrFiltered.length > 0 && lidarrFiltered.length > 0 && (
|
||||
<Divider variant="dashed" my="xl" />
|
||||
)}
|
||||
{lidarrFiltered.map((media: any, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
<LidarrMediaDisplay media={media} />
|
||||
{index < lidarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{lidarrFiltered.length > 0 && readarrFiltered.length > 0 && (
|
||||
<Divider variant="dashed" my="xl" />
|
||||
)}
|
||||
{readarrFiltered.map((media: any, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
<ReadarrMediaDisplay media={media} />
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Image, Group, Title, Badge, Text, ActionIcon, Anchor, ScrollArea } from '@mantine/core';
|
||||
import {
|
||||
Image,
|
||||
Group,
|
||||
Title,
|
||||
Badge,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
ScrollArea,
|
||||
createStyles,
|
||||
} from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { IconLink as Link } from '@tabler/icons';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { serviceItem } from '../../../tools/types';
|
||||
@@ -14,13 +25,25 @@ export interface IMedia {
|
||||
episodeNumber?: number;
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
overview: {
|
||||
[theme.fn.largerThan('sm')]: {
|
||||
width: 400,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export function MediaDisplay(props: { media: IMedia }) {
|
||||
const { media }: { media: IMedia } = props;
|
||||
const { classes, cx } = useStyles();
|
||||
const phone = useMediaQuery('(min-width: 800px)');
|
||||
return (
|
||||
<Group position="apart">
|
||||
<Text>
|
||||
{media.poster && (
|
||||
<Image
|
||||
width={phone ? 250 : 100}
|
||||
height={phone ? 400 : 160}
|
||||
style={{
|
||||
float: 'right',
|
||||
}}
|
||||
@@ -28,12 +51,10 @@ export function MediaDisplay(props: { media: IMedia }) {
|
||||
fit="cover"
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
width={250}
|
||||
height={400}
|
||||
/>
|
||||
)}
|
||||
<Group direction="column">
|
||||
<Group noWrap mr="sm" style={{ minWidth: 400 }}>
|
||||
<Group direction="column" style={{ minWidth: phone ? 450 : '65vw' }}>
|
||||
<Group noWrap mr="sm" className={classes.overview}>
|
||||
<Title order={3}>{media.title}</Title>
|
||||
{media.imdbId && (
|
||||
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
|
||||
@@ -65,9 +86,9 @@ export function MediaDisplay(props: { media: IMedia }) {
|
||||
)}
|
||||
</Group>
|
||||
<Group direction="column" position="apart">
|
||||
<ScrollArea style={{ height: 250 }}>{media.overview}</ScrollArea>
|
||||
<ScrollArea style={{ height: 280, maxWidth: 700 }}>{media.overview}</ScrollArea>
|
||||
<Group align="center" position="center" spacing="xs">
|
||||
{media.genres.map((genre: string, i: number) => (
|
||||
{media.genres.slice(-5).map((genre: string, i: number) => (
|
||||
<Badge size="sm" key={i}>
|
||||
{genre}
|
||||
</Badge>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { IconClock as Clock } from '@tabler/icons';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
|
||||
|
||||
export const DateModule: IModule = {
|
||||
title: 'Date',
|
||||
@@ -20,13 +21,14 @@ export const DateModule: IModule = {
|
||||
|
||||
export default function DateComponent(props: any) {
|
||||
const [date, setDate] = useState(new Date());
|
||||
const setSafeInterval = useSetSafeInterval();
|
||||
const { config } = useConfig();
|
||||
const isFullTime = config?.modules?.[DateModule.title]?.options?.full?.value ?? false;
|
||||
const formatString = isFullTime ? 'HH:mm' : 'h:mm A';
|
||||
// Change date on minute change
|
||||
// Note: Using 10 000ms instead of 1000ms to chill a little :)
|
||||
useEffect(() => {
|
||||
setInterval(() => {
|
||||
setSafeInterval(() => {
|
||||
setDate(new Date());
|
||||
}, 1000 * 60);
|
||||
}, []);
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { Table, Text, Tooltip, Title, Group, Progress, Skeleton, ScrollArea } from '@mantine/core';
|
||||
import {
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
Title,
|
||||
Group,
|
||||
Progress,
|
||||
Skeleton,
|
||||
ScrollArea,
|
||||
Center,
|
||||
Image,
|
||||
} from '@mantine/core';
|
||||
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 { IModule } from '../modules';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
|
||||
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
|
||||
import { humanFileSize } from '../../../tools/humanFileSize';
|
||||
|
||||
export const DownloadsModule: IModule = {
|
||||
title: 'Torrent',
|
||||
@@ -22,36 +36,32 @@ export const DownloadsModule: IModule = {
|
||||
|
||||
export default function DownloadComponent() {
|
||||
const { config } = useConfig();
|
||||
const qBittorrentService = config.services
|
||||
.filter((service) => service.type === 'qBittorrent')
|
||||
.at(0);
|
||||
const delugeService = config.services.filter((service) => service.type === 'Deluge').at(0);
|
||||
const { height, width } = useViewportSize();
|
||||
const downloadServices =
|
||||
config.services.filter(
|
||||
(service) =>
|
||||
service.type === 'qBittorrent' ||
|
||||
service.type === 'Transmission' ||
|
||||
service.type === 'Deluge'
|
||||
) ?? [];
|
||||
const hideComplete: boolean =
|
||||
(config?.modules?.[DownloadsModule.title]?.options?.hidecomplete?.value as boolean) ?? false;
|
||||
|
||||
const [delugeTorrents, setDelugeTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
const [qBittorrentTorrents, setqBittorrentTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
|
||||
const [torrents, setTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
const setSafeInterval = useSetSafeInterval();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
if (qBittorrentService) {
|
||||
setInterval(() => {
|
||||
axios
|
||||
.post('/api/modules/downloads?dlclient=qbit', { ...qBittorrentService })
|
||||
.then((res) => {
|
||||
setqBittorrentTorrents(res.data.torrents);
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
if (delugeService) {
|
||||
setInterval(() => {
|
||||
axios.post('/api/modules/downloads?dlclient=deluge', { ...delugeService }).then((res) => {
|
||||
setDelugeTorrents(res.data.torrents);
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
}, [config.modules]);
|
||||
setIsLoading(true);
|
||||
if (downloadServices.length === 0) return;
|
||||
setSafeInterval(() => {
|
||||
// Send one request with each download service inside
|
||||
axios.post('/api/modules/downloads', { config }).then((response) => {
|
||||
setTorrents(response.data);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, 5000);
|
||||
}, [config.services]);
|
||||
|
||||
if (!qBittorrentService && !delugeService) {
|
||||
if (downloadServices.length === 0) {
|
||||
return (
|
||||
<Group direction="column">
|
||||
<Title order={3}>No supported download clients found!</Title>
|
||||
@@ -63,7 +73,7 @@ export default function DownloadComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (qBittorrentTorrents.length === 0 && delugeTorrents.length === 0) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={40} mt={10} />
|
||||
@@ -74,68 +84,106 @@ export default function DownloadComponent() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DEVICE_WIDTH = 576;
|
||||
const ths = (
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Download</th>
|
||||
<th>Upload</th>
|
||||
<th>Size</th>
|
||||
{width > 576 ? <th>Down</th> : ''}
|
||||
{width > 576 ? <th>Up</th> : ''}
|
||||
<th>ETA</th>
|
||||
<th>Progress</th>
|
||||
</tr>
|
||||
);
|
||||
// Loop over qBittorrent torrents merging with deluge torrents
|
||||
const torrents: NormalizedTorrent[] = [];
|
||||
delugeTorrents.forEach((delugeTorrent) =>
|
||||
torrents.push({ ...delugeTorrent, progress: delugeTorrent.progress / 100 })
|
||||
);
|
||||
qBittorrentTorrents.forEach((torrent) => torrents.push(torrent));
|
||||
const rows = torrents.map((torrent) => {
|
||||
if (torrent.progress === 1 && hideComplete) {
|
||||
return [];
|
||||
// Convert Seconds to readable format.
|
||||
function calculateETA(givenSeconds: number) {
|
||||
// If its superior than one day return > 1 day
|
||||
if (givenSeconds > 86400) {
|
||||
return '> 1 day';
|
||||
}
|
||||
const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
|
||||
const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
|
||||
return (
|
||||
<tr key={torrent.id}>
|
||||
<td>
|
||||
<Tooltip position="top" label={torrent.name}>
|
||||
<Text
|
||||
style={{
|
||||
maxWidth: '30vw',
|
||||
}}
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
>
|
||||
{torrent.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text>{(torrent.progress * 100).toFixed(1)}%</Text>
|
||||
<Progress
|
||||
radius="lg"
|
||||
color={torrent.progress === 1 ? 'green' : 'blue'}
|
||||
value={torrent.progress * 100}
|
||||
size="lg"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
// Transform the givenSeconds into a readable format. e.g. 1h 2m 3s
|
||||
const hours = Math.floor(givenSeconds / 3600);
|
||||
const minutes = Math.floor((givenSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(givenSeconds % 60);
|
||||
// Only show hours if it's greater than 0.
|
||||
const hoursString = hours > 0 ? `${hours}h ` : '';
|
||||
const minutesString = minutes > 0 ? `${minutes}m ` : '';
|
||||
const secondsString = seconds > 0 ? `${seconds}s` : '';
|
||||
return `${hoursString}${minutesString}${secondsString}`;
|
||||
}
|
||||
// Loop over qBittorrent torrents merging with deluge torrents
|
||||
const rows = torrents
|
||||
.filter((torrent) => !(torrent.progress === 1 && hideComplete))
|
||||
.map((torrent) => {
|
||||
const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
|
||||
const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
|
||||
const size = torrent.totalSelected;
|
||||
return (
|
||||
<tr key={torrent.id}>
|
||||
<td>
|
||||
<Tooltip position="top" label={torrent.name}>
|
||||
<Text
|
||||
style={{
|
||||
maxWidth: '30vw',
|
||||
}}
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
>
|
||||
{torrent.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">{humanFileSize(size)}</Text>
|
||||
</td>
|
||||
{width > 576 ? (
|
||||
<td>
|
||||
<Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
|
||||
</td>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{width > 576 ? (
|
||||
<td>
|
||||
<Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
|
||||
</td>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<td>
|
||||
<Text size="xs">{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text>{(torrent.progress * 100).toFixed(1)}%</Text>
|
||||
<Progress
|
||||
radius="lg"
|
||||
color={
|
||||
torrent.state === 'paused' ? 'yellow' : torrent.progress === 1 ? 'green' : 'blue'
|
||||
}
|
||||
value={torrent.progress * 100}
|
||||
size="lg"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
const easteregg = (
|
||||
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Image fit="cover" height={300} src="https://danjohnvelasco.github.io/images/empty.png" />
|
||||
</Center>
|
||||
);
|
||||
return (
|
||||
<Group noWrap grow direction="column">
|
||||
<Title order={4}>Your torrents</Title>
|
||||
<Group noWrap grow direction="column" mt="xl">
|
||||
<ScrollArea sx={{ height: 300 }}>
|
||||
<Table highlightOnHover>
|
||||
<thead>{ths}</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</Table>
|
||||
{rows.length > 0 ? (
|
||||
<Table highlightOnHover>
|
||||
<thead>{ths}</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</Table>
|
||||
) : (
|
||||
easteregg
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -8,39 +8,9 @@ import { Datum, ResponsiveLine } from '@nivo/line';
|
||||
import { useListState } from '@mantine/hooks';
|
||||
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { humanFileSize } from '../../../tools/humanFileSize';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
/**
|
||||
* Format bytes as human-readable text.
|
||||
*
|
||||
* @param bytes Number of bytes.
|
||||
* @param si True to use metric (SI) units, aka powers of 1000. False to use
|
||||
* binary (IEC), aka powers of 1024.
|
||||
* @param dp Number of decimal places to display.
|
||||
*
|
||||
* @return Formatted string.
|
||||
*/
|
||||
function humanFileSize(initialBytes: number, si = true, dp = 1) {
|
||||
const thresh = si ? 1000 : 1024;
|
||||
let bytes = initialBytes;
|
||||
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
const units = si
|
||||
? ['kb', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||
let u = -1;
|
||||
const r = 10 ** dp;
|
||||
|
||||
do {
|
||||
bytes /= thresh;
|
||||
u += 1;
|
||||
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||
|
||||
return `${bytes.toFixed(dp)} ${units[u]}`;
|
||||
}
|
||||
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
|
||||
|
||||
export const TotalDownloadsModule: IModule = {
|
||||
title: 'Download Speed',
|
||||
@@ -56,42 +26,29 @@ interface torrentHistory {
|
||||
}
|
||||
|
||||
export default function TotalDownloadsComponent() {
|
||||
const setSafeInterval = useSetSafeInterval();
|
||||
const { config } = useConfig();
|
||||
const qBittorrentService = config.services
|
||||
.filter((service) => service.type === 'qBittorrent')
|
||||
.at(0);
|
||||
const delugeService = config.services.filter((service) => service.type === 'Deluge').at(0);
|
||||
const downloadServices =
|
||||
config.services.filter(
|
||||
(service) =>
|
||||
service.type === 'qBittorrent' ||
|
||||
service.type === 'Transmission' ||
|
||||
service.type === 'Deluge'
|
||||
) ?? [];
|
||||
|
||||
const [delugeTorrents, setDelugeTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
const [torrentHistory, torrentHistoryHandlers] = useListState<torrentHistory>([]);
|
||||
const [qBittorrentTorrents, setqBittorrentTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
|
||||
const torrents: NormalizedTorrent[] = [];
|
||||
delugeTorrents.forEach((delugeTorrent) =>
|
||||
torrents.push({ ...delugeTorrent, progress: delugeTorrent.progress / 100 })
|
||||
);
|
||||
qBittorrentTorrents.forEach((torrent) => torrents.push(torrent));
|
||||
const [torrents, setTorrents] = useState<NormalizedTorrent[]>([]);
|
||||
|
||||
const totalDownloadSpeed = torrents.reduce((acc, torrent) => acc + torrent.downloadSpeed, 0);
|
||||
const totalUploadSpeed = torrents.reduce((acc, torrent) => acc + torrent.uploadSpeed, 0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
// Get the current download speed of qBittorrent.
|
||||
if (qBittorrentService) {
|
||||
axios
|
||||
.post('/api/modules/downloads?dlclient=qbit', { ...qBittorrentService })
|
||||
.then((res) => {
|
||||
setqBittorrentTorrents(res.data.torrents);
|
||||
});
|
||||
if (delugeService) {
|
||||
axios.post('/api/modules/downloads?dlclient=deluge', { ...delugeService }).then((res) => {
|
||||
setDelugeTorrents(res.data.torrents);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (downloadServices.length === 0) return;
|
||||
setSafeInterval(() => {
|
||||
axios.post('/api/modules/downloads', { config }).then((response) => {
|
||||
setTorrents(response.data);
|
||||
});
|
||||
}, 1000);
|
||||
}, [config.modules]);
|
||||
}, [config.services]);
|
||||
|
||||
useEffect(() => {
|
||||
torrentHistoryHandlers.append({
|
||||
@@ -101,7 +58,7 @@ export default function TotalDownloadsComponent() {
|
||||
});
|
||||
}, [totalDownloadSpeed, totalUploadSpeed]);
|
||||
|
||||
if (!qBittorrentService && !delugeService) {
|
||||
if (downloadServices.length === 0) {
|
||||
return (
|
||||
<Group direction="column">
|
||||
<Title order={4}>No supported download clients found!</Title>
|
||||
|
||||
@@ -4,4 +4,3 @@ export * from './search';
|
||||
export * from './ping';
|
||||
export * from './weather';
|
||||
export * from './downloads';
|
||||
export * from './system';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Card, Group, Menu, Switch, TextInput, useMantineTheme } from '@mantine/core';
|
||||
import { Button, Card, Group, Menu, Switch, TextInput, useMantineColorScheme } from '@mantine/core';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { IModule } from './modules';
|
||||
|
||||
@@ -91,18 +91,50 @@ function getItems(module: IModule) {
|
||||
|
||||
export function ModuleWrapper(props: any) {
|
||||
const { module }: { module: IModule } = props;
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { config, setConfig } = useConfig();
|
||||
const enabledModules = config.modules ?? {};
|
||||
// Remove 'Module' from enabled modules titles
|
||||
const isShown = enabledModules[module.title]?.enabled ?? false;
|
||||
const theme = useMantineTheme();
|
||||
const items: JSX.Element[] = getItems(module);
|
||||
|
||||
if (!isShown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card {...props} hidden={!isShown} withBorder radius="lg" shadow="sm">
|
||||
<Card
|
||||
{...props}
|
||||
hidden={!isShown}
|
||||
withBorder
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
style={{
|
||||
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
borderColor: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'} \
|
||||
${(config.settings.appOpacity || 100) / 100}`,
|
||||
}}
|
||||
>
|
||||
<ModuleMenu
|
||||
module={module}
|
||||
styles={{
|
||||
root: {
|
||||
position: 'absolute',
|
||||
top: 15,
|
||||
right: 15,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<module.component />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModuleMenu(props: any) {
|
||||
const { module, styles } = props;
|
||||
const items: JSX.Element[] = getItems(module);
|
||||
return (
|
||||
<>
|
||||
{module.options && (
|
||||
<Menu
|
||||
size="lg"
|
||||
@@ -112,9 +144,7 @@ export function ModuleWrapper(props: any) {
|
||||
position="left"
|
||||
styles={{
|
||||
root: {
|
||||
position: 'absolute',
|
||||
top: 15,
|
||||
right: 15,
|
||||
...props?.styles?.root,
|
||||
},
|
||||
body: {
|
||||
// Add shadow and elevation to the body
|
||||
@@ -128,7 +158,6 @@ export function ModuleWrapper(props: any) {
|
||||
))}
|
||||
</Menu>
|
||||
)}
|
||||
<module.component />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function PingComponent(props: any) {
|
||||
.catch(() => {
|
||||
setOnline('down');
|
||||
});
|
||||
}, []);
|
||||
}, [config.modules?.[PingModule.title]?.enabled]);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { TextInput, Kbd, createStyles, Text, Popover } from '@mantine/core';
|
||||
import { useForm, useHotkeys } from '@mantine/hooks';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Kbd, createStyles, Text, Popover, Autocomplete } from '@mantine/core';
|
||||
import { useDebouncedValue, useForm, useHotkeys } from '@mantine/hooks';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IconSearch as Search,
|
||||
IconBrandYoutube as BrandYoutube,
|
||||
IconDownload as Download,
|
||||
} from '@tabler/icons';
|
||||
import axios from 'axios';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
@@ -32,8 +33,22 @@ export default function SearchBar(props: any) {
|
||||
const [icon, setIcon] = useState(<Search />);
|
||||
const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
|
||||
const textInput = useRef<HTMLInputElement>();
|
||||
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
|
||||
// Find a service with the type of 'Overseerr'
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
query: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [debounced, cancel] = useDebouncedValue(form.values.query, 250);
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
if (form.values.query !== debounced || form.values.query === '') return;
|
||||
axios
|
||||
.get(`/api/modules/search?q=${form.values.query}`)
|
||||
.then((res) => setResults(res.data ?? []));
|
||||
}, [debounced]);
|
||||
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
|
||||
const { classes, cx } = useStyles();
|
||||
const rightSection = (
|
||||
<div className={classes.hide}>
|
||||
@@ -43,12 +58,6 @@ export default function SearchBar(props: any) {
|
||||
</div>
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
query: '',
|
||||
},
|
||||
});
|
||||
|
||||
// If enabled modules doesn't contain the module, return null
|
||||
// If module in enabled
|
||||
|
||||
@@ -57,6 +66,10 @@ export default function SearchBar(props: any) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const autocompleteData = results.map((result) => ({
|
||||
label: result.phrase,
|
||||
value: result.phrase,
|
||||
}));
|
||||
return (
|
||||
<form
|
||||
onChange={() => {
|
||||
@@ -100,8 +113,9 @@ export default function SearchBar(props: any) {
|
||||
onFocusCapture={() => setOpened(true)}
|
||||
onBlurCapture={() => setOpened(false)}
|
||||
target={
|
||||
<TextInput
|
||||
<Autocomplete
|
||||
variant="filled"
|
||||
data={autocompleteData}
|
||||
icon={icon}
|
||||
ref={textInput}
|
||||
rightSectionWidth={90}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import {
|
||||
Center,
|
||||
Group,
|
||||
RingProgress,
|
||||
Title,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { Center, Group, RingProgress, Title, useMantineTheme } from '@mantine/core';
|
||||
import { IconCpu } from '@tabler/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import si from 'systeminformation';
|
||||
import { useListState } from '@mantine/hooks';
|
||||
import { IModule } from '../modules';
|
||||
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
|
||||
|
||||
export const SystemModule: IModule = {
|
||||
title: 'System info',
|
||||
@@ -28,13 +23,13 @@ interface ApiResponse {
|
||||
|
||||
export default function SystemInfo(args: any) {
|
||||
const [data, setData] = useState<ApiResponse>();
|
||||
|
||||
const setSafeInterval = useSetSafeInterval();
|
||||
// Refresh data every second
|
||||
useEffect(() => {
|
||||
setInterval(() => {
|
||||
setSafeInterval(() => {
|
||||
axios.get('/api/modules/systeminfo').then((res) => setData(res.data));
|
||||
}, 1000);
|
||||
}, [args]);
|
||||
}, []);
|
||||
|
||||
// Update data every time data changes
|
||||
const [cpuLoadHistory, cpuLoadHistoryHandlers] =
|
||||
|
||||
@@ -18,7 +18,7 @@ import { IModule } from '../modules';
|
||||
import { WeatherResponse } from './WeatherInterface';
|
||||
|
||||
export const WeatherModule: IModule = {
|
||||
title: 'Weather (beta)',
|
||||
title: 'Weather',
|
||||
description: 'Look up the current weather in your location',
|
||||
icon: Sun,
|
||||
component: WeatherComponent,
|
||||
@@ -160,7 +160,7 @@ export default function WeatherComponent(props: any) {
|
||||
return null;
|
||||
}
|
||||
function usePerferedUnit(value: number): string {
|
||||
return isFahrenheit ? `${(value * (9 / 5)).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
|
||||
return isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
|
||||
}
|
||||
return (
|
||||
<Group p="sm" spacing="xs" direction="column">
|
||||
|
||||
@@ -6,6 +6,7 @@ import AppShelf from '../components/AppShelf/AppShelf';
|
||||
import LoadConfigComponent from '../components/Config/LoadConfig';
|
||||
import { Config } from '../tools/types';
|
||||
import { useConfig } from '../tools/state';
|
||||
import Layout from '../components/layout/Layout';
|
||||
|
||||
export async function getServerSideProps(
|
||||
context: GetServerSidePropsContext
|
||||
@@ -46,9 +47,9 @@ export default function HomePage(props: any) {
|
||||
setConfig(initialConfig);
|
||||
}, [initialConfig]);
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<AppShelf />
|
||||
<LoadConfigComponent />
|
||||
</>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,30 @@ import { useState } from 'react';
|
||||
import { AppProps } from 'next/app';
|
||||
import { getCookie, setCookies } from 'cookies-next';
|
||||
import Head from 'next/head';
|
||||
import { MantineProvider, ColorScheme, ColorSchemeProvider } from '@mantine/core';
|
||||
import { MantineProvider, ColorScheme, ColorSchemeProvider, MantineTheme } from '@mantine/core';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import Layout from '../components/layout/Layout';
|
||||
import { ConfigProvider } from '../tools/state';
|
||||
import { theme } from '../tools/theme';
|
||||
import { styles } from '../tools/styles';
|
||||
import { ColorTheme } from '../tools/color';
|
||||
|
||||
export default function App(props: AppProps & { colorScheme: ColorScheme }) {
|
||||
export default function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
|
||||
const { Component, pageProps } = props;
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(props.colorScheme);
|
||||
|
||||
const [primaryColor, setPrimaryColor] = useState<MantineTheme['primaryColor']>('red');
|
||||
const [secondaryColor, setSecondaryColor] = useState<MantineTheme['primaryColor']>('orange');
|
||||
const [primaryShade, setPrimaryShade] = useState<MantineTheme['primaryShade']>(6);
|
||||
const colorTheme = {
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
setPrimaryColor,
|
||||
setSecondaryColor,
|
||||
primaryShade,
|
||||
setPrimaryShade,
|
||||
};
|
||||
|
||||
const toggleColorScheme = (value?: ColorScheme) => {
|
||||
const nextColorScheme = value || (colorScheme === 'dark' ? 'light' : 'dark');
|
||||
setColorScheme(nextColorScheme);
|
||||
@@ -25,31 +37,31 @@ export default function App(props: AppProps & { colorScheme: ColorScheme }) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Homarr 🦞</title>
|
||||
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
|
||||
<link rel="shortcut icon" href="/favicon.svg" />
|
||||
</Head>
|
||||
|
||||
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
|
||||
<MantineProvider
|
||||
theme={{
|
||||
...theme,
|
||||
colorScheme,
|
||||
}}
|
||||
styles={{
|
||||
...styles,
|
||||
}}
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
>
|
||||
<NotificationsProvider limit={4} position="bottom-left">
|
||||
<ConfigProvider>
|
||||
<Layout>
|
||||
<ColorTheme.Provider value={colorTheme}>
|
||||
<MantineProvider
|
||||
theme={{
|
||||
...theme,
|
||||
primaryColor,
|
||||
primaryShade,
|
||||
colorScheme,
|
||||
}}
|
||||
styles={{
|
||||
...styles,
|
||||
}}
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
>
|
||||
<NotificationsProvider limit={4} position="bottom-left">
|
||||
<ConfigProvider>
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
</NotificationsProvider>
|
||||
</MantineProvider>
|
||||
</ConfigProvider>
|
||||
</NotificationsProvider>
|
||||
</MantineProvider>
|
||||
</ColorTheme.Provider>
|
||||
</ColorSchemeProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
15
src/pages/_middleware.ts
Normal file
15
src/pages/_middleware.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export function middleware(req: NextRequest, ev: NextFetchEvent) {
|
||||
const ok = req.cookies.password === process.env.PASSWORD;
|
||||
const url = req.nextUrl.clone();
|
||||
if (
|
||||
!ok &&
|
||||
url.pathname !== '/login' &&
|
||||
process.env.PASSWORD &&
|
||||
url.pathname !== '/api/configs/tryPassword'
|
||||
) {
|
||||
url.pathname = '/login';
|
||||
}
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
25
src/pages/api/configs/tryPassword.tsx
Normal file
25
src/pages/api/configs/tryPassword.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
function Post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { tried } = req.body;
|
||||
// Try to match the password with the PASSWORD env variable
|
||||
if (tried === process.env.PASSWORD) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
return res.status(200).json({
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Filter out if the reuqest is a POST or a GET
|
||||
if (req.method === 'POST') {
|
||||
return Post(req, res);
|
||||
}
|
||||
return res.status(405).json({
|
||||
statusCode: 405,
|
||||
message: 'Method not allowed',
|
||||
});
|
||||
};
|
||||
@@ -1,42 +1,60 @@
|
||||
import { Deluge } from '@ctrl/deluge';
|
||||
import { QBittorrent } from '@ctrl/qbittorrent';
|
||||
import { NormalizedTorrent } from '@ctrl/shared-torrent';
|
||||
import { Transmission } from '@ctrl/transmission';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Config } from '../../../tools/types';
|
||||
|
||||
async function Post(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Get the type of service from the request url
|
||||
const { dlclient } = req.query;
|
||||
const { body } = req;
|
||||
// Get login, password and url from the body
|
||||
const { username, password, url } = body;
|
||||
if (!dlclient || (!username && !password) || !url) {
|
||||
return res.status(400).json({
|
||||
error: 'Wrong request',
|
||||
const torrents: NormalizedTorrent[] = [];
|
||||
const { config }: { config: Config } = req.body;
|
||||
const qBittorrentService = config.services
|
||||
.filter((service) => service.type === 'qBittorrent')
|
||||
.at(0);
|
||||
const delugeService = config.services.filter((service) => service.type === 'Deluge').at(0);
|
||||
const transmissionService = config.services
|
||||
.filter((service) => service.type === 'Transmission')
|
||||
.at(0);
|
||||
if (!qBittorrentService && !delugeService && !transmissionService) {
|
||||
return res.status(500).json({
|
||||
statusCode: 500,
|
||||
message: 'Missing service',
|
||||
});
|
||||
}
|
||||
let client: Deluge | QBittorrent;
|
||||
switch (dlclient) {
|
||||
case 'qbit':
|
||||
client = new QBittorrent({
|
||||
baseUrl: new URL(url).origin,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
break;
|
||||
case 'deluge':
|
||||
client = new Deluge({
|
||||
baseUrl: new URL(url).origin,
|
||||
password,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({
|
||||
error: 'Wrong request',
|
||||
});
|
||||
if (qBittorrentService) {
|
||||
torrents.push(
|
||||
...(
|
||||
await new QBittorrent({
|
||||
baseUrl: qBittorrentService.url,
|
||||
username: qBittorrentService.username,
|
||||
password: qBittorrentService.password,
|
||||
}).getAllData()
|
||||
).torrents
|
||||
);
|
||||
}
|
||||
const data = await client.getAllData();
|
||||
res.status(200).json({
|
||||
torrents: data.torrents,
|
||||
});
|
||||
if (delugeService) {
|
||||
torrents.push(
|
||||
...(
|
||||
await new Deluge({
|
||||
baseUrl: delugeService.url,
|
||||
password: delugeService.password,
|
||||
}).getAllData()
|
||||
).torrents
|
||||
);
|
||||
}
|
||||
if (transmissionService) {
|
||||
torrents.push(
|
||||
...(
|
||||
await new Transmission({
|
||||
baseUrl: transmissionService.url,
|
||||
username: transmissionService.username,
|
||||
password: transmissionService.password,
|
||||
}).getAllData()
|
||||
).torrents
|
||||
);
|
||||
}
|
||||
res.status(200).json(torrents);
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
|
||||
19
src/pages/api/modules/search.ts
Normal file
19
src/pages/api/modules/search.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import axios from 'axios';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { q } = req.query;
|
||||
const response = await axios.get(`https://duckduckgo.com/ac/?q=${q}`);
|
||||
res.status(200).json(response.data);
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Filter out if the reuqest is a POST or a GET
|
||||
if (req.method === 'GET') {
|
||||
return Get(req, res);
|
||||
}
|
||||
return res.status(405).json({
|
||||
statusCode: 405,
|
||||
message: 'Method not allowed',
|
||||
});
|
||||
};
|
||||
@@ -7,6 +7,8 @@ import { Config } from '../tools/types';
|
||||
import { useConfig } from '../tools/state';
|
||||
import { migrateToIdConfig } from '../tools/migrate';
|
||||
import { getConfig } from '../tools/getConfig';
|
||||
import { useColorTheme } from '../tools/color';
|
||||
import Layout from '../components/layout/Layout';
|
||||
|
||||
export async function getServerSideProps({
|
||||
req,
|
||||
@@ -28,14 +30,17 @@ export async function getServerSideProps({
|
||||
export default function HomePage(props: any) {
|
||||
const { config: initialConfig }: { config: Config } = props;
|
||||
const { setConfig } = useConfig();
|
||||
const { setPrimaryColor, setSecondaryColor } = useColorTheme();
|
||||
useEffect(() => {
|
||||
const migratedConfig = migrateToIdConfig(initialConfig);
|
||||
setPrimaryColor(migratedConfig.settings.primaryColor || 'red');
|
||||
setSecondaryColor(migratedConfig.settings.secondaryColor || 'orange');
|
||||
setConfig(migratedConfig);
|
||||
}, [initialConfig]);
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<AppShelf />
|
||||
<LoadConfigComponent />
|
||||
</>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
111
src/pages/login.tsx
Normal file
111
src/pages/login.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { PasswordInput, Anchor, Paper, Title, Text, Container, Group, Button } from '@mantine/core';
|
||||
import { setCookies } from 'cookies-next';
|
||||
import { useForm } from '@mantine/hooks';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import axios from 'axios';
|
||||
import { IconCheck, IconX } from '@tabler/icons';
|
||||
|
||||
// TODO: Add links to the wiki articles about the login process.
|
||||
export default function AuthenticationTitle() {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Container
|
||||
size={420}
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
width: 420,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Title
|
||||
align="center"
|
||||
sx={(theme) => ({ fontFamily: `Greycliff CF, ${theme.fontFamily}`, fontWeight: 900 })}
|
||||
>
|
||||
Welcome back!
|
||||
</Title>
|
||||
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||
Please enter the{' '}
|
||||
<Anchor<'a'> href="#" size="sm" onClick={(event) => event.preventDefault()}>
|
||||
password
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md" style={{ width: 420 }}>
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
setCookies('password', values.password, {
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
showNotification({
|
||||
id: 'load-data',
|
||||
loading: true,
|
||||
title: 'Checking your password',
|
||||
message: 'Your password is being checked...',
|
||||
autoClose: false,
|
||||
disallowClose: true,
|
||||
});
|
||||
axios
|
||||
.post('/api/configs/tryPassword', {
|
||||
tried: values.password,
|
||||
})
|
||||
.then((res) => {
|
||||
setTimeout(() => {
|
||||
if (res.data.success === true) {
|
||||
updateNotification({
|
||||
id: 'load-data',
|
||||
color: 'teal',
|
||||
title: 'Password correct',
|
||||
message:
|
||||
'Notification will close in 2 seconds, you can close this notification now',
|
||||
icon: <IconCheck />,
|
||||
autoClose: 300,
|
||||
onClose: () => {
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
if (res.data.success === false) {
|
||||
updateNotification({
|
||||
id: 'load-data',
|
||||
color: 'red',
|
||||
title: 'Password is wrong, please try again.',
|
||||
message:
|
||||
'Notification will close in 2 seconds, you can close this notification now',
|
||||
icon: <IconX />,
|
||||
autoClose: 2000,
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
})}
|
||||
>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
mt="md"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Group position="apart" mt="md">
|
||||
<Anchor<'a'> onClick={(event) => event.preventDefault()} href="#" size="sm">
|
||||
Forgot password?
|
||||
</Anchor>
|
||||
</Group>
|
||||
<Button fullWidth type="submit" mt="xl">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
28
src/tools/color.ts
Normal file
28
src/tools/color.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { MantineTheme } from '@mantine/core';
|
||||
|
||||
type colorThemeContextType = {
|
||||
primaryColor: MantineTheme['primaryColor'];
|
||||
secondaryColor: MantineTheme['primaryColor'];
|
||||
primaryShade: MantineTheme['primaryShade'];
|
||||
setPrimaryColor: (color: MantineTheme['primaryColor']) => void;
|
||||
setSecondaryColor: (color: MantineTheme['primaryColor']) => void;
|
||||
setPrimaryShade: (shade: MantineTheme['primaryShade']) => void;
|
||||
};
|
||||
|
||||
export const ColorTheme = createContext<colorThemeContextType>({
|
||||
primaryColor: 'red',
|
||||
secondaryColor: 'orange',
|
||||
primaryShade: 6,
|
||||
setPrimaryColor: () => {},
|
||||
setSecondaryColor: () => {},
|
||||
setPrimaryShade: () => {},
|
||||
});
|
||||
|
||||
export function useColorTheme() {
|
||||
const context = useContext(ColorTheme);
|
||||
if (context === undefined) {
|
||||
throw new Error('useColorTheme must be used within a ColorTheme.Provider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
22
src/tools/hooks/useSetSafeInterval.tsx
Normal file
22
src/tools/hooks/useSetSafeInterval.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function useSetSafeInterval() {
|
||||
const timers = useRef<NodeJS.Timer[]>([]);
|
||||
|
||||
function setSafeInterval(callback: () => void, delay: number) {
|
||||
const newInterval = setInterval(callback, delay);
|
||||
timers.current.push(newInterval);
|
||||
return newInterval;
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
timers.current.forEach((t) => {
|
||||
clearInterval(t);
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return setSafeInterval;
|
||||
}
|
||||
31
src/tools/humanFileSize.ts
Normal file
31
src/tools/humanFileSize.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Format bytes as human-readable text.
|
||||
*
|
||||
* @param bytes Number of bytes.
|
||||
* @param si True to use metric (SI) units, aka powers of 1000. False to use
|
||||
* binary (IEC), aka powers of 1024.
|
||||
* @param dp Number of decimal places to display.
|
||||
*
|
||||
* @return Formatted string.
|
||||
*/
|
||||
export function humanFileSize(initialBytes: number, si = true, dp = 1) {
|
||||
const thresh = si ? 1000 : 1024;
|
||||
let bytes = initialBytes;
|
||||
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
const units = si
|
||||
? ['kb', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||
let u = -1;
|
||||
const r = 10 ** dp;
|
||||
|
||||
do {
|
||||
bytes /= thresh;
|
||||
u += 1;
|
||||
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||
|
||||
return `${bytes.toFixed(dp)} ${units[u]}`;
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import { MantineProviderProps } from '@mantine/core';
|
||||
|
||||
export const theme: MantineProviderProps['theme'] = {
|
||||
primaryColor: 'red',
|
||||
primaryShade: 6,
|
||||
};
|
||||
export const theme: MantineProviderProps['theme'] = {};
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { MantineTheme } from '@mantine/core';
|
||||
import { OptionValues } from '../components/modules/modules';
|
||||
|
||||
export interface Settings {
|
||||
searchUrl: string;
|
||||
title?: string;
|
||||
logo?: string;
|
||||
favicon?: string;
|
||||
primaryColor?: MantineTheme['primaryColor'];
|
||||
secondaryColor?: MantineTheme['primaryColor'];
|
||||
primaryShade?: MantineTheme['primaryShade'];
|
||||
background?: string;
|
||||
appOpacity?: number;
|
||||
widgetPosition?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
@@ -31,6 +41,7 @@ export const ServiceTypeList = [
|
||||
'Readarr',
|
||||
'Sonarr',
|
||||
'qBittorrent',
|
||||
'Transmission',
|
||||
];
|
||||
export type ServiceType =
|
||||
| 'Other'
|
||||
@@ -41,7 +52,8 @@ export type ServiceType =
|
||||
| 'Radarr'
|
||||
| 'Readarr'
|
||||
| 'Sonarr'
|
||||
| 'qBittorrent';
|
||||
| 'qBittorrent'
|
||||
| 'Transmission';
|
||||
|
||||
export interface serviceItem {
|
||||
id: string;
|
||||
@@ -53,4 +65,5 @@ export interface serviceItem {
|
||||
apiKey?: string;
|
||||
password?: string;
|
||||
username?: string;
|
||||
openedUrl?: string;
|
||||
}
|
||||
|
||||
179
yarn.lock
179
yarn.lock
@@ -1583,17 +1583,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ctrl/deluge@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "@ctrl/deluge@npm:4.0.0"
|
||||
"@ctrl/deluge@npm:^4.1.0":
|
||||
version: 4.1.0
|
||||
resolution: "@ctrl/deluge@npm:4.1.0"
|
||||
dependencies:
|
||||
"@ctrl/magnet-link": ^3.1.0
|
||||
"@ctrl/shared-torrent": ^4.1.0
|
||||
"@ctrl/magnet-link": ^3.1.1
|
||||
"@ctrl/shared-torrent": ^4.1.1
|
||||
"@ctrl/url-join": ^2.0.0
|
||||
formdata-node: ^4.3.2
|
||||
got: ^12.0.1
|
||||
got: ^12.1.0
|
||||
tough-cookie: ^4.0.0
|
||||
checksum: d4b828fb580a3e4c589169044b78e74d2d1c6ea3ff24f24c9aba59a5fc88320c494eebe814aa0f048e772d698ddd5979f8cd92d4144b0550227bc502342c82ed
|
||||
checksum: a17f974e1b98a9086e1036604a86d3e14b5cf9c8d0fd997357dd4522dc296f0ef92e2697231f97f7211c0224e35256af966f722b6b316a363533328908cd8d5e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -1606,6 +1606,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ctrl/magnet-link@npm:^3.1.1":
|
||||
version: 3.1.1
|
||||
resolution: "@ctrl/magnet-link@npm:3.1.1"
|
||||
dependencies:
|
||||
"@ctrl/ts-base32": ^2.1.1
|
||||
checksum: 82533b50e2a60b2cfbad19879b0b16dbdbf2cfb633cda519d9cac7ab4039d52f98bc10185a5f6ffd29cfe415d709b8748ebe7cf763e522e0c4dcee8dde6506fe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ctrl/qbittorrent@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "@ctrl/qbittorrent@npm:4.0.0"
|
||||
@@ -1630,6 +1639,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ctrl/shared-torrent@npm:^4.1.1":
|
||||
version: 4.1.1
|
||||
resolution: "@ctrl/shared-torrent@npm:4.1.1"
|
||||
dependencies:
|
||||
got: ^12.1.0
|
||||
checksum: 1273c9088a920eed5afca945b11e83a6b64d4268ad0b09e916e7e2214ea8092b998ab16525885f8f24af2c75893e3fd7d4542e7e9d6dfe4688da57e47c31b165
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ctrl/torrent-file@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "@ctrl/torrent-file@npm:2.0.1"
|
||||
@@ -1639,6 +1657,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ctrl/transmission@npm:^4.1.1":
|
||||
version: 4.1.1
|
||||
resolution: "@ctrl/transmission@npm:4.1.1"
|
||||
dependencies:
|
||||
"@ctrl/magnet-link": ^3.1.0
|
||||
"@ctrl/shared-torrent": ^4.1.1
|
||||
"@ctrl/url-join": ^2.0.0
|
||||
got: ^12.1.0
|
||||
checksum: 218ed4c00f70c46c90cd2a5e90f8390beee06a2cf7d76c2445ad2bcfb89ad1e6ea9cf237a7b3aa990fdf81fc9b9d4aa9900fa21e041457e8bb177dbd0b319b0a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ctrl/ts-base32@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "@ctrl/ts-base32@npm:2.1.1"
|
||||
@@ -2197,130 +2227,130 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/core@npm:^4.2.6":
|
||||
version: 4.2.7
|
||||
resolution: "@mantine/core@npm:4.2.7"
|
||||
"@mantine/core@npm:^4.2.8":
|
||||
version: 4.2.8
|
||||
resolution: "@mantine/core@npm:4.2.8"
|
||||
dependencies:
|
||||
"@mantine/styles": 4.2.7
|
||||
"@mantine/styles": 4.2.8
|
||||
"@popperjs/core": ^2.9.3
|
||||
"@radix-ui/react-scroll-area": ^0.1.1
|
||||
react-popper: ^2.2.5
|
||||
react-textarea-autosize: ^8.3.2
|
||||
peerDependencies:
|
||||
"@mantine/hooks": 4.2.7
|
||||
"@mantine/hooks": 4.2.8
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: f86d17fe8793bf37ef40eba9bf369db268e64923fe907ebcd8d977685bf1efda8c2b6a0f490cc3de87212273e930107e7d9e7135e4babe087a3e40d0f85b44af
|
||||
checksum: a7434d542657e5b196dc795503f667a4eff0cc4eed3870c3bd3ae1f645e01bc9c9e3dd32387907700cb96a41a70b836c0003756f5f488e7db7f61dee175386e6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/dates@npm:^4.2.6":
|
||||
version: 4.2.7
|
||||
resolution: "@mantine/dates@npm:4.2.7"
|
||||
"@mantine/dates@npm:^4.2.8":
|
||||
version: 4.2.8
|
||||
resolution: "@mantine/dates@npm:4.2.8"
|
||||
peerDependencies:
|
||||
"@mantine/core": 4.2.7
|
||||
"@mantine/hooks": 4.2.7
|
||||
"@mantine/core": 4.2.8
|
||||
"@mantine/hooks": 4.2.8
|
||||
dayjs: ^1.10.5
|
||||
react: ">=16.8.0"
|
||||
checksum: f343252c768928be72a35aed6522d5e73b10c2934b76cbc4761695087870bafd34f4491dbc55bd47ee5e399f995041044a41f3a8a2aa3b67d233d68c59ca7931
|
||||
checksum: 8aa69e30da0269e259b129827cf1c4496cd9f1aef22fd709fb9ae76840be3377541d289ec0e630004aeb7647fdb08a1a84651d72cb539f3491d887f626dff298
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/dropzone@npm:^4.2.6":
|
||||
version: 4.2.7
|
||||
resolution: "@mantine/dropzone@npm:4.2.7"
|
||||
"@mantine/dropzone@npm:^4.2.8":
|
||||
version: 4.2.8
|
||||
resolution: "@mantine/dropzone@npm:4.2.8"
|
||||
dependencies:
|
||||
react-dropzone: ^11.4.2
|
||||
peerDependencies:
|
||||
"@mantine/core": 4.2.7
|
||||
"@mantine/hooks": 4.2.7
|
||||
"@mantine/core": 4.2.8
|
||||
"@mantine/hooks": 4.2.8
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: 2b8d45a36f3d5275a1d03a0f595683f881c295137b46c2e08f73fa18a180b2ec125426e522c8fb822823666c72ae8c782f8855ea24586104fbf2c8ca9122652e
|
||||
checksum: 219e5fcc576a8d734c509b9da1b8e7e52a3c1a4aff7b2dc018a191be333e03c08139dc9695edd9911709c1e3454fff3b70b42ab2ef0e9587d1c9ff3f4f5865a4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/form@npm:^4.2.6":
|
||||
version: 4.2.7
|
||||
resolution: "@mantine/form@npm:4.2.7"
|
||||
"@mantine/form@npm:^4.2.8":
|
||||
version: 4.2.8
|
||||
resolution: "@mantine/form@npm:4.2.8"
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
checksum: d60cfd48ab4ef149df4dc68c3024428c51c5e1267af451882b68ef00162df2b7a07bd8fc5d734f8495957fa49769d6e444459ccf6e19e297c6737481ca85b4e1
|
||||
checksum: 0b17d214b9e4aab58a41a7c44fa5618091b24fe95d9741c3c7aaea86cbc52f93668d35b363460f1fb278eda0482b7922c308e06e354ae2d9d49b45d9ddafaf67
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/hooks@npm:^4.2.6":
|
||||
version: 4.2.7
|
||||
resolution: "@mantine/hooks@npm:4.2.7"
|
||||
"@mantine/hooks@npm:^4.2.8":
|
||||
version: 4.2.8
|
||||
resolution: "@mantine/hooks@npm:4.2.8"
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
checksum: 66dc8887b7913334ed1ce6f4be0353f4273142b10d1e820d4395edbad5fc7dc7f8483e07abe5956d7463fc77365340765c80843d8f825dc00c447310eb58831d
|
||||
checksum: 371bc3fa19130838d1a53454291b84c41390f9e8d4d89166c3ba36b60e5e671502b221a98834a42be3de0c6ab878eb0a950a58f8770e44ad6d9cba1468ef0aae
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/next@npm:^4.2.6":
|
||||
version: 4.2.7
|
||||
resolution: "@mantine/next@npm:4.2.7"
|
||||
"@mantine/next@npm:^4.2.8":
|
||||
version: 4.2.8
|
||||
resolution: "@mantine/next@npm:4.2.8"
|
||||
dependencies:
|
||||
"@mantine/ssr": 4.2.7
|
||||
"@mantine/ssr": 4.2.8
|
||||
peerDependencies:
|
||||
next: "*"
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: 4aa9384559ca7882aa2719629a79e2eb8b60c8491a6a22ff12c535585701d9b7f2d577bc8c043c6ea7669e890440606c253fd7e4656b05b4f5d815db5c121b27
|
||||
checksum: 48d658a6c1954a30906c34602a37da4b00ca3712819ba1cc1719045a95d412d1f3c6d847116b85f37c34dfdbac84929c525fdb20b4b93734f6016e1988924bfa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/notifications@npm:^4.2.6":
|
||||
version: 4.2.7
|
||||
resolution: "@mantine/notifications@npm:4.2.7"
|
||||
"@mantine/notifications@npm:^4.2.8":
|
||||
version: 4.2.8
|
||||
resolution: "@mantine/notifications@npm:4.2.8"
|
||||
dependencies:
|
||||
react-transition-group: ^4.4.2
|
||||
peerDependencies:
|
||||
"@mantine/core": 4.2.7
|
||||
"@mantine/hooks": 4.2.7
|
||||
"@mantine/core": 4.2.8
|
||||
"@mantine/hooks": 4.2.8
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: 576395cb60cd5cd0251f4c9542c4eb4c711fb542f0160c339f139ffb8eaeaf6ff440e73f9e0b9304a1f7ac2e8813db5817d2698ad1805f4593499f4816c0dcde
|
||||
checksum: dc13bb2091526e7f2ca7eb06d82ee5b5305208b41cc3ec769fa2aac09908faf8bba3d36bd10c8098d7c1a9f0487b5da92ab443dd238a576903633153ccfc6605
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/prism@npm:^4.2.6":
|
||||
version: 4.2.7
|
||||
resolution: "@mantine/prism@npm:4.2.7"
|
||||
"@mantine/prism@npm:^4.2.8":
|
||||
version: 4.2.8
|
||||
resolution: "@mantine/prism@npm:4.2.8"
|
||||
dependencies:
|
||||
prism-react-renderer: ^1.2.1
|
||||
peerDependencies:
|
||||
"@mantine/core": 4.2.7
|
||||
"@mantine/hooks": 4.2.7
|
||||
"@mantine/core": 4.2.8
|
||||
"@mantine/hooks": 4.2.8
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: 489b16b3ab775e8494b13b43eb588e79de858793b3a0fe0ce06194a163c66918a364a13822bc633f0b14091318085c1adac140650b50ab4acc2efcabb2226975
|
||||
checksum: 0e4405993e772249633b1585db1266ea857e7b8ad21ef89a4cf78ce8e811f2be0461218ef97686a51bafd83ff41fb646f903ca587cebaae22629a8f5936c0ae2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/ssr@npm:4.2.7":
|
||||
version: 4.2.7
|
||||
resolution: "@mantine/ssr@npm:4.2.7"
|
||||
"@mantine/ssr@npm:4.2.8":
|
||||
version: 4.2.8
|
||||
resolution: "@mantine/ssr@npm:4.2.8"
|
||||
dependencies:
|
||||
"@emotion/cache": 11.7.1
|
||||
"@emotion/react": 11.7.1
|
||||
"@emotion/serialize": 1.0.2
|
||||
"@emotion/server": 11.4.0
|
||||
"@emotion/utils": 1.0.0
|
||||
"@mantine/styles": 4.2.7
|
||||
"@mantine/styles": 4.2.8
|
||||
csstype: 3.0.9
|
||||
html-react-parser: 1.3.0
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: dcbbe3c4992f16c147ebb66fe156561cc1e6f6c0166ac33cc0d422c3250864c3d80773f32bd755e6c300f7035fe3341cffcd3a0ccb919775e782e227bd3876d1
|
||||
checksum: f2588004ffa65890e4e88ff23aae54124ccc96edda8fcdf4fee9ec93219e156a9862ce6c8473c8096b4944858c56cd8268cd8451118ce55c8358f8c569699a54
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mantine/styles@npm:4.2.7":
|
||||
version: 4.2.7
|
||||
resolution: "@mantine/styles@npm:4.2.7"
|
||||
"@mantine/styles@npm:4.2.8":
|
||||
version: 4.2.8
|
||||
resolution: "@mantine/styles@npm:4.2.8"
|
||||
dependencies:
|
||||
"@emotion/cache": 11.7.1
|
||||
"@emotion/react": 11.7.1
|
||||
@@ -2331,7 +2361,7 @@ __metadata:
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: f8a1dc2ca9be269e249671b2dc8a5849e859775c133a607723313aa2978cd7b3669217749d9ce6a8abbecfdaab567e4549af9683383030236883dde777a8628c
|
||||
checksum: 03bbddecb1837bca42e2667cb548d821adfb758c66d71c7719390b3921483d3d4997a03b1aaceccc4e557160522000e68978aa3c8b38f2ae3e4a9d85927e519d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -6941,10 +6971,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dayjs@npm:^1.11.2":
|
||||
version: 1.11.2
|
||||
resolution: "dayjs@npm:1.11.2"
|
||||
checksum: 78f8bd04a9e5f5554aa06eacda65a7d59e162d39f621a46fd34fb3b51367c3662426d86b4e2f4ac535f81e0c4d5af3e8a83b37e672412eb556267d726c61f8f3
|
||||
"dayjs@npm:^1.11.3":
|
||||
version: 1.11.3
|
||||
resolution: "dayjs@npm:1.11.3"
|
||||
checksum: c87e06b562a51ae6568cc5b840c7579d82a0f8af7163128c858fe512d3d71d07bd8e8e464b8cc41b8698a9e26b80ab2c082d14a1cd4c33df5692d77ccdfc5a43
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9081,7 +9111,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"got@npm:^12.0.1, got@npm:^12.0.4":
|
||||
"got@npm:^12.0.1, got@npm:^12.0.4, got@npm:^12.1.0":
|
||||
version: 12.1.0
|
||||
resolution: "got@npm:12.1.0"
|
||||
dependencies:
|
||||
@@ -9395,20 +9425,21 @@ __metadata:
|
||||
resolution: "homarr@workspace:."
|
||||
dependencies:
|
||||
"@babel/core": ^7.17.8
|
||||
"@ctrl/deluge": ^4.0.0
|
||||
"@ctrl/deluge": ^4.1.0
|
||||
"@ctrl/qbittorrent": ^4.0.0
|
||||
"@ctrl/shared-torrent": ^4.1.0
|
||||
"@ctrl/transmission": ^4.1.1
|
||||
"@dnd-kit/core": ^6.0.1
|
||||
"@dnd-kit/sortable": ^7.0.0
|
||||
"@dnd-kit/utilities": ^3.2.0
|
||||
"@mantine/core": ^4.2.6
|
||||
"@mantine/dates": ^4.2.6
|
||||
"@mantine/dropzone": ^4.2.6
|
||||
"@mantine/form": ^4.2.6
|
||||
"@mantine/hooks": ^4.2.6
|
||||
"@mantine/next": ^4.2.6
|
||||
"@mantine/notifications": ^4.2.6
|
||||
"@mantine/prism": ^4.2.6
|
||||
"@mantine/core": ^4.2.8
|
||||
"@mantine/dates": ^4.2.8
|
||||
"@mantine/dropzone": ^4.2.8
|
||||
"@mantine/form": ^4.2.8
|
||||
"@mantine/hooks": ^4.2.8
|
||||
"@mantine/next": ^4.2.8
|
||||
"@mantine/notifications": ^4.2.8
|
||||
"@mantine/prism": ^4.2.8
|
||||
"@next/bundle-analyzer": ^12.1.4
|
||||
"@next/eslint-plugin-next": ^12.1.4
|
||||
"@nivo/core": ^0.79.0
|
||||
@@ -9422,7 +9453,7 @@ __metadata:
|
||||
"@typescript-eslint/parser": ^5.16.0
|
||||
axios: ^0.27.2
|
||||
cookies-next: ^2.0.4
|
||||
dayjs: ^1.11.2
|
||||
dayjs: ^1.11.3
|
||||
eslint: ^8.11.0
|
||||
eslint-config-airbnb: ^19.0.4
|
||||
eslint-config-airbnb-typescript: ^16.1.0
|
||||
|
||||
Reference in New Issue
Block a user