Vectrum
Sign In
170 lines · 6.4 KB · TypeScript
"use client";

import { useActionState, useEffect, useId, useState } from "react";
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { Led, Spinner, Switch, TabList, TabPanel, Tabs } from "@unlocalhosted/metalui";
import type { FormState } from "@/app/actions/types";
import { ConfirmButton } from "../confirm-button";
import { vectrumDark, vectrumLight } from "../editor-theme";
import { useLanguage } from "../file-editor";
import { BookIcon, CodeIcon, EyeIcon, TrashIcon } from "../icons";
import { MarkdownPreview } from "../markdown-preview";
import { TextField } from "../settings-fields";
import { useDarkMode } from "../theme-toggle";
import { Alert, Box, Button, ButtonLink, Kbd } from "../ui";

type Tab = "write" | "preview";

/**
 * Writing a wiki page, like GitHub's: its title, the Markdown with a preview, and an optional edit
 * message; saving commits it to the wiki. Editing an existing page also offers to delete it.
 */
export function WikiEditor({
  action,
  deleteAction,
  wiki,
  originalPath,
  baseSha,
  initialTitle,
  initialContent,
  cancelHref,
}: {
  action: (prev: FormState, formData: FormData) => Promise<FormState>;
  deleteAction?: () => Promise<void>;
  /** The wiki's base URL, for links in the preview. */
  wiki: string;
  /** The page's file, or "" for a new page. */
  originalPath: string;
  baseSha: string;
  initialTitle: string;
  initialContent: string;
  cancelHref: string;
}) {
  const formId = useId();
  const [state, formAction, pending] = useActionState(action, undefined);
  const [title, setTitle] = useState(initialTitle);
  const [content, setContent] = useState(initialContent);
  const [tab, setTab] = useState<Tab>("write");
  const [wrap, setWrap] = useState(true);
  const dark = useDarkMode();
  const lang = useLanguage("page.md");
  const dirty = content !== initialContent || title !== initialTitle;
  const extensions = [...(lang ? [lang.ext] : []), ...(wrap ? [EditorView.lineWrapping] : [])];

  // Ctrl/Cmd+S saves; warn before leaving with unsaved edits.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key === "s") {
        e.preventDefault();
        (document.getElementById(formId) as HTMLFormElement | null)?.requestSubmit();
      }
    };
    const onBeforeUnload = (e: BeforeUnloadEvent) => {
      if (dirty && !pending) e.preventDefault();
    };
    window.addEventListener("keydown", onKey);
    window.addEventListener("beforeunload", onBeforeUnload);
    return () => {
      window.removeEventListener("keydown", onKey);
      window.removeEventListener("beforeunload", onBeforeUnload);
    };
  }, [dirty, pending, formId]);

  return (
    <div className="flex flex-col gap-4">
      <form id={formId} action={formAction} className="flex flex-col gap-4">
        <input type="hidden" name="originalPath" value={originalPath} />
        <input type="hidden" name="baseSha" value={baseSha} />
        <input type="hidden" name="content" value={content} />

        <TextField
          label="Title"
          name="title"
          icon={<BookIcon />}
          required
          maxLength={200}
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          autoFocus={!initialTitle}
          placeholder="Getting Started"
        />

        <Tabs<Tab> value={tab} onValueChange={setTab}>
          <Box className="overflow-hidden">
            <div className="rule-b flex min-h-12 flex-wrap items-center gap-3 px-3 py-2">
              <TabList<Tab>
                aria-label="Editor view"
                size="compact"
                items={[
                  { value: "write", label: "Write", icon: <CodeIcon /> },
                  { value: "preview", label: "Preview", icon: <EyeIcon /> },
                ]}
              />
              <span className="hidden type-meta text-ink3 md:inline">
                Markdown. Link pages with <code className="font-mono">[[Page Name]]</code> or <code className="font-mono">[[Text|Page Name]]</code>.
              </span>
              <label className="ml-auto flex cursor-pointer items-center gap-2 type-meta text-ink2">
                <Switch size="small" checked={wrap} onCheckedChange={setWrap} /> Soft wrap
              </label>
            </div>
            <TabPanel<Tab> value="write" keepMounted>
              <CodeMirror
                value={content}
                onChange={setContent}
                theme={dark ? vectrumDark : vectrumLight}
                extensions={extensions}
                minHeight="460px"
                basicSetup={{ highlightActiveLine: true, foldGutter: false, tabSize: 2 }}
                aria-label="Page content"
                className="py-2"
              />
            </TabPanel>
            <TabPanel<Tab> value="preview">
              <div className="min-h-[460px] px-6 py-8 md:px-10">
                <MarkdownPreview wiki={wiki}>{content}</MarkdownPreview>
              </div>
            </TabPanel>
          </Box>
        </Tabs>

        <TextField
          label="Edit message"
          name="message"
          optional
          maxLength={500}
          placeholder={!originalPath ? `Create ${title.trim() || "page"}` : `Update ${title.trim() || "page"}`}
          description="Describes the change in the page’s history."
        />

        {state?.error && <Alert>{state.error}</Alert>}
      </form>

      <div className="flex flex-wrap items-center gap-2">
        {deleteAction && (
          <ConfirmButton action={deleteAction} message={`Delete ${initialTitle}? Its history stays in the wiki’s repository.`} variant="invisible" confirmLabel="Delete Page">
            <TrashIcon /> Delete Page
          </ConfirmButton>
        )}
        <span className="flex items-center gap-1 type-meta text-ink2 max-sm:hidden">
          <Kbd>Ctrl</Kbd> <Kbd>S</Kbd> saves.
          {dirty && (
            <span className="ml-2 inline-flex items-center gap-1.5 text-ink">
              <Led kind="waiting" /> Unsaved changes
            </span>
          )}
        </span>
        <div className="ml-auto flex gap-2">
          <ButtonLink href={cancelHref}>Cancel</ButtonLink>
          <Button
            form={formId}
            variant="primary"
            disabled={pending || !title.trim()}
            icon={pending ? <Spinner size="small" label="Saving" /> : <BookIcon />}
          >
            {pending ? "Saving…" : "Save Page"}
          </Button>
        </div>
      </div>
    </div>
  );
}