mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-14 17:26:26 +01:00
✨ Notebook-widget (#962)
* ⬆️ Add required dependencies * ✨ Add basic widget definition for `notebook` * 🌐 Add basic translations for `notebook` widget * 🐛 Fix `WidgetMenu` zIndex property * ⚡️ Use dynamic import for the `notebook` widget * 🌐 Update translations * 🚨 Disable eslint `no-param-reassign` rule * ✨ Add `notebook` widget * ➕ Add `immer` as a dependency * fix: currentConfig not loaded in useEffect callback fixes #1249 * ♻️ Notebook widget UI (#1266) * ♻️ Refactor note widget * 🐛 Fix translations * 💄 Widget styling changes * 🔒 Fix lockfile * 💄 Remove primary color from edit button * 💄 Fix css * ✨ Add the ability to hide an option * 🔥 Remove aria-labels * ♻️ Address pull request feedback * 🐛 Remove wrong description from default value --------- Co-authored-by: gnattu <gnattu@users.noreply.github.com> Co-authored-by: Manuel <manuel.ruwe@bluewin.ch> Co-authored-by: Tagaishi <Tagaishi@hotmail.ch> Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -45,7 +45,7 @@
|
|||||||
"@nivo/core": "^0.83.0",
|
"@nivo/core": "^0.83.0",
|
||||||
"@nivo/line": "^0.83.0",
|
"@nivo/line": "^0.83.0",
|
||||||
"@react-native-async-storage/async-storage": "^1.18.1",
|
"@react-native-async-storage/async-storage": "^1.18.1",
|
||||||
"@tabler/icons-react": "^2.18.0",
|
"@tabler/icons-react": "^2.20.0",
|
||||||
"@tanstack/query-async-storage-persister": "^4.27.1",
|
"@tanstack/query-async-storage-persister": "^4.27.1",
|
||||||
"@tanstack/query-sync-storage-persister": "^4.27.1",
|
"@tanstack/query-sync-storage-persister": "^4.27.1",
|
||||||
"@tanstack/react-query": "^4.2.1",
|
"@tanstack/react-query": "^4.2.1",
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
"geo-tz": "^7.0.7",
|
"geo-tz": "^7.0.7",
|
||||||
"html-entities": "^2.3.3",
|
"html-entities": "^2.3.3",
|
||||||
"i18next": "^22.5.1",
|
"i18next": "^22.5.1",
|
||||||
|
"immer": "^10.0.2",
|
||||||
"js-file-download": "^0.4.12",
|
"js-file-download": "^0.4.12",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"moment-timezone": "^0.5.43",
|
"moment-timezone": "^0.5.43",
|
||||||
|
|||||||
15
public/locales/en/modules/notebook.json
Normal file
15
public/locales/en/modules/notebook.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"descriptor": {
|
||||||
|
"name": "Notebook",
|
||||||
|
"description": "A markdown-based interactive widget for you to write down your notes!",
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings for the notebook widget",
|
||||||
|
"showToolbar": {
|
||||||
|
"label": "Show the toolbar to help you write markdown"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"label": "The content of the notebook"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ActionIcon, Menu } from '@mantine/core';
|
|||||||
import { IconLayoutKanban, IconPencil, IconSettings, IconTrash } from '@tabler/icons-react';
|
import { IconLayoutKanban, IconPencil, IconSettings, IconTrash } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
|
import { useColorTheme } from '../../../tools/color';
|
||||||
import { useEditModeStore } from '../Views/useEditModeStore';
|
import { useEditModeStore } from '../Views/useEditModeStore';
|
||||||
|
|
||||||
interface GenericTileMenuProps {
|
interface GenericTileMenuProps {
|
||||||
@@ -11,12 +12,14 @@ interface GenericTileMenuProps {
|
|||||||
displayEdit: boolean;
|
displayEdit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GenericTileMenu = ({
|
export const GenericTileMenu = (
|
||||||
|
{
|
||||||
handleClickEdit,
|
handleClickEdit,
|
||||||
handleClickChangePosition,
|
handleClickChangePosition,
|
||||||
handleClickDelete,
|
handleClickDelete,
|
||||||
displayEdit,
|
displayEdit,
|
||||||
}: GenericTileMenuProps) => {
|
}: GenericTileMenuProps
|
||||||
|
) => {
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||||
|
|
||||||
@@ -28,13 +31,13 @@ export const GenericTileMenu = ({
|
|||||||
<Menu withinPortal withArrow position="right">
|
<Menu withinPortal withArrow position="right">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
style={{ zIndex: 1 }}
|
||||||
size="md"
|
size="md"
|
||||||
radius="md"
|
radius="md"
|
||||||
variant="light"
|
variant="light"
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
top={8}
|
top={8}
|
||||||
right={8}
|
right={8}
|
||||||
style={{ zIndex: 1 }}
|
|
||||||
>
|
>
|
||||||
<IconSettings />
|
<IconSettings />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ const WidgetOptionTypeSwitch: FC<{
|
|||||||
const info = option.info ?? false;
|
const info = option.info ?? false;
|
||||||
const link = option.infoLink ?? undefined;
|
const link = option.infoLink ?? undefined;
|
||||||
|
|
||||||
|
if (option.hide) return null;
|
||||||
|
|
||||||
switch (option.type) {
|
switch (option.type) {
|
||||||
case 'switch':
|
case 'switch':
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { rssRouter } from './routers/rss';
|
|||||||
import { timezoneRouter } from './routers/timezone';
|
import { timezoneRouter } from './routers/timezone';
|
||||||
import { usenetRouter } from './routers/usenet/router';
|
import { usenetRouter } from './routers/usenet/router';
|
||||||
import { weatherRouter } from './routers/weather';
|
import { weatherRouter } from './routers/weather';
|
||||||
|
import { notebookRouter } from './routers/notebook';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -37,6 +38,7 @@ export const rootRouter = createTRPCRouter({
|
|||||||
timezone: timezoneRouter,
|
timezone: timezoneRouter,
|
||||||
usenet: usenetRouter,
|
usenet: usenetRouter,
|
||||||
weather: weatherRouter,
|
weather: weatherRouter,
|
||||||
|
notebook: notebookRouter
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
37
src/server/api/routers/notebook.ts
Normal file
37
src/server/api/routers/notebook.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getConfig } from '~/tools/config/getConfig';
|
||||||
|
import { BackendConfigType } from '~/types/config';
|
||||||
|
import { INotebookWidget } from '~/widgets/notebook/NotebookWidgetTile';
|
||||||
|
|
||||||
|
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||||
|
|
||||||
|
export const notebookRouter = createTRPCRouter({
|
||||||
|
update: publicProcedure
|
||||||
|
.input(z.object({ widgetId: z.string(), content: z.string(), configName: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const config = getConfig(input.configName);
|
||||||
|
const widget = config.widgets.find((widget) => widget.id === input.widgetId) as
|
||||||
|
| INotebookWidget
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!widget) {
|
||||||
|
return new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Specified widget was not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.properties.content = input.content;
|
||||||
|
|
||||||
|
const newConfig: BackendConfigType = {
|
||||||
|
...config,
|
||||||
|
widgets: [...config.widgets.filter((w) => w.id !== widget.id), widget],
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetPath = path.join('data/configs', `${input.configName}.json`);
|
||||||
|
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -43,6 +43,7 @@ export const dashboardNamespaces = [
|
|||||||
'modules/dns-hole-summary',
|
'modules/dns-hole-summary',
|
||||||
'modules/dns-hole-controls',
|
'modules/dns-hole-controls',
|
||||||
'modules/bookmark',
|
'modules/bookmark',
|
||||||
|
'modules/notebook',
|
||||||
'widgets/error-boundary',
|
'widgets/error-boundary',
|
||||||
'widgets/draggable-list',
|
'widgets/draggable-list',
|
||||||
'widgets/location',
|
'widgets/location',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import torrent from './torrent/TorrentTile';
|
|||||||
import usenet from './useNet/UseNetTile';
|
import usenet from './useNet/UseNetTile';
|
||||||
import videoStream from './video/VideoStreamTile';
|
import videoStream from './video/VideoStreamTile';
|
||||||
import weather from './weather/WeatherTile';
|
import weather from './weather/WeatherTile';
|
||||||
|
import notebook from './notebook/NotebookWidgetTile';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
calendar,
|
calendar,
|
||||||
@@ -32,4 +33,5 @@ export default {
|
|||||||
'dns-hole-summary': dnsHoleSummary,
|
'dns-hole-summary': dnsHoleSummary,
|
||||||
'dns-hole-controls': dnsHoleControls,
|
'dns-hole-controls': dnsHoleControls,
|
||||||
bookmark,
|
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 {
|
interface ICommonWidgetOptions {
|
||||||
info?: boolean;
|
info?: boolean;
|
||||||
|
hide?: boolean;
|
||||||
infoLink?: string;
|
infoLink?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user