🔀 Merge branch 'dev' into feature/add-basic-authentication

This commit is contained in:
Manuel
2023-08-13 15:12:20 +02:00
202 changed files with 3334 additions and 1502 deletions

View File

@@ -1,8 +1,11 @@
import { Stack, Text, Title } from '@mantine/core';
import { Stack, Text, createStyles } from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconClock } from '@tabler/icons-react';
import dayjs from 'dayjs';
import moment from 'moment-timezone';
import { useRouter } from 'next/router';
import { useEffect, useRef, useState } from 'react';
import { getLanguageByCode } from '~/tools/language';
import { api } from '~/utils/api';
import { useSetSafeInterval } from '../../hooks/useSetSafeInterval';
import { defineWidget } from '../helper';
@@ -16,6 +19,39 @@ const definition = defineWidget({
type: 'switch',
defaultValue: false,
},
dateFormat: {
type: 'select',
defaultValue: 'dddd, MMMM D',
data: () => [
{ value: 'hide' },
{ value: 'dddd, MMMM D', label: moment().format('dddd, MMMM D') },
{ value: 'dddd, D MMMM', label: moment().format('dddd, D MMMM') },
{ value: 'MMM D', label: moment().format('MMM D') },
{ value: 'D MMM', label: moment().format('D MMM') },
{ value: 'DD/MM/YYYY', label: moment().format('DD/MM/YYYY') },
{ value: 'MM/DD/YYYY', label: moment().format('MM/DD/YYYY') },
{ value: 'DD/MM', label: moment().format('DD/MM') },
{ value: 'MM/DD', label: moment().format('MM/DD') },
],
},
enableTimezone: {
type: 'switch',
defaultValue: false,
},
timezoneLocation: {
type: 'location',
defaultValue: {
name: 'Paris',
latitude: 48.85341,
longitude: 2.3488,
},
},
titleState: {
type: 'select',
defaultValue: 'both',
data: [{ value: 'both' }, { value: 'city' }, { value: 'none' }],
info: true,
},
},
gridstack: {
minWidth: 1,
@@ -33,52 +69,102 @@ interface DateTileProps {
}
function DateTile({ widget }: DateTileProps) {
const date = useDateState();
const date = useDateState(
widget.properties.enableTimezone ? widget.properties.timezoneLocation : undefined
);
const formatString = widget.properties.display24HourFormat ? 'HH:mm' : 'h:mm A';
const { width, ref } = useElementSize();
const { ref, width } = useElementSize();
const { cx, classes } = useStyles();
return (
<Stack ref={ref} spacing="xs" justify="space-around" align="center" style={{ height: '100%' }}>
<Title>{dayjs(date).format(formatString)}</Title>
{width > 200 && <Text size="lg">{dayjs(date).format('dddd, MMMM D')}</Text>}
<Stack ref={ref} className={cx(classes.wrapper, 'dashboard-tile-clock-wrapper')}>
{widget.properties.enableTimezone && widget.properties.titleState !== 'none' && (
<Text
size={width < 150 ? 'sm' : 'lg'}
className={cx(classes.extras, 'dashboard-tile-clock-city')}
>
{widget.properties.timezoneLocation.name}
{widget.properties.titleState === 'both' && moment(date).format(' (z)')}
</Text>
)}
<Text className={cx(classes.clock, 'dashboard-tile-clock-hour')}>
{moment(date).format(formatString)}
</Text>
{!widget.properties.dateFormat.includes('hide') && (
<Text
size={width < 150 ? 'sm' : 'lg'}
pt="0.2rem"
className={cx(classes.extras, 'dashboard-tile-clock-date')}
>
{moment(date).format(widget.properties.dateFormat)}
</Text>
)}
</Stack>
);
}
const useStyles = createStyles(()=>({
wrapper:{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-evenly',
alignItems: 'center',
height: '100%',
gap: 0,
},
clock:{
lineHeight: '1',
whiteSpace: 'nowrap',
fontWeight: 700,
fontSize: '2.125rem',
},
extras:{
lineHeight: '1',
whiteSpace: 'nowrap',
}
}))
/**
* State which updates when the minute is changing
* @returns current date updated every new minute
*/
const useDateState = () => {
const [date, setDate] = useState(new Date());
const useDateState = (location?: { latitude: number; longitude: number }) => {
//Gets a timezone from user input location. If location is undefined, then it means it's a local timezone so keep undefined
const { data: timezone } = api.timezone.at.useQuery(location!, {
enabled: location !== undefined,
});
const { locale } = useRouter();
const [date, setDate] = useState(getNewDate(timezone));
const setSafeInterval = useSetSafeInterval();
const timeoutRef = useRef<NodeJS.Timeout>(); // reference for initial timeout until first minute change
useEffect(() => {
timeoutRef.current = setTimeout(() => {
setDate(new Date());
// Starts intervall which update the date every minute
setSafeInterval(() => {
setDate(new Date());
}, 1000 * 60);
}, getMsUntilNextMinute());
const language = getLanguageByCode(locale ?? 'en');
moment.locale(language.momentLocale);
setDate(getNewDate(timezone));
timeoutRef.current = setTimeout(
() => {
setDate(getNewDate(timezone));
// Starts interval which update the date every minute
setSafeInterval(() => {
setDate(getNewDate(timezone));
}, 1000 * 60);
//1 minute - current seconds and milliseconds count
},
1000 * 60 - (1000 * moment().seconds() + moment().milliseconds())
);
return () => timeoutRef.current && clearTimeout(timeoutRef.current);
}, []);
}, [timezone, locale]);
return date;
};
// calculates the amount of milliseconds until next minute starts.
const getMsUntilNextMinute = () => {
const now = new Date();
const nextMinute = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours(),
now.getMinutes() + 1
);
return nextMinute.getTime() - now.getTime();
//Returns a local date if no inputs or returns date from input zone
const getNewDate = (timezone?: string) => {
if (timezone) {
return moment().tz(timezone);
}
return moment();
};
export default definition;

