diff --git a/surfsense_backend/app/routes/reports_routes.py b/surfsense_backend/app/routes/reports_routes.py index c9974ddb0..e32d7adcd 100644 --- a/surfsense_backend/app/routes/reports_routes.py +++ b/surfsense_backend/app/routes/reports_routes.py @@ -1,8 +1,9 @@ """ -Report routes for read, export (PDF/DOCX), and delete operations. +Report routes for read, update, export (PDF/DOCX), and delete operations. -No create or update endpoints here — reports are generated inline by the -agent tool during chat and stored as Markdown in the database. +Reports are generated inline by the agent tool during chat and stored as +Markdown in the database. Users can edit report content via the Plate editor +and save changes through the PUT endpoint. Export to PDF/DOCX is on-demand — PDF uses pypandoc (Markdown→Typst) + typst-py (Typst→PDF); DOCX uses pypandoc directly. @@ -33,7 +34,7 @@ from app.db import ( User, get_async_session, ) -from app.schemas import ReportContentRead, ReportRead +from app.schemas import ReportContentRead, ReportContentUpdate, ReportRead from app.schemas.reports import ReportVersionInfo from app.users import current_active_user from app.utils.rbac import check_search_space_access @@ -259,6 +260,47 @@ async def read_report_content( ) from None +@router.put("/reports/{report_id}/content", response_model=ReportContentRead) +async def update_report_content( + report_id: int, + body: ReportContentUpdate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Update the Markdown content of a report. + + The caller must be a member of the search space the report belongs to. + Returns the updated report content including version siblings. + """ + try: + report = await _get_report_with_access(report_id, session, user) + + report.content = body.content + session.add(report) + await session.commit() + await session.refresh(report) + + versions = await _get_version_siblings(session, report) + + return ReportContentRead( + id=report.id, + title=report.title, + content=report.content, + report_metadata=report.report_metadata, + report_group_id=report.report_group_id, + versions=versions, + ) + except HTTPException: + raise + except SQLAlchemyError: + await session.rollback() + raise HTTPException( + status_code=500, + detail="Database error occurred while updating report content", + ) from None + + @router.get("/reports/{report_id}/export") async def export_report( report_id: int, diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index e3f03b29c..7e3ba1936 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -76,7 +76,13 @@ from .rbac_schemas import ( RoleUpdate, UserSearchSpaceAccess, ) -from .reports import ReportBase, ReportContentRead, ReportRead, ReportVersionInfo +from .reports import ( + ReportBase, + ReportContentRead, + ReportContentUpdate, + ReportRead, + ReportVersionInfo, +) from .search_source_connector import ( MCPConnectorCreate, MCPConnectorRead, @@ -189,6 +195,7 @@ __all__ = [ # Report schemas "ReportBase", "ReportContentRead", + "ReportContentUpdate", "ReportRead", "ReportVersionInfo", "RoleCreate", diff --git a/surfsense_backend/app/schemas/reports.py b/surfsense_backend/app/schemas/reports.py index 9909d8601..9a7765507 100644 --- a/surfsense_backend/app/schemas/reports.py +++ b/surfsense_backend/app/schemas/reports.py @@ -51,3 +51,9 @@ class ReportContentRead(BaseModel): class Config: from_attributes = True + + +class ReportContentUpdate(BaseModel): + """Schema for updating a report's Markdown content.""" + + content: str diff --git a/surfsense_web/app/globals.css b/surfsense_web/app/globals.css index 4095fc660..c192a27be 100644 --- a/surfsense_web/app/globals.css +++ b/surfsense_web/app/globals.css @@ -4,6 +4,8 @@ @plugin "tailwindcss-animate"; +@plugin "tailwind-scrollbar-hide"; + @custom-variant dark (&:is(.dark *)); @theme { @@ -46,6 +48,8 @@ --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); --syntax-bg: #f5f5f5; + --brand: oklch(0.623 0.214 259.815); + --highlight: oklch(0.852 0.199 91.936); } .dark { @@ -82,6 +86,8 @@ --sidebar-border: oklch(0.269 0 0); --sidebar-ring: oklch(0.439 0 0); --syntax-bg: #1e1e1e; + --brand: oklch(0.707 0.165 254.624); + --highlight: oklch(0.852 0.199 91.936); } @theme inline { @@ -123,6 +129,8 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --color-brand: var(--brand); + --color-highlight: var(--highlight); } @layer base { diff --git a/surfsense_web/components.json b/surfsense_web/components.json index 6e57ca9e3..35ebd5cd0 100644 --- a/surfsense_web/components.json +++ b/surfsense_web/components.json @@ -1,21 +1,24 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@plate": "https://platejs.org/r/{name}.json" + } } diff --git a/surfsense_web/components/editor/plate-editor.tsx b/surfsense_web/components/editor/plate-editor.tsx new file mode 100644 index 000000000..4f47bc590 --- /dev/null +++ b/surfsense_web/components/editor/plate-editor.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { MarkdownPlugin } from '@platejs/markdown'; +import { Plate, usePlateEditor } from 'platejs/react'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; + +import { AutoformatKit } from '@/components/editor/plugins/autoformat-classic-kit'; +import { BasicNodesKit } from '@/components/editor/plugins/basic-nodes-kit'; +import { CalloutKit } from '@/components/editor/plugins/callout-kit'; +import { CodeBlockKit } from '@/components/editor/plugins/code-block-kit'; +import { FloatingToolbarKit } from '@/components/editor/plugins/floating-toolbar-kit'; +import { IndentKit } from '@/components/editor/plugins/indent-kit'; +import { LinkKit } from '@/components/editor/plugins/link-kit'; +import { ListKit } from '@/components/editor/plugins/list-classic-kit'; +import { MathKit } from '@/components/editor/plugins/math-kit'; +import { SelectionKit } from '@/components/editor/plugins/selection-kit'; +import { SlashCommandKit } from '@/components/editor/plugins/slash-command-kit'; +import { TableKit } from '@/components/editor/plugins/table-kit'; +import { ToggleKit } from '@/components/editor/plugins/toggle-kit'; +import { Editor, EditorContainer } from '@/components/ui/editor'; + +interface PlateEditorProps { + /** Markdown string to load as initial content */ + markdown?: string; + /** Called when the editor content changes, with serialized markdown */ + onMarkdownChange?: (markdown: string) => void; + /** Whether the editor is read-only */ + readOnly?: boolean; + /** Placeholder text */ + placeholder?: string; + /** Editor container variant */ + variant?: 'default' | 'demo' | 'comment' | 'select'; + /** Editor text variant */ + editorVariant?: 'default' | 'demo' | 'fullWidth' | 'none'; + /** Additional className for the container */ + className?: string; +} + +export function PlateEditor({ + markdown, + onMarkdownChange, + readOnly = false, + placeholder = 'Type...', + variant = 'default', + editorVariant = 'default', + className, +}: PlateEditorProps) { + const lastMarkdownRef = useRef(markdown); + + const editor = usePlateEditor({ + plugins: [ + ...BasicNodesKit, + ...TableKit, + ...ListKit, + ...CodeBlockKit, + ...LinkKit, + ...CalloutKit, + ...ToggleKit, + ...IndentKit, + ...MathKit, + ...SelectionKit, + ...SlashCommandKit, + ...FloatingToolbarKit, + ...AutoformatKit, + MarkdownPlugin.configure({ + options: { + remarkPlugins: [remarkGfm, remarkMath], + }, + }), + ], + // Use markdown deserialization for initial value if provided + value: markdown + ? (editor) => editor.getApi(MarkdownPlugin).markdown.deserialize(markdown) + : undefined, + }); + + // Update editor content when markdown prop changes externally + // (e.g., version switching in report panel) + useEffect(() => { + if (markdown !== undefined && markdown !== lastMarkdownRef.current) { + lastMarkdownRef.current = markdown; + const newValue = editor.getApi(MarkdownPlugin).markdown.deserialize(markdown); + editor.tf.reset(); + editor.tf.setValue(newValue); + } + }, [markdown, editor]); + + return ( + { + if (onMarkdownChange) { + const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value }); + lastMarkdownRef.current = md; + onMarkdownChange(md); + } + }} + > + + + + + ); +} diff --git a/surfsense_web/components/editor/plugins/autoformat-classic-kit.tsx b/surfsense_web/components/editor/plugins/autoformat-classic-kit.tsx new file mode 100644 index 000000000..23bbac734 --- /dev/null +++ b/surfsense_web/components/editor/plugins/autoformat-classic-kit.tsx @@ -0,0 +1,250 @@ +'use client'; + +import type { AutoformatBlockRule, AutoformatRule } from '@platejs/autoformat'; +import type { SlateEditor } from 'platejs'; + +import { + autoformatArrow, + autoformatLegal, + autoformatLegalHtml, + autoformatMath, + AutoformatPlugin, + autoformatPunctuation, + autoformatSmartQuotes, +} from '@platejs/autoformat'; +import { insertEmptyCodeBlock } from '@platejs/code-block'; +import { toggleList, toggleTaskList, unwrapList } from '@platejs/list-classic'; +import { openNextToggles } from '@platejs/toggle/react'; +import { ElementApi, isType, KEYS } from 'platejs'; + +const preFormat: AutoformatBlockRule['preFormat'] = (editor) => + unwrapList(editor); + +const format = (editor: SlateEditor, customFormatting: any) => { + if (editor.selection) { + const parentEntry = editor.api.parent(editor.selection); + + if (!parentEntry) return; + + const [node] = parentEntry; + + if (ElementApi.isElement(node) && !isType(editor, node, KEYS.codeBlock)) { + customFormatting(); + } + } +}; + +const formatTaskList = (editor: SlateEditor, defaultChecked = false) => { + format(editor, () => toggleTaskList(editor, defaultChecked)); +}; + +const formatList = (editor: SlateEditor, elementType: string) => { + format(editor, () => + toggleList(editor, { + type: elementType, + }) + ); +}; + +const autoformatMarks: AutoformatRule[] = [ + { + match: '***', + mode: 'mark', + type: [KEYS.bold, KEYS.italic], + }, + { + match: '__*', + mode: 'mark', + type: [KEYS.underline, KEYS.italic], + }, + { + match: '__**', + mode: 'mark', + type: [KEYS.underline, KEYS.bold], + }, + { + match: '___***', + mode: 'mark', + type: [KEYS.underline, KEYS.bold, KEYS.italic], + }, + { + match: '**', + mode: 'mark', + type: KEYS.bold, + }, + { + match: '__', + mode: 'mark', + type: KEYS.underline, + }, + { + match: '*', + mode: 'mark', + type: KEYS.italic, + }, + { + match: '_', + mode: 'mark', + type: KEYS.italic, + }, + { + match: '~~', + mode: 'mark', + type: KEYS.strikethrough, + }, + { + match: '^', + mode: 'mark', + type: KEYS.sup, + }, + { + match: '~', + mode: 'mark', + type: KEYS.sub, + }, + { + match: '==', + mode: 'mark', + type: KEYS.highlight, + }, + { + match: '≡', + mode: 'mark', + type: KEYS.highlight, + }, + { + match: '`', + mode: 'mark', + type: KEYS.code, + }, +]; + +const autoformatBlocks: AutoformatRule[] = [ + { + match: '# ', + mode: 'block', + preFormat, + type: KEYS.h1, + }, + { + match: '## ', + mode: 'block', + preFormat, + type: KEYS.h2, + }, + { + match: '### ', + mode: 'block', + preFormat, + type: KEYS.h3, + }, + { + match: '#### ', + mode: 'block', + preFormat, + type: KEYS.h4, + }, + { + match: '##### ', + mode: 'block', + preFormat, + type: KEYS.h5, + }, + { + match: '###### ', + mode: 'block', + preFormat, + type: KEYS.h6, + }, + { + match: '> ', + mode: 'block', + preFormat, + type: KEYS.blockquote, + }, + { + match: '```', + mode: 'block', + preFormat, + type: KEYS.codeBlock, + format: (editor) => { + insertEmptyCodeBlock(editor, { + defaultType: KEYS.p, + insertNodesOptions: { select: true }, + }); + }, + }, + { + match: '+ ', + mode: 'block', + preFormat: openNextToggles, + type: KEYS.toggle, + }, + { + match: ['---', '—-', '___ '], + mode: 'block', + type: KEYS.hr, + format: (editor) => { + editor.tf.setNodes({ type: KEYS.hr }); + editor.tf.insertNodes({ + children: [{ text: '' }], + type: KEYS.p, + }); + }, + }, +]; + +const autoformatLists: AutoformatRule[] = [ + { + match: ['* ', '- '], + mode: 'block', + preFormat, + type: KEYS.li, + format: (editor) => formatList(editor, KEYS.ulClassic), + }, + { + match: [String.raw`^\d+\.$ `, String.raw`^\d+\)$ `], + matchByRegex: true, + mode: 'block', + preFormat, + type: KEYS.li, + format: (editor) => formatList(editor, KEYS.olClassic), + }, + { + match: '[] ', + mode: 'block', + type: KEYS.taskList, + format: (editor) => formatTaskList(editor, false), + }, + { + match: '[x] ', + mode: 'block', + type: KEYS.taskList, + format: (editor) => formatTaskList(editor, true), + }, +]; + +export const AutoformatKit = [ + AutoformatPlugin.configure({ + options: { + enableUndoOnDelete: true, + rules: [ + ...autoformatBlocks, + ...autoformatMarks, + ...autoformatSmartQuotes, + ...autoformatPunctuation, + ...autoformatLegal, + ...autoformatLegalHtml, + ...autoformatArrow, + ...autoformatMath, + ...autoformatLists, + ].map((rule) => ({ + ...rule, + query: (editor) => + !editor.api.some({ + match: { type: editor.getType(KEYS.codeBlock) }, + }), + })), + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/basic-blocks-base-kit.tsx b/surfsense_web/components/editor/plugins/basic-blocks-base-kit.tsx new file mode 100644 index 000000000..ce533e8d5 --- /dev/null +++ b/surfsense_web/components/editor/plugins/basic-blocks-base-kit.tsx @@ -0,0 +1,35 @@ +import { + BaseBlockquotePlugin, + BaseH1Plugin, + BaseH2Plugin, + BaseH3Plugin, + BaseH4Plugin, + BaseH5Plugin, + BaseH6Plugin, + BaseHorizontalRulePlugin, +} from '@platejs/basic-nodes'; +import { BaseParagraphPlugin } from 'platejs'; + +import { BlockquoteElementStatic } from '@/components/ui/blockquote-node-static'; +import { + H1ElementStatic, + H2ElementStatic, + H3ElementStatic, + H4ElementStatic, + H5ElementStatic, + H6ElementStatic, +} from '@/components/ui/heading-node-static'; +import { HrElementStatic } from '@/components/ui/hr-node-static'; +import { ParagraphElementStatic } from '@/components/ui/paragraph-node-static'; + +export const BaseBasicBlocksKit = [ + BaseParagraphPlugin.withComponent(ParagraphElementStatic), + BaseH1Plugin.withComponent(H1ElementStatic), + BaseH2Plugin.withComponent(H2ElementStatic), + BaseH3Plugin.withComponent(H3ElementStatic), + BaseH4Plugin.withComponent(H4ElementStatic), + BaseH5Plugin.withComponent(H5ElementStatic), + BaseH6Plugin.withComponent(H6ElementStatic), + BaseBlockquotePlugin.withComponent(BlockquoteElementStatic), + BaseHorizontalRulePlugin.withComponent(HrElementStatic), +]; diff --git a/surfsense_web/components/editor/plugins/basic-blocks-kit.tsx b/surfsense_web/components/editor/plugins/basic-blocks-kit.tsx new file mode 100644 index 000000000..ddd61f326 --- /dev/null +++ b/surfsense_web/components/editor/plugins/basic-blocks-kit.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { + BlockquotePlugin, + H1Plugin, + H2Plugin, + H3Plugin, + H4Plugin, + H5Plugin, + H6Plugin, + HorizontalRulePlugin, +} from '@platejs/basic-nodes/react'; +import { ParagraphPlugin } from 'platejs/react'; + +import { BlockquoteElement } from '@/components/ui/blockquote-node'; +import { + H1Element, + H2Element, + H3Element, + H4Element, + H5Element, + H6Element, +} from '@/components/ui/heading-node'; +import { HrElement } from '@/components/ui/hr-node'; +import { ParagraphElement } from '@/components/ui/paragraph-node'; + +export const BasicBlocksKit = [ + ParagraphPlugin.withComponent(ParagraphElement), + H1Plugin.configure({ + node: { + component: H1Element, + }, + rules: { + break: { empty: 'reset' }, + }, + shortcuts: { toggle: { keys: 'mod+alt+1' } }, + }), + H2Plugin.configure({ + node: { + component: H2Element, + }, + rules: { + break: { empty: 'reset' }, + }, + shortcuts: { toggle: { keys: 'mod+alt+2' } }, + }), + H3Plugin.configure({ + node: { + component: H3Element, + }, + rules: { + break: { empty: 'reset' }, + }, + shortcuts: { toggle: { keys: 'mod+alt+3' } }, + }), + H4Plugin.configure({ + node: { + component: H4Element, + }, + rules: { + break: { empty: 'reset' }, + }, + shortcuts: { toggle: { keys: 'mod+alt+4' } }, + }), + H5Plugin.configure({ + node: { + component: H5Element, + }, + rules: { + break: { empty: 'reset' }, + }, + }), + H6Plugin.configure({ + node: { + component: H6Element, + }, + rules: { + break: { empty: 'reset' }, + }, + }), + BlockquotePlugin.configure({ + node: { component: BlockquoteElement }, + shortcuts: { toggle: { keys: 'mod+shift+period' } }, + }), + HorizontalRulePlugin.withComponent(HrElement), +]; diff --git a/surfsense_web/components/editor/plugins/basic-marks-base-kit.tsx b/surfsense_web/components/editor/plugins/basic-marks-base-kit.tsx new file mode 100644 index 000000000..7463d1ee0 --- /dev/null +++ b/surfsense_web/components/editor/plugins/basic-marks-base-kit.tsx @@ -0,0 +1,27 @@ +import { + BaseBoldPlugin, + BaseCodePlugin, + BaseHighlightPlugin, + BaseItalicPlugin, + BaseKbdPlugin, + BaseStrikethroughPlugin, + BaseSubscriptPlugin, + BaseSuperscriptPlugin, + BaseUnderlinePlugin, +} from '@platejs/basic-nodes'; + +import { CodeLeafStatic } from '@/components/ui/code-node-static'; +import { HighlightLeafStatic } from '@/components/ui/highlight-node-static'; +import { KbdLeafStatic } from '@/components/ui/kbd-node-static'; + +export const BaseBasicMarksKit = [ + BaseBoldPlugin, + BaseItalicPlugin, + BaseUnderlinePlugin, + BaseCodePlugin.withComponent(CodeLeafStatic), + BaseStrikethroughPlugin, + BaseSubscriptPlugin, + BaseSuperscriptPlugin, + BaseHighlightPlugin.withComponent(HighlightLeafStatic), + BaseKbdPlugin.withComponent(KbdLeafStatic), +]; diff --git a/surfsense_web/components/editor/plugins/basic-marks-kit.tsx b/surfsense_web/components/editor/plugins/basic-marks-kit.tsx new file mode 100644 index 000000000..d2fe62817 --- /dev/null +++ b/surfsense_web/components/editor/plugins/basic-marks-kit.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { + BoldPlugin, + CodePlugin, + HighlightPlugin, + ItalicPlugin, + KbdPlugin, + StrikethroughPlugin, + SubscriptPlugin, + SuperscriptPlugin, + UnderlinePlugin, +} from '@platejs/basic-nodes/react'; + +import { CodeLeaf } from '@/components/ui/code-node'; +import { HighlightLeaf } from '@/components/ui/highlight-node'; +import { KbdLeaf } from '@/components/ui/kbd-node'; + +export const BasicMarksKit = [ + BoldPlugin, + ItalicPlugin, + UnderlinePlugin, + CodePlugin.configure({ + node: { component: CodeLeaf }, + shortcuts: { toggle: { keys: 'mod+e' } }, + }), + StrikethroughPlugin.configure({ + shortcuts: { toggle: { keys: 'mod+shift+x' } }, + }), + SubscriptPlugin.configure({ + shortcuts: { toggle: { keys: 'mod+comma' } }, + }), + SuperscriptPlugin.configure({ + shortcuts: { toggle: { keys: 'mod+period' } }, + }), + HighlightPlugin.configure({ + node: { component: HighlightLeaf }, + shortcuts: { toggle: { keys: 'mod+shift+h' } }, + }), + KbdPlugin.withComponent(KbdLeaf), +]; diff --git a/surfsense_web/components/editor/plugins/basic-nodes-kit.tsx b/surfsense_web/components/editor/plugins/basic-nodes-kit.tsx new file mode 100644 index 000000000..6f8341635 --- /dev/null +++ b/surfsense_web/components/editor/plugins/basic-nodes-kit.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { BasicBlocksKit } from './basic-blocks-kit'; +import { BasicMarksKit } from './basic-marks-kit'; + +export const BasicNodesKit = [...BasicBlocksKit, ...BasicMarksKit]; diff --git a/surfsense_web/components/editor/plugins/callout-kit.tsx b/surfsense_web/components/editor/plugins/callout-kit.tsx new file mode 100644 index 000000000..ca862372b --- /dev/null +++ b/surfsense_web/components/editor/plugins/callout-kit.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { CalloutPlugin } from '@platejs/callout/react'; + +import { CalloutElement } from '@/components/ui/callout-node'; + +export const CalloutKit = [CalloutPlugin.withComponent(CalloutElement)]; + diff --git a/surfsense_web/components/editor/plugins/code-block-base-kit.tsx b/surfsense_web/components/editor/plugins/code-block-base-kit.tsx new file mode 100644 index 000000000..9a5a69de6 --- /dev/null +++ b/surfsense_web/components/editor/plugins/code-block-base-kit.tsx @@ -0,0 +1,23 @@ +import { + BaseCodeBlockPlugin, + BaseCodeLinePlugin, + BaseCodeSyntaxPlugin, +} from '@platejs/code-block'; +import { all, createLowlight } from 'lowlight'; + +import { + CodeBlockElementStatic, + CodeLineElementStatic, + CodeSyntaxLeafStatic, +} from '@/components/ui/code-block-node-static'; + +const lowlight = createLowlight(all); + +export const BaseCodeBlockKit = [ + BaseCodeBlockPlugin.configure({ + node: { component: CodeBlockElementStatic }, + options: { lowlight }, + }), + BaseCodeLinePlugin.withComponent(CodeLineElementStatic), + BaseCodeSyntaxPlugin.withComponent(CodeSyntaxLeafStatic), +]; diff --git a/surfsense_web/components/editor/plugins/code-block-kit.tsx b/surfsense_web/components/editor/plugins/code-block-kit.tsx new file mode 100644 index 000000000..74cb748eb --- /dev/null +++ b/surfsense_web/components/editor/plugins/code-block-kit.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { + CodeBlockPlugin, + CodeLinePlugin, + CodeSyntaxPlugin, +} from '@platejs/code-block/react'; +import { all, createLowlight } from 'lowlight'; + +import { + CodeBlockElement, + CodeLineElement, + CodeSyntaxLeaf, +} from '@/components/ui/code-block-node'; + +const lowlight = createLowlight(all); + +export const CodeBlockKit = [ + CodeBlockPlugin.configure({ + node: { component: CodeBlockElement }, + options: { lowlight }, + shortcuts: { toggle: { keys: 'mod+alt+8' } }, + }), + CodeLinePlugin.withComponent(CodeLineElement), + CodeSyntaxPlugin.withComponent(CodeSyntaxLeaf), +]; diff --git a/surfsense_web/components/editor/plugins/floating-toolbar-kit.tsx b/surfsense_web/components/editor/plugins/floating-toolbar-kit.tsx new file mode 100644 index 000000000..924439a64 --- /dev/null +++ b/surfsense_web/components/editor/plugins/floating-toolbar-kit.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { createPlatePlugin } from 'platejs/react'; + +import { FloatingToolbar } from '@/components/ui/floating-toolbar'; +import { FloatingToolbarButtons } from '@/components/ui/floating-toolbar-buttons'; + +export const FloatingToolbarKit = [ + createPlatePlugin({ + key: 'floating-toolbar', + render: { + afterEditable: () => ( + + + + ), + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/indent-kit.tsx b/surfsense_web/components/editor/plugins/indent-kit.tsx new file mode 100644 index 000000000..c2adbb2b4 --- /dev/null +++ b/surfsense_web/components/editor/plugins/indent-kit.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { IndentPlugin } from '@platejs/indent/react'; +import { KEYS } from 'platejs'; + +export const IndentKit = [ + IndentPlugin.configure({ + inject: { + targetPlugins: [ + ...KEYS.heading, + KEYS.p, + KEYS.blockquote, + KEYS.codeBlock, + KEYS.toggle, + ], + }, + }), +]; + diff --git a/surfsense_web/components/editor/plugins/link-base-kit.tsx b/surfsense_web/components/editor/plugins/link-base-kit.tsx new file mode 100644 index 000000000..5f22809ee --- /dev/null +++ b/surfsense_web/components/editor/plugins/link-base-kit.tsx @@ -0,0 +1,5 @@ +import { BaseLinkPlugin } from '@platejs/link'; + +import { LinkElementStatic } from '@/components/ui/link-node-static'; + +export const BaseLinkKit = [BaseLinkPlugin.withComponent(LinkElementStatic)]; diff --git a/surfsense_web/components/editor/plugins/link-kit.tsx b/surfsense_web/components/editor/plugins/link-kit.tsx new file mode 100644 index 000000000..91b1f892d --- /dev/null +++ b/surfsense_web/components/editor/plugins/link-kit.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { LinkPlugin } from '@platejs/link/react'; + +import { LinkElement } from '@/components/ui/link-node'; +import { LinkFloatingToolbar } from '@/components/ui/link-toolbar'; + +export const LinkKit = [ + LinkPlugin.configure({ + render: { + node: LinkElement, + afterEditable: () => , + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/list-classic-kit.tsx b/surfsense_web/components/editor/plugins/list-classic-kit.tsx new file mode 100644 index 000000000..4502ceb6f --- /dev/null +++ b/surfsense_web/components/editor/plugins/list-classic-kit.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { + BulletedListPlugin, + ListItemContentPlugin, + ListItemPlugin, + ListPlugin, + NumberedListPlugin, + TaskListPlugin, +} from '@platejs/list-classic/react'; + +import { + BulletedListElement, + ListItemElement, + NumberedListElement, + TaskListElement, +} from '@/components/ui/list-classic-node'; + +export const ListKit = [ + ListPlugin, + ListItemPlugin, + ListItemContentPlugin, + BulletedListPlugin.configure({ + node: { component: BulletedListElement }, + shortcuts: { toggle: { keys: 'mod+alt+5' } }, + }), + NumberedListPlugin.configure({ + node: { component: NumberedListElement }, + shortcuts: { toggle: { keys: 'mod+alt+6' } }, + }), + TaskListPlugin.configure({ + node: { component: TaskListElement }, + shortcuts: { toggle: { keys: 'mod+alt+7' } }, + }), + ListItemPlugin.withComponent(ListItemElement), +]; diff --git a/surfsense_web/components/editor/plugins/math-kit.tsx b/surfsense_web/components/editor/plugins/math-kit.tsx new file mode 100644 index 000000000..118fa6c47 --- /dev/null +++ b/surfsense_web/components/editor/plugins/math-kit.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { EquationPlugin, InlineEquationPlugin } from '@platejs/math/react'; + +import { EquationElement, InlineEquationElement } from '@/components/ui/equation-node'; + +export const MathKit = [ + EquationPlugin.withComponent(EquationElement), + InlineEquationPlugin.withComponent(InlineEquationElement), +]; + diff --git a/surfsense_web/components/editor/plugins/selection-kit.tsx b/surfsense_web/components/editor/plugins/selection-kit.tsx new file mode 100644 index 000000000..d010ddfca --- /dev/null +++ b/surfsense_web/components/editor/plugins/selection-kit.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { BlockSelectionPlugin } from '@platejs/selection/react'; + +import { BlockSelection } from '@/components/ui/block-selection'; + +export const SelectionKit = [ + BlockSelectionPlugin.configure({ + render: { + belowRootNodes: BlockSelection as any, + }, + options: { + isSelectable: (element) => { + // Exclude specific block types from selection + if (['code_line', 'td', 'th'].includes(element.type as string)) { + return false; + } + + return true; + }, + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/slash-command-kit.tsx b/surfsense_web/components/editor/plugins/slash-command-kit.tsx new file mode 100644 index 000000000..3d33b904d --- /dev/null +++ b/surfsense_web/components/editor/plugins/slash-command-kit.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { SlashInputPlugin, SlashPlugin } from '@platejs/slash-command/react'; +import { KEYS } from 'platejs'; + +import { SlashInputElement } from '@/components/ui/slash-node'; + +export const SlashCommandKit = [ + SlashPlugin.configure({ + options: { + trigger: '/', + triggerPreviousCharPattern: /^\s?$/, + triggerQuery: (editor) => + !editor.api.some({ + match: { type: editor.getType(KEYS.codeBlock) }, + }), + }, + }), + SlashInputPlugin.withComponent(SlashInputElement), +]; + diff --git a/surfsense_web/components/editor/plugins/table-base-kit.tsx b/surfsense_web/components/editor/plugins/table-base-kit.tsx new file mode 100644 index 000000000..c7aed2984 --- /dev/null +++ b/surfsense_web/components/editor/plugins/table-base-kit.tsx @@ -0,0 +1,20 @@ +import { + BaseTableCellHeaderPlugin, + BaseTableCellPlugin, + BaseTablePlugin, + BaseTableRowPlugin, +} from '@platejs/table'; + +import { + TableCellElementStatic, + TableCellHeaderElementStatic, + TableElementStatic, + TableRowElementStatic, +} from '@/components/ui/table-node-static'; + +export const BaseTableKit = [ + BaseTablePlugin.withComponent(TableElementStatic), + BaseTableRowPlugin.withComponent(TableRowElementStatic), + BaseTableCellPlugin.withComponent(TableCellElementStatic), + BaseTableCellHeaderPlugin.withComponent(TableCellHeaderElementStatic), +]; diff --git a/surfsense_web/components/editor/plugins/table-kit.tsx b/surfsense_web/components/editor/plugins/table-kit.tsx new file mode 100644 index 000000000..e0b54f1e4 --- /dev/null +++ b/surfsense_web/components/editor/plugins/table-kit.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { + TableCellHeaderPlugin, + TableCellPlugin, + TablePlugin, + TableRowPlugin, +} from '@platejs/table/react'; + +import { + TableCellElement, + TableCellHeaderElement, + TableElement, + TableRowElement, +} from '@/components/ui/table-node'; + +export const TableKit = [ + TablePlugin.withComponent(TableElement), + TableRowPlugin.withComponent(TableRowElement), + TableCellPlugin.withComponent(TableCellElement), + TableCellHeaderPlugin.withComponent(TableCellHeaderElement), +]; diff --git a/surfsense_web/components/editor/plugins/toggle-kit.tsx b/surfsense_web/components/editor/plugins/toggle-kit.tsx new file mode 100644 index 000000000..c19d10a37 --- /dev/null +++ b/surfsense_web/components/editor/plugins/toggle-kit.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { TogglePlugin } from '@platejs/toggle/react'; + +import { ToggleElement } from '@/components/ui/toggle-node'; + +export const ToggleKit = [ + TogglePlugin.configure({ + node: { component: ToggleElement }, + shortcuts: { toggle: { keys: 'mod+alt+9' } }, + }), +]; + diff --git a/surfsense_web/components/editor/transforms.ts b/surfsense_web/components/editor/transforms.ts new file mode 100644 index 000000000..84359d69e --- /dev/null +++ b/surfsense_web/components/editor/transforms.ts @@ -0,0 +1,184 @@ +'use client'; + +import type { PlateEditor } from 'platejs/react'; + +import { insertCallout } from '@platejs/callout'; +import { insertCodeBlock, toggleCodeBlock } from '@platejs/code-block'; +import { triggerFloatingLink } from '@platejs/link/react'; +import { insertInlineEquation } from '@platejs/math'; +import { TablePlugin } from '@platejs/table/react'; +import { + type NodeEntry, + type Path, + type TElement, + KEYS, + PathApi, +} from 'platejs'; + +const insertList = (editor: PlateEditor, type: string) => { + editor.tf.insertNodes( + editor.api.create.block({ + indent: 1, + listStyleType: type, + }), + { select: true } + ); +}; + +const insertBlockMap: Record< + string, + (editor: PlateEditor, type: string) => void +> = { + [KEYS.listTodo]: insertList, + [KEYS.ol]: insertList, + [KEYS.ul]: insertList, + [KEYS.codeBlock]: (editor) => insertCodeBlock(editor, { select: true }), + [KEYS.table]: (editor) => + editor.getTransforms(TablePlugin).insert.table({}, { select: true }), + [KEYS.callout]: (editor) => insertCallout(editor, { select: true }), + [KEYS.toggle]: (editor) => { + editor.tf.insertNodes( + editor.api.create.block({ type: KEYS.toggle }), + { select: true } + ); + }, +}; + +const insertInlineMap: Record< + string, + (editor: PlateEditor, type: string) => void +> = { + [KEYS.link]: (editor) => triggerFloatingLink(editor, { focused: true }), + [KEYS.equation]: (editor) => insertInlineEquation(editor), +}; + +type InsertBlockOptions = { + upsert?: boolean; +}; + +export const insertBlock = ( + editor: PlateEditor, + type: string, + options: InsertBlockOptions = {} +) => { + const { upsert = false } = options; + + editor.tf.withoutNormalizing(() => { + const block = editor.api.block(); + + if (!block) return; + + const [currentNode, path] = block; + const isCurrentBlockEmpty = editor.api.isEmpty(currentNode); + const currentBlockType = getBlockType(currentNode); + + const isSameBlockType = type === currentBlockType; + + if (upsert && isCurrentBlockEmpty && isSameBlockType) { + return; + } + + if (type in insertBlockMap) { + insertBlockMap[type](editor, type); + } else { + editor.tf.insertNodes(editor.api.create.block({ type }), { + at: PathApi.next(path), + select: true, + }); + } + + if (!isSameBlockType) { + editor.tf.removeNodes({ previousEmptyBlock: true }); + } + }); +}; + +export const insertInlineElement = (editor: PlateEditor, type: string) => { + if (insertInlineMap[type]) { + insertInlineMap[type](editor, type); + } +}; + +const setList = ( + editor: PlateEditor, + type: string, + entry: NodeEntry +) => { + editor.tf.setNodes( + editor.api.create.block({ + indent: 1, + listStyleType: type, + }), + { + at: entry[1], + } + ); +}; + +const setBlockMap: Record< + string, + (editor: PlateEditor, type: string, entry: NodeEntry) => void +> = { + [KEYS.listTodo]: setList, + [KEYS.ol]: setList, + [KEYS.ul]: setList, + [KEYS.codeBlock]: (editor) => toggleCodeBlock(editor), + [KEYS.callout]: (editor, _type, entry) => { + editor.tf.setNodes({ type: KEYS.callout }, { at: entry[1] }); + }, + [KEYS.toggle]: (editor, _type, entry) => { + editor.tf.setNodes({ type: KEYS.toggle }, { at: entry[1] }); + }, +}; + +export const setBlockType = ( + editor: PlateEditor, + type: string, + { at }: { at?: Path } = {} +) => { + editor.tf.withoutNormalizing(() => { + const setEntry = (entry: NodeEntry) => { + const [node, path] = entry; + + if (node[KEYS.listType]) { + editor.tf.unsetNodes([KEYS.listType, 'indent'], { at: path }); + } + if (type in setBlockMap) { + return setBlockMap[type](editor, type, entry); + } + if (node.type !== type) { + editor.tf.setNodes({ type }, { at: path }); + } + }; + + if (at) { + const entry = editor.api.node(at); + + if (entry) { + setEntry(entry); + + return; + } + } + + const entries = editor.api.blocks({ mode: 'lowest' }); + + entries.forEach((entry) => { + setEntry(entry); + }); + }); +}; + +export const getBlockType = (block: TElement) => { + if (block[KEYS.listType]) { + if (block[KEYS.listType] === KEYS.ol) { + return KEYS.ol; + } + if (block[KEYS.listType] === KEYS.listTodo) { + return KEYS.listTodo; + } + return KEYS.ul; + } + + return block.type; +}; diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 84b271511..4d0734015 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -1,11 +1,12 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { ChevronDownIcon, XIcon } from "lucide-react"; +import { ChevronDownIcon, SaveIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { z } from "zod"; import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; -import { MarkdownViewer } from "@/components/markdown-viewer"; +import { PlateEditor } from "@/components/editor/plate-editor"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle } from "@/components/ui/drawer"; import { @@ -112,6 +113,10 @@ function ReportPanelContent({ const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const [exporting, setExporting] = useState<"pdf" | "docx" | "md" | null>(null); + const [saving, setSaving] = useState(false); + + // Editor state — tracks the latest markdown from the Plate editor + const [editedMarkdown, setEditedMarkdown] = useState(null); // Version state const [activeReportId, setActiveReportId] = useState(reportId); @@ -165,17 +170,25 @@ function ReportPanelContent({ }; }, [activeReportId, shareToken]); - // Copy markdown content + // The current markdown: use edited version if available, otherwise original + const currentMarkdown = editedMarkdown ?? reportContent?.content ?? null; + + // Reset edited markdown when switching versions or reports + useEffect(() => { + setEditedMarkdown(null); + }, [activeReportId]); + + // Copy markdown content (uses latest editor content) const handleCopy = useCallback(async () => { - if (!reportContent?.content) return; + if (!currentMarkdown) return; try { - await navigator.clipboard.writeText(reportContent.content); + await navigator.clipboard.writeText(currentMarkdown); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (err) { console.error("Failed to copy:", err); } - }, [reportContent?.content]); + }, [currentMarkdown]); // Export report const handleExport = useCallback( @@ -188,9 +201,9 @@ function ReportPanelContent({ .slice(0, 80) || "report"; try { if (format === "md") { - // Download markdown content directly as a .md file - if (!reportContent?.content) return; - const blob = new Blob([reportContent.content], { + // Download markdown content directly as a .md file (uses latest editor content) + if (!currentMarkdown) return; + const blob = new Blob([currentMarkdown], { type: "text/markdown;charset=utf-8", }); const url = URL.createObjectURL(blob); @@ -227,9 +240,40 @@ function ReportPanelContent({ setExporting(null); } }, - [activeReportId, title, reportContent?.content] + [activeReportId, title, currentMarkdown] ); + // Save edited report content + const handleSave = useCallback(async () => { + if (!currentMarkdown || !activeReportId) return; + setSaving(true); + try { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/content`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: currentMarkdown }), + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: "Failed to save report" })); + throw new Error(errorData.detail || "Failed to save report"); + } + + // Update local state to reflect saved content + setReportContent((prev) => (prev ? { ...prev, content: currentMarkdown } : prev)); + setEditedMarkdown(null); + toast.success("Report saved successfully"); + } catch (err) { + console.error("Error saving report:", err); + toast.error(err instanceof Error ? err.message : "Failed to save report"); + } finally { + setSaving(false); + } + }, [activeReportId, currentMarkdown]); + // Show full-page skeleton only on initial load (no data loaded yet). // Once we have versions/content from a prior fetch, keep the action bar visible. const hasLoadedBefore = versions.length > 0 || reportContent !== null; @@ -258,6 +302,20 @@ function ReportPanelContent({ {/* Action bar — always visible after initial load */}
+ {/* Save button — only shown for authenticated users with unsaved edits */} + {!shareToken && editedMarkdown !== null && ( + + )} + {/* Copy button */}
diff --git a/surfsense_web/components/ui/block-selection.tsx b/surfsense_web/components/ui/block-selection.tsx new file mode 100644 index 000000000..386fe8852 --- /dev/null +++ b/surfsense_web/components/ui/block-selection.tsx @@ -0,0 +1,44 @@ +'use client'; + +import * as React from 'react'; + +import { DndPlugin } from '@platejs/dnd'; +import { useBlockSelected } from '@platejs/selection/react'; +import { cva } from 'class-variance-authority'; +import { type PlateElementProps, usePluginOption } from 'platejs/react'; + +export const blockSelectionVariants = cva( + 'pointer-events-none absolute inset-0 z-1 bg-brand/[.13] transition-opacity', + { + defaultVariants: { + active: true, + }, + variants: { + active: { + false: 'opacity-0', + true: 'opacity-100', + }, + }, + } +); + +export function BlockSelection(props: PlateElementProps) { + const isBlockSelected = useBlockSelected(); + const isDragging = usePluginOption(DndPlugin, 'isDragging'); + + if ( + !isBlockSelected || + props.plugin.key === 'tr' || + props.plugin.key === 'table' + ) + return null; + + return ( +
+ ); +} diff --git a/surfsense_web/components/ui/blockquote-node-static.tsx b/surfsense_web/components/ui/blockquote-node-static.tsx new file mode 100644 index 000000000..5d471f805 --- /dev/null +++ b/surfsense_web/components/ui/blockquote-node-static.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +import { type SlateElementProps, SlateElement } from 'platejs/static'; + +export function BlockquoteElementStatic(props: SlateElementProps) { + return ( + + ); +} diff --git a/surfsense_web/components/ui/blockquote-node.tsx b/surfsense_web/components/ui/blockquote-node.tsx new file mode 100644 index 000000000..ba5bec4e8 --- /dev/null +++ b/surfsense_web/components/ui/blockquote-node.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { type PlateElementProps, PlateElement } from 'platejs/react'; + +export function BlockquoteElement(props: PlateElementProps) { + return ( + + ); +} diff --git a/surfsense_web/components/ui/callout-node.tsx b/surfsense_web/components/ui/callout-node.tsx new file mode 100644 index 000000000..2d24cb864 --- /dev/null +++ b/surfsense_web/components/ui/callout-node.tsx @@ -0,0 +1,83 @@ +'use client'; + +import * as React from 'react'; + +import type { TCalloutElement } from 'platejs'; + +import { CalloutPlugin } from '@platejs/callout/react'; +import { cva } from 'class-variance-authority'; +import { type PlateElementProps, PlateElement, useEditorPlugin } from 'platejs/react'; + +import { cn } from '@/lib/utils'; + +const calloutVariants = cva( + 'my-1 flex w-full items-start gap-2 rounded-lg border p-4', + { + defaultVariants: { + variant: 'info', + }, + variants: { + variant: { + info: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/50', + warning: 'border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950/50', + error: 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/50', + success: 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/50', + note: 'border-muted bg-muted/50', + tip: 'border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-950/50', + }, + }, + } +); + +const calloutIcons: Record = { + info: '💡', + warning: '⚠️', + error: '🚨', + success: '✅', + note: '📝', + tip: '💜', +}; + +const variantCycle = ['info', 'warning', 'error', 'success', 'note', 'tip'] as const; + +export function CalloutElement({ + children, + ...props +}: PlateElementProps) { + const { editor } = useEditorPlugin(CalloutPlugin); + const element = props.element; + const variant = element.variant || 'info'; + const icon = element.icon || calloutIcons[variant] || '💡'; + + const cycleVariant = React.useCallback(() => { + const currentIndex = variantCycle.indexOf(variant as (typeof variantCycle)[number]); + const nextIndex = (currentIndex + 1) % variantCycle.length; + const nextVariant = variantCycle[nextIndex]; + + editor.tf.setNodes( + { + variant: nextVariant, + icon: calloutIcons[nextVariant], + }, + { at: props.path } + ); + }, [editor, variant, props.path]); + + return ( + + +
{children}
+
+ ); +} diff --git a/surfsense_web/components/ui/code-block-node-static.tsx b/surfsense_web/components/ui/code-block-node-static.tsx new file mode 100644 index 000000000..8e52618e6 --- /dev/null +++ b/surfsense_web/components/ui/code-block-node-static.tsx @@ -0,0 +1,161 @@ +import * as React from 'react'; + +import type { TCodeBlockElement } from 'platejs'; + +import { + type SlateElementProps, + type SlateLeafProps, + SlateElement, + SlateLeaf, +} from 'platejs/static'; + +export function CodeBlockElementStatic( + props: SlateElementProps +) { + return ( + +
+
+          {props.children}
+        
+
+
+ ); +} + +export function CodeLineElementStatic(props: SlateElementProps) { + return ; +} + +export function CodeSyntaxLeafStatic(props: SlateLeafProps) { + const tokenClassName = props.leaf.className as string; + + return ; +} + +/** + * DOCX-compatible code block components. + * Uses inline styles for proper rendering in Word documents. + */ + +export function CodeBlockElementDocx( + props: SlateElementProps +) { + return ( + +
+ {props.children} +
+
+ ); +} + +export function CodeLineElementDocx(props: SlateElementProps) { + return ( + + ); +} + +// Syntax highlighting color map for common token types +const syntaxColors: Record = { + 'hljs-addition': '#22863a', + 'hljs-attr': '#005cc5', + 'hljs-attribute': '#005cc5', + 'hljs-built_in': '#e36209', + 'hljs-bullet': '#735c0f', + 'hljs-comment': '#6a737d', + 'hljs-deletion': '#b31d28', + 'hljs-doctag': '#d73a49', + 'hljs-emphasis': '#24292e', + 'hljs-formula': '#6a737d', + 'hljs-keyword': '#d73a49', + 'hljs-literal': '#005cc5', + 'hljs-meta': '#005cc5', + 'hljs-name': '#22863a', + 'hljs-number': '#005cc5', + 'hljs-operator': '#005cc5', + 'hljs-quote': '#22863a', + 'hljs-regexp': '#032f62', + 'hljs-section': '#005cc5', + 'hljs-selector-attr': '#005cc5', + 'hljs-selector-class': '#005cc5', + 'hljs-selector-id': '#005cc5', + 'hljs-selector-pseudo': '#22863a', + 'hljs-selector-tag': '#22863a', + 'hljs-string': '#032f62', + 'hljs-strong': '#24292e', + 'hljs-symbol': '#e36209', + 'hljs-template-tag': '#d73a49', + 'hljs-template-variable': '#d73a49', + 'hljs-title': '#6f42c1', + 'hljs-type': '#d73a49', + 'hljs-variable': '#005cc5', +}; + +// Convert regular spaces to non-breaking spaces to preserve indentation in Word +const preserveSpaces = (text: string): string => { + // Replace regular spaces with non-breaking spaces + return text.replace(/ /g, '\u00A0'); +}; + +export function CodeSyntaxLeafDocx(props: SlateLeafProps) { + const tokenClassName = props.leaf.className as string; + + // Extract color from className + let color: string | undefined; + let fontWeight: string | undefined; + let fontStyle: string | undefined; + + if (tokenClassName) { + const classes = tokenClassName.split(' '); + for (const cls of classes) { + if (syntaxColors[cls]) { + color = syntaxColors[cls]; + } + if (cls === 'hljs-strong' || cls === 'hljs-section') { + fontWeight = 'bold'; + } + if (cls === 'hljs-emphasis') { + fontStyle = 'italic'; + } + } + } + + // Get the text content and preserve spaces + const text = props.leaf.text as string; + const preservedText = preserveSpaces(text); + + return ( + + {preservedText} + + ); +} diff --git a/surfsense_web/components/ui/code-block-node.tsx b/surfsense_web/components/ui/code-block-node.tsx new file mode 100644 index 000000000..b846297db --- /dev/null +++ b/surfsense_web/components/ui/code-block-node.tsx @@ -0,0 +1,289 @@ +'use client'; + +import * as React from 'react'; + +import { formatCodeBlock, isLangSupported } from '@platejs/code-block'; +import { BracesIcon, Check, CheckIcon, CopyIcon } from 'lucide-react'; +import { type TCodeBlockElement, type TCodeSyntaxLeaf, NodeApi } from 'platejs'; +import { + type PlateElementProps, + type PlateLeafProps, + PlateElement, + PlateLeaf, +} from 'platejs/react'; +import { useEditorRef, useElement, useReadOnly } from 'platejs/react'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +export function CodeBlockElement(props: PlateElementProps) { + const { editor, element } = props; + + return ( + +
+
+          {props.children}
+        
+ +
+ {isLangSupported(element.lang) && ( + + )} + + + + NodeApi.string(element)} + /> +
+
+
+ ); +} + +function CodeBlockCombobox() { + const [open, setOpen] = React.useState(false); + const readOnly = useReadOnly(); + const editor = useEditorRef(); + const element = useElement(); + const value = element.lang || 'plaintext'; + const [searchValue, setSearchValue] = React.useState(''); + + const items = React.useMemo( + () => + languages.filter( + (language) => + !searchValue || + language.label.toLowerCase().includes(searchValue.toLowerCase()) + ), + [searchValue] + ); + + if (readOnly) return null; + + return ( + + + + + setSearchValue('')} + > + + setSearchValue(value)} + placeholder="Search language..." + /> + No language found. + + + + {items.map((language) => ( + { + editor.tf.setNodes( + { lang: value }, + { at: element } + ); + setSearchValue(value); + setOpen(false); + }} + > + + {language.label} + + ))} + + + + + + ); +} + +function CopyButton({ + value, + ...props +}: { value: (() => string) | string } & Omit< + React.ComponentProps, + 'value' +>) { + const [hasCopied, setHasCopied] = React.useState(false); + + React.useEffect(() => { + setTimeout(() => { + setHasCopied(false); + }, 2000); + }, [hasCopied]); + + return ( + + ); +} + +export function CodeLineElement(props: PlateElementProps) { + return ; +} + +export function CodeSyntaxLeaf(props: PlateLeafProps) { + const tokenClassName = props.leaf.className as string; + + return ; +} + +const languages: { label: string; value: string }[] = [ + { label: 'Auto', value: 'auto' }, + { label: 'Plain Text', value: 'plaintext' }, + { label: 'ABAP', value: 'abap' }, + { label: 'Agda', value: 'agda' }, + { label: 'Arduino', value: 'arduino' }, + { label: 'ASCII Art', value: 'ascii' }, + { label: 'Assembly', value: 'x86asm' }, + { label: 'Bash', value: 'bash' }, + { label: 'BASIC', value: 'basic' }, + { label: 'BNF', value: 'bnf' }, + { label: 'C', value: 'c' }, + { label: 'C#', value: 'csharp' }, + { label: 'C++', value: 'cpp' }, + { label: 'Clojure', value: 'clojure' }, + { label: 'CoffeeScript', value: 'coffeescript' }, + { label: 'Coq', value: 'coq' }, + { label: 'CSS', value: 'css' }, + { label: 'Dart', value: 'dart' }, + { label: 'Dhall', value: 'dhall' }, + { label: 'Diff', value: 'diff' }, + { label: 'Docker', value: 'dockerfile' }, + { label: 'EBNF', value: 'ebnf' }, + { label: 'Elixir', value: 'elixir' }, + { label: 'Elm', value: 'elm' }, + { label: 'Erlang', value: 'erlang' }, + { label: 'F#', value: 'fsharp' }, + { label: 'Flow', value: 'flow' }, + { label: 'Fortran', value: 'fortran' }, + { label: 'Gherkin', value: 'gherkin' }, + { label: 'GLSL', value: 'glsl' }, + { label: 'Go', value: 'go' }, + { label: 'GraphQL', value: 'graphql' }, + { label: 'Groovy', value: 'groovy' }, + { label: 'Haskell', value: 'haskell' }, + { label: 'HCL', value: 'hcl' }, + { label: 'HTML', value: 'html' }, + { label: 'Idris', value: 'idris' }, + { label: 'Java', value: 'java' }, + { label: 'JavaScript', value: 'javascript' }, + { label: 'JSON', value: 'json' }, + { label: 'Julia', value: 'julia' }, + { label: 'Kotlin', value: 'kotlin' }, + { label: 'LaTeX', value: 'latex' }, + { label: 'Less', value: 'less' }, + { label: 'Lisp', value: 'lisp' }, + { label: 'LiveScript', value: 'livescript' }, + { label: 'LLVM IR', value: 'llvm' }, + { label: 'Lua', value: 'lua' }, + { label: 'Makefile', value: 'makefile' }, + { label: 'Markdown', value: 'markdown' }, + { label: 'Markup', value: 'markup' }, + { label: 'MATLAB', value: 'matlab' }, + { label: 'Mathematica', value: 'mathematica' }, + { label: 'Mermaid', value: 'mermaid' }, + { label: 'Nix', value: 'nix' }, + { label: 'Notion Formula', value: 'notion' }, + { label: 'Objective-C', value: 'objectivec' }, + { label: 'OCaml', value: 'ocaml' }, + { label: 'Pascal', value: 'pascal' }, + { label: 'Perl', value: 'perl' }, + { label: 'PHP', value: 'php' }, + { label: 'PowerShell', value: 'powershell' }, + { label: 'Prolog', value: 'prolog' }, + { label: 'Protocol Buffers', value: 'protobuf' }, + { label: 'PureScript', value: 'purescript' }, + { label: 'Python', value: 'python' }, + { label: 'R', value: 'r' }, + { label: 'Racket', value: 'racket' }, + { label: 'Reason', value: 'reasonml' }, + { label: 'Ruby', value: 'ruby' }, + { label: 'Rust', value: 'rust' }, + { label: 'Sass', value: 'scss' }, + { label: 'Scala', value: 'scala' }, + { label: 'Scheme', value: 'scheme' }, + { label: 'SCSS', value: 'scss' }, + { label: 'Shell', value: 'shell' }, + { label: 'Smalltalk', value: 'smalltalk' }, + { label: 'Solidity', value: 'solidity' }, + { label: 'SQL', value: 'sql' }, + { label: 'Swift', value: 'swift' }, + { label: 'TOML', value: 'toml' }, + { label: 'TypeScript', value: 'typescript' }, + { label: 'VB.Net', value: 'vbnet' }, + { label: 'Verilog', value: 'verilog' }, + { label: 'VHDL', value: 'vhdl' }, + { label: 'Visual Basic', value: 'vbnet' }, + { label: 'WebAssembly', value: 'wasm' }, + { label: 'XML', value: 'xml' }, + { label: 'YAML', value: 'yaml' }, +]; diff --git a/surfsense_web/components/ui/code-node-static.tsx b/surfsense_web/components/ui/code-node-static.tsx new file mode 100644 index 000000000..9b056659a --- /dev/null +++ b/surfsense_web/components/ui/code-node-static.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; + +import type { SlateLeafProps } from 'platejs/static'; + +import { SlateLeaf } from 'platejs/static'; + +export function CodeLeafStatic(props: SlateLeafProps) { + return ( + + {props.children} + + ); +} diff --git a/surfsense_web/components/ui/code-node.tsx b/surfsense_web/components/ui/code-node.tsx new file mode 100644 index 000000000..5295b9c83 --- /dev/null +++ b/surfsense_web/components/ui/code-node.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; + +import type { PlateLeafProps } from 'platejs/react'; + +import { PlateLeaf } from 'platejs/react'; + +export function CodeLeaf(props: PlateLeafProps) { + return ( + + {props.children} + + ); +} diff --git a/surfsense_web/components/ui/editor-static.tsx b/surfsense_web/components/ui/editor-static.tsx new file mode 100644 index 000000000..b9592bf35 --- /dev/null +++ b/surfsense_web/components/ui/editor-static.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; + +import type { VariantProps } from 'class-variance-authority'; + +import { cva } from 'class-variance-authority'; +import { type PlateStaticProps, PlateStatic } from 'platejs/static'; + +import { cn } from '@/lib/utils'; + +export const editorVariants = cva( + cn( + 'group/editor', + 'relative w-full cursor-text select-text overflow-x-hidden whitespace-pre-wrap break-words', + 'rounded-md ring-offset-background focus-visible:outline-none', + 'placeholder:text-muted-foreground/80 **:data-slate-placeholder:top-[auto_!important] **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!', + '[&_strong]:font-bold' + ), + { + defaultVariants: { + variant: 'none', + }, + variants: { + disabled: { + true: 'cursor-not-allowed opacity-50', + }, + focused: { + true: 'ring-2 ring-ring ring-offset-2', + }, + variant: { + ai: 'w-full px-0 text-base md:text-sm', + aiChat: + 'max-h-[min(70vh,320px)] w-full overflow-y-auto px-5 py-3 text-base md:text-sm', + default: + 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]', + demo: 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]', + fullWidth: 'size-full px-16 pt-4 pb-72 text-base sm:px-24', + none: '', + select: 'px-3 py-2 text-base data-readonly:w-fit', + }, + }, + } +); + +export function EditorStatic({ + className, + variant, + ...props +}: PlateStaticProps & VariantProps) { + return ( + + ); +} diff --git a/surfsense_web/components/ui/editor.tsx b/surfsense_web/components/ui/editor.tsx new file mode 100644 index 000000000..e609fa72c --- /dev/null +++ b/surfsense_web/components/ui/editor.tsx @@ -0,0 +1,132 @@ +'use client'; + +import * as React from 'react'; + +import type { VariantProps } from 'class-variance-authority'; +import type { PlateContentProps, PlateViewProps } from 'platejs/react'; + +import { cva } from 'class-variance-authority'; +import { PlateContainer, PlateContent, PlateView } from 'platejs/react'; + +import { cn } from '@/lib/utils'; + +const editorContainerVariants = cva( + 'relative w-full cursor-text select-text overflow-y-auto caret-primary selection:bg-brand/25 focus-visible:outline-none [&_.slate-selection-area]:z-50 [&_.slate-selection-area]:border [&_.slate-selection-area]:border-brand/25 [&_.slate-selection-area]:bg-brand/15', + { + defaultVariants: { + variant: 'default', + }, + variants: { + variant: { + comment: cn( + 'flex flex-wrap justify-between gap-1 px-1 py-0.5 text-sm', + 'rounded-md border-[1.5px] border-transparent bg-transparent', + 'has-[[data-slate-editor]:focus]:border-brand/50 has-[[data-slate-editor]:focus]:ring-2 has-[[data-slate-editor]:focus]:ring-brand/30', + 'has-aria-disabled:border-input has-aria-disabled:bg-muted' + ), + default: 'h-full', + demo: 'h-[650px]', + select: cn( + 'group rounded-md border border-input ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2', + 'has-data-readonly:w-fit has-data-readonly:cursor-default has-data-readonly:border-transparent has-data-readonly:focus-within:[box-shadow:none]' + ), + }, + }, + } +); + +export function EditorContainer({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( + + ); +} + +const editorVariants = cva( + cn( + 'group/editor', + 'relative w-full cursor-text select-text overflow-x-hidden whitespace-pre-wrap break-words', + 'rounded-md ring-offset-background focus-visible:outline-none', + '**:data-slate-placeholder:!top-1/2 **:data-slate-placeholder:-translate-y-1/2 placeholder:text-muted-foreground/80 **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!', + '[&_strong]:font-bold' + ), + { + defaultVariants: { + variant: 'default', + }, + variants: { + disabled: { + true: 'cursor-not-allowed opacity-50', + }, + focused: { + true: 'ring-2 ring-ring ring-offset-2', + }, + variant: { + ai: 'w-full px-0 text-base md:text-sm', + aiChat: + 'max-h-[min(70vh,320px)] w-full overflow-y-auto px-3 py-2 text-base md:text-sm', + comment: cn('rounded-none border-none bg-transparent text-sm'), + default: + 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]', + demo: 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]', + fullWidth: 'size-full px-16 pt-4 pb-72 text-base sm:px-24', + none: '', + select: 'px-3 py-2 text-base data-readonly:w-fit', + }, + }, + } +); + +export type EditorProps = PlateContentProps & + VariantProps; + +export const Editor = ({ + className, + disabled, + focused, + variant, + ref, + ...props +}: EditorProps & { ref?: React.RefObject }) => ( + +); + +Editor.displayName = 'Editor'; + +export function EditorView({ + className, + variant, + ...props +}: PlateViewProps & VariantProps) { + return ( + + ); +} + +EditorView.displayName = 'EditorView'; diff --git a/surfsense_web/components/ui/equation-node.tsx b/surfsense_web/components/ui/equation-node.tsx new file mode 100644 index 000000000..66b3bb3d7 --- /dev/null +++ b/surfsense_web/components/ui/equation-node.tsx @@ -0,0 +1,183 @@ +'use client'; + +import * as React from 'react'; + +import type { TEquationElement } from 'platejs'; + +import { + useEquationElement, + useEquationInput, +} from '@platejs/math/react'; +import { RadicalIcon } from 'lucide-react'; +import { type PlateElementProps, PlateElement, useSelected } from 'platejs/react'; + +import { cn } from '@/lib/utils'; + +export function EquationElement({ + children, + ...props +}: PlateElementProps) { + const element = props.element; + const selected = useSelected(); + const katexRef = React.useRef(null); + const [isEditing, setIsEditing] = React.useState(false); + + useEquationElement({ + element, + katexRef, + options: { + displayMode: true, + throwOnError: false, + }, + }); + + const { props: inputProps, ref: inputRef, onDismiss, onSubmit } = useEquationInput({ + isInline: false, + open: isEditing, + onClose: () => setIsEditing(false), + }); + + return ( + +
setIsEditing(true)} + > + {element.texExpression ? ( +
+ ) : ( +
+ + Add an equation +
+ )} +
+ + {isEditing && ( +
+