@guard-rail/core 0.6.0 → 0.7.1

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.
package/README.md CHANGED
@@ -119,16 +119,26 @@ Links take `as="a"` (so `href` type-checks) and edit both the text and the URL:
119
119
  <GRail id="nav.cta" as="a" type="link" href="/contact">Get in touch</GRail>
120
120
  ```
121
121
 
122
+ Clicking a link in edit mode opens its editor rather than following it, so the
123
+ editor has an **Open** button to go to the page — same tab for your own pages
124
+ (you stay signed in and in edit mode), a new tab for other sites. That's how an
125
+ editor moves around a site whose navigation is all `<GRail>` links.
126
+
122
127
  To edit, visit any page with `?edit=true`, sign in with a Supabase user, and
123
128
  click a field. `?edit=true` itself only grants a short (~10 minute) window to
124
129
  reach the sign-in form — a normal visitor never sees any Guardrail chrome, and
125
130
  a stray copy of that window (a shared computer, an old tab) self-expires
126
131
  rather than showing a sign-in bar indefinitely. It's a dismissable prompt, not
127
132
  a standing invitation: a signed-in editor's toolbar isn't on that timer at
128
- all, and persists for as long as their real session does, with no need to
129
- revisit `?edit=true` again after the first sign-in. `?edit=false` explicitly
130
- hides the toolbar (also for ~10 minutes) if you want to preview the site
131
- without it while still signed in.
133
+ all, and persists for as long as their real session does.
134
+
135
+ The toolbar's exit button ends the editing session, not the sign-in: the
136
+ overlay goes away, and `?edit=true` brings it straight back with no password.
137
+ The sign-in form only reappears once the session itself is gone (expired, or
138
+ ended elsewhere). `?edit=false` does the same exit from the URL, and an exit
139
+ holds until the next `?edit=true` rather than lapsing on a timer. (Someone
140
+ signed in to an account with no role on the site gets a real **Sign out**
141
+ instead, to switch accounts.)
132
142
 
133
143
  ### In Client Components
134
144
 
@@ -344,9 +354,13 @@ Editors get add/remove/reorder controls; anonymous visitors get the template
344
354
  rendered N times with no admin JS, same SSR/CSR split as `<GRail>` itself.
345
355
 
346
356
  **Routed items** — a product, a blog post — additionally get their own page.
347
- Guardrail never owns routing: pass `routed`, which prompts for a slug when an
348
- item is added, and the developer writes an ordinary dynamic route that
349
- resolves the item by that slug:
357
+ Guardrail never owns routing: pass `routed` with the base path your dynamic
358
+ route lives at (`routed="/products"` for `app/products/[slug]/page.tsx`). That
359
+ prompts for a slug when an item is added, and gives each item's editing toolbar
360
+ an **Open** button to its page — otherwise an editor could only reach it by
361
+ typing the URL, since clicking its fields edits them. (`routed` alone, without
362
+ a path, still works; it just has no Open button.) The developer writes an
363
+ ordinary dynamic route that resolves the item by that slug:
350
364
 
351
365
  ```tsx
352
366
  // app/products/[slug]/page.tsx
@@ -452,7 +466,7 @@ surrounding `<Link>`).
452
466
 
453
467
  ## Extending the toolbar
454
468
 
455
- The bundled toolbar (edit indicator, Team, Log out) has one extension point:
469
+ The bundled toolbar (edit indicator, Team, Exit edit mode) has one extension point:
456
470
 
457
471
  ```tsx
458
472
  withGuardrail(RootLayout, {
@@ -496,7 +510,9 @@ export default withGuardrail(RootLayout, {
496
510
  });
497
511
  ```
498
512
 
