mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Render notes as Markdown with resource mentions (#1136)
Notes in the web UI now render through ReactMarkdown. A mention like @instance/778528005 or @user/cooldude2606 turns into a link to that resource's page, labelled with its name if the client has it and the id otherwise. The stored note keeps the mention as written. Line breaks are kept as hard breaks so existing plain text notes look the same. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c3abf27266
commit
ba518faa05
3 files changed
+146
-2
No files matched your search
@@ -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 `@<type>/<id>` 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 <type>]
|
||||
|
||||
@@ -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 = /(?<![\w@])@(?:(host|instance|role|mod_pack)\/(\d+)|(user)\/(#?[\w.-]*[\w-]))/g;
|
||||
|
||||
export function mentionPath(resourceType: string, resourceId: string) {
|
||||
switch (resourceType) {
|
||||
case "host": return `/hosts/${resourceId}/view`;
|
||||
case "instance": return `/instances/${resourceId}/view`;
|
||||
case "role": return `/roles/${resourceId}/view`;
|
||||
case "mod_pack": return `/mods/mod-packs/${resourceId}/view`;
|
||||
case "user": return `/users/${encodeURIComponent(resourceId)}/view`;
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type ResourceMentionProps = {
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Link to a resource showing its name, or its id if the name is not
|
||||
* available to this client.
|
||||
*/
|
||||
export default function ResourceMention(props: ResourceMentionProps) {
|
||||
switch (props.resourceType) {
|
||||
case "host": return <HostMention id={props.resourceId} />;
|
||||
case "instance": return <InstanceMention id={props.resourceId} />;
|
||||
case "role": return <RoleMention id={props.resourceId} />;
|
||||
case "mod_pack": return <ModPackMention id={props.resourceId} />;
|
||||
case "user": return <UserMention id={props.resourceId} />;
|
||||
default: return <>@{props.resourceType}/{props.resourceId}</>;
|
||||
}
|
||||
}
|
||||
|
||||
function MentionLink(props: { resourceType: string, id: string, name?: string }) {
|
||||
return <Link to={mentionPath(props.resourceType, props.id)!}>{props.name ?? props.id}</Link>;
|
||||
}
|
||||
|
||||
function HostMention(props: { id: string }) {
|
||||
const [host] = useHost(Number(props.id));
|
||||
return <MentionLink resourceType="host" id={props.id} name={host?.name} />;
|
||||
}
|
||||
|
||||
function InstanceMention(props: { id: string }) {
|
||||
const [instance] = useInstance(Number(props.id));
|
||||
return <MentionLink resourceType="instance" id={props.id} name={instance?.name} />;
|
||||
}
|
||||
|
||||
function RoleMention(props: { id: string }) {
|
||||
const [role] = useRole(Number(props.id));
|
||||
return <MentionLink resourceType="role" id={props.id} name={role?.name} />;
|
||||
}
|
||||
|
||||
function ModPackMention(props: { id: string }) {
|
||||
const [modPack] = useModPack(Number(props.id));
|
||||
return <MentionLink resourceType="mod_pack" id={props.id} name={modPack?.name} />;
|
||||
}
|
||||
|
||||
function UserMention(props: { id: string }) {
|
||||
const [user] = useUser(props.id);
|
||||
return <MentionLink resourceType="user" id={props.id} name={user?.name} />;
|
||||
}
|
||||
@@ -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 <ResourceMention resourceType={resourceType} resourceId={resourceId} />;
|
||||
}
|
||||
return <a {...props} target="_blank" rel="noopener noreferrer" />;
|
||||
},
|
||||
};
|
||||
|
||||
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/<id> or @user/<name>"
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
: <>
|
||||
{note
|
||||
? <div style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{note.content}</div>
|
||||
? <div style={{ overflowWrap: "anywhere" }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkNoteText]} components={noteComponents}>
|
||||
{note.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
: <Text type="secondary">{synced ? "No note" : "Loading"}</Text>
|
||||
}
|
||||
{note && (note.updatedBy || note.updatedAtMs) && <div>
|
||||
|
||||
Reference in new issue
Block a user