View File

@@ -1,20 +1,25 @@
import { Card, Center, Container, Stack, Text } from '@mantine/core';
import { Box, Card, Center, Container, Flex, Text } from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import {
IconAd,
IconBarrierBlock,
IconPercentage,
IconSearch,
IconWorldWww,
TablerIconsProps,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
import { RouterOutputs, api } from '~/utils/api';
import { formatNumber } from '../../tools/client/math';
import { formatNumber, formatPercentage } from '../../tools/client/math';
import { defineWidget } from '../helper';
import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets';
const availableLayouts = ['grid', 'row', 'column'] as const;
type AvailableLayout = (typeof availableLayouts)[number];
const definition = defineWidget({
id: 'dns-hole-summary',
icon: IconAd,
@@ -23,10 +28,15 @@ const definition = defineWidget({
type: 'switch',
defaultValue: true,
},
layout: {
type: 'select',
defaultValue: 'grid' as AvailableLayout,
data: availableLayouts.map((x) => ({ value: x })),
},
},
gridstack: {
minWidth: 2,
minHeight: 2,
minHeight: 1,
maxWidth: 12,
maxHeight: 12,
},
@@ -40,7 +50,6 @@ interface DnsHoleSummaryWidgetProps {
}
function DnsHoleSummaryWidgetTile({ widget }: DnsHoleSummaryWidgetProps) {
const { t } = useTranslation('modules/dns-hole-summary');
const { isInitialLoading, data } = useDnsHoleSummeryQuery();
if (isInitialLoading || !data) {
@@ -48,139 +57,47 @@ function DnsHoleSummaryWidgetTile({ widget }: DnsHoleSummaryWidgetProps) {
}
return (
<Container
display="grid"
h="100%"
style={{
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: '1fr 1fr',
marginLeft: -20,
marginRight: -20,
}}
>
<Card
m="xs"
sx={(theme) => {
if (!widget.properties.usePiHoleColors) {
return {};
}
if (theme.colorScheme === 'dark') {
return {
backgroundColor: 'rgba(240, 82, 60, 0.4)',
};
}
return {
backgroundColor: 'rgba(240, 82, 60, 0.2)',
};
}}
withBorder
>
<Center h="100%">
<Stack align="center" spacing="xs">
<IconBarrierBlock size={30} />
<div>
<Text align="center">{formatNumber(data.adsBlockedToday, 0)}</Text>
<Text align="center" lh={1.2} size="sm">
{t('card.metrics.queriesBlockedToday')}
</Text>
</div>
</Stack>
</Center>
</Card>
<Card
m="xs"
sx={(theme) => {
if (!widget.properties.usePiHoleColors) {
return {};
}
if (theme.colorScheme === 'dark') {
return {
backgroundColor: 'rgba(255, 165, 20, 0.4)',
};
}
return {
backgroundColor: 'rgba(255, 165, 20, 0.4)',
};
}}
withBorder
>
<Center h="100%">
<Stack align="center" spacing="xs">
<IconPercentage size={30} />
<Text align="center">{(data.adsBlockedTodayPercentage * 100).toFixed(2)}%</Text>
</Stack>
</Center>
</Card>
<Card
m="xs"
sx={(theme) => {
if (!widget.properties.usePiHoleColors) {
return {};
}
if (theme.colorScheme === 'dark') {
return {
backgroundColor: 'rgba(0, 175, 218, 0.4)',
};
}
return {
backgroundColor: 'rgba(0, 175, 218, 0.4)',
};
}}
withBorder
>
<Center h="100%">
<Stack align="center" spacing="xs">
<IconSearch size={30} />
<div>
<Text align="center">{formatNumber(data.dnsQueriesToday, 3)}</Text>
<Text align="center" lh={1.2} size="sm">
{t('card.metrics.queriesToday')}
</Text>
</div>
</Stack>
</Center>
</Card>
<Card
m="xs"
sx={(theme) => {
if (!widget.properties.usePiHoleColors) {
return {};
}
if (theme.colorScheme === 'dark') {
return {
backgroundColor: 'rgba(0, 176, 96, 0.4)',
};
}
return {
backgroundColor: 'rgba(0, 176, 96, 0.4)',
};
}}
withBorder
>
<Center h="100%">
<Stack align="center" spacing="xs">
<IconWorldWww size={30} />
<div>
<Text align="center">{formatNumber(data.domainsBeingBlocked, 0)}</Text>
<Text align="center" lh={1.2} size="sm">
{t('card.metrics.domainsOnAdlist')}
</Text>
</div>
</Stack>
</Center>
</Card>
<Container h="100%" p={0} style={constructContainerStyle(widget.properties.layout)}>
{stats.map((item) => (
<StatCard item={item} usePiHoleColors={widget.properties.usePiHoleColors} data={data} />
))}
</Container>
);
}
const stats = [
{
icon: IconBarrierBlock,
value: (x) => formatNumber(x.adsBlockedToday, 2),
label: 'card.metrics.queriesBlockedToday',
color: 'rgba(240, 82, 60, 0.4)',
},
{
icon: IconPercentage,
value: (x) => formatPercentage(x.adsBlockedTodayPercentage, 2),
color: 'rgba(255, 165, 20, 0.4)',
},
{
icon: IconSearch,
value: (x) => formatNumber(x.dnsQueriesToday, 2),
label: 'card.metrics.queriesToday',
color: 'rgba(0, 175, 218, 0.4)',
},
{
icon: IconWorldWww,
value: (x) => formatNumber(x.domainsBeingBlocked, 2),
label: 'card.metrics.domainsOnAdlist',
color: 'rgba(0, 176, 96, 0.4)',
},
] satisfies StatItem[];
type StatItem = {
icon: (props: TablerIconsProps) => JSX.Element;
value: (x: RouterOutputs['dnsHole']['summary']) => string;
label?: string;
color: string;
};
export const useDnsHoleSummeryQuery = () => {
const { name: configName } = useConfigContext();
@@ -194,4 +111,71 @@ export const useDnsHoleSummeryQuery = () => {
);
};
type StatCardProps = {
item: StatItem;
data: RouterOutputs['dnsHole']['summary'];
usePiHoleColors: boolean;
};
const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {
const { t } = useTranslation('modules/dns-hole-summary');
const { ref, height, width } = useElementSize();
const isLong = width > height + 20;
return (
<Card
ref={ref}
m="0.4rem"
p="0.2rem"
bg={usePiHoleColors ? item.color : 'rgba(96, 96, 96, 0.1)'}
style={{
flex: 1,
}}
withBorder
>
<Center h="100%" w="100%">
<Flex
h="100%"
w="100%"
align="center"
justify="space-evenly"
direction={isLong ? 'row' : 'column'}
>
<item.icon size={30} style={{ margin: '0 10' }} />
<Flex
justify="center"
direction="column"
style={{
flex: isLong ? 1 : undefined,
}}
>
<Text align="center" lh={1.2} size="md" weight="bold">
{item.value(data)}
</Text>
{item.label && (
<Text align="center" lh={1.2} size="0.75rem">
{t<string>(item.label)}
</Text>
)}
</Flex>
</Flex>
</Center>
</Card>
);
};
const constructContainerStyle = (flexLayout: (typeof availableLayouts)[number]) => {
if (flexLayout === 'grid') {
return {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: '1fr 1fr',
};
}
return {
display: 'flex',
flexDirection: flexLayout,
};
};
export default definition;

View File

@@ -14,6 +14,7 @@ import torrent from './torrent/TorrentTile';
import usenet from './useNet/UseNetTile';
import videoStream from './video/VideoStreamTile';
import weather from './weather/WeatherTile';
import notebook from './notebook/NotebookWidgetTile';
export default {
calendar,
@@ -32,4 +33,5 @@ export default {
'dns-hole-summary': dnsHoleSummary,
'dns-hole-controls': dnsHoleControls,
bookmark,
notebook,
};

View File

@@ -0,0 +1,163 @@
import { ActionIcon, createStyles, rem } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { Link, RichTextEditor } from '@mantine/tiptap';
import { IconArrowUp, IconEdit, IconEditOff } from '@tabler/icons-react';
import { BubbleMenu, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { useEffect, useRef, useState } from 'react';
import { useConfigStore } from '~/config/store';
import { useColorTheme } from '~/tools/color';
import { api } from '~/utils/api';
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
import { useConfigContext } from '../../config/provider';
import { WidgetLoading } from '../loading';
import { INotebookWidget } from './NotebookWidgetTile';
Link.configure({
openOnClick: true,
});
export function Editor({ widget }: { widget: INotebookWidget }) {
const [content, setContent] = useState(widget.properties.content);
const { enabled } = useEditModeStore();
const [isEditing, setIsEditing] = useState(false);
const { config, name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
const { primaryColor } = useColorTheme();
const { mutateAsync } = api.notebook.update.useMutation();
const [debouncedContent] = useDebouncedValue(content, 500);
const editor = useEditor({
extensions: [StarterKit, Link],
content,
editable: false,
onUpdate: (e) => {
setContent(e.editor.getHTML());
},
});
const handleEditToggle = (previous: boolean) => {
const current = !previous;
if (!editor) return current;
editor.setEditable(current);
updateConfig(
configName!,
(previous) => {
const currentWidget = previous.widgets.find((x) => x.id === widget.id);
currentWidget!.properties.content = debouncedContent;
return {
...previous,
widgets: [
...previous.widgets.filter((iterationWidget) => iterationWidget.id !== widget.id),
currentWidget!,
],
};
},
true
);
void mutateAsync({
configName: configName!,
content: debouncedContent,
widgetId: widget.id,
});
return current;
};
if (!config || !configName) return <WidgetLoading />;
return (
<>
{!enabled && (
<ActionIcon
style={{
zIndex: 1,
}}
top={7}
right={7}
pos="absolute"
color={primaryColor}
variant="light"
size={30}
radius={'md'}
onClick={() => setIsEditing(handleEditToggle)}
>
{isEditing ? <IconEditOff size={20} /> : <IconEdit size={20} />}
</ActionIcon>
)}
<RichTextEditor
p={0}
mt={0}
editor={editor}
styles={(theme) => ({
root: {
'& .ProseMirror': {
padding: '0 !important',
},
border: 'none',
},
toolbar: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
paddingTop: 0,
paddingBottom: theme.spacing.md,
},
content: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
},
})}
>
<RichTextEditor.Toolbar
style={{
display: isEditing && widget.properties.showToolbar === true ? 'flex' : 'none',
}}
>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Bold />
<RichTextEditor.Italic />
<RichTextEditor.Strikethrough />
<RichTextEditor.ClearFormatting />
<RichTextEditor.Code />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.H1 />
<RichTextEditor.H2 />
<RichTextEditor.H3 />
<RichTextEditor.H4 />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Blockquote />
<RichTextEditor.Hr />
<RichTextEditor.BulletList />
<RichTextEditor.OrderedList />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Link />
<RichTextEditor.Unlink />
</RichTextEditor.ControlsGroup>
</RichTextEditor.Toolbar>
{editor && (
<BubbleMenu editor={editor}>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Bold />
<RichTextEditor.Italic />
<RichTextEditor.Link />
</RichTextEditor.ControlsGroup>
</BubbleMenu>
)}
<RichTextEditor.Content />
</RichTextEditor>
</>
);
}

View File

@@ -0,0 +1,45 @@
import { IconNotes } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
const Editor = dynamic(() => import('./NotebookEditor').then((module) => module.Editor), {
ssr: false,
});
const definition = defineWidget({
id: 'notebook',
icon: IconNotes,
options: {
showToolbar: {
type: 'switch',
defaultValue: true,
},
content: {
type: 'text',
hide: true,
defaultValue: `<h2>Welcome to <strong>Homarr's</strong> notebook widget</h2><p>The <code>notebook</code> widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users. It is based on <a target="_blank" rel="noopener noreferrer nofollow" href="https://tiptap.dev/">Tiptap.dev</a> and supports all of its features:</p><ul><li><p>General text formatting: <strong>bold</strong>, <em>italic</em>, underline, <s>strike-through</s></p></li><li><p>Headings (h1-h6)</p></li><li><p>Sub and super scripts (&lt;sup /&gt; and &lt;sub /&gt; tags)</p></li><li><p>Ordered and bullet lists</p></li><li><p>Text align&nbsp;</p></li></ul><h3>Widget options</h3><p>This widget has two options :</p><ul><li><p>Show toolbar : Shows the toolbar when the widget is in the local edit mode.</p></li></ul>`,
},
},
gridstack: {
minWidth: 3,
minHeight: 2,
maxWidth: 12,
maxHeight: 12,
},
component: NotebookWidget,
});
export default definition;
export type INotebookWidget = IWidget<(typeof definition)['id'], typeof definition>;
interface NotebookWidgetProps {
widget: INotebookWidget;
}
function NotebookWidget(props: NotebookWidgetProps) {
return <Editor widget={props.widget} />;
}

View File

@@ -10,6 +10,7 @@ import {
Progress,
Stack,
Text,
createStyles,
useMantineTheme,
} from '@mantine/core';
import { useDisclosure, useElementSize } from '@mantine/hooks';
@@ -24,6 +25,7 @@ import {
IconUpload,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { MIN_WIDTH_MOBILE } from '~/constants/constants';
import { calculateETA } from '../../tools/client/calculateEta';
import { humanFileSize } from '../../tools/humanFileSize';
@@ -32,19 +34,20 @@ import { AppType } from '../../types/app';
interface TorrentQueueItemProps {
torrent: NormalizedTorrent;
app?: AppType;
width: number;
}
export const BitTorrrentQueueItem = ({ torrent, app }: TorrentQueueItemProps) => {
export const BitTorrrentQueueItem = ({ torrent, width, app }: TorrentQueueItemProps) => {
const [popoverOpened, { open: openPopover, close: closePopover }] = useDisclosure(false);
const theme = useMantineTheme();
const { width } = useElementSize();
const { classes } = useStyles();
const { t } = useTranslation('modules/torrents-status');
const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
const size = torrent.totalSelected;
return (
<tr key={torrent.id}>
<tr key={torrent.id} className={classes.transparentBackground}>
<td>
<Popover opened={popoverOpened} radius="md" shadow="md" width={350} withinPortal>
<Popover.Dropdown>
@@ -74,25 +77,33 @@ export const BitTorrrentQueueItem = ({ torrent, app }: TorrentQueueItemProps) =>
</Popover>
</td>
<td>
<Text size="xs">{humanFileSize(size, false)}</Text>
<Text className={classes.noTextBreak} size="xs">
{humanFileSize(size, false)}
</Text>
</td>
{theme.fn.largerThan('xs') && (
{width > MIN_WIDTH_MOBILE && (
<td>
<Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
<Text className={classes.noTextBreak} size="xs">
{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}
</Text>
</td>
)}
{theme.fn.largerThan('xs') && (
{width > MIN_WIDTH_MOBILE && (
<td>
<Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
<Text className={classes.noTextBreak} size="xs">
{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}
</Text>
</td>
)}
{theme.fn.largerThan('xs') && (
{width > MIN_WIDTH_MOBILE && (
<td>
<Text size="xs">{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}</Text>
<Text className={classes.noTextBreak} size="xs">
{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}
</Text>
</td>
)}
<td>
<Text>{(torrent.progress * 100).toFixed(1)}%</Text>
<Text className={classes.noTextBreak}>{(torrent.progress * 100).toFixed(1)}%</Text>
<Progress
radius="lg"
color={torrent.progress === 1 ? 'green' : torrent.state === 'paused' ? 'yellow' : 'blue'}
@@ -104,7 +115,7 @@ export const BitTorrrentQueueItem = ({ torrent, app }: TorrentQueueItemProps) =>
);
};
const TorrentQueuePopover = ({ torrent, app }: TorrentQueueItemProps) => {
const TorrentQueuePopover = ({ torrent, app }: Omit<TorrentQueueItemProps, 'width'>) => {
const { t } = useTranslation('modules/torrents-status');
const { colors } = useMantineTheme();
@@ -219,3 +230,12 @@ const TorrentQueuePopover = ({ torrent, app }: TorrentQueueItemProps) => {
</Stack>
);
};
const useStyles = createStyles(() => ({
noTextBreak: {
whiteSpace: 'nowrap',
},
transparentBackground: {
backgroundColor: 'transparent !important',
},
}));

View File

@@ -1,4 +1,4 @@
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { NormalizedTorrent, TorrentState } from '@ctrl/shared-torrent';
import {
Badge,
Center,
@@ -17,11 +17,12 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useTranslation } from 'next-i18next';
import { useCardStyles } from '~/components/layout/Common/useCardStyles';
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
import { useGetDownloadClientsQueue } from '../download-speed/useGetNetworkSpeed';
import { NormalizedDownloadQueueResponse } from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
import { AppIntegrationType } from '../../types/app';
import { useGetDownloadClientsQueue } from '../download-speed/useGetNetworkSpeed';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
import { BitTorrrentQueueItem } from './TorrentQueueItem';
@@ -70,6 +71,7 @@ interface TorrentTileProps {
function TorrentTile({ widget }: TorrentTileProps) {
const { t } = useTranslation('modules/torrents-status');
const { width, ref } = useElementSize();
const { classes } = useCardStyles(true);
const {
data,
@@ -154,11 +156,11 @@ function TorrentTile({ widget }: TorrentTileProps) {
</thead>
<tbody>
{filteredTorrents.map((torrent, index) => (
<BitTorrrentQueueItem key={index} torrent={torrent} app={undefined} />
<BitTorrrentQueueItem key={index} torrent={torrent} width={width} app={undefined} />
))}
{filteredTorrents.length !== torrents.length && (
<tr>
<tr className={classes.card}>
<td colSpan={width > MIN_WIDTH_MOBILE ? 6 : 3}>
<Flex gap="xs" align="center" justify="center">
<IconInfoCircle opacity={0.7} size={18} />

View File

@@ -11,9 +11,11 @@ import {
IconSun,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useElementSize } from '@mantine/hooks';
interface WeatherIconProps {
code: number;
size?: number;
}
/**
@@ -21,16 +23,17 @@ interface WeatherIconProps {
* @param code weather code from api
* @returns weather tile component
*/
export const WeatherIcon = ({ code }: WeatherIconProps) => {
export const WeatherIcon = ({ code, size=50 }: WeatherIconProps) => {
const { t } = useTranslation('modules/weather');
const { width, ref } = useElementSize();
const { icon: Icon, name } =
weatherDefinitions.find((wd) => wd.codes.includes(code)) ?? unknownWeather;
return (
<Tooltip withinPortal withArrow label={t(`card.weatherDescriptions.${name}`)}>
<Box>
<Icon style={{ float: 'left' }} size={50} />
<Icon style={{ float: 'left' }} size={size} />
</Box>
</Tooltip>
);

View File

@@ -1,6 +1,12 @@
import { Center, Group, Skeleton, Stack, Text, Title } from '@mantine/core';
import { Center, Flex, Group, Skeleton, Stack, Text, Title } from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconArrowDownRight, IconArrowUpRight, IconCloudRain } from '@tabler/icons-react';
import {
IconArrowDownRight,
IconArrowUpRight,
IconCloudRain,
IconCurrentLocation,
IconMapPin,
} from '@tabler/icons-react';
import { api } from '~/utils/api';
import { defineWidget } from '../helper';
@@ -15,6 +21,10 @@ const definition = defineWidget({
type: 'switch',
defaultValue: false,
},
displayCityName: {
type: 'switch',
defaultValue: false,
},
location: {
type: 'location',
defaultValue: {
@@ -75,21 +85,27 @@ function WeatherTile({ widget }: WeatherTileProps) {
// TODO: add widgetWrapper that is generic and uses the definition
return (
<Stack
ref={ref}
spacing="xs"
justify="space-around"
align="center"
style={{ height: '100%', width: '100%' }}
justify="space-around"
ref={ref}
spacing={0}
align="center"
>
<Group align="center" position="center" spacing="xs">
<WeatherIcon code={weather.current_weather.weathercode} />
<Title>
<Flex
align="center"
gap={width < 120 ? '0.25rem' : 'xs'}
justify={'center'}
direction={width < 200 ? 'column' : 'row'}
>
<WeatherIcon size={width < 300 ? 30 : 50} code={weather.current_weather.weathercode} />
<Title size={'h2'}>
{getPerferedUnit(
weather.current_weather.temperature,
widget.properties.displayInFahrenheit
)}
</Title>
</Group>
</Flex>
{width > 200 && (
<Group noWrap spacing="xs">
<IconArrowUpRight />
@@ -104,6 +120,13 @@ function WeatherTile({ widget }: WeatherTileProps) {
)}
</Group>
)}
{widget.properties.displayCityName && (
<Group noWrap spacing={5} align="center">
<IconMapPin height={15} width={15} />
<Text style={{ whiteSpace: 'nowrap' }}>{widget.properties.location.name}</Text>
</Group>
)}
</Stack>
);
}

View File

@@ -42,18 +42,20 @@ export type IWidgetOptionValue = (
| IDraggableEditableListInputValue<any>
| IMultipleTextInputOptionValue
| ILocationOptionValue
) & ICommonWidgetOptions;
) &
ICommonWidgetOptions;
// Interface for data type
interface DataType {
label: string;
label?: string;
value: string;
}
interface ICommonWidgetOptions {
interface ICommonWidgetOptions {
info?: boolean;
hide?: boolean;
infoLink?: string;
};
}
// will show a multi-select with specified data
export type IMultiSelectOptionValue = {
@@ -67,7 +69,7 @@ export type IMultiSelectOptionValue = {
export type ISelectOptionValue = {
type: 'select';
defaultValue: string;
data: DataType[];
data: DataType[] | (() => DataType[]);
inputProps?: Partial<SelectProps>;
};