499
- Only `getSession()` and `logout()` are required. `authenticate` is optional —
513
+ Only `getSession()` and `logout()` are required (`logout()` backs the **Sign out** shown to
514
+ an account with no role on the site — the toolbar's normal exit button ends edit mode but
515
+ keeps the session, so it doesn't call it). `authenticate` is optional —
500
516
  adapters built on magic links, SSO, or a hosted sign-in page omit it and set
501
517
  `signInUrl`, and Guardrail links there instead of rendering a password form it
502
518
  cannot fulfil. `inviteUser` is optional too; without it, invites fall back to
@@ -36,6 +36,18 @@ declare const GR_COOKIES: {
36
36
  * logout.
37
37
  */
38
38
  declare const GR_EDIT_MODE_TTL_SECONDS: number;
39
+ /**
40
+ * How long an explicit "off" (the toolbar's exit button, ?edit=false, or the
41
+ * sign-in bar's dismiss) is remembered. Long, unlike "true" above, because the
42
+ * two fail differently: a stale "true" can put a sign-in bar in front of
43
+ * someone who never asked for it, so it's kept short — a stale "false" only
44
+ * ever hides chrome, so there's nothing to bound. It has to last as long as
45
+ * the session it sits on top of: an editor who exited stays exited (instead of
46
+ * the overlay reappearing by itself once a short timer lapsed) until they ask
47
+ * for edit mode again with ?edit=true. Matches the Supabase session cookie's
48
+ * 400 days.
49
+ */
50
+ declare const GR_EDIT_MODE_OFF_TTL_SECONDS: number;
39
51
  /**
40
52
  * How long the set-a-password prompt stays up after an invite/recovery link
41
53
  * completes. Longer than the edit-mode window above — picking a password
@@ -60,4 +72,4 @@ declare const GR_TABLE = "gr_content";
60
72
  declare const GR_MEMBERS_TABLE = "gr_members";
61
73
  declare const GR_REPEATER_TABLE = "gr_repeater_items";
62
74
 
63
- export { GR_COOKIES, GR_EDIT_MODE_TTL_SECONDS, GR_MEMBERS_TABLE, GR_NEEDS_PASSWORD_TTL_SECONDS, GR_REPEATER_TABLE, GR_SCHEMA, GR_TABLE };
75
+ export { GR_COOKIES, GR_EDIT_MODE_OFF_TTL_SECONDS, GR_EDIT_MODE_TTL_SECONDS, GR_MEMBERS_TABLE, GR_NEEDS_PASSWORD_TTL_SECONDS, GR_REPEATER_TABLE, GR_SCHEMA, GR_TABLE };
package/dist/core/defs.js CHANGED
@@ -26,6 +26,7 @@ const GR_COOKIES = {
26
26
  NEEDS_PASSWORD: "gr_needs_password"
27
27
  };
28
28
  const GR_EDIT_MODE_TTL_SECONDS = 10 * 60;
29
+ const GR_EDIT_MODE_OFF_TTL_SECONDS = 400 * 24 * 60 * 60;
29
30
  const GR_NEEDS_PASSWORD_TTL_SECONDS = 30 * 60;
30
31
  const GR_SCHEMA = "guardrail";
31
32
  const GR_TABLE = "gr_content";
@@ -33,6 +34,7 @@ const GR_MEMBERS_TABLE = "gr_members";
33
34
  const GR_REPEATER_TABLE = "gr_repeater_items";
34
35
  export {
35
36
  GR_COOKIES,
37
+ GR_EDIT_MODE_OFF_TTL_SECONDS,
36
38
  GR_EDIT_MODE_TTL_SECONDS,
37
39
  GR_MEMBERS_TABLE,
38
40
  GR_NEEDS_PASSWORD_TTL_SECONDS,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/defs.ts"],"sourcesContent":["export const GR_COOKIES = {\n /**\n * Signals intent to edit. Carries no authority on its own — every write\n * still goes through a real, server-verified role check, independent of\n * this — but it does decide whether the admin chrome (the sign-in bar, or\n * the toolbar) renders at all, so it is not harmless either: a stale copy\n * of it is a real, if low-stakes, exposure (\"there's a CMS here\") to\n * whoever's browser it's sitting in next, not just its original visitor.\n *\n * Values: \"true\" (show chrome — the short-lived grace window below, for\n * someone not yet authenticated to reach the sign-in form), \"false\"\n * (explicitly suppressed, e.g. ?edit=false previewing without chrome while\n * still signed in), or absent. Absent defers entirely to whether a real\n * session exists (see withGuardrail) — an authenticated editor's chrome\n * persists for as long as their actual session does, with no separate\n * timer of its own to fall out of sync with it.\n */\n EDIT_MODE: \"gr_edit_mode\",\n /**\n * Set server-side the moment a redirect-carried session (an invite or\n * password-recovery link) completes, so the next render — which has no\n * other way to know that just happened — prompts to set a password\n * instead of showing the normal toolbar. Cleared once they do, or once\n * they explicitly skip.\n */\n NEEDS_PASSWORD: \"gr_needs_password\",\n} as const;\n\n/**\n * How long the edit-mode cookie's \"true\"/\"false\" values last before\n * reverting to the session-derived default. Short on purpose: it only ever\n * needs to outlive the handful of clicks between landing on ?edit=true and\n * actually typing a password (or, for ?edit=false, a quick chrome-free\n * preview) — not a whole working session, which persists through real auth\n * instead. Bounds how long a stray copy of this cookie can show anything to\n * whoever's browser it's left in, self-healing without needing an explicit\n * logout.\n */\nexport const GR_EDIT_MODE_TTL_SECONDS = 10 * 60;\n\n/**\n * How long the set-a-password prompt stays up after an invite/recovery link\n * completes. Longer than the edit-mode window above — picking a password\n * takes more than one click — but still bounded, rather than nagging\n * indefinitely if it's skipped and the cookie is somehow never cleared.\n */\nexport const GR_NEEDS_PASSWORD_TTL_SECONDS = 30 * 60;\n\n/**\n * Dedicated Postgres schema, not `public`. A name prefix alone only guards\n * against accidental collisions; it does nothing against a `supabase db\n * reset`, a broad `DROP ... CASCADE`, or a migration tool that diffs and\n * \"reconciles\" everything in `public` as if it owns that namespace. A\n * separate schema keeps Guardrail's tables outside that blast radius —\n * the same reason auth.* and storage.* are their own schemas, not public.*.\n *\n * Requires \"guardrail\" to be added under Settings > API > Exposed schemas in\n * the Supabase dashboard — PostgREST only serves schemas on that list, and no\n * migration can add itself to it.\n */\nexport const GR_SCHEMA = \"guardrail\";\n\nexport const GR_TABLE = \"gr_content\";\nexport const GR_MEMBERS_TABLE = \"gr_members\";\nexport const GR_REPEATER_TABLE = \"gr_repeater_items\";\n"],"mappings":"AAAO,MAAM,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBxB,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQX,gBAAgB;AAClB;AAYO,MAAM,2BAA2B,KAAK;AAQtC,MAAM,gCAAgC,KAAK;AAc3C,MAAM,YAAY;AAElB,MAAM,WAAW;AACjB,MAAM,mBAAmB;AACzB,MAAM,oBAAoB;","names":[]}
