diff --git a/backend/tests/admin.test.ts b/backend/tests/admin.test.ts index 4b14cc1..cacc1ee 100644 --- a/backend/tests/admin.test.ts +++ b/backend/tests/admin.test.ts @@ -193,7 +193,8 @@ describe("admin audit logs", () => { it("filters logs by user", async () => { const res = await request(app).get(`/api/admin/logs?user_id=${memberUserId}`).set(auth(adminToken)); expect(res.status).toBe(200); - expect((res.body as Array<{ user_id: string }>).every((l) => l.user_id === memberUserId)).toBe(true); + // Lists the offending entries, not just `false`, if this ever fails. + expect((res.body as Array<{ user_id: string }>).filter((l) => l.user_id !== memberUserId)).toEqual([]); }); }); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index de494e4..6f2254c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1463,7 +1463,24 @@ "descriptionWrite": "Write", "descriptionPreview": "Preview", "descriptionPlaceholder": "Describe the model, print settings, assembly steps...", - "descriptionMarkdownHint": "Markdown supported: **bold**, *italic*, # headings, - lists, [links](https://…) and ![images](https://…)." + "descriptionMarkdownHint": "Formatted with Markdown. Images added by URL are saved with the model.", + "toolbar": { + "label": "Formatting", + "bold": "Bold", + "boldText": "bold text", + "italic": "Italic", + "italicText": "italic text", + "heading": "Heading", + "bulletList": "Bulleted list", + "numberedList": "Numbered list", + "quote": "Quote", + "code": "Code", + "codeText": "code", + "link": "Link", + "linkText": "link text", + "image": "Image from URL", + "imageText": "image" + } }, "author": { "pageTitle": "Author", diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 735f593..a3c3699 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -1466,7 +1466,24 @@ "descriptionWrite": "Rašyti", "descriptionPreview": "Peržiūra", "descriptionPlaceholder": "Aprašykite modelį, spausdinimo nustatymus, surinkimo žingsnius...", - "descriptionMarkdownHint": "Palaikomas Markdown: **paryškinta**, *kursyvas*, # antraštės, - sąrašai, [nuorodos](https://…) ir ![paveikslėliai](https://…)." + "descriptionMarkdownHint": "Formatuojama Markdown. Pagal URL pridėti paveikslėliai išsaugomi kartu su modeliu.", + "toolbar": { + "label": "Formatavimas", + "bold": "Paryškinti", + "boldText": "paryškintas tekstas", + "italic": "Kursyvas", + "italicText": "kursyvinis tekstas", + "heading": "Antraštė", + "bulletList": "Sąrašas", + "numberedList": "Numeruotas sąrašas", + "quote": "Citata", + "code": "Kodas", + "codeText": "kodas", + "link": "Nuoroda", + "linkText": "nuorodos tekstas", + "image": "Paveikslėlis iš URL", + "imageText": "paveikslėlis" + } }, "author": { "pageTitle": "Autorius", diff --git a/frontend/src/pages/ModelDetailPage/DescriptionEditor.tsx b/frontend/src/pages/ModelDetailPage/DescriptionEditor.tsx index 4752905..0df0803 100644 --- a/frontend/src/pages/ModelDetailPage/DescriptionEditor.tsx +++ b/frontend/src/pages/ModelDetailPage/DescriptionEditor.tsx @@ -1,12 +1,35 @@ -import { useState } from "react"; +import { useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import Box from "@mui/material/Box"; import Tabs from "@mui/material/Tabs"; import Tab from "@mui/material/Tab"; import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import Tooltip from "@mui/material/Tooltip"; +import Divider from "@mui/material/Divider"; +import FormatBoldIcon from "@mui/icons-material/FormatBold"; +import FormatItalicIcon from "@mui/icons-material/FormatItalic"; +import TitleIcon from "@mui/icons-material/Title"; +import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted"; +import FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered"; +import FormatQuoteIcon from "@mui/icons-material/FormatQuote"; +import CodeIcon from "@mui/icons-material/Code"; +import InsertLinkIcon from "@mui/icons-material/InsertLink"; +import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined"; import MarkdownDescription from "../../components/MarkdownDescription"; import { dividerBorderColor } from "../../theme"; +import { + applyEdit, + insertImage, + insertLink, + toggleInline, + toggleLines, + type MarkdownEdit, + type Selection, +} from "../../utils/markdownEdits"; + +type Builder = (current: string, sel: Selection) => MarkdownEdit; type Props = { value: string; @@ -14,10 +37,102 @@ type Props = { disabled?: boolean; }; -/** The description field: Markdown source, with a tab to preview it as the detail page shows it. */ +const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); +const MOD = isMac ? "⌘" : "Ctrl+"; + +/** The description field: Markdown source with a formatting toolbar, and a tab to preview it as + * the detail page shows it. */ export default function DescriptionEditor({ value, onChange, disabled }: Props) { const { t } = useTranslation("models"); const [tab, setTab] = useState<"write" | "preview">("write"); + const inputRef = useRef(null); + + const apply = (build: Builder) => { + const textarea = inputRef.current; + if (!textarea || disabled) return; + const edit = build(textarea.value, { start: textarea.selectionStart, end: textarea.selectionEnd }); + const expected = applyEdit(textarea.value, edit); + textarea.focus(); + textarea.setSelectionRange(edit.start, edit.end); + // Through the browser's own editing, so ⌘Z undoes it like typing; it fires the change event. + const applied = edit.insert + ? document.execCommand("insertText", false, edit.insert) + : document.execCommand("delete", false); + if (!applied || textarea.value !== expected) onChange(expected); + const select = () => textarea.setSelectionRange(edit.select.start, edit.select.end); + select(); + // The fallback path re-renders before the new text is in place. + requestAnimationFrame(select); + }; + + const placeholders = { text: t("edit.toolbar.linkText"), url: "https://" }; + const actions: { key: string; label: string; icon: ReactNode; edit: Builder; dividerBefore?: boolean }[] = [ + { + key: "bold", + label: `${t("edit.toolbar.bold")} (${MOD}B)`, + icon: , + edit: (v, s) => toggleInline(v, s, "bold", t("edit.toolbar.boldText")), + }, + { + key: "italic", + label: `${t("edit.toolbar.italic")} (${MOD}I)`, + icon: , + edit: (v, s) => toggleInline(v, s, "italic", t("edit.toolbar.italicText")), + }, + { + key: "heading", + label: t("edit.toolbar.heading"), + icon: , + edit: (v, s) => toggleLines(v, s, "heading"), + }, + { + key: "bullet", + label: t("edit.toolbar.bulletList"), + icon: , + edit: (v, s) => toggleLines(v, s, "bullet"), + dividerBefore: true, + }, + { + key: "numbered", + label: t("edit.toolbar.numberedList"), + icon: , + edit: (v, s) => toggleLines(v, s, "numbered"), + }, + { + key: "quote", + label: t("edit.toolbar.quote"), + icon: , + edit: (v, s) => toggleLines(v, s, "quote"), + }, + { + key: "code", + label: t("edit.toolbar.code"), + icon: , + edit: (v, s) => toggleInline(v, s, "code", t("edit.toolbar.codeText")), + }, + { + key: "link", + label: `${t("edit.toolbar.link")} (${MOD}K)`, + icon: , + edit: (v, s) => insertLink(v, s, placeholders), + dividerBefore: true, + }, + { + key: "image", + label: t("edit.toolbar.image"), + icon: , + edit: (v, s) => insertImage(v, s, { ...placeholders, text: t("edit.toolbar.imageText") }), + }, + ]; + + const onKeyDown = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return; + const key = { b: "bold", i: "italic", k: "link" }[e.key.toLowerCase()]; + const action = key && actions.find((a) => a.key === key); + if (!action) return; + e.preventDefault(); + apply(action.edit); + }; return ( @@ -31,19 +146,48 @@ export default function DescriptionEditor({ value, onChange, disabled }: Props) {tab === "write" ? ( - onChange(e.target.value)} - disabled={disabled} - fullWidth - multiline - minRows={6} - maxRows={20} - placeholder={t("edit.descriptionPlaceholder")} - helperText={t("edit.descriptionMarkdownHint")} - inputProps={{ "aria-label": t("detail.description") }} - sx={{ mt: 1, "& textarea": { fontFamily: "monospace", fontSize: "0.85rem" } }} - /> + <> + + {actions.map((action) => [ + action.dividerBefore && ( + + ), + + + e.preventDefault()} + onClick={() => apply(action.edit)} + > + {action.icon} + + + , + ])} + + onChange(e.target.value)} + onKeyDown={onKeyDown} + inputRef={inputRef} + disabled={disabled} + fullWidth + multiline + minRows={6} + maxRows={20} + placeholder={t("edit.descriptionPlaceholder")} + helperText={t("edit.descriptionMarkdownHint")} + inputProps={{ "aria-label": t("detail.description") }} + sx={{ mt: 0.5, "& textarea": { fontFamily: "monospace", fontSize: "0.85rem" } }} + /> + ) : ( { + it("wraps the selection, keeping it selected", () => { + const { value, sel } = parse("make [this] bold"); + expect(show(value, toggleInline(value, sel, "bold", "bold text"))).toBe("make **[this]** bold"); + }); + + it("unwraps a selection that is already wrapped", () => { + const { value, sel } = parse("make **[this]** bold"); + expect(show(value, toggleInline(value, sel, "bold", "bold text"))).toBe("make [this] bold"); + }); + + it("inserts a selected placeholder at a caret", () => { + const { value, sel } = parse("say |"); + expect(show(value, toggleInline(value, sel, "italic", "italic text"))).toBe("say *[italic text]*"); + }); + + it("doesn't mistake bold markers for italic ones", () => { + const { value, sel } = parse("**[word]**"); + expect(show(value, toggleInline(value, sel, "italic", "x"))).toBe("***[word]***"); + }); + + it("fences a multi-line code selection", () => { + const { value, sel } = parse("[a\nb]"); + expect(applyEdit(value, toggleInline(value, sel, "code", "code"))).toBe("```\na\nb\n```"); + }); +}); + +describe("toggleLines", () => { + it("numbers every selected line, replacing bullets", () => { + const { value, sel } = parse("intro\n[- one\n- two]\noutro"); + expect(applyEdit(value, toggleLines(value, sel, "numbered"))).toBe("intro\n1. one\n2. two\noutro"); + }); + + it("removes the prefix when every selected line has it, skipping blank lines", () => { + const { value, sel } = parse("[> a\n\n> b]"); + expect(applyEdit(value, toggleLines(value, sel, "quote"))).toBe("a\n\nb"); + }); + + it("turns the caret's line into a heading and keeps the caret at its end", () => { + const { value, sel } = parse("Ti|tle\nbody"); + expect(show(value, toggleLines(value, sel, "heading"))).toBe("## Title|\nbody"); + }); + + it("doesn't take in the line after a selection ending in a newline", () => { + const { value, sel } = parse("[a\n]b"); + expect(applyEdit(value, toggleLines(value, sel, "bullet"))).toBe("- a\nb"); + }); +}); + +describe("insertLink and insertImage", () => { + const placeholders = { text: "link text", url: "https://" }; + + it("makes selected text a link and selects the URL to type", () => { + const { value, sel } = parse("see [the guide]"); + const edit = insertLink(value, sel, placeholders); + expect(applyEdit(value, edit)).toBe("see [the guide](https://)"); + expect(applyEdit(value, edit).slice(edit.select.start, edit.select.end)).toBe("https://"); + }); + + it("makes a selected URL the link target and selects the text to type", () => { + const { value, sel } = parse("[https://example.com]"); + const edit = insertLink(value, sel, placeholders); + expect(applyEdit(value, edit)).toBe("[link text](https://example.com)"); + expect(applyEdit(value, edit).slice(edit.select.start, edit.select.end)).toBe("link text"); + }); + + it("puts an image on its own line", () => { + const { value, sel } = parse("before|after"); + const edit = insertImage(value, sel, { text: "image", url: "https://" }); + expect(applyEdit(value, edit)).toBe("before\n\n![image](https://)\n\nafter"); + expect(applyEdit(value, edit).slice(edit.select.start, edit.select.end)).toBe("https://"); + }); +}); diff --git a/frontend/src/utils/markdownEdits.ts b/frontend/src/utils/markdownEdits.ts new file mode 100644 index 0000000..106d427 --- /dev/null +++ b/frontend/src/utils/markdownEdits.ts @@ -0,0 +1,138 @@ +// Toolbar and shortcut edits for a Markdown textarea. Each returns the range of the current text +// to replace, what to put there, and what to select afterwards, so the caller can apply it as a +// single undoable edit. + +export type Selection = { start: number; end: number }; + +export type MarkdownEdit = { + /** Range of the current text to replace. */ + start: number; + end: number; + insert: string; + /** Selection in the resulting text. */ + select: Selection; +}; + +export type InlineStyle = "bold" | "italic" | "code"; +export type LineStyle = "heading" | "bullet" | "numbered" | "quote"; + +const INLINE_MARKERS: Record = { bold: "**", italic: "*", code: "`" }; + +/** Wraps the selection in the style's marker, or unwraps it when it already has it. */ +export function toggleInline(value: string, sel: Selection, style: InlineStyle, placeholder: string): MarkdownEdit { + const selected = value.slice(sel.start, sel.end); + if (style === "code" && selected.includes("\n")) return codeBlock(sel, selected); + const marker = INLINE_MARKERS[style]; + const m = marker.length; + + // Markers just outside the selection, but not part of a longer run (italic inside **bold**). + const before = value.slice(sel.start - m, sel.start); + const after = value.slice(sel.end, sel.end + m); + const runBefore = value[sel.start - m - 1] === marker[0]; + const runAfter = value[sel.end + m] === marker[0]; + if (before === marker && after === marker && !runBefore && !runAfter) { + const start = sel.start - m; + return { start, end: sel.end + m, insert: selected, select: { start, end: start + selected.length } }; + } + if (selected.length >= 2 * m && selected.startsWith(marker) && selected.endsWith(marker)) { + const inner = selected.slice(m, -m); + return { ...sel, insert: inner, select: { start: sel.start, end: sel.start + inner.length } }; + } + const text = selected || placeholder; + return { + ...sel, + insert: `${marker}${text}${marker}`, + select: { start: sel.start + m, end: sel.start + m + text.length }, + }; +} + +function codeBlock(sel: Selection, selected: string): MarkdownEdit { + const insert = `\`\`\`\n${selected.replace(/\n$/, "")}\n\`\`\``; + return { ...sel, insert, select: { start: sel.start + 4, end: sel.start + insert.length - 4 } }; +} + +const LINE_PREFIX: Record = { + heading: /^#{1,6} /, + bullet: /^[-*+] /, + numbered: /^\d+\. /, + quote: /^> ?/, +}; +// Switching between list kinds replaces the other kind's marker instead of stacking both. +const ANY_LIST_PREFIX = /^([-*+]|\d+\.) /; + +/** Adds the style's prefix to every selected line, or removes it when all of them have it. */ +export function toggleLines(value: string, sel: Selection, style: LineStyle): MarkdownEdit { + const start = value.lastIndexOf("\n", sel.start - 1) + 1; + // A selection ending right after a newline doesn't take in the next line. + const endFrom = sel.end > sel.start && value[sel.end - 1] === "\n" ? sel.end - 1 : sel.end; + const newline = value.indexOf("\n", endFrom); + const end = newline === -1 ? value.length : newline; + const lines = value.slice(start, end).split("\n"); + + const prefix = LINE_PREFIX[style]; + const content = lines.filter((line) => line.trim()); + const remove = content.length > 0 && content.every((line) => prefix.test(line)); + let number = 0; + const next = lines.map((line) => { + if (remove) return line.replace(prefix, ""); + if (!line.trim() && lines.length > 1) return line; + const bare = style === "bullet" || style === "numbered" ? line.replace(ANY_LIST_PREFIX, "") : line; + const stripped = bare.replace(prefix, ""); + if (style === "heading") return `## ${stripped}`; + if (style === "bullet") return `- ${stripped}`; + if (style === "numbered") return `${++number}. ${stripped}`; + return `> ${stripped}`; + }); + const insert = next.join("\n"); + const caretOnly = sel.start === sel.end && lines.length === 1; + return { + start, + end, + insert, + select: caretOnly + ? { start: start + insert.length, end: start + insert.length } + : { start, end: start + insert.length }, + }; +} + +/** `[text](url)`, selecting whichever part still needs typing. */ +export function insertLink(value: string, sel: Selection, placeholders: { text: string; url: string }): MarkdownEdit { + return linkLike(value, sel, "", placeholders); +} + +/** `![alt](url)` on its own line, with the URL selected for pasting. */ +export function insertImage(value: string, sel: Selection, placeholders: { text: string; url: string }): MarkdownEdit { + const edit = linkLike(value, sel, "!", placeholders); + const lead = sel.start > 0 && value[sel.start - 1] !== "\n" ? "\n\n" : ""; + const trail = sel.end < value.length && value[sel.end] !== "\n" ? "\n\n" : ""; + return { + ...edit, + insert: `${lead}${edit.insert}${trail}`, + select: { start: edit.select.start + lead.length, end: edit.select.end + lead.length }, + }; +} + +function linkLike( + value: string, + sel: Selection, + bang: string, + placeholders: { text: string; url: string }, +): MarkdownEdit { + const selected = value.slice(sel.start, sel.end).trim(); + const isUrl = /^https?:\/\/\S+$/i.test(selected); + const text = isUrl ? placeholders.text : selected || placeholders.text; + const url = isUrl ? selected : placeholders.url; + const insert = `${bang}[${text}](${url})`; + const textStart = sel.start + bang.length + 1; + const urlStart = textStart + text.length + 2; + // A selected URL still needs its text; anything else needs its URL. + const select = isUrl + ? { start: textStart, end: textStart + text.length } + : { start: urlStart, end: urlStart + url.length }; + return { ...sel, insert, select }; +} + +/** The text after applying an edit. */ +export function applyEdit(value: string, edit: MarkdownEdit): string { + return value.slice(0, edit.start) + edit.insert + value.slice(edit.end); +}