@drawnagency/primitives 0.1.104 → 0.1.106

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 (104) 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-EWNDBKYK.js → chunk-LUAI5DWR.js} +1 -1
  19. package/dist/chunk-NLQ5F3RQ.js +43 -0
  20. package/dist/{chunk-X4PRBGB3.js → chunk-QLBHERK6.js} +9 -9
  21. package/dist/{chunk-3COSXTOY.js → chunk-XBYJQ5RS.js} +1 -1
  22. package/dist/{chunk-CXQWLG6X.js → chunk-Z7VXESB6.js} +18 -18
  23. package/dist/{chunk-WA6QPDJI.js → chunk-ZHHJVKU7.js} +6 -6
  24. package/dist/collab/rebase.js +2 -1
  25. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  26. package/dist/components/chat/ChatWidget.d.ts.map +1 -1
  27. package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -1
  28. package/dist/components/docfile/DocFilePane.d.ts +4 -3
  29. package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
  30. package/dist/components/editor/SectionOrderingModal.d.ts.map +1 -1
  31. package/dist/components/editor/index.js +8 -7
  32. package/dist/components/primitives/index.js +7 -6
  33. package/dist/components/sections/brand-guide.js +8 -7
  34. package/dist/components/sections/register-schemas.js +13 -12
  35. package/dist/components/sections/register.js +13 -12
  36. package/dist/components/shared/Navigation.d.ts +16 -1
  37. package/dist/components/shared/Navigation.d.ts.map +1 -1
  38. package/dist/components/shared/SectionSummaryRow.d.ts +24 -0
  39. package/dist/components/shared/SectionSummaryRow.d.ts.map +1 -0
  40. package/dist/components/shell/AudienceAddForm.d.ts +4 -1
  41. package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
  42. package/dist/components/shell/AudienceDetailsPanel.d.ts +28 -0
  43. package/dist/components/shell/AudienceDetailsPanel.d.ts.map +1 -0
  44. package/dist/components/shell/AudienceRow.d.ts +15 -1
  45. package/dist/components/shell/AudienceRow.d.ts.map +1 -1
  46. package/dist/components/shell/CredentialList.d.ts +18 -0
  47. package/dist/components/shell/CredentialList.d.ts.map +1 -0
  48. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  49. package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
  50. package/dist/components/shell/ViewerLoginForm.d.ts +5 -1
  51. package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
  52. package/dist/index.js +47 -29
  53. package/dist/lib/audience-overview.d.ts +53 -0
  54. package/dist/lib/audience-overview.d.ts.map +1 -0
  55. package/dist/lib/audience-secret.d.ts +11 -0
  56. package/dist/lib/audience-secret.d.ts.map +1 -0
  57. package/dist/lib/audience-secret.js +70 -0
  58. package/dist/lib/events.d.ts +3 -0
  59. package/dist/lib/events.d.ts.map +1 -1
  60. package/dist/lib/index-ops.js +3 -2
  61. package/dist/lib/index.js +13 -12
  62. package/dist/lib/nav.d.ts.map +1 -1
  63. package/dist/lib/thumb-views.d.ts +17 -0
  64. package/dist/lib/thumb-views.d.ts.map +1 -0
  65. package/dist/media/index.js +4 -3
  66. package/dist/schemas/audience.d.ts +29 -0
  67. package/dist/schemas/audience.d.ts.map +1 -1
  68. package/dist/schemas/audience.js +24 -0
  69. package/dist/schemas/auth.d.ts +1 -0
  70. package/dist/schemas/auth.d.ts.map +1 -1
  71. package/dist/schemas/auth.js +1 -1
  72. package/dist/schemas/index.js +28 -12
  73. package/dist/schemas/site-config.d.ts +1 -1
  74. package/dist/schemas/site-config.d.ts.map +1 -1
  75. package/dist/types/database.d.ts +59 -5
  76. package/dist/types/database.d.ts.map +1 -1
  77. package/package.json +9 -1
  78. package/src/auth/cookies.ts +37 -4
  79. package/src/auth/index.ts +2 -1
  80. package/src/auth/types.ts +32 -0
  81. package/src/components/chat/ChatPanel.tsx +4 -2
  82. package/src/components/chat/ChatWidget.tsx +4 -1
  83. package/src/components/docfile/DocFileOverlay.tsx +28 -2
  84. package/src/components/docfile/DocFilePane.tsx +4 -3
  85. package/src/components/editor/SectionOrderingModal.tsx +8 -73
  86. package/src/components/shared/Navigation.tsx +113 -7
  87. package/src/components/shared/SectionSummaryRow.tsx +73 -0
  88. package/src/components/shell/AudienceAddForm.tsx +18 -9
  89. package/src/components/shell/AudienceDetailsPanel.tsx +263 -0
  90. package/src/components/shell/AudienceRow.tsx +55 -13
  91. package/src/components/shell/CredentialList.tsx +285 -0
  92. package/src/components/shell/EditorShell.tsx +94 -4
  93. package/src/components/shell/SiteSettingsViewerAccess.tsx +57 -6
  94. package/src/components/shell/ViewerLoginForm.tsx +35 -15
  95. package/src/lib/audience-overview.ts +110 -0
  96. package/src/lib/audience-secret.ts +99 -0
  97. package/src/lib/events.ts +3 -0
  98. package/src/lib/nav.ts +6 -2
  99. package/src/lib/thumb-views.ts +28 -0
  100. package/src/schemas/audience.ts +52 -0
  101. package/src/schemas/auth.ts +6 -0
  102. package/src/schemas/site-config.ts +1 -1
  103. package/src/types/database.ts +59 -5
  104. package/dist/chunk-ZDQ3TT47.js +0 -30