1
+ {"version":3,"sources":["../../src/core/defs.ts"],"sourcesContent":["export const GR_COOKIES = {\n /**\n * Signals intent to edit. Carries no authority on its own — every write\n * still goes through a real, server-verified role check, independent of\n * this — but it does decide whether the admin chrome (the sign-in bar, or\n * the toolbar) renders at all, so it is not harmless either: a stale copy\n * of it is a real, if low-stakes, exposure (\"there's a CMS here\") to\n * whoever's browser it's sitting in next, not just its original visitor.\n *\n * Values: \"true\" (show chrome — the short-lived grace window below, for\n * someone not yet authenticated to reach the sign-in form), \"false\"\n * (explicitly suppressed, e.g. ?edit=false previewing without chrome while\n * still signed in), or absent. Absent defers entirely to whether a real\n * session exists (see withGuardrail) — an authenticated editor's chrome\n * persists for as long as their actual session does, with no separate\n * timer of its own to fall out of sync with it.\n */\n EDIT_MODE: \"gr_edit_mode\",\n /**\n * Set server-side the moment a redirect-carried session (an invite or\n * password-recovery link) completes, so the next render — which has no\n * other way to know that just happened — prompts to set a password\n * instead of showing the normal toolbar. Cleared once they do, or once\n * they explicitly skip.\n */\n NEEDS_PASSWORD: \"gr_needs_password\",\n} as const;\n\n/**\n * How long the edit-mode cookie's \"true\"/\"false\" values last before\n * reverting to the session-derived default. Short on purpose: it only ever\n * needs to outlive the handful of clicks between landing on ?edit=true and\n * actually typing a password (or, for ?edit=false, a quick chrome-free\n * preview) — not a whole working session, which persists through real auth\n * instead. Bounds how long a stray copy of this cookie can show anything to\n * whoever's browser it's left in, self-healing without needing an explicit\n * logout.\n */\nexport const GR_EDIT_MODE_TTL_SECONDS = 10 * 60;\n\n/**\n * How long an explicit \"off\" (the toolbar's exit button, ?edit=false, or the\n * sign-in bar's dismiss) is remembered. Long, unlike \"true\" above, because the\n * two fail differently: a stale \"true\" can put a sign-in bar in front of\n * someone who never asked for it, so it's kept short — a stale \"false\" only\n * ever hides chrome, so there's nothing to bound. It has to last as long as\n * the session it sits on top of: an editor who exited stays exited (instead of\n * the overlay reappearing by itself once a short timer lapsed) until they ask\n * for edit mode again with ?edit=true. Matches the Supabase session cookie's\n * 400 days.\n */\nexport const GR_EDIT_MODE_OFF_TTL_SECONDS = 400 * 24 * 60 * 60;\n\n/**\n * How long the set-a-password prompt stays up after an invite/recovery link\n * completes. Longer than the edit-mode window above — picking a password\n * takes more than one click — but still bounded, rather than nagging\n * indefinitely if it's skipped and the cookie is somehow never cleared.\n */\nexport const GR_NEEDS_PASSWORD_TTL_SECONDS = 30 * 60;\n\n/**\n * Dedicated Postgres schema, not `public`. A name prefix alone only guards\n * against accidental collisions; it does nothing against a `supabase db\n * reset`, a broad `DROP ... CASCADE`, or a migration tool that diffs and\n * \"reconciles\" everything in `public` as if it owns that namespace. A\n * separate schema keeps Guardrail's tables outside that blast radius —\n * the same reason auth.* and storage.* are their own schemas, not public.*.\n *\n * Requires \"guardrail\" to be added under Settings > API > Exposed schemas in\n * the Supabase dashboard — PostgREST only serves schemas on that list, and no\n * migration can add itself to it.\n */\nexport const GR_SCHEMA = \"guardrail\";\n\nexport const GR_TABLE = \"gr_content\";\nexport const GR_MEMBERS_TABLE = \"gr_members\";\nexport const GR_REPEATER_TABLE = \"gr_repeater_items\";\n"],"mappings":"AAAO,MAAM,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBxB,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQX,gBAAgB;AAClB;AAYO,MAAM,2BAA2B,KAAK;AAatC,MAAM,+BAA+B,MAAM,KAAK,KAAK;AAQrD,MAAM,gCAAgC,KAAK;AAc3C,MAAM,YAAY;AAElB,MAAM,WAAW;AACjB,MAAM,mBAAmB;AACzB,MAAM,oBAAoB;","names":[]}
@@ -12,7 +12,7 @@ import { useGRail } from "./Provider";
12
12
  import { TeamDrawer } from "./Team";
