@drawnagency/primitives 0.1.105 → 0.1.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/auth/cookies.d.ts +21 -1
  2. package/dist/auth/cookies.d.ts.map +1 -1
  3. package/dist/auth/index.d.ts +2 -1
  4. package/dist/auth/index.d.ts.map +1 -1
  5. package/dist/auth/index.js +3 -1
  6. package/dist/auth/types.d.ts +28 -0
  7. package/dist/auth/types.d.ts.map +1 -1
  8. package/dist/{chunk-PTOLUMGV.js → chunk-4RNF2DRG.js} +1 -0
  9. package/dist/{chunk-MUTHF2GT.js → chunk-5IZ2KTMU.js} +2 -2
  10. package/dist/{chunk-LZKVV4K2.js → chunk-6P4CJCXB.js} +7 -1
  11. package/dist/{chunk-CZLYECHC.js → chunk-AB3XVXI3.js} +4 -4
  12. package/dist/chunk-B7VBSE5X.js +12 -0
  13. package/dist/{chunk-BGHWUJFV.js → chunk-BOZUEKWL.js} +107 -171
  14. package/dist/{chunk-TGNS7D4F.js → chunk-CPTFFLK2.js} +1 -1
  15. package/dist/{chunk-26ALJO4K.js → chunk-D6TLUVOP.js} +1 -1
  16. package/dist/{chunk-EM5DFHDZ.js → chunk-GEENI3OC.js} +13 -4
  17. package/dist/chunk-GGXZZ77Z.js +70 -0
  18. package/dist/{chunk-X4PRBGB3.js → chunk-IPKZ5FU6.js} +83 -33
  19. package/dist/{chunk-CXQWLG6X.js → chunk-JIQBLG6Y.js} +23 -20
  20. package/dist/{chunk-EWNDBKYK.js → chunk-LUAI5DWR.js} +1 -1
  21. package/dist/chunk-NLQ5F3RQ.js +43 -0
  22. package/dist/{chunk-3COSXTOY.js → chunk-XBYJQ5RS.js} +1 -1
  23. package/dist/{chunk-WA6QPDJI.js → chunk-ZHHJVKU7.js} +6 -6
  24. package/dist/collab/rebase.js +2 -1
  25. package/dist/components/editor/SectionOrderingModal.d.ts.map +1 -1
  26. package/dist/components/editor/index.js +8 -7
  27. package/dist/components/primitives/index.js +7 -6
  28. package/dist/components/sections/Container/Container.d.ts.map +1 -1
  29. package/dist/components/sections/brand-guide.js +8 -7
  30. package/dist/components/sections/register-schemas.js +13 -12
  31. package/dist/components/sections/register.js +13 -12
  32. package/dist/components/shared/Navigation.d.ts +16 -1
  33. package/dist/components/shared/Navigation.d.ts.map +1 -1
  34. package/dist/components/shared/SectionSummaryRow.d.ts +24 -0
  35. package/dist/components/shared/SectionSummaryRow.d.ts.map +1 -0
  36. package/dist/components/shell/AudienceAddForm.d.ts +4 -1
  37. package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
  38. package/dist/components/shell/AudienceDetailsPanel.d.ts +28 -0
  39. package/dist/components/shell/AudienceDetailsPanel.d.ts.map +1 -0
  40. package/dist/components/shell/AudienceRow.d.ts +15 -1
  41. package/dist/components/shell/AudienceRow.d.ts.map +1 -1
  42. package/dist/components/shell/CredentialList.d.ts +18 -0
  43. package/dist/components/shell/CredentialList.d.ts.map +1 -0
  44. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  45. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  46. package/dist/components/shell/SectionTypePicker.d.ts.map +1 -1
  47. package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
  48. package/dist/components/shell/ViewerLoginForm.d.ts +5 -1
  49. package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
  50. package/dist/index.js +47 -29
  51. package/dist/lib/audience-overview.d.ts +53 -0
  52. package/dist/lib/audience-overview.d.ts.map +1 -0
  53. package/dist/lib/audience-secret.d.ts +11 -0
  54. package/dist/lib/audience-secret.d.ts.map +1 -0
  55. package/dist/lib/audience-secret.js +70 -0
  56. package/dist/lib/container-ops.d.ts.map +1 -1
  57. package/dist/lib/events.d.ts +3 -0
  58. package/dist/lib/events.d.ts.map +1 -1
  59. package/dist/lib/index-ops.js +3 -2
  60. package/dist/lib/index.js +13 -12
  61. package/dist/lib/insert-menu.d.ts +7 -3
  62. package/dist/lib/insert-menu.d.ts.map +1 -1
  63. package/dist/lib/nav.d.ts.map +1 -1
  64. package/dist/lib/thumb-views.d.ts +17 -0
  65. package/dist/lib/thumb-views.d.ts.map +1 -0
  66. package/dist/media/index.js +4 -3
  67. package/dist/schemas/audience.d.ts +29 -0
  68. package/dist/schemas/audience.d.ts.map +1 -1
  69. package/dist/schemas/audience.js +24 -0
  70. package/dist/schemas/auth.d.ts +1 -0
  71. package/dist/schemas/auth.d.ts.map +1 -1
  72. package/dist/schemas/auth.js +1 -1
  73. package/dist/schemas/index.js +28 -12
  74. package/dist/schemas/site-config.d.ts +1 -1
  75. package/dist/schemas/site-config.d.ts.map +1 -1
  76. package/dist/types/database.d.ts +59 -5
  77. package/dist/types/database.d.ts.map +1 -1
  78. package/package.json +9 -1
  79. package/src/auth/cookies.ts +37 -4
  80. package/src/auth/index.ts +2 -1
  81. package/src/auth/types.ts +32 -0
  82. package/src/components/editor/SectionOrderingModal.tsx +8 -73
  83. package/src/components/sections/Container/Container.tsx +12 -2
  84. package/src/components/sections/IconList/IconList.tsx +75 -22
  85. package/src/components/shared/Navigation.tsx +113 -7
  86. package/src/components/shared/SectionSummaryRow.tsx +73 -0
  87. package/src/components/shell/AudienceAddForm.tsx +18 -9
  88. package/src/components/shell/AudienceDetailsPanel.tsx +263 -0
  89. package/src/components/shell/AudienceRow.tsx +55 -13
  90. package/src/components/shell/CredentialList.tsx +285 -0
  91. package/src/components/shell/EditorShell.tsx +98 -5
  92. package/src/components/shell/MediaLibraryModal.tsx +42 -5
  93. package/src/components/shell/SectionTypePicker.tsx +28 -2
  94. package/src/components/shell/SiteSettingsViewerAccess.tsx +57 -6
  95. package/src/components/shell/ViewerLoginForm.tsx +35 -15
  96. package/src/lib/audience-overview.ts +110 -0
  97. package/src/lib/audience-secret.ts +99 -0
  98. package/src/lib/container-ops.ts +6 -3
  99. package/src/lib/events.ts +3 -0
  100. package/src/lib/insert-menu.ts +38 -4
  101. package/src/lib/nav.ts +6 -2
  102. package/src/lib/thumb-views.ts +28 -0
  103. package/src/schemas/audience.ts +52 -0
  104. package/src/schemas/auth.ts +6 -0
  105. package/src/schemas/site-config.ts +1 -1
  106. package/src/types/database.ts +59 -5
  107. package/dist/chunk-ZDQ3TT47.js +0 -30
