diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md index 7311ca25..b3a6f582 100644 --- a/docs/managing-a-cluster.md +++ b/docs/managing-a-cluster.md @@ -236,6 +236,11 @@ To be written. Free text notes can be attached to hosts, instances, users, roles, mod packs, mods and saves. They are stored on the controller and shown to everyone with access to the resource in the web interface, and can for example be used to record who runs a host or what an instance is meant for. +The web interface renders notes as Markdown. +A mention of the form `@/` becomes a link to that resource showing its name, for example `@instance/778528005` or `@user/cooldude2606`. +This works for hosts, instances, users, roles and mod packs, and the id is shown if the name is not available. +The note itself stores the mention as written. + ### List notes ctl> note list [--type ] diff --git a/packages/web_ui/src/components/ResourceMention.tsx b/packages/web_ui/src/components/ResourceMention.tsx new file mode 100644 index 00000000..1b45d335 --- /dev/null +++ b/packages/web_ui/src/components/ResourceMention.tsx @@ -0,0 +1,71 @@ +import React from "react"; + +import Link from "./Link"; +import { useHost } from "../model/host"; +import { useInstance } from "../model/instance"; +import { useModPack } from "../model/mod_pack"; +import { useRole } from "../model/roles"; +import { useUser } from "../model/user"; + +/** Matches @type/id references to resources that have a view page. */ +export const mentionPattern = /(?; + case "instance": return ; + case "role": return ; + case "mod_pack": return ; + case "user": return ; + default: return <>@{props.resourceType}/{props.resourceId}; + } +} + +function MentionLink(props: { resourceType: string, id: string, name?: string }) { + return {props.name ?? props.id}; +} + +function HostMention(props: { id: string }) { + const [host] = useHost(Number(props.id)); + return ; +} + +function InstanceMention(props: { id: string }) { + const [instance] = useInstance(Number(props.id)); + return ; +} + +function RoleMention(props: { id: string }) { + const [role] = useRole(Number(props.id)); + return ; +} + +function ModPackMention(props: { id: string }) { + const [modPack] = useModPack(Number(props.id)); + return ; +} + +function UserMention(props: { id: string }) { + const [user] = useUser(props.id); + return ; +} diff --git a/packages/web_ui/src/components/ResourceNotes.tsx b/packages/web_ui/src/components/ResourceNotes.tsx index c28231d7..c316bbb6 100644 --- a/packages/web_ui/src/components/ResourceNotes.tsx +++ b/packages/web_ui/src/components/ResourceNotes.tsx @@ -1,5 +1,6 @@ import React, { useContext, useEffect, useState } from "react"; import { Button, Input, Space, Typography } from "antd"; +import ReactMarkdown, { Components } from "react-markdown"; import * as lib from "@clusterio/lib"; import { notifyErrorHandler } from "../util/notify"; @@ -7,10 +8,70 @@ import { useAccount } from "../model/account"; import { useNote } from "../model/notes"; import { formatTimestamp } from "../util/time_format"; import ControlContext from "./ControlContext"; +import ResourceMention, { mentionPath, mentionPattern } from "./ResourceMention"; import SectionHeader from "./SectionHeader"; const { Text } = Typography; +/** + * Remark plugin turning @type/id mentions into links and line breaks into + * hard breaks. Text inside code and existing links is left alone. + */ +function remarkNoteText() { + function split(value: string) { + const nodes: any[] = []; + let last = 0; + function pushText(text: string) { + text.split("\n").forEach((line, index) => { + if (index > 0) { + nodes.push({ type: "break" }); + } + if (line) { + nodes.push({ type: "text", value: line }); + } + }); + } + for (const match of value.matchAll(mentionPattern)) { + pushText(value.slice(last, match.index)); + const resourceType = match[1] ?? match[3]; + const resourceId = match[2] ?? match[4]; + nodes.push({ + type: "link", + url: mentionPath(resourceType, resourceId), + children: [{ type: "text", value: match[0] }], + data: { hProperties: { dataResourceType: resourceType, dataResourceId: resourceId } }, + }); + last = match.index + match[0].length; + } + pushText(value.slice(last)); + return nodes; + } + function visit(node: any) { + if (!node.children || node.type === "link" || node.type === "linkReference") { + return; + } + node.children = node.children.flatMap((child: any) => { + if (child.type === "text") { + return split(child.value); + } + visit(child); + return [child]; + }); + } + return visit; +} + +const noteComponents: Components = { + a({ node, ...props }) { + const resourceType = node?.properties.dataResourceType; + const resourceId = node?.properties.dataResourceId; + if (typeof resourceType === "string" && typeof resourceId === "string") { + return ; + } + return ; + }, +}; + type ResourceNotesProps = { /** Type of resource, such as "host" or "instance". */ resourceType: string; @@ -85,12 +146,19 @@ function ResourceNotesEditor(props: ResourceNotesProps) { save(); } }} - placeholder="Write a note visible to everyone with access to this page" + placeholder={ + "Write a note visible to everyone with access to this page. " + + "Supports Markdown and links like @instance/ or @user/" + } autoFocus /> : <> {note - ?
{note.content}
+ ?
+ + {note.content} + +
: {synced ? "No note" : "Loading"} } {note && (note.updatedBy || note.updatedAtMs) &&