13
13
  import { CloseIcon, LogOutIcon, PeopleIcon } from "./Icons";
14
14
  import { S, THEME_CSS, v } from "./styles";
15
- import { GR_COOKIES, GR_EDIT_MODE_TTL_SECONDS } from "../core/defs";
15
+ import { GR_COOKIES, GR_EDIT_MODE_OFF_TTL_SECONDS } from "../core/defs";
16
16
  const initialState = { email: "", password: "" };
17
17
  const initialPasswordState = {};
18
18
  function GRailAdmin(props) {
@@ -46,13 +46,13 @@ function AdminBody({ toolbarExtra }) {
46
46
  await skipSetPasswordAction();
47
47
  router.refresh();
48
48
  };
49
- const signOut = async () => {
50
- await logoutAction();
49
+ const exitEditMode = () => {
50
+ document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_OFF_TTL_SECONDS}; SameSite=Lax`;
51
51
  router.replace(pathname);
52
52
  router.refresh();
53
53
  };
54
- const dismissEditMode = () => {
55
- document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_TTL_SECONDS}; SameSite=Lax`;
54
+ const signOut = async () => {
55
+ await logoutAction();
56
56
  router.replace(pathname);
57
57
  router.refresh();
58
58
  };
@@ -70,7 +70,7 @@ function AdminBody({ toolbarExtra }) {
70
70
  {
71
71
  type: "button",
72
72
  style: { ...S.iconBtn, color: v("muted") },
73
- onClick: dismissEditMode,
73
+ onClick: exitEditMode,
74
74
  title: "Not editing? Dismiss",
75
75
  "aria-label": "Dismiss",
76
76
  children: /* @__PURE__ */ jsx(CloseIcon, {})
@@ -88,6 +88,7 @@ function AdminBody({ toolbarExtra }) {
88
88
  {
89
89
  name: "email",
90
90
  type: "email",
91
+ autoComplete: "email",
91
92
  placeholder: "Email",
92
93
  defaultValue: formState.email,
93
94
  required: true,
@@ -99,6 +100,7 @@ function AdminBody({ toolbarExtra }) {
99
100
  {
100
101
  name: "password",
101
102
  type: "password",
103
+ autoComplete: "current-password",
102
104
  placeholder: "Password",
103
105
  required: true,
104
106
  style: S.input
@@ -110,7 +112,7 @@ function AdminBody({ toolbarExtra }) {
110
112
  {
111
113
  type: "button",
112
114
  style: { ...S.iconBtn, color: v("muted") },
113
- onClick: dismissEditMode,
115
+ onClick: exitEditMode,
114
116
  title: "Not editing? Dismiss",
115
117
  "aria-label": "Dismiss",
116
118
  children: /* @__PURE__ */ jsx(CloseIcon, {})
@@ -158,8 +160,8 @@ function AdminBody({ toolbarExtra }) {
158
160
  {
159
161
  style: { ...S.iconBtn, color: v("muted") },
160
162
  onClick: signOut,
161
- title: "Log out",
162
- "aria-label": "Log out",
163
+ title: "Sign out",
164
+ "aria-label": "Sign out",
163
165
  children: /* @__PURE__ */ jsx(LogOutIcon, {})
164
166
  }
165
167
  )
@@ -190,9 +192,9 @@ function AdminBody({ toolbarExtra }) {
190
192
  "button",
191
193
  {
192
194
  style: { ...S.iconBtn, color: v("muted") },
193
- onClick: signOut,
194
- title: "Log out",
195
- "aria-label": "Log out",
195
+ onClick: exitEditMode,
196
+ title: "Exit edit mode",
197
+ "aria-label": "Exit edit mode",
196
198
  children: /* @__PURE__ */ jsx(LogOutIcon, {})
197
199
  }
198
200
  )
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/react/Admin.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useActionState, useEffect, useState } from \"react\";\nimport { usePathname, useRouter } from \"next/navigation\";\nimport {\n loginAction,\n logoutAction,\n setPasswordAction,\n skipSetPasswordAction,\n} from \"../server/actions\";\nimport { useGRail } from \"./Provider\";\nimport { TeamDrawer } from \"./Team\";\nimport { CloseIcon, LogOutIcon, PeopleIcon } from \"./Icons\";\nimport { S, THEME_CSS, v } from \"./styles\";\nimport { GR_COOKIES, GR_EDIT_MODE_TTL_SECONDS } from \"../core/defs\";\nimport type { ActionState, SetPasswordState } from \"../core/types\";\n\nconst initialState: ActionState = { email: \"\", password: \"\" };\nconst initialPasswordState: SetPasswordState = {};\n\n/**\n * Owns the theme stylesheet so every piece of chrome — the sign-in bar, the\n * toolbar, and the field popups portaled to <body> — picks up the same\n * light/dark variables from one place.\n */\nexport function GRailAdmin(props: { toolbarExtra?: React.ReactNode }) {\n const { isEditMode } = useGRail();\n if (!isEditMode) return null;\n return (\n <>\n {/*\n precedence + href make React hoist this into <head> and dedupe it.\n Required, not cosmetic: this whole component renders as a sibling of\n <html> (withGuardrail can't reach inside the app's own layout), and\n React 19 rejects a bare <style> anywhere outside the main document.\n */}\n <style href=\"guardrail-theme\" precedence=\"default\">\n {THEME_CSS}\n </style>\n <AdminBody {...props} />\n </>\n );\n}\n\nfunction AdminBody({ toolbarExtra }: { toolbarExtra?: React.ReactNode }) {\n const { isAuth, isEditMode, needsPassword, role, auth } = useGRail();\n const [teamOpen, setTeamOpen] = useState(false);\n const [formState, formAction, isPending] = useActionState(\n loginAction,\n initialState,\n );\n const [passwordState, passwordFormAction, isSettingPassword] = useActionState(\n setPasswordAction,\n initialPasswordState,\n );\n const router = useRouter();\n const pathname = usePathname();\n\n useEffect(() => {\n if (formState.success) router.refresh();\n }, [formState.success, router]);\n\n useEffect(() => {\n if (passwordState.success) router.refresh();\n }, [passwordState.success, router]);\n\n const skipSetPassword = async () => {\n await skipSetPasswordAction();\n router.refresh();\n };\n\n const signOut = async () => {\n // logoutAction() now clears the edit-mode cookie itself — see the\n // comment there for why a client-side hard navigation right after it\n // used to race Next's own automatic post-action route refresh and lose.\n // router.replace + refresh works with that refresh instead of against\n // it: replace drops ?edit=true from the URL (a bare reload would leave\n // it there, since the cookie, not the query, is what withGuardrail\n // reads), and refresh re-renders the tree against the now-cleared\n // cookies without a full page reload.\n await logoutAction();\n router.replace(pathname);\n router.refresh();\n };\n\n // For someone who was never authenticated and doesn't want to be here at\n // all — a stray or expired-but-not-yet-cleared cookie, say. Explicit\n // \"false\" rather than deleting: a plain delete would do nothing for an\n // authenticated visitor (their session alone is enough to keep chrome\n // visible), but this button only ever renders in the not-yet-authenticated\n // branches below, where the two are equivalent — \"false\" is used anyway\n // for consistency with the one cookie-writing convention (see Syncer.tsx).\n const dismissEditMode = () => {\n document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_TTL_SECONDS}; SameSite=Lax`;\n router.replace(pathname);\n router.refresh();\n };\n\n // No intent signalled — render nothing at all.\n if (!isEditMode) return null;\n\n if (!isAuth) {\n // The adapter cannot take a password here, so hand sign-in off rather than\n // showing a form that could never succeed.\n if (!auth.password) {\n return (\n <div data-guardrail style={S.banner}>\n <div style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {!auth.signInUrl && (\n <span style={{ fontSize: 12, color: v(\"muted\") }}>\n No sign-in method is configured for this site.\n </span>\n )}\n </div>\n {auth.signInUrl && (\n <a href={auth.signInUrl} style={{ ...S.primaryBtn, textDecoration: \"none\" }}>\n Sign in\n </a>\n )}\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={dismissEditMode}\n title=\"Not editing? Dismiss\"\n aria-label=\"Dismiss\"\n >\n <CloseIcon />\n </button>\n </div>\n </div>\n );\n }\n\n return (\n <div data-guardrail style={S.banner}>\n <form action={formAction} style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {formState.error && <span style={S.error}>{formState.error}</span>}\n </div>\n <input\n name=\"email\"\n type=\"email\"\n placeholder=\"Email\"\n defaultValue={formState.email}\n required\n style={S.input}\n />\n <input\n name=\"password\"\n type=\"password\"\n placeholder=\"Password\"\n required\n style={S.input}\n />\n <button type=\"submit\" disabled={isPending} style={S.primaryBtn}>\n {isPending ? \"Authenticating…\" : \"Sign In\"}\n </button>\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={dismissEditMode}\n title=\"Not editing? Dismiss\"\n aria-label=\"Dismiss\"\n >\n <CloseIcon />\n </button>\n </form>\n </div>\n );\n }\n\n // A redirect-carried session (an invite or password-recovery link) just\n // completed — they're genuinely signed in, but have no password yet.\n // Ahead of the role check below: getCachedViewer() already claims a\n // pending invite as a side effect of resolving them, so role is normally\n // already set by the time this renders — this is purely about the\n // password, which is orthogonal to that. Skippable rather than a hard\n // gate: magic-link sign-in already works with no password at all, so\n // someone can dismiss this and keep using invite/magic links indefinitely.\n if (needsPassword) {\n return (\n <div data-guardrail style={S.banner}>\n <form action={passwordFormAction} style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {passwordState.error ? (\n <span style={S.error}>{passwordState.error}</span>\n ) : (\n <span style={{ fontSize: 12, color: v(\"muted\") }}>\n Set a password to sign in next time\n </span>\n )}\n </div>\n <input\n name=\"password\"\n type=\"password\"\n placeholder=\"New password\"\n required\n minLength={6}\n style={S.input}\n />\n <button type=\"submit\" disabled={isSettingPassword} style={S.primaryBtn}>\n {isSettingPassword ? \"Saving…\" : \"Set Password\"}\n </button>\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={skipSetPassword}\n title=\"Skip for now\"\n aria-label=\"Skip\"\n >\n <CloseIcon />\n </button>\n </form>\n </div>\n );\n }\n\n // Signed in, but holds no role on this site.\n if (!role) {\n return (\n <div data-guardrail style={S.toolbar}>\n <span style={S.toolbarLabel}>No access to this site</span>\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={signOut}\n title=\"Log out\"\n aria-label=\"Log out\"\n >\n <LogOutIcon />\n </button>\n </div>\n );\n }\n\n return (\n <>\n {teamOpen && <TeamDrawer onClose={() => setTeamOpen(false)} />}\n <div data-guardrail style={S.toolbar}>\n <span style={S.dot} />\n <span style={S.toolbarLabel}>Edit Mode</span>\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\"), width: 24, height: 24 }}\n onClick={() => setTeamOpen(true)}\n title=\"Team\"\n aria-label=\"Team\"\n >\n <PeopleIcon />\n </button>\n\n {toolbarExtra && (\n <>\n <span style={S.divider} />\n {toolbarExtra}\n </>\n )}\n\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={signOut}\n title=\"Log out\"\n aria-label=\"Log out\"\n >\n <LogOutIcon />\n </button>\n </div>\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/react/Admin.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useActionState, useEffect, useState } from \"react\";\nimport { usePathname, useRouter } from \"next/navigation\";\nimport {\n loginAction,\n logoutAction,\n setPasswordAction,\n skipSetPasswordAction,\n} from \"../server/actions\";\nimport { useGRail } from \"./Provider\";\nimport { TeamDrawer } from \"./Team\";\nimport { CloseIcon, LogOutIcon, PeopleIcon } from \"./Icons\";\nimport { S, THEME_CSS, v } from \"./styles\";\nimport { GR_COOKIES, GR_EDIT_MODE_OFF_TTL_SECONDS } from \"../core/defs\";\nimport type { ActionState, SetPasswordState } from \"../core/types\";\n\nconst initialState: ActionState = { email: \"\", password: \"\" };\nconst initialPasswordState: SetPasswordState = {};\n\n/**\n * Owns the theme stylesheet so every piece of chrome — the sign-in bar, the\n * toolbar, and the field popups portaled to <body> — picks up the same\n * light/dark variables from one place.\n */\nexport function GRailAdmin(props: { toolbarExtra?: React.ReactNode }) {\n const { isEditMode } = useGRail();\n if (!isEditMode) return null;\n return (\n <>\n {/*\n precedence + href make React hoist this into <head> and dedupe it.\n Required, not cosmetic: this whole component renders as a sibling of\n <html> (withGuardrail can't reach inside the app's own layout), and\n React 19 rejects a bare <style> anywhere outside the main document.\n */}\n <style href=\"guardrail-theme\" precedence=\"default\">\n {THEME_CSS}\n </style>\n <AdminBody {...props} />\n </>\n );\n}\n\nfunction AdminBody({ toolbarExtra }: { toolbarExtra?: React.ReactNode }) {\n const { isAuth, isEditMode, needsPassword, role, auth } = useGRail();\n const [teamOpen, setTeamOpen] = useState(false);\n const [formState, formAction, isPending] = useActionState(\n loginAction,\n initialState,\n );\n const [passwordState, passwordFormAction, isSettingPassword] = useActionState(\n setPasswordAction,\n initialPasswordState,\n );\n const router = useRouter();\n const pathname = usePathname();\n\n useEffect(() => {\n if (formState.success) router.refresh();\n }, [formState.success, router]);\n\n useEffect(() => {\n if (passwordState.success) router.refresh();\n }, [passwordState.success, router]);\n\n const skipSetPassword = async () => {\n await skipSetPasswordAction();\n router.refresh();\n };\n\n // Ends the editing session, not the sign-in: the overlay goes away and the\n // person's session stays, so ?edit=true brings it straight back with no\n // credentials. The sign-in form only returns once the session itself is\n // gone (expired, or ended elsewhere). Explicit \"false\" rather than deleting\n // the cookie: with a live session, deleting it would leave edit mode on —\n // see resolveEditMode. router.replace drops ?edit=true from the URL (a bare\n // refresh would leave it there), and refresh re-renders without chrome.\n //\n // Also what the sign-in bar's dismiss button does, for someone who was never\n // authenticated and doesn't want to be here at all.\n const exitEditMode = () => {\n document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_OFF_TTL_SECONDS}; SameSite=Lax`;\n router.replace(pathname);\n router.refresh();\n };\n\n // A real sign-out, kept only for the \"no access\" bar below: someone on the\n // wrong account has no other way to switch to the right one. logoutAction()\n // clears the edit-mode cookie itself — a client-side navigation right after\n // it used to race Next's own post-action route refresh and lose.\n const signOut = async () => {\n await logoutAction();\n router.replace(pathname);\n router.refresh();\n };\n\n // No intent signalled — render nothing at all.\n if (!isEditMode) return null;\n\n if (!isAuth) {\n // The adapter cannot take a password here, so hand sign-in off rather than\n // showing a form that could never succeed.\n if (!auth.password) {\n return (\n <div data-guardrail style={S.banner}>\n <div style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {!auth.signInUrl && (\n <span style={{ fontSize: 12, color: v(\"muted\") }}>\n No sign-in method is configured for this site.\n </span>\n )}\n </div>\n {auth.signInUrl && (\n <a href={auth.signInUrl} style={{ ...S.primaryBtn, textDecoration: \"none\" }}>\n Sign in\n </a>\n )}\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={exitEditMode}\n title=\"Not editing? Dismiss\"\n aria-label=\"Dismiss\"\n >\n <CloseIcon />\n </button>\n </div>\n </div>\n );\n }\n\n return (\n <div data-guardrail style={S.banner}>\n <form action={formAction} style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {formState.error && <span style={S.error}>{formState.error}</span>}\n </div>\n <input\n name=\"email\"\n type=\"email\"\n autoComplete=\"email\"\n placeholder=\"Email\"\n defaultValue={formState.email}\n required\n style={S.input}\n />\n <input\n name=\"password\"\n type=\"password\"\n autoComplete=\"current-password\"\n placeholder=\"Password\"\n required\n style={S.input}\n />\n <button type=\"submit\" disabled={isPending} style={S.primaryBtn}>\n {isPending ? \"Authenticating…\" : \"Sign In\"}\n </button>\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={exitEditMode}\n title=\"Not editing? Dismiss\"\n aria-label=\"Dismiss\"\n >\n <CloseIcon />\n </button>\n </form>\n </div>\n );\n }\n\n // A redirect-carried session (an invite or password-recovery link) just\n // completed — they're genuinely signed in, but have no password yet.\n // Ahead of the role check below: getCachedViewer() already claims a\n // pending invite as a side effect of resolving them, so role is normally\n // already set by the time this renders — this is purely about the\n // password, which is orthogonal to that. Skippable rather than a hard\n // gate: magic-link sign-in already works with no password at all, so\n // someone can dismiss this and keep using invite/magic links indefinitely.\n if (needsPassword) {\n return (\n <div data-guardrail style={S.banner}>\n <form action={passwordFormAction} style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {passwordState.error ? (\n <span style={S.error}>{passwordState.error}</span>\n ) : (\n <span style={{ fontSize: 12, color: v(\"muted\") }}>\n Set a password to sign in next time\n </span>\n )}\n </div>\n <input\n name=\"password\"\n type=\"password\"\n placeholder=\"New password\"\n required\n minLength={6}\n style={S.input}\n />\n <button type=\"submit\" disabled={isSettingPassword} style={S.primaryBtn}>\n {isSettingPassword ? \"Saving…\" : \"Set Password\"}\n </button>\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={skipSetPassword}\n title=\"Skip for now\"\n aria-label=\"Skip\"\n >\n <CloseIcon />\n </button>\n </form>\n </div>\n );\n }\n\n // Signed in, but holds no role on this site.\n if (!role) {\n return (\n <div data-guardrail style={S.toolbar}>\n <span style={S.toolbarLabel}>No access to this site</span>\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={signOut}\n title=\"Sign out\"\n aria-label=\"Sign out\"\n >\n <LogOutIcon />\n </button>\n </div>\n );\n }\n\n return (\n <>\n {teamOpen && <TeamDrawer onClose={() => setTeamOpen(false)} />}\n <div data-guardrail style={S.toolbar}>\n <span style={S.dot} />\n <span style={S.toolbarLabel}>Edit Mode</span>\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\"), width: 24, height: 24 }}\n onClick={() => setTeamOpen(true)}\n title=\"Team\"\n aria-label=\"Team\"\n >\n <PeopleIcon />\n </button>\n\n {toolbarExtra && (\n <>\n <span style={S.divider} />\n {toolbarExtra}\n </>\n )}\n\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={exitEditMode}\n title=\"Exit edit mode\"\n aria-label=\"Exit edit mode\"\n >\n <LogOutIcon />\n </button>\n </div>\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -2,11 +2,13 @@
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import React, { useEffect, useRef, useState } from "react";
4
4
  import { createPortal } from "react-dom";
5
+ import { useRouter } from "next/navigation";
5
6
  import { saveOverride } from "../../server/actions";
6
7
  import { GRailRender } from "./render";
7
8
  import { useGRail } from "../Provider";
8
9
  import { useAnchorRect } from "../useAnchorRect";
9
- import { CheckIcon, CloseIcon, PencilIcon, SpinnerIcon } from "../Icons";
10
+ import { CheckIcon, CloseIcon, OpenIcon, PencilIcon, SpinnerIcon } from "../Icons";
11
+ import { openHref } from "../openHref";
10
12
  import { S } from "../styles";
11
13
  import { MediaPicker } from "../MediaPicker";
12
14
  function GRailClient({
@@ -209,12 +211,15 @@ function Chrome({
209
211
  onCancel
210
212
  }) {
211
213
  const { storage } = useGRail();
214
+ const router = useRouter();
212
215
  const [pickerOpen, setPickerOpen] = useState(false);
213
216
  const GAP = 8;
214
217
  const below = rect.bottom + GAP;
215
218
  const flip = typeof window !== "undefined" && below > window.innerHeight - 120;
219
+ const PANEL_MIN_WIDTH = 260;
220
+ const maxLeft = typeof window !== "undefined" ? window.innerWidth - (isInline ? 0 : PANEL_MIN_WIDTH) - 8 : Infinity;
216
221
  const position = {
217
- left: Math.max(8, rect.left),
222
+ left: Math.max(8, Math.min(rect.left, maxLeft)),
218
223
  ...flip ? { top: Math.max(8, rect.top - GAP - 44) } : { top: below }
219
224
  };
220
225
  const actions = /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -317,7 +322,42 @@ function Chrome({
317
322
  )
318
323
  ] }),
319
324
  error && /* @__PURE__ */ jsx("div", { style: { ...S.error, marginBottom: 8 }, children: error }),
320
- /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 4, justifyContent: "flex-end" }, children: actions })
325
+ /* @__PURE__ */ jsxs(
326
+ "div",
327
+ {
328
+ style: {
329
+ display: "flex",
330
+ gap: 4,
331
+ alignItems: "center",
332
+ justifyContent: row.value.type === "link" ? "space-between" : "flex-end"
333
+ },
334
+ children: [
335
+ row.value.type === "link" && /* @__PURE__ */ jsxs(
336
+ "button",
337
+ {
338
+ type: "button",
339
+ style: {
340
+ ...S.secondaryBtn,
341
+ width: "auto",
342
+ marginBottom: 0,
343
+ display: "inline-flex",
344
+ alignItems: "center",
345
+ gap: 6
346
+ },
347
+ disabled: !row.value.href?.trim(),
348
+ onClick: () => openHref(row.value.href ?? "", (path) => router.push(path)),
349
+ title: "Open this link",
350
+ "aria-label": "Open link",
351
+ children: [
352
+ /* @__PURE__ */ jsx(OpenIcon, {}),
353
+ " Open"
354
+ ]
355
+ }
356
+ ),
357
+ /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 4 }, children: actions })
358
+ ]
359
+ }
360
+ )
321
361
  ]
322
362
  }
323
363
  );
@@ -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
  }
