Merge branch 'dev' of https://github.com/ajnart/homarr into widget-full-translation-support
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
163
src/widgets/notebook/NotebookEditor.tsx
Normal file
163
src/widgets/notebook/NotebookEditor.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
src/widgets/notebook/NotebookWidgetTile.tsx
Normal file
45
src/widgets/notebook/NotebookWidgetTile.tsx
Normal 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 (<sup /> and <sub /> tags)</p></li><li><p>Ordered and bullet lists</p></li><li><p>Text align </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} />;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ interface DataType {
|
||||
|
||||
interface ICommonWidgetOptions {
|
||||
info?: boolean;
|
||||
hide?: boolean;
|
||||
infoLink?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user