@guard-rail/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +71 -9
  2. package/dist/core/defs.d.ts +13 -1
  3. package/dist/core/defs.js +2 -0
  4. package/dist/core/defs.js.map +1 -1
  5. package/dist/core/types.d.ts +8 -0
  6. package/dist/index.client.d.ts +5 -2
  7. package/dist/index.client.js +18 -1
  8. package/dist/index.client.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +7 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/react/Admin.js +14 -12
  13. package/dist/react/Admin.js.map +1 -1
  14. package/dist/react/GRail/client.js +43 -3
  15. package/dist/react/GRail/client.js.map +1 -1
  16. package/dist/react/Icons.d.ts +2 -1
  17. package/dist/react/Icons.js +6 -0
  18. package/dist/react/Icons.js.map +1 -1
  19. package/dist/react/Repeater/client.d.ts +2 -0
  20. package/dist/react/Repeater/client.js +15 -1
  21. package/dist/react/Repeater/client.js.map +1 -1
  22. package/dist/react/Repeater/index.d.ts +8 -1
  23. package/dist/react/Repeater/index.js +19 -1
  24. package/dist/react/Repeater/index.js.map +1 -1
  25. package/dist/react/Syncer.d.ts +3 -2
  26. package/dist/react/Syncer.js +6 -2
  27. package/dist/react/Syncer.js.map +1 -1
  28. package/dist/react/openHref.d.ts +23 -0
  29. package/dist/react/openHref.js +25 -0
  30. package/dist/react/openHref.js.map +1 -0
  31. package/dist/server/access.d.ts +77 -0
  32. package/dist/server/access.js +33 -0
  33. package/dist/server/access.js.map +1 -0
  34. package/dist/server/actions.d.ts +20 -7
  35. package/dist/server/actions.js +20 -9
  36. package/dist/server/actions.js.map +1 -1
  37. package/dist/server/fieldData.d.ts +4 -2
  38. package/dist/server/fieldData.js.map +1 -1
  39. package/dist/server/repeaterActions.js +1 -1
  40. package/dist/server/repeaterActions.js.map +1 -1
  41. package/dist/server/wrapper.js +5 -5
  42. package/dist/server/wrapper.js.map +1 -1
  43. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/react/GRail/client.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { saveOverride } from \"../../server/actions\";\nimport { GRailRender } from \"./render\";\nimport { useGRail } from \"../Provider\";\nimport { useAnchorRect } from \"../useAnchorRect\";\nimport { CheckIcon, CloseIcon, PencilIcon, SpinnerIcon } from \"../Icons\";\nimport { S } from \"../styles\";\nimport { MediaPicker } from \"../MediaPicker\";\nimport type { HTMLKeys, OverrideRow, OverrideValue } from \"../../core/types\";\n\nexport function GRailClient({\n as,\n initialOverride,\n ...htmlProps\n}: {\n as?: HTMLKeys;\n initialOverride: OverrideRow;\n [key: string]: unknown;\n}) {\n const { isEditMode } = useGRail();\n const [editing, setEditing] = useState(false);\n const [saving, setSaving] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [row, setRow] = useState<OverrideRow>(initialOverride);\n\n const elementRef = useRef<HTMLElement | null>(null);\n const isImage = row.value.type === \"image\";\n // Image fields keep a rect for their whole time in edit mode (their marker\n // needs it); everything else only while actively editing.\n const rect = useAnchorRect(elementRef, editing || (isEditMode && isImage), {\n // Only the idle marker case (isImage, not actively editing) is gated —\n // an image mid-edit tracks unconditionally, same as text/link chrome.\n gateByVisibility: isImage && !editing,\n });\n\n /**\n * The text the element was rendered with when this edit session began.\n *\n * While editing, the DOM owns the text and React must not touch it. Feeding\n * state back into a contentEditable's children makes React replace the text\n * node on every keystroke, which resets the caret to offset 0 — typing then\n * appears reversed and backspace jumps to the start. Holding this value\n * constant means the children prop never changes, so React leaves the node\n * alone and the caret stays put.\n */\n const sessionTextRef = useRef<string>(initialOverride.value.text ?? \"\");\n /** Live DOM text, updated without re-rendering. Read on save. */\n const draftTextRef = useRef<string>(initialOverride.value.text ?? \"\");\n\n const isInline = row.value.type === \"text\" || row.value.type === \"button\";\n\n const beginEdit = () => {\n sessionTextRef.current = row.value.text ?? \"\";\n draftTextRef.current = row.value.text ?? \"\";\n setError(null);\n setEditing(true);\n };\n\n const updateAttribute = (key: string, value: string) =>\n setRow((prev) => ({ ...prev, value: { ...prev.value, [key]: value } }));\n\n const patchValue = (patch: Partial<OverrideValue>) =>\n setRow((prev) => ({ ...prev, value: { ...prev.value, ...patch } }));\n\n const handleSave = async () => {\n // Inline text lives in the DOM, not in state, so read it back here.\n const next: OverrideRow = isInline\n ? { ...row, value: { ...row.value, text: draftTextRef.current } }\n : row;\n\n setSaving(true);\n setError(null);\n const res = await saveOverride(next);\n setSaving(false);\n\n if (!res.success) {\n setError(res.error ?? \"Save failed.\");\n return;\n }\n setRow(next);\n setEditing(false);\n };\n\n const handleCancel = () => {\n setRow(initialOverride);\n draftTextRef.current = initialOverride.value.text ?? \"\";\n if (isInline && elementRef.current) {\n elementRef.current.textContent = initialOverride.value.text ?? \"\";\n }\n setError(null);\n setEditing(false);\n };\n\n // Clicking away from an active edit cancels it, the same as Escape. Anything\n // inside the field itself (repositioning the caret) or inside the anchored\n // toolbar/panel (both carry data-guardrail) is not \"away\" — checked via\n // closest() rather than React's onClick so a stopPropagation() inside the\n // chrome (image/link panel inputs) reliably suppresses this too, since that\n // call stops the native event before it ever reaches this document listener.\n useEffect(() => {\n if (!editing) return;\n const handlePointerDown = (e: MouseEvent) => {\n const target = e.target as Node;\n if (elementRef.current?.contains(target)) return;\n if (target instanceof Element && target.closest(\"[data-guardrail]\")) return;\n handleCancel();\n };\n document.addEventListener(\"mousedown\", handlePointerDown);\n return () => document.removeEventListener(\"mousedown\", handlePointerDown);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [editing]);\n\n if (!isEditMode) {\n return <GRailRender as={as} {...htmlProps} override={row.value} />;\n }\n\n // Frozen during the session so React never rewrites the live text node.\n const renderedValue =\n editing && isInline\n ? { ...row.value, text: sessionTextRef.current }\n : row.value;\n\n return (\n <>\n <GRailRender\n as={as}\n {...htmlProps}\n ref={elementRef}\n override={renderedValue}\n style={{\n ...(htmlProps.style as React.CSSProperties),\n // Images are left alone — no outline, position, or z-index — and\n // get a separate ImageMarker instead. Restyling the <img> itself\n // can't work for a background image with content layered over it.\n ...(isImage\n ? { cursor: \"pointer\" }\n : {\n outline: editing ? \"2px solid #2563eb\" : \"1px dashed #60a5fa\",\n outlineOffset: 2,\n cursor: editing ? \"text\" : \"pointer\",\n }),\n }}\n onClick={(e: React.MouseEvent) => {\n if (editing) return;\n e.preventDefault();\n // preventDefault only blocks the browser's native action (following\n // an href); it does not stop the click from bubbling. A field\n // rendered inside a Link would still trigger the Link's own onClick\n // and navigate anyway — stopPropagation is what actually claims the\n // click for editing instead. (Use `editable={false}` on fields\n // meant to stay pure navigation, e.g. inside a card that links to\n // that content's own page.)\n e.stopPropagation();\n beginEdit();\n }}\n // Spread only for inline text: images and links would receive invalid\n // DOM props (contentEditable on next/image, etc).\n {...(isInline\n ? {\n contentEditable: editing,\n suppressContentEditableWarning: true,\n onInput: (e: React.FormEvent<HTMLElement>) => {\n // Ref only — setState here re-renders and moves the caret.\n draftTextRef.current = e.currentTarget.textContent ?? \"\";\n },\n onKeyDown: (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n void handleSave();\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n handleCancel();\n }\n },\n }\n : {})}\n />\n\n {/*\n Portaled to <body>, not rendered next to the field. The chrome is\n position:fixed with a high z-index, but z-index only ranks within the\n nearest stacking context — rendered inline, a field inside any ancestor\n that forms one (a z-indexed or transformed hero, say) traps its own\n popup there, where unrelated content stacked higher elsewhere on the\n page can paint straight over it. Also sidesteps a transformed ancestor\n silently re-anchoring position:fixed to itself instead of the viewport.\n */}\n {rect &&\n (isImage || editing) &&\n createPortal(\n <>\n {isImage && (\n <ImageMarker rect={rect} editing={editing} onEdit={beginEdit} />\n )}\n {editing && (\n <Chrome\n rect={rect}\n isInline={isInline}\n row={row}\n error={error}\n saving={saving}\n onChange={updateAttribute}\n onPatch={patchValue}\n onSave={handleSave}\n onCancel={handleCancel}\n />\n )}\n </>,\n document.body,\n )}\n </>\n );\n}\n\n/**\n * Dashed outline plus an \"Edit\" handle for an image field, positioned from the\n * image's measured rect rather than attached to the image. Everything here is\n * portaled to <body> by the caller, so it ranks above page content whatever the\n * image's own ancestors do, and never shifts the developer's layout.\n */\nfunction ImageMarker({\n rect,\n editing,\n onEdit,\n}: {\n rect: { top: number; left: number; bottom: number; width: number };\n editing: boolean;\n onEdit: () => void;\n}) {\n const height = rect.bottom - rect.top;\n // Collapsed (display:none, zero-size) or scrolled fully out of view.\n if (rect.width <= 0 || height <= 0) return null;\n if (typeof window !== \"undefined\" && (rect.bottom < 0 || rect.top > window.innerHeight)) {\n return null;\n }\n\n const HANDLE_H = 26;\n // A logo-sized image would be mostly covered by its own handle, so small\n // ones get it just outside (above, or below when there's no room above).\n const small = height < HANDLE_H * 2 || rect.width < 120;\n let handleTop: number;\n if (small) {\n handleTop = rect.top - HANDLE_H - 4 >= 4 ? rect.top - HANDLE_H - 4 : rect.bottom + 4;\n } else {\n // Stays on screen for a tall background that's partly scrolled past, but\n // never leaves the image's own bounds.\n handleTop = Math.min(\n Math.max(rect.top + 6, 6),\n Math.max(rect.top + 6, rect.bottom - HANDLE_H - 6),\n );\n }\n const handleLeft = small ? rect.left : rect.left + 6;\n\n return (\n <>\n <div\n data-guardrail\n style={{\n ...S.markerOutline,\n top: rect.top - 2,\n left: rect.left - 2,\n width: rect.width + 4,\n height: height + 4,\n border: editing ? \"2px solid #2563eb\" : \"1px dashed #60a5fa\",\n }}\n />\n {!editing && (\n <button\n type=\"button\"\n data-guardrail\n style={{ ...S.markerHandle, top: handleTop, left: handleLeft }}\n aria-label=\"Edit image\"\n title=\"Edit image\"\n onClick={(e) => {\n // React bubbles synthetic events through portals to the field's\n // *React* ancestors — a field inside a <Link> would otherwise\n // navigate on this click, same reason the field's own onClick\n // claims its click.\n e.preventDefault();\n e.stopPropagation();\n onEdit();\n }}\n >\n <PencilIcon /> Edit\n </button>\n )}\n </>\n );\n}\n\nfunction Chrome({\n rect,\n isInline,\n row,\n error,\n saving,\n onChange,\n onPatch,\n onSave,\n onCancel,\n}: {\n rect: { top: number; left: number; bottom: number; width: number };\n isInline: boolean;\n row: OverrideRow;\n error: string | null;\n saving: boolean;\n onChange: (key: string, value: string) => void;\n onPatch: (patch: Partial<OverrideValue>) => void;\n onSave: () => void;\n onCancel: () => void;\n}) {\n const { storage } = useGRail();\n const [pickerOpen, setPickerOpen] = useState(false);\n const GAP = 8;\n // Flip above the element when there is no room below it.\n const below = rect.bottom + GAP;\n const flip = typeof window !== \"undefined\" && below > window.innerHeight - 120;\n\n const position: React.CSSProperties = {\n left: Math.max(8, rect.left),\n ...(flip ? { top: Math.max(8, rect.top - GAP - 44) } : { top: below }),\n };\n\n const actions = (\n <>\n <button\n style={S.iconBtnPrimary}\n onClick={onSave}\n disabled={saving}\n title=\"Save (Enter)\"\n aria-label=\"Save\"\n >\n {saving ? <SpinnerIcon /> : <CheckIcon />}\n </button>\n <button\n style={S.iconBtn}\n onClick={onCancel}\n disabled={saving}\n title=\"Cancel (Esc)\"\n aria-label=\"Cancel\"\n >\n <CloseIcon />\n </button>\n </>\n );\n\n // Text and buttons edit in place, so they only need the two controls.\n if (isInline) {\n return (\n <div data-guardrail style={{ ...S.anchoredBar, ...position }}>\n {actions}\n {error && <span style={{ ...S.error, margin: \"0 6px\" }}>{error}</span>}\n </div>\n );\n }\n\n // Images and links carry attributes with no on-page representation.\n return (\n <div\n data-guardrail\n style={{ ...S.anchoredPanel, ...position }}\n onClick={(e) => e.stopPropagation()}\n >\n {row.value.type === \"image\" && (\n <>\n {row.value.src && (\n // Plain img: previews must not depend on remotePatterns.\n <img src={row.value.src} alt=\"\" style={S.previewThumb} />\n )}\n\n {storage && (\n <button\n type=\"button\"\n style={S.secondaryBtn}\n onClick={() => setPickerOpen(true)}\n >\n {row.value.src ? \"Replace image\" : \"Choose image\"}\n </button>\n )}\n\n <label style={S.label}>Alt text</label>\n <input\n style={S.field}\n value={row.value.alt || \"\"}\n placeholder=\"Describe the image for screen readers\"\n onChange={(e) => onChange(\"alt\", e.target.value)}\n />\n\n {/* Only without storage: there is no picker then, so a URL is the one\n way to set an image. A typed URL has unknown proportions, so any\n stored dimensions are cleared. */}\n {!storage && (\n <>\n <label style={S.label}>Image URL</label>\n <input\n style={S.field}\n value={row.value.src || \"\"}\n onChange={(e) =>\n onPatch({ src: e.target.value, width: undefined, height: undefined })\n }\n />\n </>\n )}\n\n {pickerOpen && (\n <MediaPicker\n current={row.value.src}\n onClose={() => setPickerOpen(false)}\n onSelect={(image) =>\n onPatch({ src: image.src, width: image.width, height: image.height })\n }\n />\n )}\n </>\n )}\n\n {row.value.type === \"link\" && (\n <>\n <label style={S.label}>Link text</label>\n <input\n style={S.field}\n value={row.value.text || \"\"}\n onChange={(e) => onChange(\"text\", e.target.value)}\n />\n <label style={S.label}>URL</label>\n <input\n style={S.field}\n value={row.value.href || \"\"}\n onChange={(e) => onChange(\"href\", e.target.value)}\n />\n </>\n )}\n\n {error && <div style={{ ...S.error, marginBottom: 8 }}>{error}</div>}\n <div style={{ display: \"flex\", gap: 4, justifyContent: \"flex-end\" }}>\n {actions}\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/react/GRail/client.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useRouter } from \"next/navigation\";\nimport { saveOverride } from \"../../server/actions\";\nimport { GRailRender } from \"./render\";\nimport { useGRail } from \"../Provider\";\nimport { useAnchorRect } from \"../useAnchorRect\";\nimport { CheckIcon, CloseIcon, OpenIcon, PencilIcon, SpinnerIcon } from \"../Icons\";\nimport { openHref } from \"../openHref\";\nimport { S } from \"../styles\";\nimport { MediaPicker } from \"../MediaPicker\";\nimport type { HTMLKeys, OverrideRow, OverrideValue } from \"../../core/types\";\n\nexport function GRailClient({\n as,\n initialOverride,\n ...htmlProps\n}: {\n as?: HTMLKeys;\n initialOverride: OverrideRow;\n [key: string]: unknown;\n}) {\n const { isEditMode } = useGRail();\n const [editing, setEditing] = useState(false);\n const [saving, setSaving] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [row, setRow] = useState<OverrideRow>(initialOverride);\n\n const elementRef = useRef<HTMLElement | null>(null);\n const isImage = row.value.type === \"image\";\n // Image fields keep a rect for their whole time in edit mode (their marker\n // needs it); everything else only while actively editing.\n const rect = useAnchorRect(elementRef, editing || (isEditMode && isImage), {\n // Only the idle marker case (isImage, not actively editing) is gated —\n // an image mid-edit tracks unconditionally, same as text/link chrome.\n gateByVisibility: isImage && !editing,\n });\n\n /**\n * The text the element was rendered with when this edit session began.\n *\n * While editing, the DOM owns the text and React must not touch it. Feeding\n * state back into a contentEditable's children makes React replace the text\n * node on every keystroke, which resets the caret to offset 0 — typing then\n * appears reversed and backspace jumps to the start. Holding this value\n * constant means the children prop never changes, so React leaves the node\n * alone and the caret stays put.\n */\n const sessionTextRef = useRef<string>(initialOverride.value.text ?? \"\");\n /** Live DOM text, updated without re-rendering. Read on save. */\n const draftTextRef = useRef<string>(initialOverride.value.text ?? \"\");\n\n const isInline = row.value.type === \"text\" || row.value.type === \"button\";\n\n const beginEdit = () => {\n sessionTextRef.current = row.value.text ?? \"\";\n draftTextRef.current = row.value.text ?? \"\";\n setError(null);\n setEditing(true);\n };\n\n const updateAttribute = (key: string, value: string) =>\n setRow((prev) => ({ ...prev, value: { ...prev.value, [key]: value } }));\n\n const patchValue = (patch: Partial<OverrideValue>) =>\n setRow((prev) => ({ ...prev, value: { ...prev.value, ...patch } }));\n\n const handleSave = async () => {\n // Inline text lives in the DOM, not in state, so read it back here.\n const next: OverrideRow = isInline\n ? { ...row, value: { ...row.value, text: draftTextRef.current } }\n : row;\n\n setSaving(true);\n setError(null);\n const res = await saveOverride(next);\n setSaving(false);\n\n if (!res.success) {\n setError(res.error ?? \"Save failed.\");\n return;\n }\n setRow(next);\n setEditing(false);\n };\n\n const handleCancel = () => {\n setRow(initialOverride);\n draftTextRef.current = initialOverride.value.text ?? \"\";\n if (isInline && elementRef.current) {\n elementRef.current.textContent = initialOverride.value.text ?? \"\";\n }\n setError(null);\n setEditing(false);\n };\n\n // Clicking away from an active edit cancels it, the same as Escape. Anything\n // inside the field itself (repositioning the caret) or inside the anchored\n // toolbar/panel (both carry data-guardrail) is not \"away\" — checked via\n // closest() rather than React's onClick so a stopPropagation() inside the\n // chrome (image/link panel inputs) reliably suppresses this too, since that\n // call stops the native event before it ever reaches this document listener.\n useEffect(() => {\n if (!editing) return;\n const handlePointerDown = (e: MouseEvent) => {\n const target = e.target as Node;\n if (elementRef.current?.contains(target)) return;\n if (target instanceof Element && target.closest(\"[data-guardrail]\")) return;\n handleCancel();\n };\n document.addEventListener(\"mousedown\", handlePointerDown);\n return () => document.removeEventListener(\"mousedown\", handlePointerDown);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [editing]);\n\n if (!isEditMode) {\n return <GRailRender as={as} {...htmlProps} override={row.value} />;\n }\n\n // Frozen during the session so React never rewrites the live text node.\n const renderedValue =\n editing && isInline\n ? { ...row.value, text: sessionTextRef.current }\n : row.value;\n\n return (\n <>\n <GRailRender\n as={as}\n {...htmlProps}\n ref={elementRef}\n override={renderedValue}\n style={{\n ...(htmlProps.style as React.CSSProperties),\n // Images are left alone — no outline, position, or z-index — and\n // get a separate ImageMarker instead. Restyling the <img> itself\n // can't work for a background image with content layered over it.\n ...(isImage\n ? { cursor: \"pointer\" }\n : {\n outline: editing ? \"2px solid #2563eb\" : \"1px dashed #60a5fa\",\n outlineOffset: 2,\n cursor: editing ? \"text\" : \"pointer\",\n }),\n }}\n onClick={(e: React.MouseEvent) => {\n if (editing) return;\n e.preventDefault();\n // preventDefault only blocks the browser's native action (following\n // an href); it does not stop the click from bubbling. A field\n // rendered inside a Link would still trigger the Link's own onClick\n // and navigate anyway — stopPropagation is what actually claims the\n // click for editing instead. (Use `editable={false}` on fields\n // meant to stay pure navigation, e.g. inside a card that links to\n // that content's own page.)\n e.stopPropagation();\n beginEdit();\n }}\n // Spread only for inline text: images and links would receive invalid\n // DOM props (contentEditable on next/image, etc).\n {...(isInline\n ? {\n contentEditable: editing,\n suppressContentEditableWarning: true,\n onInput: (e: React.FormEvent<HTMLElement>) => {\n // Ref only — setState here re-renders and moves the caret.\n draftTextRef.current = e.currentTarget.textContent ?? \"\";\n },\n onKeyDown: (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n void handleSave();\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n handleCancel();\n }\n },\n }\n : {})}\n />\n\n {/*\n Portaled to <body>, not rendered next to the field. The chrome is\n position:fixed with a high z-index, but z-index only ranks within the\n nearest stacking context — rendered inline, a field inside any ancestor\n that forms one (a z-indexed or transformed hero, say) traps its own\n popup there, where unrelated content stacked higher elsewhere on the\n page can paint straight over it. Also sidesteps a transformed ancestor\n silently re-anchoring position:fixed to itself instead of the viewport.\n */}\n {rect &&\n (isImage || editing) &&\n createPortal(\n <>\n {isImage && (\n <ImageMarker rect={rect} editing={editing} onEdit={beginEdit} />\n )}\n {editing && (\n <Chrome\n rect={rect}\n isInline={isInline}\n row={row}\n error={error}\n saving={saving}\n onChange={updateAttribute}\n onPatch={patchValue}\n onSave={handleSave}\n onCancel={handleCancel}\n />\n )}\n </>,\n document.body,\n )}\n </>\n );\n}\n\n/**\n * Dashed outline plus an \"Edit\" handle for an image field, positioned from the\n * image's measured rect rather than attached to the image. Everything here is\n * portaled to <body> by the caller, so it ranks above page content whatever the\n * image's own ancestors do, and never shifts the developer's layout.\n */\nfunction ImageMarker({\n rect,\n editing,\n onEdit,\n}: {\n rect: { top: number; left: number; bottom: number; width: number };\n editing: boolean;\n onEdit: () => void;\n}) {\n const height = rect.bottom - rect.top;\n // Collapsed (display:none, zero-size) or scrolled fully out of view.\n if (rect.width <= 0 || height <= 0) return null;\n if (typeof window !== \"undefined\" && (rect.bottom < 0 || rect.top > window.innerHeight)) {\n return null;\n }\n\n const HANDLE_H = 26;\n // A logo-sized image would be mostly covered by its own handle, so small\n // ones get it just outside (above, or below when there's no room above).\n const small = height < HANDLE_H * 2 || rect.width < 120;\n let handleTop: number;\n if (small) {\n handleTop = rect.top - HANDLE_H - 4 >= 4 ? rect.top - HANDLE_H - 4 : rect.bottom + 4;\n } else {\n // Stays on screen for a tall background that's partly scrolled past, but\n // never leaves the image's own bounds.\n handleTop = Math.min(\n Math.max(rect.top + 6, 6),\n Math.max(rect.top + 6, rect.bottom - HANDLE_H - 6),\n );\n }\n const handleLeft = small ? rect.left : rect.left + 6;\n\n return (\n <>\n <div\n data-guardrail\n style={{\n ...S.markerOutline,\n top: rect.top - 2,\n left: rect.left - 2,\n width: rect.width + 4,\n height: height + 4,\n border: editing ? \"2px solid #2563eb\" : \"1px dashed #60a5fa\",\n }}\n />\n {!editing && (\n <button\n type=\"button\"\n data-guardrail\n style={{ ...S.markerHandle, top: handleTop, left: handleLeft }}\n aria-label=\"Edit image\"\n title=\"Edit image\"\n onClick={(e) => {\n // React bubbles synthetic events through portals to the field's\n // *React* ancestors — a field inside a <Link> would otherwise\n // navigate on this click, same reason the field's own onClick\n // claims its click.\n e.preventDefault();\n e.stopPropagation();\n onEdit();\n }}\n >\n <PencilIcon /> Edit\n </button>\n )}\n </>\n );\n}\n\nfunction Chrome({\n rect,\n isInline,\n row,\n error,\n saving,\n onChange,\n onPatch,\n onSave,\n onCancel,\n}: {\n rect: { top: number; left: number; bottom: number; width: number };\n isInline: boolean;\n row: OverrideRow;\n error: string | null;\n saving: boolean;\n onChange: (key: string, value: string) => void;\n onPatch: (patch: Partial<OverrideValue>) => void;\n onSave: () => void;\n onCancel: () => void;\n}) {\n const { storage } = useGRail();\n const router = useRouter();\n const [pickerOpen, setPickerOpen] = useState(false);\n const GAP = 8;\n // Flip above the element when there is no room below it.\n const below = rect.bottom + GAP;\n const flip = typeof window !== \"undefined\" && below > window.innerHeight - 120;\n\n // The panel is anchored at the field's left edge, so a field near the right\n // of the viewport (a nav link, say) would push it — and its Save button —\n // off-screen. Pull it back so its full width stays visible. 260 mirrors\n // anchoredPanel's minWidth; the inline bar is small enough not to need it.\n const PANEL_MIN_WIDTH = 260;\n const maxLeft =\n typeof window !== \"undefined\"\n ? window.innerWidth - (isInline ? 0 : PANEL_MIN_WIDTH) - 8\n : Infinity;\n\n const position: React.CSSProperties = {\n left: Math.max(8, Math.min(rect.left, maxLeft)),\n ...(flip ? { top: Math.max(8, rect.top - GAP - 44) } : { top: below }),\n };\n\n const actions = (\n <>\n <button\n style={S.iconBtnPrimary}\n onClick={onSave}\n disabled={saving}\n title=\"Save (Enter)\"\n aria-label=\"Save\"\n >\n {saving ? <SpinnerIcon /> : <CheckIcon />}\n </button>\n <button\n style={S.iconBtn}\n onClick={onCancel}\n disabled={saving}\n title=\"Cancel (Esc)\"\n aria-label=\"Cancel\"\n >\n <CloseIcon />\n </button>\n </>\n );\n\n // Text and buttons edit in place, so they only need the two controls.\n if (isInline) {\n return (\n <div data-guardrail style={{ ...S.anchoredBar, ...position }}>\n {actions}\n {error && <span style={{ ...S.error, margin: \"0 6px\" }}>{error}</span>}\n </div>\n );\n }\n\n // Images and links carry attributes with no on-page representation.\n return (\n <div\n data-guardrail\n style={{ ...S.anchoredPanel, ...position }}\n onClick={(e) => e.stopPropagation()}\n >\n {row.value.type === \"image\" && (\n <>\n {row.value.src && (\n // Plain img: previews must not depend on remotePatterns.\n <img src={row.value.src} alt=\"\" style={S.previewThumb} />\n )}\n\n {storage && (\n <button\n type=\"button\"\n style={S.secondaryBtn}\n onClick={() => setPickerOpen(true)}\n >\n {row.value.src ? \"Replace image\" : \"Choose image\"}\n </button>\n )}\n\n <label style={S.label}>Alt text</label>\n <input\n style={S.field}\n value={row.value.alt || \"\"}\n placeholder=\"Describe the image for screen readers\"\n onChange={(e) => onChange(\"alt\", e.target.value)}\n />\n\n {/* Only without storage: there is no picker then, so a URL is the one\n way to set an image. A typed URL has unknown proportions, so any\n stored dimensions are cleared. */}\n {!storage && (\n <>\n <label style={S.label}>Image URL</label>\n <input\n style={S.field}\n value={row.value.src || \"\"}\n onChange={(e) =>\n onPatch({ src: e.target.value, width: undefined, height: undefined })\n }\n />\n </>\n )}\n\n {pickerOpen && (\n <MediaPicker\n current={row.value.src}\n onClose={() => setPickerOpen(false)}\n onSelect={(image) =>\n onPatch({ src: image.src, width: image.width, height: image.height })\n }\n />\n )}\n </>\n )}\n\n {row.value.type === \"link\" && (\n <>\n <label style={S.label}>Link text</label>\n <input\n style={S.field}\n value={row.value.text || \"\"}\n onChange={(e) => onChange(\"text\", e.target.value)}\n />\n <label style={S.label}>URL</label>\n <input\n style={S.field}\n value={row.value.href || \"\"}\n onChange={(e) => onChange(\"href\", e.target.value)}\n />\n </>\n )}\n\n {error && <div style={{ ...S.error, marginBottom: 8 }}>{error}</div>}\n <div\n style={{\n display: \"flex\",\n gap: 4,\n alignItems: \"center\",\n justifyContent: row.value.type === \"link\" ? \"space-between\" : \"flex-end\",\n }}\n >\n {/* The field's own click opens this editor, so an editable link can't\n be followed by clicking it — this is how an editor gets to the page. */}\n {row.value.type === \"link\" && (\n <button\n type=\"button\"\n style={{\n ...S.secondaryBtn,\n width: \"auto\",\n marginBottom: 0,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n }}\n disabled={!row.value.href?.trim()}\n onClick={() => openHref(row.value.href ?? \"\", (path) => router.push(path))}\n title=\"Open this link\"\n aria-label=\"Open link\"\n >\n <OpenIcon /> Open\n </button>\n )}\n <div style={{ display: \"flex\", gap: 4 }}>{actions}</div>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -10,5 +10,6 @@ declare const ArrowUpIcon: () => react_jsx_runtime.JSX.Element;
