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:
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;
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"));
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
Generated
+62
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user