Files
temetro/frontend/components/notes/notes-editor.tsx
T
Khalid Abdi 67137c722d Polish footer menu, Notes spacing, and Appointments add flow
- User menu: rename "Quick nav" to "Search"; add spacing to the clinic
  submenu so it no longer touches the parent menu (sideOffset).
- Notes: lay the list, title, toolbar, and writing area out as separate
  rounded panels with gaps so they're no longer stuck together.
- Sidebar: shorten the "Appointments & Schedule" label to "Appointments".
- Appointments: replace the oversized patient form with a compact
  "New appointment" dialog — pick a patient via quick search by name/file
  number, set time/type/provider; the entry is added to today's schedule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:28:32 +03:00

223 lines
6.0 KiB
TypeScript

"use client";
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import {
Bold,
Heading1,
Heading2,
Italic,
List,
ListOrdered,
Redo2,
Save,
Trash2,
Underline as UnderlineIcon,
Undo2,
} from "lucide-react";
import { type ReactNode, useReducer, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Toolbar,
ToolbarButton,
ToolbarGroup,
ToolbarSeparator,
} from "@/components/ui/toolbar";
import type { Note } from "@/lib/notes";
function FormatButton({
active,
disabled,
label,
onClick,
children,
}: {
active?: boolean;
disabled?: boolean;
label: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<ToolbarButton
// Keep the editor selection while clicking the toolbar.
onMouseDown={(e) => e.preventDefault()}
render={
<Button
aria-label={label}
aria-pressed={active}
disabled={disabled}
onClick={onClick}
size="icon-sm"
type="button"
variant={active ? "secondary" : "ghost"}
/>
}
>
{children}
</ToolbarButton>
);
}
// Word-like rich-text editor for a single note: a COSS Toolbar driving Tiptap
// commands, a title field, and Save / Delete. Remount (via a `key` on the
// parent) to load a different note.
export function NotesEditor({
note,
saving,
onSave,
onDelete,
}: {
note: Note;
saving: boolean;
onSave: (data: { title: string; content: string }) => void;
onDelete?: () => void;
}) {
const [title, setTitle] = useState(note.title);
// Force a re-render on every editor transaction so the toolbar reflects the
// current formatting/undo state.
const [, bump] = useReducer((n: number) => n + 1, 0);
const editor = useEditor({
content: note.content,
extensions: [
StarterKit,
Placeholder.configure({ placeholder: "Write your note…" }),
],
// Required under Next's SSR to avoid a hydration mismatch.
immediatelyRender: false,
editorProps: {
attributes: {
// `note-content` styles headings/lists/etc. (see globals.css); padding +
// min-h-full make the whole card a click target, text anchored top-left.
class: "note-content min-h-full p-4 focus:outline-none",
},
},
onTransaction: bump,
});
if (!editor) return null;
const save = () =>
onSave({
title: title.trim() || "Untitled note",
content: editor.getHTML(),
});
return (
<div className="flex h-full flex-col gap-4">
<div className="flex items-center gap-2">
<Input
className="font-medium text-base"
onChange={(e) => setTitle(e.target.value)}
placeholder="Note title"
value={title}
/>
{onDelete && (
<Button
aria-label="Delete note"
onClick={onDelete}
size="icon"
type="button"
variant="ghost"
>
<Trash2 />
</Button>
)}
<Button disabled={saving} onClick={save} type="button">
<Save className="size-4" />
{saving ? "Saving…" : "Save"}
</Button>
</div>
<Toolbar className="w-fit max-w-full self-start overflow-x-auto">
<ToolbarGroup>
<FormatButton
active={editor.isActive("bold")}
label="Bold"
onClick={() => editor.chain().focus().toggleBold().run()}
>
<Bold />
</FormatButton>
<FormatButton
active={editor.isActive("italic")}
label="Italic"
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<Italic />
</FormatButton>
<FormatButton
active={editor.isActive("underline")}
label="Underline"
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<UnderlineIcon />
</FormatButton>
</ToolbarGroup>
<ToolbarSeparator orientation="vertical" />
<ToolbarGroup>
<FormatButton
active={editor.isActive("heading", { level: 1 })}
label="Heading 1"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
>
<Heading1 />
</FormatButton>
<FormatButton
active={editor.isActive("heading", { level: 2 })}
label="Heading 2"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
>
<Heading2 />
</FormatButton>
</ToolbarGroup>
<ToolbarSeparator orientation="vertical" />
<ToolbarGroup>
<FormatButton
active={editor.isActive("bulletList")}
label="Bullet list"
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<List />
</FormatButton>
<FormatButton
active={editor.isActive("orderedList")}
label="Numbered list"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrdered />
</FormatButton>
</ToolbarGroup>
<ToolbarSeparator orientation="vertical" />
<ToolbarGroup>
<FormatButton
disabled={!editor.can().undo()}
label="Undo"
onClick={() => editor.chain().focus().undo().run()}
>
<Undo2 />
</FormatButton>
<FormatButton
disabled={!editor.can().redo()}
label="Redo"
onClick={() => editor.chain().focus().redo().run()}
>
<Redo2 />
</FormatButton>
</ToolbarGroup>
</Toolbar>
<div className="min-h-0 flex-1 cursor-text overflow-y-auto rounded-2xl border bg-card/30">
<EditorContent className="h-full" editor={editor} />
</div>
</div>
);
}