@@ -4,6 +4,8 @@ import { Input } from "../shared/Input";
4
4
  import { PasswordInput } from "../shared/PasswordInput";
5
5
  import { Button } from "../shared/Button";
6
6
  import { ColorPicker } from "../shared/ColorPicker";
7
+ import { CredentialList } from "./CredentialList";
8
+ import type { ViewerCredential } from "../../schemas/audience";
7
9
 
8
10
  interface Props {
9
11
  audience: Audience;
@@ -11,6 +13,15 @@ interface Props {
11
13
  canMutate: boolean;
12
14
  onSave: (patch: { displayName?: string; color?: string | null; password?: string }) => Promise<void>;
13
15
  onDelete: () => Promise<void>;
16
+ /** Named sign-ins for this audience. Undefined in `.env`/audience mode, where
17
+ * the row keeps its single audience-level password field. */
18
+ credentials?: ViewerCredential[];
19
+ credentialActions?: {
20
+ create: (data: { username: string; label: string | null; password: string }) => Promise<void>;
21
+ setPassword: (username: string, password: string) => Promise<void>;
22
+ setLabel: (username: string, label: string | null) => Promise<void>;
23
+ delete: (username: string) => Promise<void>;
24
+ };
14
25
  }
15
26
 
16
27
  const PLACEHOLDER_COLOR = "#94a3b8";
@@ -21,7 +32,12 @@ export function AudienceRow({
21
32
  canMutate,
22
33
  onSave,
23
34
  onDelete,
35
+ credentials,
36
+ credentialActions,
24
37
  }: Props) {
38
+ // Credential mode: access comes from named sign-ins, so the row's own password
39
+ // field is gone. The two are never shown together.
40
+ const credentialMode = Boolean(credentials && credentialActions);
25
41
  const [isEditing, setIsEditing] = useState(false);
26
42
  const [draftDisplayName, setDraftDisplayName] = useState(audience.displayName);
27
43
  const [draftColor, setDraftColor] = useState(audience.color ?? PLACEHOLDER_COLOR);
@@ -76,7 +92,13 @@ export function AudienceRow({
76
92
  }
77
93
 
78
94
  async function remove() {
79
- if (!confirm(`Remove audience "${audience.displayName}"?`)) return;
95
+ const signIns = audience.credentialCount;
96
+ if (!confirm(
97
+ `Remove audience "${audience.displayName}"?` +
98
+ (signIns > 0
99
+ ? `\n\nIts ${signIns === 1 ? "sign-in" : `${signIns} sign-ins`} will be removed too, locking out anyone using them.`
100
+ : ""),
101
+ )) return;
80
102
  try {
81
103
  await onDelete();
82
104
  } catch (e) {
@@ -95,17 +117,21 @@ export function AudienceRow({
95
117
  value={draftDisplayName}
96
118
  onChange={setDraftDisplayName}
97
119
  />
98
- <PasswordInput
99
- label="New password"
100
- value={draftPassword}
101
- onChange={setDraftPassword}
102
- autoComplete="new-password"
103
- placeholder={audience.hasPassword ? "Leave blank to keep current" : "Required"}
104
- showToggle
105
- />
106
- <span className="block text-[11px] text-base-contrast-light">
107
- Passwords are hashed — the existing one can't be recovered, only replaced.
108
- </span>
120
+ {!credentialMode && (
121
+ <>
122
+ <PasswordInput
123
+ label="New password"
124
+ value={draftPassword}
125
+ onChange={setDraftPassword}
126
+ autoComplete="new-password"
127
+ placeholder={audience.hasPassword ? "Leave blank to keep current" : "Required"}
128
+ showToggle
129
+ />
130
+ <span className="block text-[11px] text-base-contrast-light">
131
+ Passwords are hashed — the existing one can't be recovered, only replaced.
132
+ </span>
133
+ </>
134
+ )}
109
135
  </div>
110
136
  </div>
111
137
  {error && <p className="text-xs text-red-600">{error}</p>}
@@ -148,7 +174,11 @@ export function AudienceRow({
148
174
  </span>
149
175
  )}
150
176
  </span>
151
- {audience.hasPassword ? (
177
+ {credentialMode ? (
178
+ <span className="text-xs text-base-contrast-light">
179
+ {audience.credentialCount === 1 ? "1 sign-in" : `${audience.credentialCount} sign-ins`}
180
+ </span>
181
+ ) : audience.hasPassword ? (
152
182
  <span
153
183
  aria-label="Password is set"
154
184
  title="Passwords are hashed and can't be shown. Edit to set a new one."
@@ -166,6 +196,18 @@ export function AudienceRow({
166
196
  <Button variant="ghost" tone="destructive" onClick={remove}>Remove</Button>
167
197
  )}
168
198
  </div>
199
+
200
+ {credentials && credentialActions && (
201
+ <CredentialList
202
+ audienceName={audience.displayName}
203
+ credentials={credentials}
204
+ canMutate={canMutate}
205
+ onCreate={credentialActions.create}
206
+ onSetPassword={credentialActions.setPassword}
207
+ onSetLabel={credentialActions.setLabel}
208
+ onDelete={credentialActions.delete}
209
+ />
210
+ )}
169
211
  </div>
170
212
  );
171
213
  }
@@ -0,0 +1,285 @@
1
+ import { useState } from "react";
2
+ import { Eye, Copy, Check } from "lucide-react";
3
+ import { Input } from "../shared/Input";
4
+ import { PasswordInput } from "../shared/PasswordInput";
5
+ import { Button } from "../shared/Button";
6
+ import {
7
+ ViewerUsernameSchema,
8
+ VIEWER_PASSWORD_MIN_LENGTH,
9
+ normalizeUsername,
10
+ slugifyAudienceName,
11
+ } from "../../schemas/audience";
12
+ import type { ViewerCredential } from "../../schemas/audience";
13
+
14
+ interface Props {
15
+ audienceName: string;
16
+ credentials: ViewerCredential[];
17
+ canMutate: boolean;
18
+ onCreate: (data: { username: string; label: string | null; password: string }) => Promise<void>;
19
+ onSetPassword: (username: string, password: string) => Promise<void>;
20
+ onSetLabel: (username: string, label: string | null) => Promise<void>;
21
+ onDelete: (username: string) => Promise<void>;
22
+ }
23
+
24
+ function CopyableSecret({ value }: { value: string }) {
25
+ const [copied, setCopied] = useState(false);
26
+ return (
27
+ <span className="flex items-center gap-2">
28
+ <code className="rounded bg-base-accent px-2 py-1 font-mono text-xs text-base-contrast select-all">{value}</code>
29
+ <Button
30
+ variant="ghost"
31
+ aria-label="Copy password"
32
+ onClick={async () => {
33
+ try {
34
+ await navigator.clipboard.writeText(value);
35
+ setCopied(true);
36
+ setTimeout(() => setCopied(false), 2000);
37
+ } catch {
38
+ // Clipboard blocked — the value is on screen and selectable.
39
+ }
40
+ }}
41
+ >
42
+ {copied ? <Check className="h-3.5 w-3.5" aria-hidden /> : <Copy className="h-3.5 w-3.5" aria-hidden />}
43
+ </Button>
44
+ </span>
45
+ );
46
+ }
47
+
48
+ function CredentialRow({
49
+ credential, canMutate, onSetPassword, onSetLabel, onDelete,
50
+ }: {
51
+ credential: ViewerCredential;
52
+ canMutate: boolean;
53
+ onSetPassword: (username: string, password: string) => Promise<void>;
54
+ onSetLabel: (username: string, label: string | null) => Promise<void>;
55
+ onDelete: (username: string) => Promise<void>;
56
+ }) {
57
+ const [editing, setEditing] = useState(false);
58
+ const [label, setLabel] = useState(credential.label ?? "");
59
+ const [password, setPassword] = useState("");
60
+ const [revealed, setRevealed] = useState<string | null>(null);
61
+ const [busy, setBusy] = useState(false);
62
+ const [error, setError] = useState<string | null>(null);
63
+
64
+ async function reveal() {
65
+ setError(null);
66
+ setBusy(true);
67
+ try {
68
+ const res = await fetch("/api/auth/viewer-password", {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({ username: credential.username }),
72
+ });
73
+ const body = await res.json().catch(() => ({}));
74
+ if (body.status === "revealed") setRevealed(body.password);
75
+ else if (body.status === "unavailable") {
76
+ setError("Hidden. Set a new password to view here.");
77
+ } else setError(body.error ?? "Couldn't load the password.");
78
+ } catch {
79
+ setError("Network error");
80
+ } finally {
81
+ setBusy(false);
82
+ }
83
+ }
84
+
85
+ async function save() {
86
+ setError(null);
87
+ if (password.length > 0 && password.length < VIEWER_PASSWORD_MIN_LENGTH) {
88
+ setError(`Password must be at least ${VIEWER_PASSWORD_MIN_LENGTH} characters`);
89
+ return;
90
+ }
91
+ setBusy(true);
92
+ try {
93
+ const nextLabel = label.trim().length > 0 ? label.trim() : null;
94
+ if (nextLabel !== (credential.label ?? null)) await onSetLabel(credential.username, nextLabel);
95
+ if (password.length > 0) {
96
+ await onSetPassword(credential.username, password);
97
+ // Show what was just set, so it can be shared without a second lookup.
98
+ setRevealed(password);
99
+ }
100
+ setPassword("");
101
+ setEditing(false);
102
+ } catch (e) {
103
+ setError(e instanceof Error ? e.message : "Save failed");
104
+ } finally {
105
+ setBusy(false);
106
+ }
107
+ }
108
+
109
+ async function remove() {
110
+ if (!confirm(
111
+ `Remove the sign-in "${credential.username}"?\n\n` +
112
+ "Anyone using it will be locked out — though a browser already signed in " +
113
+ "may keep access for up to 24 hours, until its session expires.",
114
+ )) return;
115
+ setError(null);
116
+ try {
117
+ await onDelete(credential.username);
118
+ } catch (e) {
119
+ setError(e instanceof Error ? e.message : "Remove failed");
120
+ }
121
+ }
122
+
123
+ return (
124
+ <li className="rounded border border-base-200 px-3 py-2">
125
+ <div className="flex flex-wrap items-center gap-2">
126
+ <span className="min-w-0 flex-1">
127
+ <span className="block truncate font-mono text-sm text-base-contrast">{credential.username}</span>
128
+ {credential.label && !editing && (
129
+ <span className="block truncate text-[11px] text-base-contrast-light">{credential.label}</span>
130
+ )}
131
+ </span>
132
+
133
+ {revealed ? (
134
+ <CopyableSecret value={revealed} />
135
+ ) : (
136
+ <Button variant="ghost" onClick={reveal} isLoading={busy && !editing} loadingLabel="...">
137
+ <span className="inline-flex items-center gap-1.5">
138
+ <Eye className="h-3.5 w-3.5" aria-hidden />
139
+ Show
140
+ </span>
141
+ </Button>
142
+ )}
143
+ {revealed && <Button variant="ghost" onClick={() => setRevealed(null)}>Hide</Button>}
144
+
145
+ {canMutate && !editing && <Button variant="ghost" onClick={() => setEditing(true)}>Edit</Button>}
146
+ {canMutate && !editing && (
147
+ <Button variant="ghost" tone="destructive" onClick={remove}>Remove</Button>
148
+ )}
149
+ </div>
150
+
151
+ {editing && (
152
+ <div className="mt-2 space-y-2">
153
+ <Input label="Label" value={label} onChange={setLabel} placeholder="Login Name" />
154
+ <PasswordInput
155
+ label="New password"
156
+ value={password}
157
+ onChange={setPassword}
158
+ autoComplete="new-password"
159
+ placeholder="Leave blank to keep the current one"
160
+ showToggle
161
+ />
162
+ <div className="flex justify-end gap-2">
163
+ <Button variant="secondary" onClick={() => { setEditing(false); setPassword(""); setLabel(credential.label ?? ""); setError(null); }} disabled={busy}>
164
+ Cancel
165
+ </Button>
166
+ <Button variant="primary" onClick={save} isLoading={busy} loadingLabel="Saving...">Save</Button>
167
+ </div>
168
+ </div>
169
+ )}
170
+
171
+ {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
172
+ </li>
173
+ );
174
+ }
175
+
176
+ function AddForm({ audienceName, onCreate, onCancel }: {
177
+ audienceName: string;
178
+ onCreate: Props["onCreate"];
179
+ onCancel: () => void;
180
+ }) {
181
+ const [label, setLabel] = useState("");
182
+ const [username, setUsername] = useState("");
183
+ // The username follows the label until the editor takes it over, so the
184
+ // common case is a single field and the slug stays predictable. Once they
185
+ // type in the username it stops tracking — otherwise their edit would be
186
+ // silently overwritten by the next keystroke in the label.
187
+ const [usernameOverridden, setUsernameOverridden] = useState(false);
188
+ const [password, setPassword] = useState("");
189
+ const [error, setError] = useState<string | null>(null);
190
+ const [busy, setBusy] = useState(false);
191
+
192
+ const derivedUsername = usernameOverridden ? username : slugifyAudienceName(label);
193
+
194
+ function editUsername(value: string) {
195
+ setUsernameOverridden(true);
196
+ setUsername(value);
197
+ }
198
+
199
+ async function submit() {
200
+ setError(null);
201
+ const normalized = normalizeUsername(derivedUsername);
202
+ if (normalized.length === 0) {
203
+ setError("Add a label — the username is generated from it");
204
+ return;
205
+ }
206
+ const parsed = ViewerUsernameSchema.safeParse(normalized);
207
+ if (!parsed.success) {
208
+ setError("Username: lowercase letters, numbers, dots, dashes and underscores only");
209
+ return;
210
+ }
211
+ if (password.length < VIEWER_PASSWORD_MIN_LENGTH) {
212
+ setError(`Password must be at least ${VIEWER_PASSWORD_MIN_LENGTH} characters`);
213
+ return;
214
+ }
215
+ setBusy(true);
216
+ try {
217
+ await onCreate({ username: normalized, label: label.trim() || null, password });
218
+ onCancel();
219
+ } catch (e) {
220
+ setError(e instanceof Error ? e.message : "Add failed");
221
+ } finally {
222
+ setBusy(false);
223
+ }
224
+ }
225
+
226
+ return (
227
+ <li className="space-y-2 rounded border border-base-200 px-3 py-3">
228
+ <Input label="Label" value={label} onChange={setLabel} placeholder="Login Name" />
229
+ <Input
230
+ label="Username"
231
+ value={derivedUsername}
232
+ onChange={editUsername}
233
+ autoCapitalize="none"
234
+ spellCheck={false}
235
+ />
236
+ <p className="text-[11px] text-base-contrast-light">
237
+ Generated from the label — change it if you want something different. This is what they type to sign in, and it can't be renamed later.
238
+ </p>
239
+ <PasswordInput label="Password" value={password} onChange={setPassword} autoComplete="new-password" showToggle />
240
+ <p className="text-[11px] text-base-contrast-light">
241
+ Signs in to <span className="font-medium">{audienceName}</span> and sees exactly what that audience sees.
242
+ You can look this password up again later.
243
+ </p>
244
+ {error && <p className="text-xs text-red-600">{error}</p>}
245
+ <div className="flex justify-end gap-2">
246
+ <Button variant="secondary" onClick={onCancel} disabled={busy}>Cancel</Button>
247
+ <Button variant="primary" onClick={submit} isLoading={busy} loadingLabel="Adding...">Add</Button>
248
+ </div>
249
+ </li>
250
+ );
251
+ }
252
+
253
+ /** The named sign-ins for one audience, inside its Site Settings row. */
254
+ export function CredentialList({
255
+ audienceName, credentials, canMutate, onCreate, onSetPassword, onSetLabel, onDelete,
256
+ }: Props) {
257
+ const [adding, setAdding] = useState(false);
258
+
259
+ return (
260
+ <div className="mt-2 space-y-2 border-t border-base-200 pt-2">
261
+ <p className="text-[11px] font-medium uppercase tracking-wide text-base-contrast-light">Sign-ins</p>
262
+ <ul className="space-y-2">
263
+ {credentials.map((c) => (
264
+ <CredentialRow
265
+ key={c.username}
266
+ credential={c}
267
+ canMutate={canMutate}
268
+ onSetPassword={onSetPassword}
269
+ onSetLabel={onSetLabel}
270
+ onDelete={onDelete}
271
+ />
272
+ ))}
273
+ {credentials.length === 0 && !adding && (
274
+ <li className="text-xs text-base-contrast-light">
275
+ No sign-ins yet — nobody can enter as this audience.
276
+ </li>
277
+ )}
278
+ {adding && <AddForm audienceName={audienceName} onCreate={onCreate} onCancel={() => setAdding(false)} />}
279
+ </ul>
280
+ {canMutate && !adding && (
281
+ <Button variant="secondary" onClick={() => setAdding(true)}>Add sign-in</Button>
282
+ )}
283
+ </div>
284
+ );
285
+ }
@@ -93,7 +93,11 @@ import { generateSiteNav } from "../../lib/nav";
93
93
  import { deriveSiteDocuments, type SiteDocument } from "../../lib/documents";
94
94
  import { setSiteDocuments, closeDocFile } from "../docfile/doc-file-state";
95
95
  import { pagesInNavOrder, moveNavEntry, addGroup, renameGroup, setGroupStartCollapsed, deleteGroup, type NavLocation } from "../../lib/nav-tree";
96
- import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent } from "../../lib/events";
96
+ import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent, audienceDetailsEvent } from "../../lib/events";
97
+ import { AudienceDetailsPanel, type AudienceDetail } from "./AudienceDetailsPanel";
98
+ import { buildAudienceOverview, type SectionDisplay } from "../../lib/audience-overview";
99
+ import { toThumbViews } from "../../lib/thumb-views";
100
+ import type { ViewerCredential } from "../../schemas/audience";
97
101
  import {
98
102
  homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
99
103
  reorderVisibleSectionInPage, audiencePasses,
@@ -263,6 +267,13 @@ export default function EditorShell({
263
267
  // fully landed when part of it was refused.
264
268
  const [saveWarnings, setSaveWarnings] = useState<string[]>([]);
265
269
  const [activePageId, setActivePageId] = useState<string | null>(null);
270
+ // The Audience Details surface, opened from the nav. Rendered INSTEAD of the
271
+ // section canvas so the toolbar, unsaved drafts and modals all stay mounted —
272
+ // an editor reviewing access never leaves the editor.
273
+ const [showAudienceDetails, setShowAudienceDetails] = useState(false);
274
+ // null = the auth provider has no named sign-ins (`.env` mode → route 501s),
275
+ // which is also what turns the reveal buttons off.
276
+ const [viewerCredentials, setViewerCredentials] = useState<ViewerCredential[] | null>(null);
266
277
  const [movingSectionId, setMovingSectionId] = useState<string | null>(null);
267
278
  // Set by recoverConflict when a rebase surfaces overlapping edits; drives the
268
279
  // keep-mine/take-theirs dialog. `resolve` is the promise resolver recovery
@@ -369,6 +380,41 @@ export default function EditorShell({
369
380
  return resolvedActivePage.order.map((id) => sectionsById[id]).filter((s): s is LoadedSection => !!s);
370
381
  }, [resolvedActivePage, sectionsById]);
371
382
 
383
+ // Same model the /audiences route builds server-side, from the shell's own
384
+ // live state — so an editor sees their SAVED draft's access, not the published
385
+ // snapshot the route would show.
386
+ const audienceDetails = useMemo<AudienceDetail[]>(() => {
387
+ if (!showAudienceDetails) return [];
388
+ const cache = new Map<string, SectionDisplay>();
389
+ const display = (sectionId: string): SectionDisplay => {
390
+ const cached = cache.get(sectionId);
391
+ if (cached) return cached;
392
+ const loaded = sectionsById[sectionId];
393
+ const type = loaded?.section.type ?? siteIndex.sections[sectionId]?.type ?? "unknown";
394
+ const definition = getSection(type);
395
+ const resolved: SectionDisplay = {
396
+ label: loaded
397
+ ? definition?.getLabel?.(loaded.section) ?? definition?.label ?? type
398
+ : definition?.label ?? type,
399
+ typeLabel: definition?.label ?? type,
400
+ thumbnails: loaded ? toThumbViews(definition?.getThumbnails?.(loaded.section) ?? [], mediaManifest) : [],
401
+ };
402
+ cache.set(sectionId, resolved);
403
+ return resolved;
404
+ };
405
+ return audiences.map((a) => ({
406
+ name: a.name,
407
+ displayName: a.displayName,
408
+ color: a.color,
409
+ isDefault: a.isDefault,
410
+ hasPassword: a.hasPassword,
411
+ credentials: (viewerCredentials ?? [])
412
+ .filter((c) => c.audienceName === a.name)
413
+ .map((c) => ({ username: c.username, label: c.label })),
414
+ overview: buildAudienceOverview(siteIndex, display, a.name),
415
+ }));
416
+ }, [showAudienceDetails, audiences, viewerCredentials, siteIndex, sectionsById, mediaManifest]);
417
+
372
418
  const pageAudiences = useMemo(() => {
373
419
  if (!resolvedActivePage || resolvedActivePage.access.length === 0) return audiences;
374
420
  return audiences.filter((a) => resolvedActivePage.access.includes(a.name));
@@ -965,11 +1011,16 @@ export default function EditorShell({
965
1011
  useEffect(() => {
966
1012
  if (!resolvedActivePage) return;
967
1013
  const nav = generateSiteNav({
968
- index: siteIndex, activeSections, activePageId: resolvedActivePage.id, mode: "editor", audience: null,
1014
+ index: siteIndex, activeSections,
1015
+ // The Audience Details surface is not a page: clearing the active id drops
1016
+ // the previous page's highlight (and its heading tree), so the sidebar
1017
+ // never shows you in two places at once.
1018
+ activePageId: showAudienceDetails ? null : resolvedActivePage.id,
1019
+ mode: "editor", audience: null,
969
1020
  documents: siteDocuments ?? [],
970
1021
  });
971
1022
  siteNavChangeEvent.dispatch(nav);
972
- }, [siteIndex, activeSections, resolvedActivePage, siteDocuments]);
1023
+ }, [siteIndex, activeSections, resolvedActivePage, siteDocuments, showAudienceDetails]);
973
1024
 
974
1025
  const goToPage = useCallback((pageId: string) => {
975
1026
  const page = pageById(siteIndexRef.current, pageId);
@@ -986,7 +1037,25 @@ export default function EditorShell({
986
1037
  }, []);
987
1038
 
988
1039
  // Nav island → shell page selection.
989
- useEffect(() => pageSelectEvent.listen((id) => goToPage(id)), [goToPage]);
1040
+ useEffect(() => pageSelectEvent.listen((id) => { setShowAudienceDetails(false); goToPage(id); }), [goToPage]);
1041
+ useEffect(() => audienceDetailsEvent.listen(() => setShowAudienceDetails(true)), []);
1042
+
1043
+ // Fetched on open rather than at mount: most editing sessions never look at
1044
+ // this, and the list changes whenever Site Settings adds or removes a sign-in.
1045
+ useEffect(() => {
1046
+ if (!showAudienceDetails) return;
1047
+ let cancelled = false;
1048
+ (async () => {
1049
+ try {
1050
+ const res = await fetch("/api/auth/credentials");
1051
+ if (cancelled) return;
1052
+ setViewerCredentials(res.ok ? (await res.json()).credentials ?? [] : null);
1053
+ } catch {
1054
+ if (!cancelled) setViewerCredentials(null);
1055
+ }
1056
+ })();
1057
+ return () => { cancelled = true; };
1058
+ }, [showAudienceDetails]);
990
1059
 
991
1060
  // Back/forward maps the URL back to the active page (no reload).
992
1061
  useEffect(() => {
@@ -1879,6 +1948,26 @@ export default function EditorShell({
1879
1948
  document pane) lives here for exactly the same reason: hiding
1880
1949
  main would hide the toolbar and FABs too. */}
1881
1950
  <div className="bp-band-shift bp-page-content">
1951
+ {showAudienceDetails ? (
1952
+ <div className="py-8">
1953
+ <div className="flex flex-wrap items-baseline justify-between gap-3">
1954
+ <h1 className="text-2xl font-bold text-base-contrast">Audience details</h1>
1955
+ <Button
1956
+ variant="secondary"
1957
+ onClick={() => resolvedActivePage && pageSelectEvent.dispatch(resolvedActivePage.id)}
1958
+ >
1959
+ Back to editing
1960
+ </Button>
1961
+ </div>
1962
+ <p className="mt-2 max-w-2xl text-sm text-base-contrast-light">
1963
+ What each audience can see, and the sign-ins that reach it. Reflects your
1964
+ current working copy — add or remove sign-ins under Site settings → Audiences.
1965
+ </p>
1966
+ <div className="mt-6">
1967
+ <AudienceDetailsPanel audiences={audienceDetails} revealSupported={viewerCredentials !== null} />
1968
+ </div>
1969
+ </div>
1970
+ ) : (
1882
1971
  <HistoryOrEditorContent
1883
1972
  index={siteIndex}
1884
1973
  activeSections={activeSections}
@@ -1907,6 +1996,7 @@ export default function EditorShell({
1907
1996
  onTakeover={confirmTakeover}
1908
1997
  />
1909
1998
  </HistoryOrEditorContent>
1999
+ )}
1910
2000
  </div>
1911
2001
  <GlobalModal />
1912
2002
  <SiteSettingsModal
@@ -2361,7 +2451,10 @@ function EditorContent({
2361
2451
  }, [onBlockMove]);
2362
2452
 
2363
2453
  return (
2364
- <div>
2454
+ // `.section-wrapper:first-child` zeroes the 2.25rem that every other section
2455
+ // reserves above itself for its hover chrome, so the first section's controls
2456
+ // would sit under the fixed toolbar. Re-reserve it on the list instead.
2457
+ <div className="pt-9">
2365
2458
  {displaySections.map(({ section, meta }, index) => {
2366
2459
  const definition = getSection(section.type);
2367
2460
  if (!definition) {
@@ -94,7 +94,7 @@ function DocumentTile({ doc, manage, isSelected, refCount, showFolderChip, onCli
94
94
  isSelected: boolean;
95
95
  refCount: number;
96
96
  showFolderChip: boolean;
97
- onClick: () => void;
97
+ onClick: (e: React.MouseEvent) => void;
98
98
  }) {
99
99
  return (
100
100
  <button
@@ -262,7 +262,7 @@ function UploadZone({ onUpload, onFolderUpload }: {
262
262
 
263
263
  function MediaCard({ item, manage, isSelected, dragIds, onClick, children }: {
264
264
  item: MediaItem; manage: boolean; isSelected: boolean;
265
- dragIds: string[]; onClick: () => void; children: React.ReactNode;
265
+ dragIds: string[]; onClick: (e: React.MouseEvent) => void; children: React.ReactNode;
266
266
  }) {
267
267
  const drag = useMediaDraggable<HTMLDivElement>({
268
268
  enabled: manage,
@@ -446,7 +446,18 @@ export function MediaLibraryModal({
446
446
  })
447
447
  : [];
448
448
 
449
+ // Visual order of the item grid: documents render before images, and a CSS
450
+ // grid lays both out in source order, so an index range here is exactly the
451
+ // range a shift-click spans on screen — rows included.
452
+ const orderedIds = [...filteredDocuments.map((doc) => doc.id), ...filtered.map((item) => item.id)];
453
+
454
+ // Anchor for shift-click range selection. Stored as an id, not an index:
455
+ // `filtered`/`filteredDocuments` are recomputed every render, so an index
456
+ // would go stale on search, filter, or folder navigation.
457
+ const rangeAnchorId = useRef<string | null>(null);
458
+
449
459
  function toggleSelect(id: string) {
460
+ rangeAnchorId.current = id;
450
461
  setSelected((prev) => {
451
462
  const next = new Set(prev);
452
463
  if (next.has(id)) {
@@ -458,6 +469,32 @@ export function MediaLibraryModal({
458
469
  });
459
470
  }
460
471
 
472
+ function selectRangeTo(id: string) {
473
+ const anchor = rangeAnchorId.current;
474
+ const from = anchor === null ? -1 : orderedIds.indexOf(anchor);
475
+ const to = orderedIds.indexOf(id);
476
+ // No usable anchor (never clicked, or it was filtered/deleted away) — fall
477
+ // back to a plain toggle so the click is never a no-op.
478
+ if (from === -1 || to === -1) {
479
+ toggleSelect(id);
480
+ return;
481
+ }
482
+ const range = orderedIds.slice(Math.min(from, to), Math.max(from, to) + 1);
483
+ // The anchor stays put so the range can be re-extended by shift-clicking
484
+ // again, and the range adds rather than toggles (matching file explorers).
485
+ setSelected((prev) => new Set([...prev, ...range]));
486
+ }
487
+
488
+ function handleItemClick(id: string, e: React.MouseEvent) {
489
+ if (e.shiftKey) {
490
+ // Shift-click otherwise drags a native text selection across the grid.
491
+ e.preventDefault();
492
+ selectRangeTo(id);
493
+ } else {
494
+ toggleSelect(id);
495
+ }
496
+ }
497
+
461
498
  const [confirmDelete, setConfirmDelete] = useState(false);
462
499
 
463
500
  // One selection, two kinds. Everything downstream — delete dispatch, usage
@@ -837,7 +874,7 @@ export function MediaLibraryModal({
837
874
  isSelected={selected.has(doc.id)}
838
875
  refCount={documentReferenceCountMap[doc.id] ?? 0}
839
876
  showFolderChip={filtering && documentsFolderEnabled}
840
- onClick={() => (manage ? toggleSelect(doc.id) : onSelect(doc.id))}
877
+ onClick={(e) => (manage ? handleItemClick(doc.id, e) : onSelect(doc.id))}
841
878
  />
842
879
  ))}
843
880
  {filtered.map((item) => {
@@ -853,11 +890,11 @@ export function MediaLibraryModal({
853
890
  manage={manage}
854
891
  isSelected={isSelected}
855
892
  dragIds={dragIds}
856
- onClick={() => {
893
+ onClick={(e) => {
857
894
  if (mode === "select") {
858
895
  onSelect(item.id);
859
896
  } else {
860
- toggleSelect(item.id);
897
+ handleItemClick(item.id, e);
861
898
  }
862
899
  }}
863
900
  >