diff --git a/surfsense_web/components/editor/editor-save-context.tsx b/surfsense_web/components/editor/editor-save-context.tsx new file mode 100644 index 000000000..b72eec0ae --- /dev/null +++ b/surfsense_web/components/editor/editor-save-context.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +interface EditorSaveContextValue { + /** Callback to save the current editor content */ + onSave?: () => void; + /** Whether there are unsaved changes */ + hasUnsavedChanges: boolean; + /** Whether a save operation is in progress */ + isSaving: boolean; + /** Whether the user can toggle between editing and viewing modes */ + canToggleMode: boolean; +} + +export const EditorSaveContext = createContext({ + hasUnsavedChanges: false, + isSaving: false, + canToggleMode: false, +}); + +export function useEditorSave() { + return useContext(EditorSaveContext); +} + diff --git a/surfsense_web/components/editor/plate-editor.tsx b/surfsense_web/components/editor/plate-editor.tsx index 510d95110..6eb72b030 100644 --- a/surfsense_web/components/editor/plate-editor.tsx +++ b/surfsense_web/components/editor/plate-editor.tsx @@ -22,13 +22,19 @@ import { TableKit } from '@/components/editor/plugins/table-kit'; import { ToggleKit } from '@/components/editor/plugins/toggle-kit'; import { Editor, EditorContainer } from '@/components/ui/editor'; import { escapeMdxExpressions } from '@/components/editor/utils/escape-mdx'; +import { EditorSaveContext } from '@/components/editor/editor-save-context'; 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 */ + /** + * Force permanent read-only mode (e.g. public/shared view). + * When true, the editor cannot be toggled to editing mode. + * When false (default), the editor starts in viewing mode but + * the user can switch to editing via the mode toolbar button. + */ readOnly?: boolean; /** Placeholder text */ placeholder?: string; @@ -38,6 +44,12 @@ interface PlateEditorProps { editorVariant?: 'default' | 'demo' | 'fullWidth' | 'none'; /** Additional className for the container */ className?: string; + /** Save callback. When provided, a save button appears in the toolbar on unsaved changes. */ + onSave?: () => void; + /** Whether there are unsaved changes */ + hasUnsavedChanges?: boolean; + /** Whether a save is in progress */ + isSaving?: boolean; } export function PlateEditor({ @@ -48,10 +60,17 @@ export function PlateEditor({ variant = 'default', editorVariant = 'default', className, + onSave, + hasUnsavedChanges = false, + isSaving = false, }: PlateEditorProps) { const lastMarkdownRef = useRef(markdown); + // Always initialize the editor in readOnly mode (viewing mode). + // For non-forced readOnly, the user can toggle to editing via ModeToolbarButton. + // For forced readOnly, the mode button is hidden and readOnly stays true. const editor = usePlateEditor({ + readOnly: true, plugins: [ ...BasicNodesKit, ...TableKit, @@ -95,21 +114,36 @@ export function PlateEditor({ } }, [markdown, editor]); + // When not forced read-only, the user can toggle between editing/viewing. + const canToggleMode = !readOnly; + return ( - { - if (onMarkdownChange) { - const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value }); - lastMarkdownRef.current = md; - onMarkdownChange(md); - } + - - - - + { + if (onMarkdownChange) { + const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value }); + lastMarkdownRef.current = md; + onMarkdownChange(md); + } + }} + > + + + + + ); } diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 4d0734015..8f29bcb57 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { ChevronDownIcon, SaveIcon, XIcon } from "lucide-react"; +import { ChevronDownIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; @@ -302,21 +302,7 @@ function ReportPanelContent({ {/* Action bar — always visible after initial load */}
- {/* Save button — only shown for authenticated users with unsaved edits */} - {!shareToken && editedMarkdown !== null && ( - - )} - - {/* Copy button */} + {/* Copy button */}
) : reportContent.content ? ( - + ) : (

No content available.

diff --git a/surfsense_web/components/ui/dropdown-menu.tsx b/surfsense_web/components/ui/dropdown-menu.tsx index a9df1a5b2..bffc32762 100644 --- a/surfsense_web/components/ui/dropdown-menu.tsx +++ b/surfsense_web/components/ui/dropdown-menu.tsx @@ -1,228 +1,257 @@ -"use client"; +"use client" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; -import type * as React from "react"; +import * as React from "react" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils" -function DropdownMenu({ ...props }: React.ComponentProps) { - return ; +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return } function DropdownMenuPortal({ - ...props + ...props }: React.ComponentProps) { - return ; + return ( + + ) } function DropdownMenuTrigger({ - ...props + ...props }: React.ComponentProps) { - return ; + return ( + + ) } function DropdownMenuContent({ - className, - sideOffset = 4, - ...props + className, + sideOffset = 4, + ...props }: React.ComponentProps) { - return ( - - - - ); + return ( + + + + ) } -function DropdownMenuGroup({ ...props }: React.ComponentProps) { - return ; +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) } function DropdownMenuItem({ - className, - inset, - variant = "default", - ...props + className, + inset, + variant = "default", + ...props }: React.ComponentProps & { - inset?: boolean; - variant?: "default" | "destructive"; + inset?: boolean + variant?: "default" | "destructive" }) { - return ( - - ); + return ( + + ) } function DropdownMenuCheckboxItem({ - className, - children, - checked, - ...props + className, + children, + checked, + ...props }: React.ComponentProps) { - return ( - - - - - - - {children} - - ); + return ( + + + + + + + {children} + + ) } function DropdownMenuRadioGroup({ - ...props + ...props }: React.ComponentProps) { - return ; + return ( + + ) } function DropdownMenuRadioItem({ - className, - children, - ...props + className, + children, + ...props }: React.ComponentProps) { - return ( - - - - - - - {children} - - ); + return ( + + + + + + + {children} + + ) } function DropdownMenuLabel({ - className, - inset, - ...props + className, + inset, + ...props }: React.ComponentProps & { - inset?: boolean; + inset?: boolean }) { - return ( - - ); + return ( + + ) } function DropdownMenuSeparator({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ); + return ( + + ) } -function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { - return ( - - ); +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) } -function DropdownMenuSub({ ...props }: React.ComponentProps) { - return ; +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return } function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props + className, + inset, + children, + ...props }: React.ComponentProps & { - inset?: boolean; + inset?: boolean }) { - return ( - - {children} - - - ); + return ( + + {children} + + + ) } function DropdownMenuSubContent({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ); + return ( + + ) } export { - DropdownMenu, - DropdownMenuPortal, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, -}; + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/surfsense_web/components/ui/fixed-toolbar-buttons.tsx b/surfsense_web/components/ui/fixed-toolbar-buttons.tsx index 24ee9a44f..7fef1c443 100644 --- a/surfsense_web/components/ui/fixed-toolbar-buttons.tsx +++ b/surfsense_web/components/ui/fixed-toolbar-buttons.tsx @@ -8,6 +8,7 @@ import { HighlighterIcon, ItalicIcon, RedoIcon, + SaveIcon, StrikethroughIcon, UnderlineIcon, UndoIcon, @@ -15,9 +16,13 @@ import { import { KEYS } from 'platejs'; import { useEditorReadOnly, useEditorRef } from 'platejs/react'; +import { useEditorSave } from '@/components/editor/editor-save-context'; +import { Spinner } from '@/components/ui/spinner'; + import { InsertToolbarButton } from './insert-toolbar-button'; import { LinkToolbarButton } from './link-toolbar-button'; import { MarkToolbarButton } from './mark-toolbar-button'; +import { ModeToolbarButton } from './mode-toolbar-button'; import { MoreToolbarButton } from './more-toolbar-button'; import { ToolbarButton, ToolbarGroup } from './toolbar'; import { TurnIntoToolbarButton } from './turn-into-toolbar-button'; @@ -25,81 +30,109 @@ import { TurnIntoToolbarButton } from './turn-into-toolbar-button'; export function FixedToolbarButtons() { const readOnly = useEditorReadOnly(); const editor = useEditorRef(); - - if (readOnly) return null; + const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave(); return (
- - { - editor.undo(); - editor.tf.focus(); - }} - > - - + {!readOnly && ( + <> + + { + editor.undo(); + editor.tf.focus(); + }} + > + + - { - editor.redo(); - editor.tf.focus(); - }} - > - - - + { + editor.redo(); + editor.tf.focus(); + }} + > + + + - - - - + + + + - - - - + + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - - + + + + - - - + + + - - - + + + + + {/* Save button — appears when in editing mode with unsaved changes */} + {onSave && hasUnsavedChanges && ( + + + {isSaving ? ( + + ) : ( + + )} + + + )} + + )} + +
+ + {canToggleMode && ( + + + + )}
); } - diff --git a/surfsense_web/components/ui/mode-toolbar-button.tsx b/surfsense_web/components/ui/mode-toolbar-button.tsx new file mode 100644 index 000000000..fab0b591e --- /dev/null +++ b/surfsense_web/components/ui/mode-toolbar-button.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { BookOpenIcon, PenLineIcon } from 'lucide-react'; +import { usePlateState } from 'platejs/react'; + +import { ToolbarButton } from './toolbar'; + +export function ModeToolbarButton() { + const [readOnly, setReadOnly] = usePlateState('readOnly'); + + return ( + setReadOnly(!readOnly)} + > + {readOnly ? : } + + ); +} diff --git a/surfsense_web/components/ui/separator.tsx b/surfsense_web/components/ui/separator.tsx index 2d5f1fa7a..4c24b2a88 100644 --- a/surfsense_web/components/ui/separator.tsx +++ b/surfsense_web/components/ui/separator.tsx @@ -1,28 +1,28 @@ -"use client"; +"use client" -import * as SeparatorPrimitive from "@radix-ui/react-separator"; -import type * as React from "react"; +import * as React from "react" +import { Separator as SeparatorPrimitive } from "radix-ui" -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils" function Separator({ - className, - orientation = "horizontal", - decorative = true, - ...props + className, + orientation = "horizontal", + decorative = true, + ...props }: React.ComponentProps) { - return ( - - ); + return ( + + ) } -export { Separator }; +export { Separator } diff --git a/surfsense_web/components/ui/toolbar.tsx b/surfsense_web/components/ui/toolbar.tsx index 0dc9d85eb..80610e5c7 100644 --- a/surfsense_web/components/ui/toolbar.tsx +++ b/surfsense_web/components/ui/toolbar.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import * as ToolbarPrimitive from '@radix-ui/react-toolbar'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; import { type VariantProps, cva } from 'class-variance-authority'; import { ChevronDown } from 'lucide-react'; @@ -12,7 +13,7 @@ import { DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { Separator } from '@/components/ui/separator'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; export function Toolbar({ @@ -327,6 +328,32 @@ function withTooltip(Component: T) { }; } +function TooltipContent({ + children, + className, + // CHANGE + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + {children} + {/* CHANGE */} + {/* */} + + + ); +} + export function ToolbarMenuGroup({ children, className,