feat: Anchor integration and Anchor Note widget (#5040)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-04-10 20:43:43 +02:00
1 parent 57ea82f088
commit 7879b31630
20 files changed
+949 -2

No files matched your search

@@ -0,0 +1,114 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrationAsync } from "@homarr/integrations";
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const noteIdInput = z.object({
noteId: z.string(),
});
const logger = createLogger({ module: "anchorNotesRouter" });
const isJsonDeltaString = (value: string) => {
try {
const parsed: unknown = JSON.parse(value);
return typeof parsed === "object" && parsed !== null && Array.isArray((parsed as { ops?: unknown }).ops);
} catch {
return false;
}
};
const normalizeAnchorContent = (content: string | undefined) => {
if (content === undefined) return undefined;
const trimmed = content.trim();
if (!trimmed) {
return JSON.stringify({ ops: [{ insert: "\n" }] });
}
if (isJsonDeltaString(content)) return content;
const normalizedText = content.endsWith("\n") ? content : `${content}\n`;
return JSON.stringify({ ops: [{ insert: normalizedText }] });
};
export const anchorNotesRouter = createTRPCRouter({
listNotes: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor"))
.input(anchorNotesListInputSchema)
.query(async ({ ctx, input }) => {
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
const { data } = await handler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return data;
}),
getNote: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor"))
.input(noteIdInput)
.query(async ({ ctx, input }) => {
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
const { data } = await handler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return data;
}),
updateNote: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "anchor"))
.input(anchorNoteUpdateInputSchema)
.mutation(async ({ ctx, input }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
const normalizedContent = normalizeAnchorContent(input.content);
const isContentNormalized =
input.content !== undefined && normalizedContent !== undefined && input.content !== normalizedContent;
try {
const updatedNote = await integrationInstance.updateNoteAsync({
noteId: input.noteId,
...(input.title !== undefined ? { title: input.title } : {}),
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
});
const noteHandler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
await noteHandler.invalidateAsync();
return updatedNote;
} catch (error) {
if (error instanceof ResponseError && error.statusCode === 400) {
logger.warn("Anchor update note failed validation", {
noteId: input.noteId,
integrationId: ctx.integration.id,
contentLength: input.content?.length ?? 0,
contentIsJsonDelta: input.content ? isJsonDeltaString(input.content) : false,
contentNormalized: isContentNormalized,
});
}
if (error instanceof ResponseError && error.statusCode === 403) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to edit this note",
});
}
if (error instanceof ResponseError && error.statusCode === 404) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Note not found",
});
}
throw error;
}
}),
});
+1
View File
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
export const widgetRouter = createTRPCRouter({
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
+9
View File
@@ -312,6 +312,14 @@ export const integrationDefs = {
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/ical"),
},
anchor: {
name: "Anchor",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg",
category: ["notes"],
// @ts-expect-error - docs page will be created when integration is merged
documentationUrl: createDocumentationLink("/docs/integrations/anchor"),
},
truenas: {
name: "TrueNAS",
secretKinds: [["username", "password"]],
@@ -452,6 +460,7 @@ export const integrationCategories = [
"firewall",
"timetable",
"photoService",
"notes",
"mediaMonitoring",
"speedtest",
] as const;
+1
View File
@@ -5,6 +5,7 @@ export const widgetKinds = [
"iframe",
"video",
"notebook",
"anchorNote",
"dnsHoleSummary",
"dnsHoleControls",
"smartHome-entityState",
@@ -0,0 +1,118 @@
import type { fetch as undiciFetch } from "undici";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import { anchorNoteSchema, anchorNoteSummaryListSchema } from "./anchor-types";
import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary, AnchorNoteUpdateInput } from "./anchor-types";
const logger = createLogger({ module: "anchorIntegration" });
export class AnchorIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/notes", { limit: 1 }), {
headers: {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
anchorNoteSummaryListSchema.parse(await response.json());
return { success: true };
}
public async listNotesAsync(input: AnchorNotesListInput = {}): Promise<AnchorNoteSummary[]> {
const queryParams = buildListQuery(input);
const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, "/api/notes", {
queryParams,
});
return anchorNoteSummaryListSchema.parse(response);
}
public async getNoteAsync(noteId: string): Promise<AnchorNote> {
const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, `/api/notes/${noteId}`);
return anchorNoteSchema.parse(response);
}
public async updateNoteAsync(input: AnchorNoteUpdateInput): Promise<AnchorNote> {
const { noteId, ...updateData } = input;
const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, `/api/notes/${noteId}`, {
method: "PATCH",
body: updateData,
});
if (response === null) {
return this.getNoteAsync(noteId);
}
return anchorNoteSchema.parse(response);
}
private async requestAsync(
fetchAsync: typeof undiciFetch,
path: `/${string}`,
options?: {
queryParams?: Record<string, string>;
method?: string;
body?: unknown;
},
): Promise<unknown> {
const url = this.url(path, options?.queryParams);
const headers: Record<string, string> = {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
};
if (options?.body !== undefined) {
headers["Content-Type"] = "application/json";
}
const response = await fetchAsync(url, {
method: options?.method ?? "GET",
headers,
body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
let errorBody: string | null = null;
try {
errorBody = await response.text();
} catch {
errorBody = null;
}
logger.warn("Anchor request failed", {
statusCode: response.status,
url,
errorBody: errorBody ? errorBody.slice(0, 200) : null,
});
throw new ResponseError(response);
}
if (response.status === 204) {
return null;
}
return await response.json();
}
}
const buildListQuery = (input: AnchorNotesListInput) => {
const queryParams: Record<string, string> = {};
if (input.search?.trim()) {
queryParams.search = input.search.trim();
}
if (input.tagId?.trim()) {
queryParams.tagId = input.tagId.trim();
}
if (typeof input.limit === "number") {
queryParams.limit = input.limit.toString();
}
return Object.keys(queryParams).length > 0 ? queryParams : undefined;
};
@@ -0,0 +1,43 @@
import { z } from "zod/v4";
export const anchorNotesListInputSchema = z.object({
search: z.string().optional(),
tagId: z.string().optional(),
limit: z.number().min(1).max(200).optional(),
});
export type AnchorNotesListInput = z.infer<typeof anchorNotesListInputSchema>;
export const anchorNotePermissionSchema = z.enum(["owner", "viewer", "editor"]);
export type AnchorNotePermission = z.infer<typeof anchorNotePermissionSchema>;
export const anchorNoteSummarySchema = z.object({
id: z.string(),
title: z.string(),
updatedAt: z.string(),
isPinned: z.boolean(),
tagIds: z.array(z.string()),
permission: anchorNotePermissionSchema,
});
export type AnchorNoteSummary = z.infer<typeof anchorNoteSummarySchema>;
export const anchorNoteSchema = anchorNoteSummarySchema.extend({
content: z.string().nullable().optional(),
createdAt: z.string(),
isArchived: z.boolean(),
background: z.string().nullable().optional(),
userId: z.string(),
});
export type AnchorNote = z.infer<typeof anchorNoteSchema>;
export const anchorNoteSummaryListSchema = z.array(anchorNoteSummarySchema);
export const anchorNoteUpdateInputSchema = z
.object({
noteId: z.string(),
title: z.string().optional(),
content: z.string().optional(),
})
.refine((value) => value.title !== undefined || value.content !== undefined, {
message: "At least one field to update must be provided",
});
export type AnchorNoteUpdateInput = z.infer<typeof anchorNoteUpdateInputSchema>;
@@ -1,6 +1,7 @@
import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
@@ -72,6 +73,7 @@ type IntegrationInstance = new (integration: IntegrationInput) => Integration;
// factories are an array, to differentiate in js between class constructors and functions
export const integrationCreators = {
anchor: AnchorIntegration,
piHole: [createPiHoleIntegrationAsync],
adGuardHome: AdGuardHomeIntegration,
homeAssistant: HomeAssistantIntegration,
+10
View File
@@ -1,5 +1,6 @@
// General integrations
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { AnchorIntegration } from "./anchor/anchor-integration";
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
@@ -58,10 +59,19 @@ export type {
export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types";
export type { Notification } from "./interfaces/notifications/notification-types";
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
export type {
AnchorNote,
AnchorNotePermission,
AnchorNoteUpdateInput,
AnchorNotesListInput,
AnchorNoteSummary,
} from "./anchor/anchor-types";
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
// Helpers
+1
View File
@@ -12,6 +12,7 @@ export * from "./unifi-controller/unifi-controller-types";
export * from "./opnsense/opnsense-types";
export * from "./interfaces/media-releases";
export * from "./coolify/coolify-types";
export * from "./anchor/anchor-types";
export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
@@ -0,0 +1,30 @@
import dayjs from "dayjs";
import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const anchorNotesListRequestHandler = createCachedIntegrationRequestHandler<
AnchorNoteSummary[],
"anchor",
AnchorNotesListInput
>({
queryKey: "anchorNotesList",
cacheDuration: dayjs.duration(15, "seconds"),
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return instance.listNotesAsync(input);
},
});
export const anchorNoteRequestHandler = createCachedIntegrationRequestHandler<AnchorNote, "anchor", { noteId: string }>(
{
queryKey: "anchorNote",
cacheDuration: dayjs.duration(15, "seconds"),
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return instance.getNoteAsync(input.noteId);
},
},
);
+28
View File
@@ -1606,6 +1606,34 @@
}
}
},
"anchorNote": {
"name": "Anchor Note",
"description": "Displays a selected note from Anchor",
"option": {
"noteId": {
"label": "Note"
},
"showTitle": {
"label": "Show title"
},
"showUpdatedAt": {
"label": "Show last updated"
}
},
"empty": "Select a note to display",
"emptyContent": "This note has no content",
"notFound": "Note not found",
"noNotes": "No notes found",
"integrationRequired": "Select an Anchor integration first",
"updatedAt": "Updated {date}",
"untitled": "Untitled",
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"readOnlyViewer": "You have viewer access to this note. Editing is disabled.",
"saveForbidden": "You do not have permission to edit this note.",
"saveFailed": "Failed to save this note. Please try again."
},
"iframe": {
"name": "iFrame",
"description": "Embed any content from the internet. Some websites may restrict access.",
+1
View File
@@ -76,6 +76,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"react-quill-new": "^3.7.0",
"recharts": "^2.15.4",
"video.js": "^8.23.7",
"zod": "^4.3.6"
+2
View File
@@ -1,4 +1,5 @@
import type { WidgetOptionType } from "../options";
import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
import { WidgetLocationInput } from "./widget-location-input";
@@ -13,6 +14,7 @@ import { WidgetSwitchInput } from "./widget-switch-input";
import { WidgetTextInput } from "./widget-text-input";
const mapping = {
anchorNote: WidgetAnchorNoteInput,
text: WidgetTextInput,
location: WidgetLocationInput,
multiSelect: WidgetMultiSelectInput,
@@ -0,0 +1,63 @@
"use client";
import { useMemo, useState } from "react";
import { Loader, Select } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetAnchorNoteInput = ({ property, kind, options }: CommonWidgetInputProps<"anchorNote">) => {
const t = useI18n();
const tWidget = useWidgetInputTranslation(kind, property);
const tAnchor = useScopedI18n("widget.anchorNote");
const form = useFormContext();
const integrationId = form.values.integrationIds[0];
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const { data: notes, isPending } = clientApi.widget.anchorNotes.listNotes.useQuery(
{
integrationId: integrationId ?? "",
search: debouncedSearch.trim() ? debouncedSearch : undefined,
limit: 200,
},
{
enabled: Boolean(integrationId),
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const noteOptions = useMemo(
() =>
notes?.map((note) => ({
value: note.id,
label: note.title || tAnchor("untitled"),
})) ?? [],
[notes, tAnchor],
);
return (
<Select
label={tWidget("label")}
description={options.withDescription ? tWidget("description") : undefined}
placeholder={integrationId ? t("common.select.placeholder") : tAnchor("integrationRequired")}
nothingFoundMessage={integrationId ? tAnchor("noNotes") : tAnchor("integrationRequired")}
searchable
searchValue={search}
onSearchChange={setSearch}
data={noteOptions}
leftSection={isPending && integrationId ? <Loader size={16} /> : undefined}
disabled={!integrationId}
{...form.getInputProps(`options.${property}`)}
/>
);
};
@@ -0,0 +1,115 @@
.homarr-anchor-quill {
display: flex;
flex-direction: column;
height: 100%;
}
.homarr-anchor-quill .ql-toolbar.ql-snow,
.homarr-anchor-quill .ql-container.ql-snow {
border: 0;
}
.homarr-anchor-quill .ql-toolbar.ql-snow {
background: transparent;
border-radius: 0;
}
.homarr-anchor-quill .ql-container.ql-snow {
background: transparent;
border-radius: 0;
flex: 1;
display: flex;
flex-direction: column;
}
.homarr-anchor-quill .ql-editor {
min-height: 160px;
flex: 1;
padding: 0;
color: var(--mantine-color-text);
font-size: var(--mantine-font-size-sm, 0.95rem);
line-height: 1.6;
}
.homarr-anchor-quill .ql-editor.ql-blank::before {
color: var(--mantine-color-dimmed);
}
.homarr-anchor-quill .ql-editor ol,
.homarr-anchor-quill .ql-editor ul {
padding-left: 1.5rem;
}
.homarr-anchor-quill .ql-editor li {
margin: 0.25rem 0;
}
.homarr-anchor-quill .ql-editor li[data-list="unchecked"],
.homarr-anchor-quill .ql-editor li[data-list="checked"] {
padding-left: 28px !important;
list-style: none;
position: relative;
}
.homarr-anchor-quill .ql-editor li[data-list="checked"] > .ql-ui,
.homarr-anchor-quill .ql-editor li[data-list="unchecked"] > .ql-ui {
position: absolute;
left: 0;
top: 0.35em;
width: 18px !important;
height: 18px !important;
border-radius: 5px;
box-sizing: border-box;
transition: all 150ms ease;
background: transparent;
padding: 0 !important;
margin: 0 !important;
}
.homarr-anchor-quill .ql-editor li[data-list="checked"] > .ql-ui::before,
.homarr-anchor-quill .ql-editor li[data-list="unchecked"] > .ql-ui::before {
content: "" !important;
display: none !important;
}
.homarr-anchor-quill .ql-editor li[data-list="unchecked"] > .ql-ui {
border: 2px solid var(--mantine-color-dimmed);
opacity: 0.7;
}
.homarr-anchor-quill .ql-editor li[data-list="unchecked"]:hover > .ql-ui {
border-color: var(--mantine-primary-color-filled);
opacity: 1;
}
.homarr-anchor-quill .ql-editor li[data-list="checked"] > .ql-ui {
border: 2px solid var(--mantine-primary-color-filled);
background: var(--mantine-primary-color-filled);
}
.homarr-anchor-quill .ql-editor li[data-list="checked"] > .ql-ui::after {
content: "";
display: block;
position: absolute;
left: 50%;
top: 45%;
width: 5px;
height: 9px;
border: solid var(--mantine-color-white, #fff);
border-width: 0 2px 2px 0;
transform: translate(-50%, -50%) rotate(45deg);
}
.homarr-anchor-quill .ql-editor li[data-list="checked"] {
color: var(--mantine-color-dimmed);
text-decoration: line-through;
text-decoration-color: var(--mantine-color-dimmed);
}
.homarr-anchor-quill--readonly .ql-toolbar.ql-snow {
display: none;
}
.homarr-anchor-quill--readonly .ql-container.ql-snow {
border-radius: 0;
}
@@ -0,0 +1,320 @@
"use client";
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import dynamic from "next/dynamic";
import { Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
import { Quill } from "react-quill-new";
import type { DeltaStatic } from "react-quill-new";
import type ReactQuillComponent from "react-quill-new";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import "react-quill-new/dist/quill.snow.css";
import "./anchor-note.css";
type ReactQuillProps = ComponentProps<typeof ReactQuillComponent>;
type ReactQuillOnChange = NonNullable<ReactQuillProps["onChange"]>;
const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false });
const quillDeltaOperationSchema = z.object({
insert: z.unknown().optional(),
attributes: z.record(z.string(), z.unknown()).optional(),
});
const quillDeltaSchema = z.object({
ops: z.array(quillDeltaOperationSchema),
});
type QuillDelta = z.infer<typeof quillDeltaSchema>;
const DeltaConstructor = Quill.import("delta") as new (
ops?: QuillDelta["ops"] | { ops: QuillDelta["ops"] },
) => DeltaStatic;
const quillModules = {
toolbar: [
["bold", "italic", "underline", "strike"],
[{ header: [1, 2, 3, 4, false] }],
[{ align: [] }],
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
[{ indent: "-1" }, { indent: "+1" }],
["blockquote", "code-block"],
["link"],
["clean"],
],
history: {
delay: 1000,
maxStack: 200,
userOnly: true,
},
};
const readOnlyModules = {
toolbar: false,
};
const quillFormats = [
"bold",
"italic",
"underline",
"strike",
"header",
"align",
"list",
"indent",
"blockquote",
"code-block",
"link",
];
const emptyDelta = (): DeltaStatic => new DeltaConstructor([{ insert: "\n" }]);
const toPlainTextDelta = (value: string): DeltaStatic => {
const normalized = value.endsWith("\n") ? value : `${value}\n`;
return new DeltaConstructor([{ insert: normalized }]);
};
const parseStoredContent = (content?: string | null): DeltaStatic => {
if (!content) return emptyDelta();
try {
const parsedDelta = quillDeltaSchema.safeParse(JSON.parse(content));
if (parsedDelta.success) {
return new DeltaConstructor(parsedDelta.data.ops);
}
} catch {
return toPlainTextDelta(content);
}
return toPlainTextDelta(content);
};
const stringifyDelta = (delta: unknown): string => {
const parsedDelta = quillDeltaSchema.safeParse(delta);
if (parsedDelta.success) {
return JSON.stringify(parsedDelta.data);
}
return JSON.stringify(emptyDelta());
};
const canEditPermission = (permission: AnchorNotePermission) => {
return permission === "owner" || permission === "editor";
};
const isForbiddenError = (error: unknown): boolean => {
if (typeof error !== "object" || error === null) return false;
const data = (error as { data?: unknown }).data;
if (typeof data !== "object" || data === null) return false;
return (data as { code?: unknown }).code === "FORBIDDEN";
};
export default function AnchorNoteWidget({ options, integrationIds }: WidgetComponentProps<"anchorNote">) {
const t = useScopedI18n("widget.anchorNote");
const noteId = options.noteId.trim();
if (!noteId) {
return (
<Center h="100%">
<Text c="dimmed">{t("empty")}</Text>
</Center>
);
}
// It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
return <AnchorNoteWidgetContent options={options} integrationId={integrationId} noteId={noteId} />;
}
interface AnchorNoteWidgetContentProps {
options: WidgetComponentProps<"anchorNote">["options"];
integrationId: string;
noteId: string;
}
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const [note, { refetch }] = clientApi.widget.anchorNotes.getNote.useSuspenseQuery(
{
integrationId,
noteId,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
const [isEditing, setIsEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState(note.title);
const [draftContent, setDraftContent] = useState(note.content ?? "");
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
if (isEditing) return;
startTransition(() => {
setDraftTitle(note.title);
setDraftContent(note.content ?? "");
});
}, [isEditing, note.content, note.title]);
const canEdit = canEditPermission(note.permission);
const isViewer = note.permission === "viewer";
const updatedAt = useMemo(() => new Date(note.updatedAt), [note.updatedAt]);
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
const hasChanges = useMemo(() => {
const normalizedTitle = draftTitle.trim() || note.title;
return normalizedTitle !== note.title || draftContent !== (note.content ?? "");
}, [draftContent, draftTitle, note.content, note.title]);
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
const readOnlyValue = useMemo(() => parseStoredContent(note.content), [note.content]);
const handleEditorChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (!isEditing || source !== "user") return;
const next = stringifyDelta(editor.getContents());
setDraftContent(next);
},
[isEditing],
);
const handleEdit = useCallback(() => {
if (!canEdit) return;
startTransition(() => {
setDraftTitle(note.title);
setDraftContent(note.content ?? "");
setSaveError(null);
setIsEditing(true);
});
}, [canEdit, note.content, note.title]);
const handleCancel = useCallback(() => {
startTransition(() => {
setDraftTitle(note.title);
setDraftContent(note.content ?? "");
setSaveError(null);
setIsEditing(false);
});
}, [note.content, note.title]);
const handleSave = useCallback(async () => {
if (!canEdit) return;
const normalizedTitle = draftTitle.trim() || note.title || t("untitled");
if (!hasChanges) {
startTransition(() => {
setSaveError(null);
setIsEditing(false);
});
return;
}
await updateNoteAsync(
{
integrationId,
noteId,
title: normalizedTitle,
content: draftContent,
},
{
onSuccess() {
void (async () => {
await refetch();
startTransition(() => {
setSaveError(null);
setIsEditing(false);
});
})();
},
onError(error) {
if (isForbiddenError(error)) {
void (async () => {
await refetch();
startTransition(() => {
setSaveError(t("saveForbidden"));
setIsEditing(false);
});
})();
return;
}
startTransition(() => {
setSaveError(t("saveFailed"));
});
},
},
);
}, [canEdit, draftContent, draftTitle, hasChanges, integrationId, note.title, noteId, refetch, t, updateNoteAsync]);
return (
<Stack h="100%" gap="xs" p="sm">
<Group justify="space-between" align="flex-start">
<Stack gap={2} style={{ flex: 1 }}>
{isEditing ? (
<TextInput value={draftTitle} onChange={(event) => setDraftTitle(event.currentTarget.value)} size="sm" />
) : (
options.showTitle && <Text fw={600}>{note.title || t("untitled")}</Text>
)}
{!isEditing && options.showUpdatedAt && (
<Text size="xs" c="dimmed">
{t("updatedAt", { date: updatedAtRelative })}
</Text>
)}
{!isEditing && isViewer && (
<Text size="xs" c="dimmed">
{t("readOnlyViewer")}
</Text>
)}
{saveError && (
<Text size="xs" c="red">
{saveError}
</Text>
)}
</Stack>
<Group gap="xs">
{isEditing ? (
<>
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
{t("save")}
</Button>
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
{t("cancel")}
</Button>
</>
) : (
<Button size="xs" variant="light" onClick={handleEdit} disabled={!canEdit || isUpdating}>
{t("edit")}
</Button>
)}
</Group>
</Group>
<div className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`} style={{ flex: 1 }}>
<ReactQuill
theme="snow"
readOnly={!isEditing}
value={isEditing ? editorValue : readOnlyValue}
onChange={handleEditorChange}
modules={isEditing ? quillModules : readOnlyModules}
formats={quillFormats}
placeholder={t("emptyContent")}
/>
</div>
</Stack>
);
};
+20
View File
@@ -0,0 +1,20 @@
import { IconNotes } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
supportedIntegrations: ["anchor"],
createOptions() {
return optionsBuilder.from((factory) => ({
noteId: factory.anchorNote(),
showTitle: factory.switch({
defaultValue: true,
}),
showUpdatedAt: factory.switch({
defaultValue: true,
}),
}));
},
}).withDynamicImport(() => import("./component"));
+2
View File
@@ -7,6 +7,7 @@ import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import * as anchorNote from "./anchor-note";
import * as app from "./app";
import * as bookmarks from "./bookmarks";
import * as calendar from "./calendar";
@@ -55,6 +56,7 @@ export const widgetImports = {
clock,
weather,
app,
anchorNote,
notebook,
iframe,
video,
+7
View File
@@ -19,6 +19,8 @@ interface TextInput extends CommonInput<string> {
validate?: z.ZodType<string>;
}
type AnchorNoteInput = CommonInput<string>;
interface MultiSelectInput<TOptions extends SelectOption[]> extends CommonInput<
inferSelectOptionValue<TOptions[number]>[]
> {
@@ -86,6 +88,11 @@ const optionsFactory = {
withDescription: input?.withDescription ?? false,
validate: input?.validate,
}),
anchorNote: (input?: AnchorNoteInput) => ({
type: "anchorNote" as const,
defaultValue: input?.defaultValue ?? "",
withDescription: input?.withDescription ?? false,
}),
multiSelect: <const TOptions extends SelectOption[]>(input: MultiSelectInput<TOptions>) => ({
type: "multiSelect" as const,
defaultValue: input.defaultValue ?? [],
+62 -2
View File
@@ -2326,6 +2326,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
react-quill-new:
specifier: ^3.7.0
version: 3.8.3(quill-delta@5.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
recharts:
specifier: ^2.15.4
version: 2.15.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -7019,6 +7022,9 @@ packages:
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -7059,6 +7065,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
fast-equals@5.3.3:
resolution: {integrity: sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==}
engines: {node: '>=6.0.0'}
@@ -8149,6 +8158,10 @@ packages:
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
@@ -8898,6 +8911,9 @@ packages:
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
parchment@3.0.0:
resolution: {integrity: sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -9297,6 +9313,14 @@ packages:
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
quill-delta@5.1.0:
resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==}
engines: {node: '>= 12.0.0'}
quill@2.0.3:
resolution: {integrity: sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==}
engines: {npm: '>=8.2.3'}
radix3@1.1.2:
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
@@ -9385,6 +9409,13 @@ packages:
react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-quill-new@3.8.3:
resolution: {integrity: sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==}
peerDependencies:
quill-delta: ^5.1.0
react: ^16 || ^17 || ^18 || ^19
react-dom: ^16 || ^17 || ^18 || ^19
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -14066,7 +14097,7 @@ snapshots:
'@types/hast@3.0.4':
dependencies:
'@types/unist': 2.0.11
'@types/unist': 3.0.3
'@types/http-errors@2.0.4': {}
@@ -14087,7 +14118,7 @@ snapshots:
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 2.0.11
'@types/unist': 3.0.3
'@types/mdurl@2.0.0': {}
@@ -16188,6 +16219,8 @@ snapshots:
eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {}
events@3.3.0: {}
execa@5.1.1:
@@ -16245,6 +16278,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
fast-equals@5.3.3: {}
fast-fifo@1.3.2: {}
@@ -17372,6 +17407,8 @@ snapshots:
lodash.isarguments@3.1.0: {}
lodash.isequal@4.5.0: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
@@ -18185,6 +18222,8 @@ snapshots:
pako@2.1.0: {}
parchment@3.0.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -18629,6 +18668,19 @@ snapshots:
quick-format-unescaped@4.0.4: {}
quill-delta@5.1.0:
dependencies:
fast-diff: 1.3.0
lodash.clonedeep: 4.5.0
lodash.isequal: 4.5.0
quill@2.0.3:
dependencies:
eventemitter3: 5.0.4
lodash-es: 4.17.21
parchment: 3.0.0
quill-delta: 5.1.0
radix3@1.1.2: {}
ramda-adjunct@5.1.0(ramda@0.30.1):
@@ -18730,6 +18782,14 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-quill-new@3.8.3(quill-delta@5.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
lodash-es: 4.17.21
quill: 2.0.3
quill-delta: 5.1.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6