@@ -1,17 +1,34 @@
1
1
  import { Fragment, jsx } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { listRepeaterItems } from "../../server/repeaterData";
4
- import { getCachedViewer } from "../../server/session";
4
+ import { canEdit } from "../../server/access";
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
- const viewer = await getCachedViewer();
9
- if (viewer?.role) {
17
+ if (await canEdit()) {
10
18
  const rendered = items.map((item) => ({
11
19
  id: item.id,
20
+ href: itemHref(routed, item.slug),
12
21
  node: /* @__PURE__ */ jsx(Item, { item }, item.id)
13
22
  }));
14
- return /* @__PURE__ */ jsx(RepeaterClient, { repeaterId: id, routed, items: rendered, children });
23
+ return /* @__PURE__ */ jsx(
24
+ RepeaterClient,
25
+ {
26
+ repeaterId: id,
27
+ routed: routed !== void 0 && routed !== false,
28
+ items: rendered,
29
+ children
30
+ }
31
+ );
15
32
  }
16
33
  if (items.length === 0) return /* @__PURE__ */ jsx(Fragment, { children: children ?? null });
17
34
  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 { canEdit } from \"../../server/access\";\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\n // canEdit(), not just \"has a role\": an editor who has exited edit mode is\n // still signed in, and must get the plain list a visitor sees — no\n // add/remove/reorder chrome, no admin JS — until they re-enter edit mode.\n if (await canEdit()) {\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":"AAuEY,SAcqB,UAdrB;AAvEZ,OAAO,WAAW;AAClB,SAAS,yBAAyB;AAClC,SAAS,eAAe;AACxB,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;AAKxC,MAAI,MAAM,QAAQ,GAAG;AAQnB,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":[]}
@@ -48,8 +48,9 @@ declare function canEdit(): Promise<boolean>;
48
48
  * (which hands it to the client as useGRail().isEditMode) and canEdit() so the
49
49
  * two can't drift apart.
50
50
  *
51
- * "false" is an explicit, deliberate suppression (?edit=false while still
52
- * signed in, to preview without chrome) and wins outright. Otherwise, a real
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
53
54
  * session is enough on its own — an authenticated editor's chrome persists for
54
55
  * as long as that session does, with no separate cookie timer to fall out of
55
56
  * sync with it. "true" only matters for someone not yet authenticated: a
@@ -1 +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 (?edit=false while still\n * signed in, to preview without chrome) and wins outright. 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;AAuBO,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":[]}
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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guard-rail/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Lightweight inline content editing for developer-owned Next.js sites.",
5
5
  "license": "ISC",
6
6
  "type": "module",