10
10
  declare const ArrowDownIcon: () => react_jsx_runtime.JSX.Element;
11
11
  declare const LogOutIcon: () => react_jsx_runtime.JSX.Element;
12
12
  declare const SpinnerIcon: () => react_jsx_runtime.JSX.Element;
13
+ declare const OpenIcon: () => react_jsx_runtime.JSX.Element;
13
14
 
14
- export { ArrowDownIcon, ArrowUpIcon, CheckIcon, CloseIcon, LogOutIcon, PencilIcon, PeopleIcon, PlusIcon, SpinnerIcon, TrashIcon };
15
+ export { ArrowDownIcon, ArrowUpIcon, CheckIcon, CloseIcon, LogOutIcon, OpenIcon, PencilIcon, PeopleIcon, PlusIcon, SpinnerIcon, TrashIcon };
@@ -58,12 +58,18 @@ const SpinnerIcon = () => /* @__PURE__ */ jsx("svg", { ...base, "aria-hidden": "
58
58
  repeatCount: "indefinite"
59
59
  }
60
60
  ) }) });
61
+ const OpenIcon = () => /* @__PURE__ */ jsxs("svg", { ...base, strokeWidth: 2, "aria-hidden": "true", children: [
62
+ /* @__PURE__ */ jsx("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" }),
63
+ /* @__PURE__ */ jsx("polyline", { points: "15 3 21 3 21 9" }),
64
+ /* @__PURE__ */ jsx("line", { x1: "10", y1: "14", x2: "21", y2: "3" })
65
+ ] });
61
66
  export {
62
67
  ArrowDownIcon,
63
68
  ArrowUpIcon,
64
69
  CheckIcon,
65
70
  CloseIcon,
66
71
  LogOutIcon,
72
+ OpenIcon,
67
73
  PencilIcon,
68
74
  PeopleIcon,
69
75
  PlusIcon,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/react/Icons.tsx"],"sourcesContent":["import React from \"react\";\n\n/**\n * Inline SVG rather than an icon package: Guardrail's chrome should not add a\n * runtime dependency to every consuming app.\n */\nconst base = {\n width: 14,\n height: 14,\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n strokeWidth: 3,\n strokeLinecap: \"round\" as const,\n strokeLinejoin: \"round\" as const,\n};\n\nexport const CheckIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <polyline points=\"20 6 9 17 4 12\" />\n </svg>\n);\n\nexport const CloseIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n <line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n </svg>\n);\n\nexport const PencilIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M12 20h9\" />\n <path d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z\" />\n </svg>\n);\n\nexport const PeopleIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\" />\n <circle cx=\"9\" cy=\"7\" r=\"4\" />\n <path d=\"M23 21v-2a4 4 0 0 0-3-3.87\" />\n <path d=\"M16 3.13a4 4 0 0 1 0 7.75\" />\n </svg>\n);\n\nexport const PlusIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n);\n\nexport const TrashIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <polyline points=\"3 6 5 6 21 6\" />\n <path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\" />\n <path d=\"M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2\" />\n </svg>\n);\n\nexport const ArrowUpIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\" />\n <polyline points=\"6 11 12 5 18 11\" />\n </svg>\n);\n\nexport const ArrowDownIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <polyline points=\"18 13 12 19 6 13\" />\n </svg>\n);\n\nexport const LogOutIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" />\n <polyline points=\"16 17 21 12 16 7\" />\n <line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\" />\n </svg>\n);\n\nexport const SpinnerIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <path d=\"M21 12a9 9 0 1 1-6.2-8.6\">\n <animateTransform\n attributeName=\"transform\"\n type=\"rotate\"\n from=\"0 12 12\"\n to=\"360 12 12\"\n dur=\"0.7s\"\n repeatCount=\"indefinite\"\n />\n </path>\n </svg>\n);\n"],"mappings":"AAmBI,cAKF,YALE;AAnBJ,OAAO,WAAW;AAMlB,MAAM,OAAO;AAAA,EACX,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,eAAe;AAAA,EACf,gBAAgB;AAClB;AAEO,MAAM,YAAY,MACvB,oBAAC,SAAK,GAAG,MAAM,eAAY,QACzB,8BAAC,cAAS,QAAO,kBAAiB,GACpC;AAGK,MAAM,YAAY,MACvB,qBAAC,SAAK,GAAG,MAAM,eAAY,QACzB;AAAA,sBAAC,UAAK,IAAG,MAAK,IAAG,KAAI,IAAG,KAAI,IAAG,MAAK;AAAA,EACpC,oBAAC,UAAK,IAAG,KAAI,IAAG,KAAI,IAAG,MAAK,IAAG,MAAK;AAAA,GACtC;AAGK,MAAM,aAAa,MACxB,qBAAC,SAAK,GAAG,MAAM,aAAa,GAAG,eAAY,QACzC;AAAA,sBAAC,UAAK,GAAE,YAAW;AAAA,EACnB,oBAAC,UAAK,GAAE,2DAA0D;AAAA,GACpE;AAGK,MAAM,aAAa,MACxB,qBAAC,SAAK,GAAG,MAAM,aAAa,GAAG,eAAY,QACzC;AAAA,sBAAC,UAAK,GAAE,6CAA4C;AAAA,EACpD,oBAAC,YAAO,IAAG,KAAI,IAAG,KAAI,GAAE,KAAI;AAAA,EAC5B,oBAAC,UAAK,GAAE,8BAA6B;AAAA,EACrC,oBAAC,UAAK,GAAE,6BAA4B;AAAA,GACtC;AAGK,MAAM,WAAW,MACtB,qBAAC,SAAK,GAAG,MAAM,eAAY,QACzB;AAAA,sBAAC,UAAK,IAAG,MAAK,IAAG,KAAI,IAAG,MAAK,IAAG,MAAK;AAAA,EACrC,oBAAC,UAAK,IAAG,KAAI,IAAG,MAAK,IAAG,MAAK,IAAG,MAAK;AAAA,GACvC;AAGK,MAAM,YAAY,MACvB,qBAAC,SAAK,GAAG,MAAM,aAAa,GAAG,eAAY,QACzC;AAAA,sBAAC,cAAS,QAAO,gBAAe;AAAA,EAChC,oBAAC,UAAK,GAAE,iDAAgD;AAAA,EACxD,oBAAC,UAAK,GAAE,0DAAyD;AAAA,GACnE;AAGK,MAAM,cAAc,MACzB,qBAAC,SAAK,GAAG,MAAM,eAAY,QACzB;AAAA,sBAAC,UAAK,IAAG,MAAK,IAAG,MAAK,IAAG,MAAK,IAAG,KAAI;AAAA,EACrC,oBAAC,cAAS,QAAO,mBAAkB;AAAA,GACrC;AAGK,MAAM,gBAAgB,MAC3B,qBAAC,SAAK,GAAG,MAAM,eAAY,QACzB;AAAA,sBAAC,UAAK,IAAG,MAAK,IAAG,KAAI,IAAG,MAAK,IAAG,MAAK;AAAA,EACrC,oBAAC,cAAS,QAAO,oBAAmB;AAAA,GACtC;AAGK,MAAM,aAAa,MACxB,qBAAC,SAAK,GAAG,MAAM,aAAa,GAAG,eAAY,QACzC;AAAA,sBAAC,UAAK,GAAE,2CAA0C;AAAA,EAClD,oBAAC,cAAS,QAAO,oBAAmB;AAAA,EACpC,oBAAC,UAAK,IAAG,MAAK,IAAG,MAAK,IAAG,KAAI,IAAG,MAAK;AAAA,GACvC;AAGK,MAAM,cAAc,MACzB,oBAAC,SAAK,GAAG,MAAM,eAAY,QACzB,8BAAC,UAAK,GAAE,4BACN;AAAA,EAAC;AAAA;AAAA,IACC,eAAc;AAAA,IACd,MAAK;AAAA,IACL,MAAK;AAAA,IACL,IAAG;AAAA,IACH,KAAI;AAAA,IACJ,aAAY;AAAA;AACd,GACF,GACF;","names":[]}
