NobGit
public anord read

Ledger

Why work hard when you can work easier?

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 86% CSS 10% SQL 3% Shell 1% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/orgs/anord/ledger.git
ssh://[email protected]:2222/orgs/anord/ledger.git

Commit

Fixing loading issue when putting Ledger behind domain

fee4a2a
Alex Nord <[email protected]> 3 months ago
.env.example                           |   2 +-
 apps/web/src/components/PageEditor.tsx | 275 ++++++++++++++++++++-------------
 2 files changed, 165 insertions(+), 112 deletions(-)

Diff

diff --git a/.env.example b/.env.example
index 110021f..e1314d5 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,6 @@
 NODE_ENV=development
 LEDGER_APP_URL=http://localhost:5173
-LEDGER_API_URL=http://localhost:4000
+LEDGER_API_URL=http://api:4000
 LEDGER_ALLOWED_HOSTS=localhost,127.0.0.1
 VITE_API_URL=
 PORT=4000
diff --git a/apps/web/src/components/PageEditor.tsx b/apps/web/src/components/PageEditor.tsx
index b4d331a..6764cb6 100644
--- a/apps/web/src/components/PageEditor.tsx
+++ b/apps/web/src/components/PageEditor.tsx
@@ -1,4 +1,5 @@
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
+import type { PageDetail } from "@ledger/shared";
 import { api } from "../lib/api";
 import { MarkdownComposerHint } from "./MarkdownComposerHint";
 
@@ -31,141 +32,193 @@ function hasMeaningfulMarkdown(value: string) {
 
 export function PageEditor({
   spaces,
+  initialPage,
+  mode = "create",
   variant = "panel",
-  onCreated,
+  onSaved,
   onCancel
 }: {
   spaces: Array<{ id: string; name: string; key: string }>;
-  variant?: "panel" | "dialog";
-  onCreated?: (slug: string) => void;
+  initialPage?: (Pick<PageDetail, "id" | "spaceId" | "title" | "slug" | "bodyMarkdown" | "excerpt" | "visibility" | "state" | "tags"> & {
+    parentPageId?: string | null;
+  }) | null;
+  mode?: "create" | "edit";
+  variant?: "panel" | "dialog" | "page";
+  onSaved?: (slug: string) => void;
   onCancel?: () => void;
 }) {
-  const [form, setForm] = useState({
+  const createEmptyForm = () => ({
     ...emptyPage,
-    spaceId: spaces[0]?.id ?? ""
+    spaceId: initialPage?.spaceId ?? spaces[0]?.id ?? "",
+    title: initialPage?.title ?? "",
+    slug: initialPage?.slug ?? "",
+    bodyMarkdown: initialPage?.bodyMarkdown ?? "",
+    excerpt: initialPage?.excerpt ?? "",
+    visibility: initialPage?.visibility ?? "internal",
+    state: initialPage?.state ?? "draft",
+    tagNames: initialPage?.tags ?? [],
+    parentPageId: initialPage?.parentPageId ?? null
   });
+
+  const [form, setForm] = useState(createEmptyForm);
+  const [tagInput, setTagInput] = useState((initialPage?.tags ?? []).join(", "));
   const [status, setStatus] = useState<string | null>(null);
   const isDialog = variant === "dialog";
+  const isPage = variant === "page";
   const showMarkdownHelper = useMemo(() => !hasMeaningfulMarkdown(form.bodyMarkdown), [form.bodyMarkdown]);
+  const canPublish = form.state === "published";
+
+  useEffect(() => {
+    setForm(createEmptyForm());
+    setTagInput((initialPage?.tags ?? []).join(", "));
+  }, [initialPage, spaces]);
 
-  async function submit() {
+  async function submit(nextState?: "draft" | "published") {
     try {
-      const result = await api.post<{ slug: string }>("/api/pages", {
+      const payload = {
         ...form,
+        state: nextState ?? form.state,
         tagNames: form.tagNames,
         allowedRoleKeys: form.visibility === "restricted" ? ["viewer"] : []
-      });
-      setStatus(`Saved page ${result.slug}`);
-      setForm({
-        ...emptyPage,
-        spaceId: spaces[0]?.id ?? ""
-      });
-      onCreated?.(result.slug);
+      };
+      const result =
+        mode === "edit" && initialPage
+          ? await api.put<{ slug: string }>(`/api/pages/${initialPage.id}`, payload)
+          : await api.post<{ slug: string }>("/api/pages", payload);
+      setStatus(mode === "edit" ? "Document saved." : `Saved page ${result.slug}`);
+      if (mode === "create") {
+        setForm({
+          ...emptyPage,
+          spaceId: spaces[0]?.id ?? ""
+        });
+        setTagInput("");
+      }
+      onSaved?.(result.slug);
     } catch (error) {
       setStatus(error instanceof Error ? error.message : "Could not save page");
     }
   }
 
   return (
-    <section className={isDialog ? "editor-dialog" : "panel"}>
-      <div className="panel__header">
+    <section className={isPage ? "composer-shell" : isDialog ? "editor-dialog" : "panel"}>
+      <div className={`panel__header${isPage ? " composer-header" : ""}`}>
         <div>
-          <p className="eyebrow">Publishing</p>
-          <h3>{isDialog ? "New document" : "Create a new page"}</h3>
+          <p className="eyebrow">{mode === "edit" ? "Editor" : "Publishing"}</p>
+          <h3>{mode === "edit" ? "Edit document" : isDialog ? "New document" : isPage ? "New document" : "Create a new page"}</h3>
+        </div>
+        <div className="composer-header__actions">
+          <span className="pill">{mode === "edit" ? "Draft editor" : "Editor"}</span>
+          {onCancel ? (
+            <button type="button" className="button-secondary" onClick={onCancel}>
+              Cancel
+            </button>
+          ) : null}
         </div>
-        <span className="pill">Editor</span>
-      </div>
-      <label className="field">
-        Space
-        <select
-          value={form.spaceId}
-          onChange={(event) => setForm((current) => ({ ...current, spaceId: event.target.value }))}
-        >
-          {spaces.map((space) => (
-            <option key={space.id} value={space.id}>
-              {space.name}
-            </option>
-          ))}
-        </select>
-      </label>
-      <label className="field">
-        Title
-        <input
-          value={form.title}
-          onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))}
-        />
-      </label>
-      <label className="field">
-        Slug
-        <input
-          value={form.slug}
-          onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
-        />
-      </label>
-      <label className="field">
-        Excerpt
-        <input
-          value={form.excerpt}
-          onChange={(event) => setForm((current) => ({ ...current, excerpt: event.target.value }))}
-        />
-      </label>
-      <div className="field-grid">
-        <label className="field">
-          Visibility
-          <select
-            value={form.visibility}
-            onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))}
-          >
-            <option value="public">Public</option>
-            <option value="internal">Internal</option>
-            <option value="restricted">Restricted</option>
-          </select>
-        </label>
-        <label className="field">
-          State
-          <select
-            value={form.state}
-            onChange={(event) => setForm((current) => ({ ...current, state: event.target.value }))}
-          >
-            <option value="draft">Draft</option>
-            <option value="published">Published</option>
-          </select>
-        </label>
       </div>