@@ -1,11 +1,11 @@
1
1
  import type { LoadedSection } from "../../lib/loader";
2
- import type { Thumbnail } from "../../lib/registry";
3
2
  import { getSection } from "../../lib/registry";
4
- import { getMediaProvider } from "../../media";
5
3
  import type { MediaManifest } from "../../media/types";
6
4
  import { DragHandle } from "./DragHandle";
7
5
  import { useSortableRow } from "../../hooks/useSortableRow";
8
6
  import { DropEdgeIndicator } from "./DropEdgeIndicator";
7
+ import { SectionSummaryRow } from "../shared/SectionSummaryRow";
8
+ import { toThumbViews } from "../../lib/thumb-views";
9
9
  import { cn } from "../../lib/cn";
10
10
 
11
11
  interface SectionOrderingModalProps {
@@ -14,16 +14,6 @@ interface SectionOrderingModalProps {
14
14
  onReorder: (fromIndex: number, toIndex: number) => void;
15
15
  }
16
16
 
17
- function resolveThumbnailSrc(thumb: Thumbnail, manifest: MediaManifest): string | null {
18
- if (thumb.type === "color") return null;
19
- const item = manifest.images[thumb.src];
20
- if (!item) return null;
21
- const provider = getMediaProvider();
22
- const resolved = provider.resolve(item, [200]);
23
- if (!resolved) return null;
24
- return resolved.tag === "img" || resolved.tag === "video" ? resolved.src : null;
25
- }
26
-
27
17
  function SectionRow({
28
18
  section,
29
19
  index,
@@ -44,9 +34,6 @@ function SectionRow({
44
34
  const definition = getSection(section.section.type);
45
35
  const label = definition?.getLabel?.(section.section) ?? definition?.label ?? section.section.type;
46
36
  const typeLabel = definition?.label ?? section.section.type;
47
- const allThumbnails = definition?.getThumbnails?.(section.section) ?? [];
48
- const thumbnails = allThumbnails.slice(0, 3);
49
- const remainingCount = Math.max(0, allThumbnails.length - 3);
50
37
 
51
38
  return (
52
39
  <div
@@ -62,68 +49,16 @@ function SectionRow({
62
49
  <DragHandle ref={handleRef} />
63
50
  </div>
64
51
 
65
- <div className={cn(
66
- "flex min-w-0 flex-1 items-center gap-3 rounded-md border border-base-200 bg-base px-3 py-2",
67
- isDragging && "outline outline-2 outline-primary/50",
68
- )}>
69
- <div className="min-w-0 flex-1">
70
- <div className="text-xs text-base-contrast-light">{typeLabel}</div>
71
- <div className="truncate text-sm font-medium text-base-contrast">
72
- {label}
73
- </div>
74
- </div>
75
-
76
- {thumbnails.length > 0 && (
77
- <div className="flex items-center -space-x-2">
78
- {thumbnails.map((thumb, i) => (
79
- thumb.type === "color" ? (
80
- <div
81
- key={i}
82
- className="h-8 w-8 rounded border-2 border-base"
83
- style={{ backgroundColor: thumb.value }}
84
- />
85
- ) : (
86
- <ResolvedImage
87
- key={i}
88
- thumb={thumb}
89
- manifest={mediaManifest}
90
- />
91
- )
92
- ))}
93
- {remainingCount > 0 && (
94
- <div className="ml-1 flex h-8 w-8 items-center justify-center rounded border-2 border-base bg-base-accent text-xs text-base-contrast-light">
95
- +{remainingCount}
96
- </div>
97
- )}
98
- </div>
99
- )}
100
- </div>
52
+ <SectionSummaryRow
53
+ label={label}
54
+ typeLabel={typeLabel}
55
+ thumbnails={toThumbViews(definition?.getThumbnails?.(section.section) ?? [], mediaManifest)}
56
+ className={cn(isDragging && "outline outline-2 outline-primary/50")}
57
+ />
101
58
  </div>
102
59
  );
103
60
  }
104
61
 
105
- function ResolvedImage({
106
- thumb,
107
- manifest,
108
- }: {
109
- thumb: Thumbnail & { type: "image" };
110
- manifest: MediaManifest;
111
- }) {
112
- const src = resolveThumbnailSrc(thumb, manifest);
113
- if (!src) {
114
- return (
115
- <div className="h-8 w-8 rounded border-2 border-base bg-base-accent" />
116
- );
117
- }
118
- return (
119
- <img
120
- src={src}
121
- alt={thumb.alt ?? ""}
122
- className="h-8 w-8 rounded border-2 border-base object-cover"
123
- />
124
- );
125
- }
126
-
127
62
  export function SectionOrderingModal({
128
63
  sections,
129
64
  mediaManifest,
@@ -1,11 +1,11 @@
1
1
  import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
2
- import { ChevronRight } from "lucide-react";
2
+ import { ChevronRight, Users } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
4
  import { Toggle } from "./Toggle";
5
5
  import type { SiteNav, NavItem, GroupNavItem, PageNavItem, DocGroupNavItem, DocNavItem } from "../../lib/nav";
6
6
  import { groupDisplayLabel } from "../../lib/nav-tree";
7
7
  import { openDocFile, closeDocFile } from "../docfile/doc-file-state";
8
- import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, editorDirtyEvent } from "../../lib/events";
8
+ import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, editorDirtyEvent, audienceDetailsEvent } from "../../lib/events";
9
9
  import { useActiveHeadings } from "../../hooks/useActiveHeadings";
10
10
  import { formatDate } from "../../lib/timestamp";
11
11
  import { Popover } from "./Popover";
@@ -25,6 +25,17 @@ interface Props {
25
25
  * the freed column to chat. Static per page render — deliberately keyed to
26
26
  * chat being ENABLED, not open, so the nav never flaps when chat toggles. */
27
27
  desktopBreakpoint?: "lg" | "xl";
28
+ /** Link to the auto-generated Audience Details page, or null to omit it.
29
+ * The caller owns the gate (editors and the default audience only), because
30
+ * only it knows the requester — this component just renders what it is told. */
31
+ audienceDetailsHref?: string | null;
32
+ /** Highlight the Audience details link as the current page. Passed by the
33
+ * /audiences route; the editor sets it locally when its own surface is open. */
34
+ audienceDetailsActive?: boolean;
35
+ /** Who is signed in, for the bottom identity row and its log-out button.
36
+ * Null on a public (password-disabled) site, where nobody is signed in.
37
+ * `kind` only picks the post-logout destination. */
38
+ identity?: { label: string; sublabel?: string; kind: "editor" | "viewer" } | null;
28
39
  }
29
40
 
30
41
  // Both branches are literal class strings so Tailwind's scanner sees them.
@@ -33,7 +44,7 @@ const BP_CLASSES = {
33
44
  xl: { hidden: "xl:hidden", leftAuto: "xl:left-auto", translate: "xl:translate-x-0", block: "xl:block" },
34
45
  } as const;
35
46
 
36
- export default function Navigation({ siteNav: initialNav, siteName, darkMode, lastUpdated, logo, onPageSelect, desktopBreakpoint = "lg" }: Props) {
47
+ export default function Navigation({ siteNav: initialNav, siteName, darkMode, lastUpdated, logo, onPageSelect, desktopBreakpoint = "lg", audienceDetailsHref = null, audienceDetailsActive = false, identity = null }: Props) {
37
48
  const bp = BP_CLASSES[desktopBreakpoint];
38
49
  const [isOpen, setIsOpen] = useState(false);
39
50
  const [isEditMode, setIsEditMode] = useState(false);
@@ -45,6 +56,13 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
45
56
  const [isDark, setIsDark] = useState(false);
46
57
  const [siteNav, setSiteNav] = useState<SiteNav>(initialNav);
47
58
  const [showHistory, setShowHistory] = useState(false);
59
+ // Editor only: the audience surface opens in place (EditorShell swaps its
60
+ // body) rather than navigating, so the nav owns the highlight. Cleared by any
61
+ // page selection — including one made from the Pages modal, hence the listener
62
+ // rather than only the local click handler.
63
+ const [editorAudienceOpen, setEditorAudienceOpen] = useState(false);
64
+ const [loggingOut, setLoggingOut] = useState(false);
65
+ const [logoutError, setLogoutError] = useState<string | null>(null);
48
66
  // Keyed by group id for authored groups and by label for the derived editor
49
67
  // buckets ("Hidden from Menu", "Archive"). Generated ids never collide with
50
68
  // those two literals, so one record safely serves both.
@@ -61,7 +79,8 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
61
79
  const unlistenNav = siteNavChangeEvent.listen((n) => setSiteNav(n));
62
80
  const unlistenDark = darkModeEvent.listen((mode) => setCurrentDarkMode(mode as typeof darkMode));
63
81
  const unlistenName = siteNameChangeEvent.listen((name) => setCurrentSiteName(name));
64
- return () => { unlistenEdit(); unlistenDirty(); unlistenNav(); unlistenDark(); unlistenName(); };
82
+ const unlistenPage = pageSelectEvent.listen(() => setEditorAudienceOpen(false));
83
+ return () => { unlistenEdit(); unlistenDirty(); unlistenNav(); unlistenDark(); unlistenName(); unlistenPage(); };
65
84
  }, []);
66
85
 
67
86
  useEffect(() => {
@@ -72,6 +91,25 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
72
91
  return () => observer.disconnect();
73
92
  }, []);
74
93
 
94
+ // /api/auth/sign-out clears BOTH the editor session and the audience cookie,
95
+ // so one call serves either kind of session; only the destination differs.
96
+ const handleLogout = useCallback(async () => {
97
+ setLogoutError(null);
98
+ setLoggingOut(true);
99
+ try {
100
+ const res = await fetch("/api/auth/sign-out", { method: "POST" });
101
+ if (!res.ok) {
102
+ setLogoutError("Log out failed");
103
+ setLoggingOut(false);
104
+ return;
105
+ }
106
+ window.location.assign(identity?.kind === "editor" ? "/edit/login" : "/login");
107
+ } catch {
108
+ setLogoutError("Network error");
109
+ setLoggingOut(false);
110
+ }
111
+ }, [identity?.kind]);
112
+
75
113
  const handleThemeToggle = useCallback((next: boolean) => {
76
114
  const root = document.documentElement;
77
115
  root.classList.add("theme-changing");
@@ -178,6 +216,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
178
216
  ) : null;
179
217
 
180
218
  const handlePageClick = useCallback((id: string) => {
219
+ setEditorAudienceOpen(false);
181
220
  if (onPageSelect) onPageSelect(id);
182
221
  else pageSelectEvent.dispatch(id);
183
222
  setIsOpen(false);
@@ -187,6 +226,15 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
187
226
  // a same-island handler is supplied; otherwise a real <a> for the viewer.
188
227
  const asButton = isEditMode || !!onPageSelect;
189
228
 
229
+ // Active either because the /audiences route said so, or because the editor's
230
+ // in-place surface is open.
231
+ const audienceActive = audienceDetailsActive || editorAudienceOpen;
232
+ const audienceLinkClass = cn(
233
+ "inline-flex items-center gap-1.5 text-xs transition-colors hover:text-primary",
234
+ // Same active treatment as a nav page link.
235
+ audienceActive ? "font-bold text-primary" : "text-base-contrast-light",
236
+ );
237
+
190
238
  const renderPage = (page: PageNavItem) => (
191
239
  <li key={page.id}>
192
240
  {asButton ? (
@@ -338,23 +386,57 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
338
386
  {isEditMode && renderPageGroup("Hidden from Menu", siteNav.hidden)}
339
387
  {isEditMode && renderPageGroup("Archive", siteNav.archive, true)}
340
388
 
389
+ {/* Bottom stack, in order: dark-mode toggle, audience details, last
390
+ updated, then who you are. Every block carries its own top divider
391
+ and is centred, so the stack reads as evenly separated rows — and
392
+ every line shares one type size (text-xs). */}
341
393
  <div className="mt-auto">
342
394
  {currentDarkMode === "optional" && (
343
395
  <div className="mx-4 border-t border-base-200 py-4">
344
- <div className="flex items-center justify-center gap-2 text-sm text-base-contrast-light">
396
+ <div className="flex items-center justify-center gap-2 text-xs text-base-contrast-light">
345
397
  <span className={cn(!isDark && "text-base-contrast")}>Light</span>
346
398
  <Toggle checked={isDark} onChange={handleThemeToggle} label="Toggle dark mode" />
347
399
  <span className={cn(isDark && "text-base-contrast")}>Dark</span>
348
400
  </div>
349
401
  </div>
350
402
  )}
403
+ {/* A real anchor, not a pageSelectEvent: /audiences is a platform
404
+ route, and in the editor it deliberately navigates out of the shell
405
+ (drafts live in IndexedDB, so leaving and coming back loses
406
+ nothing). */}
407
+ {audienceDetailsHref && (
408
+ <div className="mx-4 border-t border-base-200 py-3 text-center">
409
+ {isEditMode ? (
410
+ // In the editor this is a surface, not a destination: EditorShell
411
+ // swaps its body so the toolbar (and unsaved work) stay put.
412
+ <button
413
+ onClick={() => { setEditorAudienceOpen(true); audienceDetailsEvent.dispatch(); setIsOpen(false); }}
414
+ aria-current={audienceActive ? "page" : undefined}
415
+ className={cn("cursor-pointer", audienceLinkClass)}
416
+ >
417
+ <Users className="h-3.5 w-3.5 shrink-0" aria-hidden />
418
+ Audience details
419
+ </button>
420
+ ) : (
421
+ <a
422
+ href={audienceDetailsHref}
423
+ aria-current={audienceActive ? "page" : undefined}
424
+ className={audienceLinkClass}
425
+ >
426
+ <Users className="h-3.5 w-3.5 shrink-0" aria-hidden />
427
+ Audience details
428
+ </a>
429
+ )}
430
+ </div>
431
+ )}
432
+
351
433
  {lastUpdated && (
352
- <div ref={historyAnchorRef} className={cn(currentDarkMode !== "optional" && "border-t border-base-200", "mx-4 py-4 text-center")}>
434
+ <div ref={historyAnchorRef} className="mx-4 border-t border-base-200 py-3 text-center">
353
435
  {isEditMode ? (
354
436
  <div className="relative">
355
437
  <button onClick={() => setShowHistory((p) => !p)}
356
438
  aria-label="View history" aria-expanded={showHistory}
357
- className="inline-flex cursor-pointer items-center gap-1 text-xs text-base-contrast-light transition-colors hover:text-primary">
439
+ className="inline-flex cursor-pointer items-center gap-2 text-xs text-base-contrast-light transition-colors hover:text-primary">
358
440
  Last updated {formatDate(lastUpdated)}
359
441
  {/* The panel opens upward, so the caret points up when closed
360
442
  and flips when open — the affordance that this is a toggle
@@ -380,6 +462,30 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
380
462
  )}
381
463
  </div>
382
464
  )}
465
+
466
+ {identity && (
467
+ <div className="mx-4 border-t border-base-200 py-3 text-center">
468
+ {/* Identity and sign-out share ONE line, dot-separated. The button
469
+ is phrasing content inside the <p> so it sits inline and
470
+ inherits the row's type size rather than declaring its own.
471
+ The audience moves to `title` — visible text stays terse. */}
472
+ <p
473
+ className="truncate text-xs text-base-contrast-light"
474
+ title={identity.sublabel ? `${identity.label} · ${identity.sublabel}` : identity.label}
475
+ >
476
+ <span className="text-base-contrast">{identity.label}</span>
477
+ {" · "}
478
+ <button
479
+ onClick={handleLogout}
480
+ disabled={loggingOut}
481
+ className="cursor-pointer underline-offset-2 transition-colors hover:text-primary hover:underline disabled:cursor-wait"
482
+ >
483
+ {loggingOut ? "Logging out..." : "Log out"}
484
+ </button>
485
+ </p>
486
+ {logoutError && <p className="mt-1 text-xs text-red-600">{logoutError}</p>}
487
+ </div>
488
+ )}
383
489
  </div>
384
490
  </nav>
385
491
  </>
@@ -0,0 +1,73 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import type { ThumbView } from "../../lib/thumb-views";
4
+
5
+ interface Props {
6
+ /** Section's own label (registry getLabel), e.g. its heading. */
7
+ label: string;
8
+ /** Section type's display name, e.g. "Colour palette". */
9
+ typeLabel: string;
10
+ /** Already resolved to URLs/colours by the caller. Rendered at most 3 + count. */
11
+ thumbnails: ThumbView[];
12
+ /** Dim the row (audience overview: this section isn't visible to them). */
13
+ muted?: boolean;
14
+ /** Slot at the right edge, before the thumbnails — badges, status pills. */
15
+ trailing?: ReactNode;
16
+ className?: string;
17
+ }
18
+
19
+ const MAX_THUMBS = 3;
20
+
21
+ /**
22
+ * One section as a compact card: type over label, with a thumbnail cluster.
23
+ * Shared by the reorder modal (which wraps it in a drag handle + drop
24
+ * indicator) and the Audience Details page (which dims unavailable rows), so
25
+ * both read as the same list of the same site.
26
+ */
27
+ export function SectionSummaryRow({ label, typeLabel, thumbnails, muted, trailing, className }: Props) {
28
+ const shown = thumbnails.slice(0, MAX_THUMBS);
29
+ const remaining = Math.max(0, thumbnails.length - MAX_THUMBS);
30
+
31
+ return (
32
+ <div className={cn(
33
+ "flex min-w-0 flex-1 items-center gap-3 rounded-md border border-base-200 bg-base px-3 py-2",
34
+ muted && "opacity-50",
35
+ className,
36
+ )}>
37
+ <div className="min-w-0 flex-1">
38
+ <div className="text-xs text-base-contrast-light">{typeLabel}</div>
39
+ <div className="truncate text-sm font-medium text-base-contrast">{label}</div>
40
+ </div>
41
+
42
+ {trailing}
43
+
44
+ {shown.length > 0 && (
45
+ <div className="flex items-center -space-x-2">
46
+ {shown.map((thumb, i) =>
47
+ thumb.kind === "color" ? (
48
+ <div
49
+ key={i}
50
+ className="h-8 w-8 rounded border-2 border-base"
51
+ style={{ backgroundColor: thumb.value }}
52
+ />
53
+ ) : thumb.src ? (
54
+ <img
55
+ key={i}
56
+ src={thumb.src}
57
+ alt={thumb.alt}
58
+ className="h-8 w-8 rounded border-2 border-base object-cover"
59
+ />
60
+ ) : (
61
+ <div key={i} className="h-8 w-8 rounded border-2 border-base bg-base-accent" />
62
+ ),
63
+ )}
64
+ {remaining > 0 && (
65
+ <div className="ml-1 flex h-8 w-8 items-center justify-center rounded border-2 border-base bg-base-accent text-xs text-base-contrast-light">
66
+ +{remaining}
67
+ </div>
68
+ )}
69
+ </div>
70
+ )}
71
+ </div>
72
+ );
73
+ }
@@ -8,11 +8,14 @@ import { ColorPicker } from "../shared/ColorPicker";
8
8
  interface Props {
9
9
  onSubmit: (data: { displayName: string; color: string | null; password: string }) => Promise<void>;
10
10
  onCancel: () => void;
11
+ /** Credential mode: an audience has no password of its own — access comes from
12
+ * the named sign-ins added to it afterwards, so the field is hidden. */
13
+ requirePassword?: boolean;
11
14
  }
12
15
 
13
16
  const DEFAULT_COLOR = "#3b82f6";
14
17
 
15
- export function AudienceAddForm({ onSubmit, onCancel }: Props) {
18
+ export function AudienceAddForm({ onSubmit, onCancel, requirePassword = true }: Props) {
16
19
  const [name, setName] = useState("");
17
20
  const [color, setColor] = useState(DEFAULT_COLOR);
18
21
  const [password, setPassword] = useState("");
@@ -32,7 +35,7 @@ export function AudienceAddForm({ onSubmit, onCancel }: Props) {
32
35
  setError("Invalid color");
33
36
  return;
34
37
  }
35
- if (password.length === 0) {
38
+ if (requirePassword && password.length === 0) {
36
39
  setError("Password is required");
37
40
  return;
38
41
  }
@@ -61,13 +64,19 @@ export function AudienceAddForm({ onSubmit, onCancel }: Props) {
61
64
  value={name}
62
65
  onChange={setName}
63
66
  />
64
- <PasswordInput
65
- label="Password"
66
- value={password}
67
- onChange={setPassword}
68
- autoComplete="new-password"
69
- showToggle
70
- />
67
+ {requirePassword ? (
68
+ <PasswordInput
69
+ label="Password"
70
+ value={password}
71
+ onChange={setPassword}
72
+ autoComplete="new-password"
73
+ showToggle
74
+ />
75
+ ) : (
76
+ <p className="text-[11px] text-base-contrast-light">
77
+ Add sign-ins to this audience once it exists — that is what grants access.
78
+ </p>
79
+ )}
71
80
  </div>
72
81
  </div>
73
82
  {error && <p className="text-xs text-red-600">{error}</p>}