1
+ {"version":3,"sources":["../../src/react/Icons.tsx"],"sourcesContent":["import React from \"react\";\n\n/**\n * Inline SVG rather than an icon package: Guardrail's chrome should not add a\n * runtime dependency to every consuming app.\n */\nconst base = {\n width: 14,\n height: 14,\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n strokeWidth: 3,\n strokeLinecap: \"round\" as const,\n strokeLinejoin: \"round\" as const,\n};\n\nexport const CheckIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <polyline points=\"20 6 9 17 4 12\" />\n </svg>\n);\n\nexport const CloseIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n <line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n </svg>\n);\n\nexport const PencilIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M12 20h9\" />\n <path d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z\" />\n </svg>\n);\n\nexport const PeopleIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\" />\n <circle cx=\"9\" cy=\"7\" r=\"4\" />\n <path d=\"M23 21v-2a4 4 0 0 0-3-3.87\" />\n <path d=\"M16 3.13a4 4 0 0 1 0 7.75\" />\n </svg>\n);\n\nexport const PlusIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n);\n\nexport const TrashIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <polyline points=\"3 6 5 6 21 6\" />\n <path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\" />\n <path d=\"M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2\" />\n </svg>\n);\n\nexport const ArrowUpIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\" />\n <polyline points=\"6 11 12 5 18 11\" />\n </svg>\n);\n\nexport const ArrowDownIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <polyline points=\"18 13 12 19 6 13\" />\n </svg>\n);\n\nexport const LogOutIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" />\n <polyline points=\"16 17 21 12 16 7\" />\n <line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\" />\n </svg>\n);\n\nexport const SpinnerIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <path d=\"M21 12a9 9 0 1 1-6.2-8.6\">\n <animateTransform\n attributeName=\"transform\"\n type=\"rotate\"\n from=\"0 12 12\"\n to=\"360 12 12\"\n dur=\"0.7s\"\n repeatCount=\"indefinite\"\n />\n </path>\n </svg>\n);\n\nexport const OpenIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\" />\n <polyline points=\"15 3 21 3 21 9\" />\n <line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\" />\n </svg>\n);\n"],"mappings":"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","names":[]}
@@ -3,6 +3,8 @@ import React__default from 'react';
3
3
 