-      <label className="field">
-        Tags
-        <input
-          placeholder="comma,separated,tags"
-          onChange={(event) =>
-            setForm((current) => ({
-              ...current,
-              tagNames: event.target.value
-                .split(",")
-                .map((tag) => tag.trim())
-                .filter(Boolean)
-            }))
-          }
-        />
-      </label>
-      <label className="field">
-        Markdown
-        {showMarkdownHelper ? <MarkdownComposerHint /> : null}
-        <textarea
-          className={`editor-textarea${isDialog ? " editor-textarea-dialog" : ""}`}
-          value={form.bodyMarkdown}
-          onChange={(event) => setForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
-          placeholder="Write Markdown, use / for blocks, or paste existing docs…"
-          aria-label="Document markdown body"
-        />
-      </label>
-      <div className="panel__footer">
-        <button onClick={submit}>Create draft</button>
-        {onCancel ? (
-          <button type="button" className="button-secondary" onClick={onCancel}>
-            Cancel
-          </button>
-        ) : null}
-        {status ? <p className="muted">{status}</p> : null}
+      <div className={isPage ? "composer-layout" : "stack-page"}>
+        <div className="composer-main">
+          <label className="field">
+            Title
+            <input
+              value={form.title}
+              placeholder="Untitled document"
+              onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))}
+            />
+          </label>
+          <label className="field">
+            Markdown
+            {showMarkdownHelper ? <MarkdownComposerHint /> : null}
+            <textarea
+              className={`editor-textarea${isDialog ? " editor-textarea-dialog" : ""}${isPage ? " editor-textarea-page" : ""}`}
+              value={form.bodyMarkdown}
+              onChange={(event) => setForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
+              placeholder="Write Markdown, use / for blocks, or paste existing docs…"
+              aria-label="Document markdown body"
+            />
+          </label>
+        </div>
+
+        <aside className={`composer-sidebar${isPage ? " rail-card" : ""}`}>
+          <label className="field">
+            Space
+            <select
+              value={form.spaceId}
+              onChange={(event) => setForm((current) => ({ ...current, spaceId: event.target.value }))}
+            >
+              {spaces.map((space) => (
+                <option key={space.id} value={space.id}>
+                  {space.name}
+                </option>
+              ))}
+            </select>
+          </label>
+          <label className="field">
+            Slug
+            <input
+              value={form.slug}
+              placeholder="generated-from-title"
+              onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
+            />
+          </label>
+          <label className="field">
+            Excerpt
+            <input
+              value={form.excerpt}
+              placeholder="Short summary shown in lists"
+              onChange={(event) => setForm((current) => ({ ...current, excerpt: event.target.value }))}
+            />
+          </label>
+          <div className="field-grid">
+            <label className="field">
+              Visibility
+              <select
+                value={form.visibility}
+                onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value as "public" | "internal" | "restricted" }))}
+              >
+                <option value="public">Public</option>
+                <option value="internal">Internal</option>
+                <option value="restricted">Restricted</option>
+              </select>
+            </label>
+            <label className="field">
+              State
+              <select
+                value={form.state}
+                onChange={(event) => setForm((current) => ({ ...current, state: event.target.value as "draft" | "published" }))}
+              >
+                <option value="draft">Draft</option>
+                <option value="published">Published</option>
+              </select>
+            </label>
+          </div>
+          <label className="field">
+            Tags
+            <input
+              value={tagInput}
+              placeholder="comma,separated,tags"
+              onChange={(event) => {
+                const value = event.target.value;
+                setTagInput(value);
+                setForm((current) => ({
+                  ...current,
+                  tagNames: value
+                    .split(",")
+                    .map((tag) => tag.trim())
+                    .filter(Boolean)
+                }));
+              }}
+            />
+          </label>
+          <div className="composer-toolbar">
+            <button type="button" onClick={() => void submit("draft")}>
+              {mode === "edit" ? "Save draft" : "Create draft"}
+            </button>
+            <button type="button" className="button-secondary" onClick={() => void submit("published")}>
+              {canPublish || mode === "edit" ? "Publish" : "Create and publish"}
+            </button>
+            {status ? <p className="muted">{status}</p> : null}
+          </div>
+        </aside>
       </div>
     </section>
   );