4
4
  interface RenderedItem {
5
5
  id: string;
6
+ /** The item's own page, when the repeater was told where its route lives. */
7
+ href?: string;
6
8
  /** Already rendered server-side — this component only wraps it in chrome. */
7
9
  node: React__default.ReactNode;
8
10
  }
@@ -6,6 +6,7 @@ import { addRepeaterItem, removeRepeaterItem, reorderRepeaterItems } from "../..
6
6
  import {
7
7
  ArrowDownIcon,
8
8
  ArrowUpIcon,
9
+ OpenIcon,
9
10
  PlusIcon,
10
11
  SpinnerIcon,
11
12
  TrashIcon
@@ -55,8 +56,21 @@ function RepeaterClient({
55
56
  };
56
57
  return /* @__PURE__ */ jsxs(Fragment, { children: [
57
58
  items.length === 0 && children,
58
- items.map(({ id, node }, index) => /* @__PURE__ */ jsxs("div", { style: S.repeaterItem, children: [
59
+ items.map(({ id, href, node }, index) => /* @__PURE__ */ jsxs("div", { style: S.repeaterItem, children: [
59
60
  /* @__PURE__ */ jsxs("div", { style: S.repeaterToolbar, children: [
61
+ href && // Clicking the item's fields edits them rather than following
62
+ // any link around them, so this is the way to its own page. A
63
+ // same-tab router push: the session keeps edit mode on.
64
+ /* @__PURE__ */ jsx(
65
+ "button",
66
+ {
67
+ style: S.iconBtn,
68
+ onClick: () => router.push(href),
69
+ "aria-label": "Open page",
70
+ title: "Open this item's page",
71
+ children: /* @__PURE__ */ jsx(OpenIcon, {})
72
+ }
73
+ ),
60
74
  /* @__PURE__ */ jsx(
61
75
  "button",
62
76
  {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/react/Repeater/client.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useState, useTransition } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport { addRepeaterItem, removeRepeaterItem, reorderRepeaterItems } from \"../../server/repeaterActions\";\nimport {\n ArrowDownIcon,\n ArrowUpIcon,\n PlusIcon,\n SpinnerIcon,\n TrashIcon,\n} from \"../Icons\";\nimport { S, v } from \"../styles\";\n\ninterface RenderedItem {\n id: string;\n /** Already rendered server-side — this component only wraps it in chrome. */\n node: React.ReactNode;\n}\n\nexport function RepeaterClient({\n repeaterId,\n routed,\n items,\n children,\n}: {\n repeaterId: string;\n routed?: boolean;\n items: RenderedItem[];\n children?: React.ReactNode;\n}) {\n const router = useRouter();\n const [error, setError] = useState<string | null>(null);\n const [adding, setAdding] = useState(false);\n const [slug, setSlug] = useState(\"\");\n const [pending, startTransition] = useTransition();\n\n /**\n * There is deliberately no local copy of `items`. Each item's node can\n * contain <GRail> fields, which only exist as a resolved server render —\n * they cannot be reproduced by holding data in client state and re-invoking\n * a component function in the browser. router.refresh() re-runs <GRepeater>\n * on the server and delivers freshly rendered nodes as new props, the same\n * pattern used for the login flow in Admin.tsx.\n */\n const run = (fn: () => Promise<{ success: boolean; error?: string }>) =>\n startTransition(async () => {\n setError(null);\n const res = await fn();\n if (!res.success) setError(res.error ?? \"Something went wrong.\");\n router.refresh();\n });\n\n const move = (id: string, dir: -1 | 1) => {\n const ids = items.map((i) => i.id);\n const index = ids.indexOf(id);\n const target = index + dir;\n if (target < 0 || target >= ids.length) return;\n const a = ids[index]!;\n const b = ids[target]!;\n ids[index] = b;\n ids[target] = a;\n run(() => reorderRepeaterItems(repeaterId, ids));\n };\n\n const submitAdd = () => {\n if (routed && !slug.trim()) return;\n run(async () => {\n const res = await addRepeaterItem(\n repeaterId,\n routed ? { slug: slug.trim() } : undefined,\n );\n if (res.success) {\n setAdding(false);\n setSlug(\"\");\n }\n return res;\n });\n };\n\n return (\n <>\n {items.length === 0 && children}\n\n {items.map(({ id, node }, index) => (\n <div key={id} style={S.repeaterItem}>\n <div style={S.repeaterToolbar}>\n <button\n style={S.iconBtn}\n disabled={pending || index === 0}\n onClick={() => move(id, -1)}\n aria-label=\"Move up\"\n title=\"Move up\"\n >\n <ArrowUpIcon />\n </button>\n <button\n style={S.iconBtn}\n disabled={pending || index === items.length - 1}\n onClick={() => move(id, 1)}\n aria-label=\"Move down\"\n title=\"Move down\"\n >\n <ArrowDownIcon />\n </button>\n <button\n style={{ ...S.iconBtn, color: v(\"danger\") }}\n disabled={pending}\n onClick={() => {\n if (confirm(\"Remove this item and its content?\")) {\n run(() => removeRepeaterItem(repeaterId, id));\n }\n }}\n aria-label=\"Remove\"\n title=\"Remove\"\n >\n <TrashIcon />\n </button>\n </div>\n {node}\n </div>\n ))}\n\n {error && <div style={{ ...S.error, marginTop: 8 }}>{error}</div>}\n\n <div style={S.repeaterAddRow}>\n {adding && routed ? (\n <>\n <input\n style={S.repeaterSlugInput}\n placeholder=\"url-slug\"\n value={slug}\n autoFocus\n onChange={(e) => setSlug(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") submitAdd();\n if (e.key === \"Escape\") setAdding(false);\n }}\n />\n <button style={S.primaryBtn} disabled={pending} onClick={submitAdd}>\n {pending ? <SpinnerIcon /> : \"Add\"}\n </button>\n <button\n style={S.cancelBtn}\n disabled={pending}\n onClick={() => {\n setAdding(false);\n setSlug(\"\");\n }}\n >\n Cancel\n </button>\n </>\n ) : (\n <button\n type=\"button\"\n style={S.repeaterAddBtn}\n disabled={pending}\n onClick={() => (routed ? setAdding(true) : submitAdd())}\n >\n <PlusIcon /> Add {routed ? \"item\" : \"card\"}\n </button>\n )}\n </div>\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/react/Repeater/client.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useState, useTransition } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport { addRepeaterItem, removeRepeaterItem, reorderRepeaterItems } from \"../../server/repeaterActions\";\nimport {\n ArrowDownIcon,\n ArrowUpIcon,\n OpenIcon,\n PlusIcon,\n SpinnerIcon,\n TrashIcon,\n} from \"../Icons\";\nimport { S, v } from \"../styles\";\n\ninterface RenderedItem {\n id: string;\n /** The item's own page, when the repeater was told where its route lives. */\n href?: string;\n /** Already rendered server-side — this component only wraps it in chrome. */\n node: React.ReactNode;\n}\n\nexport function RepeaterClient({\n repeaterId,\n routed,\n items,\n children,\n}: {\n repeaterId: string;\n routed?: boolean;\n items: RenderedItem[];\n children?: React.ReactNode;\n}) {\n const router = useRouter();\n const [error, setError] = useState<string | null>(null);\n const [adding, setAdding] = useState(false);\n const [slug, setSlug] = useState(\"\");\n const [pending, startTransition] = useTransition();\n\n /**\n * There is deliberately no local copy of `items`. Each item's node can\n * contain <GRail> fields, which only exist as a resolved server render —\n * they cannot be reproduced by holding data in client state and re-invoking\n * a component function in the browser. router.refresh() re-runs <GRepeater>\n * on the server and delivers freshly rendered nodes as new props, the same\n * pattern used for the login flow in Admin.tsx.\n */\n const run = (fn: () => Promise<{ success: boolean; error?: string }>) =>\n startTransition(async () => {\n setError(null);\n const res = await fn();\n if (!res.success) setError(res.error ?? \"Something went wrong.\");\n router.refresh();\n });\n\n const move = (id: string, dir: -1 | 1) => {\n const ids = items.map((i) => i.id);\n const index = ids.indexOf(id);\n const target = index + dir;\n if (target < 0 || target >= ids.length) return;\n const a = ids[index]!;\n const b = ids[target]!;\n ids[index] = b;\n ids[target] = a;\n run(() => reorderRepeaterItems(repeaterId, ids));\n };\n\n const submitAdd = () => {\n if (routed && !slug.trim()) return;\n run(async () => {\n const res = await addRepeaterItem(\n repeaterId,\n routed ? { slug: slug.trim() } : undefined,\n );\n if (res.success) {\n setAdding(false);\n setSlug(\"\");\n }\n return res;\n });\n };\n\n return (\n <>\n {items.length === 0 && children}\n\n {items.map(({ id, href, node }, index) => (\n <div key={id} style={S.repeaterItem}>\n <div style={S.repeaterToolbar}>\n {href && (\n // Clicking the item's fields edits them rather than following\n // any link around them, so this is the way to its own page. A\n // same-tab router push: the session keeps edit mode on.\n <button\n style={S.iconBtn}\n onClick={() => router.push(href)}\n aria-label=\"Open page\"\n title=\"Open this item's page\"\n >\n <OpenIcon />\n </button>\n )}\n <button\n style={S.iconBtn}\n disabled={pending || index === 0}\n onClick={() => move(id, -1)}\n aria-label=\"Move up\"\n title=\"Move up\"\n >\n <ArrowUpIcon />\n </button>\n <button\n style={S.iconBtn}\n disabled={pending || index === items.length - 1}\n onClick={() => move(id, 1)}\n aria-label=\"Move down\"\n title=\"Move down\"\n >\n <ArrowDownIcon />\n </button>\n <button\n style={{ ...S.iconBtn, color: v(\"danger\") }}\n disabled={pending}\n onClick={() => {\n if (confirm(\"Remove this item and its content?\")) {\n run(() => removeRepeaterItem(repeaterId, id));\n }\n }}\n aria-label=\"Remove\"\n title=\"Remove\"\n >\n <TrashIcon />\n </button>\n </div>\n {node}\n </div>\n ))}\n\n {error && <div style={{ ...S.error, marginTop: 8 }}>{error}</div>}\n\n <div style={S.repeaterAddRow}>\n {adding && routed ? (\n <>\n <input\n style={S.repeaterSlugInput}\n placeholder=\"url-slug\"\n value={slug}\n autoFocus\n onChange={(e) => setSlug(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") submitAdd();\n if (e.key === \"Escape\") setAdding(false);\n }}\n />\n <button style={S.primaryBtn} disabled={pending} onClick={submitAdd}>\n {pending ? <SpinnerIcon /> : \"Add\"}\n </button>\n <button\n style={S.cancelBtn}\n disabled={pending}\n onClick={() => {\n setAdding(false);\n setSlug(\"\");\n }}\n >\n Cancel\n </button>\n </>\n ) : (\n <button\n type=\"button\"\n style={S.repeaterAddBtn}\n disabled={pending}\n onClick={() => (routed ? setAdding(true) : submitAdd())}\n >\n <PlusIcon /> Add {routed ? \"item\" : \"card\"}\n </button>\n )}\n </div>\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -14,8 +14,15 @@ interface GRepeaterProps {
14
14
  * Marks this repeater as routed: each item gets its own URL (a product, a
15
15
  * post), so adding one prompts for a slug. Omit for a plain row of items on
16
16
  * one page (service cards) — those need no slug at all.
17
+ *
18
+ * Pass the base path your dynamic route lives at (`routed="/products"` for
19
+ * `app/products/[slug]/page.tsx`) and each item's editing toolbar gets an
20
+ * Open button to its page — without it, an editor could only reach an item's
21
+ * page by typing the URL, since clicking its fields edits them. Guardrail
22
+ * never owns routing, so it can't work that path out for itself; `true`
23
+ * works as before, just without the button.
17
24
  */
18
- routed?: boolean;
25
+ routed?: boolean | string;
19
26
  /** Shown in place of the list when it has no items yet. */
20
27
  children?: React__default.ReactNode;
21
28
  }
@@ -3,15 +3,33 @@ import React from "react";
3
3
  import { listRepeaterItems } from "../../server/repeaterData";
4
4
  import { getCachedViewer } from "../../server/session";
5
5
  import { RepeaterClient } from "./client";
6
+ function itemHref(routed, slug) {
7
+ if (typeof routed !== "string" || !routed.startsWith("/") || !slug) return void 0;
8
+ return `${routed.replace(/\/+$/, "")}/${slug}`;
9
+ }
6
10
  async function GRepeater({ id, as: Item, routed, children }) {
11
+ if (typeof routed === "string" && !routed.startsWith("/")) {
12
+ console.warn(
13
+ `Guardrail: <GRepeater id="${id}" routed="${routed}"> \u2014 the base path must start with "/" (e.g. "/products"); no Open button will be shown.`
14
+ );
15
+ }
7
16
  const items = await listRepeaterItems(id);
8
17
  const viewer = await getCachedViewer();
9
18
  if (viewer?.role) {
10
19
  const rendered = items.map((item) => ({
11
20
  id: item.id,
21
+ href: itemHref(routed, item.slug),
12
22
  node: /* @__PURE__ */ jsx(Item, { item }, item.id)
13
23
  }));
14
- return /* @__PURE__ */ jsx(RepeaterClient, { repeaterId: id, routed, items: rendered, children });
24
+ return /* @__PURE__ */ jsx(
25
+ RepeaterClient,
26
+ {
27
+ repeaterId: id,
28
+ routed: routed !== void 0 && routed !== false,
29
+ items: rendered,
30
+ children
31
+ }
32
+ );
15
33
  }
16
34
  if (items.length === 0) return /* @__PURE__ */ jsx(Fragment, { children: children ?? null });
17
35
  return /* @__PURE__ */ jsx(Fragment, { children: items.map((item) => /* @__PURE__ */ jsx(Item, { item }, item.id)) });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/react/Repeater/index.tsx"],"sourcesContent":["import React from \"react\";\nimport { listRepeaterItems } from \"../../server/repeaterData\";\nimport { getCachedViewer } from \"../../server/session\";\nimport { RepeaterClient } from \"./client\";\nimport type { GRRepeaterItem } from \"../../core/types\";\n\nexport interface RepeaterItemProps {\n item: GRRepeaterItem;\n}\n\nexport interface GRepeaterProps {\n /** The repeater's own key. Item fields are namespaced under it: `${id}.${item.id}.*`. */\n id: string;\n /** The developer's own component — the one place the item's shape is defined. */\n as: React.ComponentType<RepeaterItemProps>;\n /**\n * Marks this repeater as routed: each item gets its own URL (a product, a\n * post), so adding one prompts for a slug. Omit for a plain row of items on\n * one page (service cards) — those need no slug at all.\n */\n routed?: boolean;\n /** Shown in place of the list when it has no items yet. */\n children?: React.ReactNode;\n}\n\n/**\n * Renders N instances of one developer-authored template component, with the\n * marketer controlling how many and in what order — not what shape they take.\n * That's the whole primitive: no schema, no field-type registry, no relations.\n * An item's own fields are just <GRail> fields living under the item's id,\n * identical in kind to every other field on the page.\n *\n * Server Component by default, same split as <GRail>: anonymous visitors get\n * the template rendered N times with no admin JS; the add/remove/reorder\n * chrome only loads for someone who can actually write.\n */\nexport async function GRepeater({ id, as: Item, routed, children }: GRepeaterProps) {\n const items = await listRepeaterItems(id);\n const viewer = await getCachedViewer();\n\n if (viewer?.role) {\n // Rendered here, on the server, and handed down as already-resolved\n // elements — never as the Item component itself. Item almost certainly\n // renders <GRail>, an async Server Component; passing the function across\n // the client boundary would let RepeaterClient try to invoke it in the\n // browser, which cannot execute a Server Component at all. An element\n // (the *result* of calling it) is plain serializable RSC content and\n // composes into a Client Component exactly like `children` does.\n const rendered = items.map((item) => ({\n id: item.id,\n node: <Item key={item.id} item={item} />,\n }));\n\n return (\n <RepeaterClient repeaterId={id} routed={routed} items={rendered}>\n {children}\n </RepeaterClient>\n );\n }\n\n if (items.length === 0) return <>{children ?? null}</>;\n return (\n <>\n {items.map((item) => (\n <Item key={item.id} item={item} />\n ))}\n </>\n );\n}\n"],"mappings":"AAkDY,SAUqB,UAVrB;AAlDZ,OAAO,WAAW;AAClB,SAAS,yBAAyB;AAClC,SAAS,uBAAuB;AAChC,SAAS,sBAAsB;AAiC/B,eAAsB,UAAU,EAAE,IAAI,IAAI,MAAM,QAAQ,SAAS,GAAmB;AAClF,QAAM,QAAQ,MAAM,kBAAkB,EAAE;AACxC,QAAM,SAAS,MAAM,gBAAgB;AAErC,MAAI,QAAQ,MAAM;AAQhB,UAAM,WAAW,MAAM,IAAI,CAAC,UAAU;AAAA,MACpC,IAAI,KAAK;AAAA,MACT,MAAM,oBAAC,QAAmB,QAAT,KAAK,EAAgB;AAAA,IACxC,EAAE;AAEF,WACE,oBAAC,kBAAe,YAAY,IAAI,QAAgB,OAAO,UACpD,UACH;AAAA,EAEJ;AAEA,MAAI,MAAM,WAAW,EAAG,QAAO,gCAAG,sBAAY,MAAK;AACnD,SACE,gCACG,gBAAM,IAAI,CAAC,SACV,oBAAC,QAAmB,QAAT,KAAK,EAAgB,CACjC,GACH;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/react/Repeater/index.tsx"],"sourcesContent":["import React from \"react\";\nimport { listRepeaterItems } from \"../../server/repeaterData\";\nimport { getCachedViewer } from \"../../server/session\";\nimport { RepeaterClient } from \"./client\";\nimport type { GRRepeaterItem } from \"../../core/types\";\n\nexport interface RepeaterItemProps {\n item: GRRepeaterItem;\n}\n\nexport interface GRepeaterProps {\n /** The repeater's own key. Item fields are namespaced under it: `${id}.${item.id}.*`. */\n id: string;\n /** The developer's own component — the one place the item's shape is defined. */\n as: React.ComponentType<RepeaterItemProps>;\n /**\n * Marks this repeater as routed: each item gets its own URL (a product, a\n * post), so adding one prompts for a slug. Omit for a plain row of items on\n * one page (service cards) — those need no slug at all.\n *\n * Pass the base path your dynamic route lives at (`routed=\"/products\"` for\n * `app/products/[slug]/page.tsx`) and each item's editing toolbar gets an\n * Open button to its page — without it, an editor could only reach an item's\n * page by typing the URL, since clicking its fields edits them. Guardrail\n * never owns routing, so it can't work that path out for itself; `true`\n * works as before, just without the button.\n */\n routed?: boolean | string;\n /** Shown in place of the list when it has no items yet. */\n children?: React.ReactNode;\n}\n\n/** `/products` + `wool-throw` -> `/products/wool-throw`; undefined when the path can't be built. */\nfunction itemHref(routed: GRepeaterProps[\"routed\"], slug?: string): string | undefined {\n if (typeof routed !== \"string\" || !routed.startsWith(\"/\") || !slug) return undefined;\n return `${routed.replace(/\\/+$/, \"\")}/${slug}`;\n}\n\n/**\n * Renders N instances of one developer-authored template component, with the\n * marketer controlling how many and in what order — not what shape they take.\n * That's the whole primitive: no schema, no field-type registry, no relations.\n * An item's own fields are just <GRail> fields living under the item's id,\n * identical in kind to every other field on the page.\n *\n * Server Component by default, same split as <GRail>: anonymous visitors get\n * the template rendered N times with no admin JS; the add/remove/reorder\n * chrome only loads for someone who can actually write.\n */\nexport async function GRepeater({ id, as: Item, routed, children }: GRepeaterProps) {\n if (typeof routed === \"string\" && !routed.startsWith(\"/\")) {\n console.warn(\n `Guardrail: <GRepeater id=\"${id}\" routed=\"${routed}\"> — the base path must start with \"/\" (e.g. \"/products\"); no Open button will be shown.`,\n );\n }\n const items = await listRepeaterItems(id);\n const viewer = await getCachedViewer();\n\n if (viewer?.role) {\n // Rendered here, on the server, and handed down as already-resolved\n // elements — never as the Item component itself. Item almost certainly\n // renders <GRail>, an async Server Component; passing the function across\n // the client boundary would let RepeaterClient try to invoke it in the\n // browser, which cannot execute a Server Component at all. An element\n // (the *result* of calling it) is plain serializable RSC content and\n // composes into a Client Component exactly like `children` does.\n const rendered = items.map((item) => ({\n id: item.id,\n href: itemHref(routed, item.slug),\n node: <Item key={item.id} item={item} />,\n }));\n\n return (\n <RepeaterClient\n repeaterId={id}\n routed={routed !== undefined && routed !== false}\n items={rendered}\n >\n {children}\n </RepeaterClient>\n );\n }\n\n if (items.length === 0) return <>{children ?? null}</>;\n return (\n <>\n {items.map((item) => (\n <Item key={item.id} item={item} />\n ))}\n </>\n );\n}\n"],"mappings":"AAqEY,SAcqB,UAdrB;AArEZ,OAAO,WAAW;AAClB,SAAS,yBAAyB;AAClC,SAAS,uBAAuB;AAChC,SAAS,sBAAsB;AA8B/B,SAAS,SAAS,QAAkC,MAAmC;AACrF,MAAI,OAAO,WAAW,YAAY,CAAC,OAAO,WAAW,GAAG,KAAK,CAAC,KAAM,QAAO;AAC3E,SAAO,GAAG,OAAO,QAAQ,QAAQ,EAAE,CAAC,IAAI,IAAI;AAC9C;AAaA,eAAsB,UAAU,EAAE,IAAI,IAAI,MAAM,QAAQ,SAAS,GAAmB;AAClF,MAAI,OAAO,WAAW,YAAY,CAAC,OAAO,WAAW,GAAG,GAAG;AACzD,YAAQ;AAAA,MACN,6BAA6B,EAAE,aAAa,MAAM;AAAA,IACpD;AAAA,EACF;AACA,QAAM,QAAQ,MAAM,kBAAkB,EAAE;AACxC,QAAM,SAAS,MAAM,gBAAgB;AAErC,MAAI,QAAQ,MAAM;AAQhB,UAAM,WAAW,MAAM,IAAI,CAAC,UAAU;AAAA,MACpC,IAAI,KAAK;AAAA,MACT,MAAM,SAAS,QAAQ,KAAK,IAAI;AAAA,MAChC,MAAM,oBAAC,QAAmB,QAAT,KAAK,EAAgB;AAAA,IACxC,EAAE;AAEF,WACE;AAAA,MAAC;AAAA;AAAA,QACC,YAAY;AAAA,QACZ,QAAQ,WAAW,UAAa,WAAW;AAAA,QAC3C,OAAO;AAAA,QAEN;AAAA;AAAA,IACH;AAAA,EAEJ;AAEA,MAAI,MAAM,WAAW,EAAG,QAAO,gCAAG,sBAAY,MAAK;AACnD,SACE,gCACG,gBAAM,IAAI,CAAC,SACV,oBAAC,QAAmB,QAAT,KAAK,EAAgB,CACjC,GACH;AAEJ;","names":[]}
@@ -11,8 +11,9 @@
11
11
  * leave the cookie alone. Treating "no param" as "turn it off" was a past
12
12
  * bug: it silently ended the edit session on the very first click to a page
13
13
  * that didn't happen to carry the query string. Only an explicit ?edit=false
14
- * turns it off from the URL; logging out is the normal way to end a real
15
- * session (and, unlike this cookie, actually needs to — see logoutAction).
14
+ * turns it off from the URL, or the toolbar's exit button (Admin.tsx), which
15
+ * writes the same value. Either way the editor's sign-in is left alone, so
16
+ * ?edit=true brings the overlay straight back.
16
17
  */
17
18
  declare function EditSyncer(): null;
18
19
 
@@ -1,7 +1,11 @@
1
1
  "use client";
2
2
  import { useEffect } from "react";
3
3
  import { useSearchParams } from "next/navigation";
4
- import { GR_COOKIES, GR_EDIT_MODE_TTL_SECONDS } from "../core/defs";
4
+ import {
5
+ GR_COOKIES,
6
+ GR_EDIT_MODE_OFF_TTL_SECONDS,
7
+ GR_EDIT_MODE_TTL_SECONDS
8
+ } from "../core/defs";
5
9
  function EditSyncer() {
6
10
  const searchParams = useSearchParams();
7
11
  useEffect(() => {
@@ -13,7 +17,7 @@ function EditSyncer() {
13
17
  document.cookie = `${GR_COOKIES.EDIT_MODE}=true; path=/; max-age=${GR_EDIT_MODE_TTL_SECONDS}; SameSite=Lax`;
14
18
  window.location.reload();
15
19
  } else if (edit === "false" && current !== "false") {
16
- document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_TTL_SECONDS}; SameSite=Lax`;
20
+ document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_OFF_TTL_SECONDS}; SameSite=Lax`;
17
21
  window.location.reload();
18
22
  }
19
23
  }, [searchParams]);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/react/Syncer.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { GR_COOKIES, GR_EDIT_MODE_TTL_SECONDS } from \"../core/defs\";\n\n/**\n * Turns edit mode on (or explicitly off) from the URL, via a short-lived\n * cookie rather than a persistent one — see the comment on GR_COOKIES for\n * why that boundary moved here. ?edit=true is a one-time switch, not a value\n * that has to keep appearing on every URL to hold that state, but \"holding\n * that state\" now means \"for the next ~10 minutes, or until real auth takes\n * over,\" not indefinitely.\n *\n * A page that simply doesn't mention `edit` — which is every normal link on\n * the site, including a repeater's card linking to its own route — must\n * leave the cookie alone. Treating \"no param\" as \"turn it off\" was a past\n * bug: it silently ended the edit session on the very first click to a page\n * that didn't happen to carry the query string. Only an explicit ?edit=false\n * turns it off from the URL; logging out is the normal way to end a real\n * session (and, unlike this cookie, actually needs to — see logoutAction).\n */\nexport function EditSyncer() {\n const searchParams = useSearchParams();\n\n useEffect(() => {\n const edit = searchParams.get(\"edit\");\n const current = document.cookie.match(\n new RegExp(`(?:^|; )${GR_COOKIES.EDIT_MODE}=([^;]*)`),\n )?.[1];\n\n if (edit === \"true\" && current !== \"true\") {\n document.cookie = `${GR_COOKIES.EDIT_MODE}=true; path=/; max-age=${GR_EDIT_MODE_TTL_SECONDS}; SameSite=Lax`;\n window.location.reload();\n } else if (edit === \"false\" && current !== \"false\") {\n document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_TTL_SECONDS}; SameSite=Lax`;\n window.location.reload();\n }\n }, [searchParams]);\n\n return null;\n}\n"],"mappings":";AAEA,SAAS,iBAAiB;AAC1B,SAAS,uBAAuB;AAChC,SAAS,YAAY,gCAAgC;AAkB9C,SAAS,aAAa;AAC3B,QAAM,eAAe,gBAAgB;AAErC,YAAU,MAAM;AACd,UAAM,OAAO,aAAa,IAAI,MAAM;AACpC,UAAM,UAAU,SAAS,OAAO;AAAA,MAC9B,IAAI,OAAO,WAAW,WAAW,SAAS,UAAU;AAAA,IACtD,IAAI,CAAC;AAEL,QAAI,SAAS,UAAU,YAAY,QAAQ;AACzC,eAAS,SAAS,GAAG,WAAW,SAAS,0BAA0B,wBAAwB;AAC3F,aAAO,SAAS,OAAO;AAAA,IACzB,WAAW,SAAS,WAAW,YAAY,SAAS;AAClD,eAAS,SAAS,GAAG,WAAW,SAAS,2BAA2B,wBAAwB;AAC5F,aAAO,SAAS,OAAO;AAAA,IACzB;AAAA,EACF,GAAG,CAAC,YAAY,CAAC;AAEjB,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/react/Syncer.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport {\n GR_COOKIES,\n GR_EDIT_MODE_OFF_TTL_SECONDS,\n GR_EDIT_MODE_TTL_SECONDS,\n} from \"../core/defs\";\n\n/**\n * Turns edit mode on (or explicitly off) from the URL, via a short-lived\n * cookie rather than a persistent one — see the comment on GR_COOKIES for\n * why that boundary moved here. ?edit=true is a one-time switch, not a value\n * that has to keep appearing on every URL to hold that state, but \"holding\n * that state\" now means \"for the next ~10 minutes, or until real auth takes\n * over,\" not indefinitely.\n *\n * A page that simply doesn't mention `edit` — which is every normal link on\n * the site, including a repeater's card linking to its own route — must\n * leave the cookie alone. Treating \"no param\" as \"turn it off\" was a past\n * bug: it silently ended the edit session on the very first click to a page\n * that didn't happen to carry the query string. Only an explicit ?edit=false\n * turns it off from the URL, or the toolbar's exit button (Admin.tsx), which\n * writes the same value. Either way the editor's sign-in is left alone, so\n * ?edit=true brings the overlay straight back.\n */\nexport function EditSyncer() {\n const searchParams = useSearchParams();\n\n useEffect(() => {\n const edit = searchParams.get(\"edit\");\n const current = document.cookie.match(\n new RegExp(`(?:^|; )${GR_COOKIES.EDIT_MODE}=([^;]*)`),\n )?.[1];\n\n if (edit === \"true\" && current !== \"true\") {\n document.cookie = `${GR_COOKIES.EDIT_MODE}=true; path=/; max-age=${GR_EDIT_MODE_TTL_SECONDS}; SameSite=Lax`;\n window.location.reload();\n } else if (edit === \"false\" && current !== \"false\") {\n document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_OFF_TTL_SECONDS}; SameSite=Lax`;\n window.location.reload();\n }\n }, [searchParams]);\n\n return null;\n}\n"],"mappings":";AAEA,SAAS,iBAAiB;AAC1B,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAmBA,SAAS,aAAa;AAC3B,QAAM,eAAe,gBAAgB;AAErC,YAAU,MAAM;AACd,UAAM,OAAO,aAAa,IAAI,MAAM;AACpC,UAAM,UAAU,SAAS,OAAO;AAAA,MAC9B,IAAI,OAAO,WAAW,WAAW,SAAS,UAAU;AAAA,IACtD,IAAI,CAAC;AAEL,QAAI,SAAS,UAAU,YAAY,QAAQ;AACzC,eAAS,SAAS,GAAG,WAAW,SAAS,0BAA0B,wBAAwB;AAC3F,aAAO,SAAS,OAAO;AAAA,IACzB,WAAW,SAAS,WAAW,YAAY,SAAS;AAClD,eAAS,SAAS,GAAG,WAAW,SAAS,2BAA2B,4BAA4B;AAChG,aAAO,SAAS,OAAO;AAAA,IACzB;AAAA,EACF,GAAG,CAAC,YAAY,CAAC;AAEjB,SAAO;AACT;","names":[]}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Follows a link on an editor's behalf.
3
+ *
4
+ * An editable link can't be followed by clicking it — in edit mode that click
5
+ * opens its editor instead — so a site whose navigation is all <GRail> links
6
+ * would otherwise be reachable only by typing URLs. The editor chrome calls
7
+ * this from an explicit "Open" button.
8
+ *
9
+ * Same-origin links go through the router, in the same tab: the session is
10
+ * what keeps edit mode on, so the editor lands on the next page still in it.
11
+ * Anything off-site opens in a new tab, so editing context isn't lost.
12
+ *
13
+ * Takes the link as currently typed, saved or not — handy for checking a URL
14
+ * before committing it, but an unsaved edit does not survive a same-tab
15
+ * navigation.
16
+ *
17
+ * The scheme is checked because this value is typed by an editor and then
18
+ * executed by the browser: `javascript:` and friends are ignored outright
19
+ * rather than navigated to.
20
+ */
21
+ declare function openHref(href: string, push: (path: string) => void): void;
22
+
23
+ export { openHref };
@@ -0,0 +1,25 @@
1
+ function openHref(href, push) {
2
+ const target = href.trim();
3
+ if (!target) return;
4
+ let url;
5
+ try {
6
+ url = new URL(target, window.location.href);
7
+ } catch {
8
+ return;
9
+ }
10
+ if (url.protocol === "http:" || url.protocol === "https:") {
11
+ if (url.origin === window.location.origin) {
12
+ push(url.pathname + url.search + url.hash);
13
+ } else {
14
+ window.open(url.href, "_blank", "noopener,noreferrer");
15
+ }
16
+ return;
17
+ }
18
+ if (url.protocol === "mailto:" || url.protocol === "tel:") {
19
+ window.location.href = url.href;
20
+ }
21
+ }
22
+ export {
23
+ openHref
24
+ };
25
+ //# sourceMappingURL=openHref.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/react/openHref.ts"],"sourcesContent":["/**\n * Follows a link on an editor's behalf.\n *\n * An editable link can't be followed by clicking it — in edit mode that click\n * opens its editor instead — so a site whose navigation is all <GRail> links\n * would otherwise be reachable only by typing URLs. The editor chrome calls\n * this from an explicit \"Open\" button.\n *\n * Same-origin links go through the router, in the same tab: the session is\n * what keeps edit mode on, so the editor lands on the next page still in it.\n * Anything off-site opens in a new tab, so editing context isn't lost.\n *\n * Takes the link as currently typed, saved or not — handy for checking a URL\n * before committing it, but an unsaved edit does not survive a same-tab\n * navigation.\n *\n * The scheme is checked because this value is typed by an editor and then\n * executed by the browser: `javascript:` and friends are ignored outright\n * rather than navigated to.\n */\nexport function openHref(href: string, push: (path: string) => void): void {\n const target = href.trim();\n if (!target) return;\n\n let url: URL;\n try {\n url = new URL(target, window.location.href);\n } catch {\n return;\n }\n\n if (url.protocol === \"http:\" || url.protocol === \"https:\") {\n if (url.origin === window.location.origin) {\n push(url.pathname + url.search + url.hash);\n } else {\n window.open(url.href, \"_blank\", \"noopener,noreferrer\");\n }\n return;\n }\n\n if (url.protocol === \"mailto:\" || url.protocol === \"tel:\") {\n window.location.href = url.href;\n }\n}\n"],"mappings":"AAoBO,SAAS,SAAS,MAAc,MAAoC;AACzE,QAAM,SAAS,KAAK,KAAK;AACzB,MAAI,CAAC,OAAQ;AAEb,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,QAAQ,OAAO,SAAS,IAAI;AAAA,EAC5C,QAAQ;AACN;AAAA,EACF;AAEA,MAAI,IAAI,aAAa,WAAW,IAAI,aAAa,UAAU;AACzD,QAAI,IAAI,WAAW,OAAO,SAAS,QAAQ;AACzC,WAAK,IAAI,WAAW,IAAI,SAAS,IAAI,IAAI;AAAA,IAC3C,OAAO;AACL,aAAO,KAAK,IAAI,MAAM,UAAU,qBAAqB;AAAA,IACvD;AACA;AAAA,EACF;AAEA,MAAI,IAAI,aAAa,aAAa,IAAI,aAAa,QAAQ;AACzD,WAAO,SAAS,OAAO,IAAI;AAAA,EAC7B;AACF;","names":[]}
@@ -0,0 +1,77 @@
1
+ import { GRRole, GRViewer } from '../core/types.js';
2
+ import 'react';
3
+
4
+ /**
5
+ * Role checks, server-side only.
6
+ *
7
+ * Deliberately NOT in a "use server" file: every export of one of those is a
8
+ * callable Server Action endpoint, which is the wrong shape for an
9
+ * authorization helper — it should only ever run inside the server code that
10
+ * calls it.
11
+ *
12
+ * The client's copy of the role (useGRail().role) decides what to *show*; it
13
+ * never decides what is *allowed*. A hidden button is not authorization, so
14
+ * any Server Action behind a custom admin UI must call one of these itself.
15
+ */
16
+ /**
17
+ * Returns the signed-in viewer if their role is in `allowed`, otherwise null.
18
+ * The building block for the two helpers below; reach for this directly only
19
+ * when you need the viewer itself (their email, say), not just a yes/no.
20
+ */
21
+ declare function requireRole(allowed: readonly GRRole[]): Promise<GRViewer | null>;
22
+ /** True for a signed-in admin of this site — team management, and everything an editor can do. */
23
+ declare function isAdmin(): Promise<boolean>;
24
+ /**
25
+ * True for anyone who may edit this site's content: an editor, or an admin
26
+ * (admins can do everything editors can). The check for a custom drawer's
27
+ * Server Action that changes content. Use isAdmin() instead for anything that
28
+ * touches the team.
29
+ */
30
+ declare function isEditor(): Promise<boolean>;
31
+ /**
32
+ * True when this viewer may edit AND the site is currently in edit mode — the
33
+ * one check for "should I show editing UI / do edit-only work right now."
34
+ * Admin vs editor doesn't matter; use isAdmin() when it does.
35
+ *
36
+ * For a custom drawer's trigger rendered in a Server Component. In a Client
37
+ * Component, read `canEdit` from useGRail() instead — same answer, same rule.
38
+ * Don't use it as the only authorization on a Server Action: edit mode is a
39
+ * display preference (a cookie the visitor controls), so the action should
40
+ * call isEditor() — this answers "show it," that answers "allow it."
41
+ *
42
+ * Reads cookies, which makes the calling route dynamic — nothing new in
43
+ * practice, since withGuardrail() already does the same in the root layout.
44
+ */
45
+ declare function canEdit(): Promise<boolean>;
46
+ /**
47
+ * The one definition of "is the site in edit mode," shared by withGuardrail()
48
+ * (which hands it to the client as useGRail().isEditMode) and canEdit() so the
49
+ * two can't drift apart.
50
+ *
51
+ * "false" is an explicit, deliberate suppression (the toolbar's exit button,
52
+ * or ?edit=false, while still signed in) and wins outright — it lasts until
53
+ * the next ?edit=true, and ends only the editing session, not the sign-in. Otherwise, a real
54
+ * session is enough on its own — an authenticated editor's chrome persists for
55
+ * as long as that session does, with no separate cookie timer to fall out of
56
+ * sync with it. "true" only matters for someone not yet authenticated: a
57
+ * short-lived grace window (see GR_EDIT_MODE_TTL_SECONDS) to actually reach
58
+ * the sign-in form, not a standing invitation — the previous design kept this
59
+ * cookie alive indefinitely once set, which meant a stray copy of it kept
60
+ * showing the sign-in bar to whoever's browser it was sitting in, for as long
61
+ * as that browser stayed open.
62
+ *
63
+ * needsPassword is set by completeAuthRedirectAction the moment an
64
+ * invite/recovery link finishes — it forces chrome on regardless of the
65
+ * edit-mode cookie, since the whole point is to show the set-password prompt
66
+ * right away, not require also having ?edit=true or an unsuppressed cookie.
67
+ */
68
+ declare function resolveEditMode(viewer: GRViewer | null, cookieStore: {
69
+ get(name: string): {
70
+ value: string;
71
+ } | undefined;
72
+ }): {
73
+ isEditMode: boolean;
74
+ needsPassword: boolean;
75
+ };
76
+
77
+ export { canEdit, isAdmin, isEditor, requireRole, resolveEditMode };
@@ -0,0 +1,33 @@
1
+ import { cookies } from "next/headers";
2
+ import { getCachedViewer } from "./session";
3
+ import { GR_COOKIES } from "../core/defs";
4
+ async function requireRole(allowed) {
5
+ const viewer = await getCachedViewer();
6
+ if (!viewer || !viewer.role || !allowed.includes(viewer.role)) return null;
7
+ return viewer;
8
+ }
9
+ async function isAdmin() {
10
+ return await requireRole(["admin"]) !== null;
11
+ }
12
+ async function isEditor() {
13
+ return await requireRole(["admin", "editor"]) !== null;
14
+ }
15
+ async function canEdit() {
16
+ const viewer = await requireRole(["admin", "editor"]);
17
+ if (!viewer) return false;
18
+ return resolveEditMode(viewer, await cookies()).isEditMode;
19
+ }
20
+ function resolveEditMode(viewer, cookieStore) {
21
+ const editCookie = cookieStore.get(GR_COOKIES.EDIT_MODE)?.value;
22
+ const needsPassword = cookieStore.get(GR_COOKIES.NEEDS_PASSWORD)?.value === "true";
23
+ const isEditMode = needsPassword || (editCookie === "false" ? false : editCookie === "true" || viewer !== null);
24
+ return { isEditMode, needsPassword };
25
+ }
26
+ export {
27
+ canEdit,
28
+ isAdmin,
29
+ isEditor,
30
+ requireRole,
31
+ resolveEditMode
32
+ };
33
+ //# sourceMappingURL=access.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/server/access.ts"],"sourcesContent":["import { cookies } from \"next/headers\";\nimport { getCachedViewer } from \"./session\";\nimport { GR_COOKIES } from \"../core/defs\";\nimport type { GRRole, GRViewer } from \"../core/types\";\n\n/**\n * Role checks, server-side only.\n *\n * Deliberately NOT in a \"use server\" file: every export of one of those is a\n * callable Server Action endpoint, which is the wrong shape for an\n * authorization helper — it should only ever run inside the server code that\n * calls it.\n *\n * The client's copy of the role (useGRail().role) decides what to *show*; it\n * never decides what is *allowed*. A hidden button is not authorization, so\n * any Server Action behind a custom admin UI must call one of these itself.\n */\n\n/**\n * Returns the signed-in viewer if their role is in `allowed`, otherwise null.\n * The building block for the two helpers below; reach for this directly only\n * when you need the viewer itself (their email, say), not just a yes/no.\n */\nexport async function requireRole(\n allowed: readonly GRRole[],\n): Promise<GRViewer | null> {\n const viewer = await getCachedViewer();\n if (!viewer || !viewer.role || !allowed.includes(viewer.role)) return null;\n return viewer;\n}\n\n/** True for a signed-in admin of this site — team management, and everything an editor can do. */\nexport async function isAdmin(): Promise<boolean> {\n return (await requireRole([\"admin\"])) !== null;\n}\n\n/**\n * True for anyone who may edit this site's content: an editor, or an admin\n * (admins can do everything editors can). The check for a custom drawer's\n * Server Action that changes content. Use isAdmin() instead for anything that\n * touches the team.\n */\nexport async function isEditor(): Promise<boolean> {\n return (await requireRole([\"admin\", \"editor\"])) !== null;\n}\n\n/**\n * True when this viewer may edit AND the site is currently in edit mode — the\n * one check for \"should I show editing UI / do edit-only work right now.\"\n * Admin vs editor doesn't matter; use isAdmin() when it does.\n *\n * For a custom drawer's trigger rendered in a Server Component. In a Client\n * Component, read `canEdit` from useGRail() instead — same answer, same rule.\n * Don't use it as the only authorization on a Server Action: edit mode is a\n * display preference (a cookie the visitor controls), so the action should\n * call isEditor() — this answers \"show it,\" that answers \"allow it.\"\n *\n * Reads cookies, which makes the calling route dynamic — nothing new in\n * practice, since withGuardrail() already does the same in the root layout.\n */\nexport async function canEdit(): Promise<boolean> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return false;\n return resolveEditMode(viewer, await cookies()).isEditMode;\n}\n\n/**\n * The one definition of \"is the site in edit mode,\" shared by withGuardrail()\n * (which hands it to the client as useGRail().isEditMode) and canEdit() so the\n * two can't drift apart.\n *\n * \"false\" is an explicit, deliberate suppression (the toolbar's exit button,\n * or ?edit=false, while still signed in) and wins outright — it lasts until\n * the next ?edit=true, and ends only the editing session, not the sign-in. Otherwise, a real\n * session is enough on its own — an authenticated editor's chrome persists for\n * as long as that session does, with no separate cookie timer to fall out of\n * sync with it. \"true\" only matters for someone not yet authenticated: a\n * short-lived grace window (see GR_EDIT_MODE_TTL_SECONDS) to actually reach\n * the sign-in form, not a standing invitation — the previous design kept this\n * cookie alive indefinitely once set, which meant a stray copy of it kept\n * showing the sign-in bar to whoever's browser it was sitting in, for as long\n * as that browser stayed open.\n *\n * needsPassword is set by completeAuthRedirectAction the moment an\n * invite/recovery link finishes — it forces chrome on regardless of the\n * edit-mode cookie, since the whole point is to show the set-password prompt\n * right away, not require also having ?edit=true or an unsuppressed cookie.\n */\nexport function resolveEditMode(\n viewer: GRViewer | null,\n cookieStore: { get(name: string): { value: string } | undefined },\n): { isEditMode: boolean; needsPassword: boolean } {\n const editCookie = cookieStore.get(GR_COOKIES.EDIT_MODE)?.value;\n const needsPassword = cookieStore.get(GR_COOKIES.NEEDS_PASSWORD)?.value === \"true\";\n const isEditMode =\n needsPassword ||\n (editCookie === \"false\" ? false : editCookie === \"true\" || viewer !== null);\n return { isEditMode, needsPassword };\n}\n"],"mappings":"AAAA,SAAS,eAAe;AACxB,SAAS,uBAAuB;AAChC,SAAS,kBAAkB;AAqB3B,eAAsB,YACpB,SAC0B;AAC1B,QAAM,SAAS,MAAM,gBAAgB;AACrC,MAAI,CAAC,UAAU,CAAC,OAAO,QAAQ,CAAC,QAAQ,SAAS,OAAO,IAAI,EAAG,QAAO;AACtE,SAAO;AACT;AAGA,eAAsB,UAA4B;AAChD,SAAQ,MAAM,YAAY,CAAC,OAAO,CAAC,MAAO;AAC5C;AAQA,eAAsB,WAA6B;AACjD,SAAQ,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC,MAAO;AACtD;AAgBA,eAAsB,UAA4B;AAChD,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,gBAAgB,QAAQ,MAAM,QAAQ,CAAC,EAAE;AAClD;AAwBO,SAAS,gBACd,QACA,aACiD;AACjD,QAAM,aAAa,YAAY,IAAI,WAAW,SAAS,GAAG;AAC1D,QAAM,gBAAgB,YAAY,IAAI,WAAW,cAAc,GAAG,UAAU;AAC5E,QAAM,aACJ,kBACC,eAAe,UAAU,QAAQ,eAAe,UAAU,WAAW;AACxE,SAAO,EAAE,YAAY,cAAc;AACrC;","names":[]}