@agent-native/core 0.172.3 → 0.172.4

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 (39) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/actions/get-settings.ts +2 -5
  3. package/corpus/templates/calendar/actions/list-events.ts +21 -80
  4. package/corpus/templates/calendar/actions/update-settings.ts +11 -16
  5. package/corpus/templates/calendar/actions/view-screen.ts +25 -10
  6. package/corpus/templates/calendar/app/components/calendar/CommandPalette.tsx +7 -1
  7. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +5 -2
  8. package/corpus/templates/calendar/app/lib/calendar-timezone.ts +3 -18
  9. package/corpus/templates/calendar/app/lib/event-form-utils.ts +3 -62
  10. package/corpus/templates/calendar/changelog/2026-07-22-calendar-grid-local-time.md +6 -0
  11. package/corpus/templates/calendar/changelog/2026-08-19-the-calendar-loads-again-for-accounts-whose-saved-timezone-w.md +6 -0
  12. package/corpus/templates/calendar/server/handlers/settings.ts +8 -24
  13. package/corpus/templates/calendar/server/lib/calendar-settings.ts +58 -0
  14. package/corpus/templates/calendar/server/lib/find-time.ts +15 -93
  15. package/corpus/templates/calendar/shared/settings.ts +15 -9
  16. package/corpus/templates/calendar/shared/timezone.ts +127 -0
  17. package/corpus/templates/slides/actions/list-decks.ts +79 -5
  18. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +21 -1
  19. package/corpus/templates/slides/app/components/editor/ShareDialog.tsx +49 -21
  20. package/corpus/templates/slides/app/context/DeckContext.tsx +49 -21
  21. package/corpus/templates/slides/app/pages/DeckEditor.tsx +2 -2
  22. package/corpus/templates/slides/app/pages/Index.tsx +11 -13
  23. package/corpus/templates/slides/changelog/2026-08-24-deck-previews-and-sharing-live-in-the-overflow-menu.md +5 -0
  24. package/dist/agent/production-agent.js +5 -1
  25. package/dist/agent/types.d.ts +5 -0
  26. package/dist/collab/awareness.d.ts +2 -2
  27. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  28. package/dist/observability/posthog-ai.d.ts +36 -11
  29. package/dist/observability/posthog-ai.js +99 -30
  30. package/dist/observability/routes.d.ts +3 -3
  31. package/dist/observability/routes.js +9 -2
  32. package/dist/observability/traces.js +330 -142
  33. package/dist/progress/routes.d.ts +1 -1
  34. package/dist/resources/handlers.d.ts +1 -1
  35. package/dist/server/realtime-token.d.ts +1 -1
  36. package/dist/server/transcribe-voice.d.ts +1 -1
  37. package/dist/tracking/providers.js +9 -3
  38. package/docs/content/observability.mdx +27 -10
  39. package/package.json +3 -3
@@ -3,6 +3,12 @@ import type {
3
3
  FindTimeParticipant,
4
4
  FindTimeSlot,
5
5
  } from "../../shared/api.js";
6
+ import {
7
+ addDaysToDateKey,
8
+ dateKeyInTimezone,
9
+ dateTimeInTimezoneToIso,
10
+ isCalendarTimezone,
11
+ } from "../../shared/timezone.js";
6
12
 
7
13
  export interface AvailabilitySchedule {
8
14
  timezone: string;
@@ -37,108 +43,24 @@ const DEFAULT_SCHEDULE: AvailabilitySchedule["schedule"] = {
37
43
  sunday: [],
38
44
  };
39
45
 
46
+ /**
47
+ * These live in shared/timezone.ts so the grid, the actions, and this module
48
+ * cannot drift on DST edges. Kept under their original names because callers
49
+ * across the template import them from here.
50
+ */
40
51
  export function normalizeTimezone(timezone?: string): string {
41
- if (!timezone) return "UTC";
42
- try {
43
- new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
44
- return timezone;
45
- } catch {
46
- return "UTC";
47
- }
48
- }
49
-
50
- function datePartsInTimezone(date: Date, timezone: string) {
51
- const parts = new Intl.DateTimeFormat("en-US", {
52
- timeZone: timezone,
53
- hourCycle: "h23",
54
- year: "numeric",
55
- month: "2-digit",
56
- day: "2-digit",
57
- hour: "2-digit",
58
- minute: "2-digit",
59
- second: "2-digit",
60
- }).formatToParts(date);
61
- const get = (type: string) =>
62
- Number(parts.find((part) => part.type === type)?.value ?? "0");
63
- return {
64
- year: get("year"),
65
- month: get("month"),
66
- day: get("day"),
67
- hour: get("hour"),
68
- minute: get("minute"),
69
- second: get("second"),
70
- };
52
+ return isCalendarTimezone(timezone) ? timezone : "UTC";
71
53
  }
72
54
 
73
- export function dateOnlyInTimezone(date: Date, timezone: string): string {
74
- const parts = datePartsInTimezone(date, timezone);
75
- return [
76
- String(parts.year).padStart(4, "0"),
77
- String(parts.month).padStart(2, "0"),
78
- String(parts.day).padStart(2, "0"),
79
- ].join("-");
80
- }
81
-
82
- export function addDaysToDateOnly(dateOnly: string, days: number): string {
83
- const [year, month, day] = dateOnly.split("-").map(Number);
84
- const date = new Date(Date.UTC(year, month - 1, day + days));
85
- return [
86
- String(date.getUTCFullYear()).padStart(4, "0"),
87
- String(date.getUTCMonth() + 1).padStart(2, "0"),
88
- String(date.getUTCDate()).padStart(2, "0"),
89
- ].join("-");
90
- }
91
-
92
- function offsetMsForTimezone(date: Date, timezone: string): number {
93
- const parts = datePartsInTimezone(date, timezone);
94
- const asUtc = Date.UTC(
95
- parts.year,
96
- parts.month - 1,
97
- parts.day,
98
- parts.hour,
99
- parts.minute,
100
- parts.second,
101
- );
102
- return asUtc - date.getTime();
103
- }
55
+ export const dateOnlyInTimezone = dateKeyInTimezone;
56
+ export const addDaysToDateOnly = addDaysToDateKey;
104
57
 
105
58
  export function zonedDateTimeToUtcIso(
106
59
  dateOnly: string,
107
60
  time: string,
108
61
  timezone: string,
109
62
  ): string {
110
- const [year, month, day] = dateOnly.split("-").map(Number);
111
- const [hour, minute] = time.split(":").map(Number);
112
- const wallClockUtc = Date.UTC(year, month - 1, day, hour, minute, 0);
113
- const offsets = new Set<number>();
114
- for (let hours = -36; hours <= 36; hours += 6) {
115
- offsets.add(
116
- offsetMsForTimezone(
117
- new Date(wallClockUtc + hours * 60 * 60 * 1000),
118
- timezone,
119
- ),
120
- );
121
- }
122
-
123
- const candidates = [...offsets]
124
- .map((offset) => new Date(wallClockUtc - offset))
125
- .map((candidate) => ({
126
- candidate,
127
- localWallClock:
128
- candidate.getTime() + offsetMsForTimezone(candidate, timezone),
129
- }))
130
- .sort((a, b) => {
131
- const aDelta = a.localWallClock - wallClockUtc;
132
- const bDelta = b.localWallClock - wallClockUtc;
133
- if (aDelta === 0 && bDelta === 0) {
134
- return a.candidate.getTime() - b.candidate.getTime();
135
- }
136
- if (aDelta >= 0 && bDelta < 0) return -1;
137
- if (aDelta < 0 && bDelta >= 0) return 1;
138
- return Math.abs(aDelta) - Math.abs(bDelta);
139
- });
140
-
141
- return candidates[0].candidate.toISOString();
63
+ return dateTimeInTimezoneToIso(dateOnly, time, timezone);
142
64
  }
143
65
 
144
66
  function normalizeDateBound(value: string, timezone: string): string {
@@ -3,6 +3,7 @@ import {
3
3
  DEFAULT_CALENDAR_WEEK_START,
4
4
  isCalendarWeekStart,
5
5
  } from "./calendar-week.js";
6
+ import { isCalendarTimezone } from "./timezone.js";
6
7
 
7
8
  export const DEFAULT_SETTINGS: Settings = {
8
9
  timezone: "America/New_York",
@@ -12,33 +13,38 @@ export const DEFAULT_SETTINGS: Settings = {
12
13
  weekStart: DEFAULT_CALENDAR_WEEK_START,
13
14
  };
14
15
 
15
- export function normalizeCalendarSettings(input: unknown): Settings {
16
+ export function normalizeCalendarSettings(
17
+ input: unknown,
18
+ fallbacks?: Partial<Settings>,
19
+ ): Settings {
20
+ const defaults = fallbacks
21
+ ? normalizeCalendarSettings(fallbacks)
22
+ : DEFAULT_SETTINGS;
16
23
  const raw =
17
24
  input && typeof input === "object"
18
25
  ? (input as Partial<Settings>)
19
26
  : ({} as Partial<Settings>);
20
27
 
21
28
  return {
22
- timezone:
23
- typeof raw.timezone === "string"
24
- ? raw.timezone
25
- : DEFAULT_SETTINGS.timezone,
29
+ timezone: isCalendarTimezone(raw.timezone)
30
+ ? raw.timezone
31
+ : defaults.timezone,
26
32
  bookingPageTitle:
27
33
  typeof raw.bookingPageTitle === "string"
28
34
  ? raw.bookingPageTitle
29
- : DEFAULT_SETTINGS.bookingPageTitle,
35
+ : defaults.bookingPageTitle,
30
36
  bookingPageDescription:
31
37
  typeof raw.bookingPageDescription === "string"
32
38
  ? raw.bookingPageDescription
33
- : DEFAULT_SETTINGS.bookingPageDescription,
39
+ : defaults.bookingPageDescription,
34
40
  defaultEventDuration:
35
41
  typeof raw.defaultEventDuration === "number" &&
36
42
  Number.isFinite(raw.defaultEventDuration) &&
37
43
  raw.defaultEventDuration > 0
38
44
  ? raw.defaultEventDuration
39
- : DEFAULT_SETTINGS.defaultEventDuration,
45
+ : defaults.defaultEventDuration,
40
46
  weekStart: isCalendarWeekStart(raw.weekStart)
41
47
  ? raw.weekStart
42
- : DEFAULT_SETTINGS.weekStart,
48
+ : defaults.weekStart,
43
49
  };
44
50
  }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Constructing an `Intl.DateTimeFormat` costs ~45µs, and resolving one wall
3
+ * clock probes the zone a dozen times — so formatters are cached per zone and
4
+ * option set. Only the date varies per call, and that is an argument to
5
+ * `format`/`formatToParts`, never part of the formatter.
6
+ */
7
+ const formatterCache = new Map<string, Intl.DateTimeFormat>();
8
+
9
+ export function timezoneFormatter(
10
+ timezone: string | undefined,
11
+ options: Intl.DateTimeFormatOptions,
12
+ locale?: string,
13
+ ): Intl.DateTimeFormat {
14
+ const key = `${locale ?? ""}\u0000${timezone ?? ""}\u0000${JSON.stringify(options)}`;
15
+ let formatter = formatterCache.get(key);
16
+ if (!formatter) {
17
+ formatter = new Intl.DateTimeFormat(locale, {
18
+ ...options,
19
+ timeZone: timezone,
20
+ });
21
+ formatterCache.set(key, formatter);
22
+ }
23
+ return formatter;
24
+ }
25
+
26
+ /**
27
+ * Whether a value names a zone this calendar can use. Only a `RangeError` means
28
+ * Intl rejected the zone; any other failure is a real fault and must surface
29
+ * instead of being reported as "invalid".
30
+ *
31
+ * Several older helpers around the template still run their own version of this
32
+ * check with a bare `catch`; prefer this one and delete those as you touch them.
33
+ */
34
+ export function isCalendarTimezone(value: unknown): value is string {
35
+ if (typeof value !== "string" || !value.trim()) return false;
36
+ try {
37
+ new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
38
+ return true;
39
+ } catch (error) {
40
+ if (error instanceof RangeError) return false;
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ const OFFSET_PROBE_OPTIONS: Intl.DateTimeFormatOptions = {
46
+ hourCycle: "h23",
47
+ year: "numeric",
48
+ month: "2-digit",
49
+ day: "2-digit",
50
+ hour: "2-digit",
51
+ minute: "2-digit",
52
+ second: "2-digit",
53
+ };
54
+
55
+ function offsetMsInTimezone(date: Date, timezone: string): number {
56
+ const parts = timezoneFormatter(
57
+ timezone,
58
+ OFFSET_PROBE_OPTIONS,
59
+ "en-US",
60
+ ).formatToParts(date);
61
+ const values = new Map(parts.map((part) => [part.type, part.value]));
62
+ const asUtc = Date.UTC(
63
+ Number(values.get("year")),
64
+ Number(values.get("month")) - 1,
65
+ Number(values.get("day")),
66
+ Number(values.get("hour")),
67
+ Number(values.get("minute")),
68
+ Number(values.get("second")),
69
+ );
70
+ return asUtc - date.getTime();
71
+ }
72
+
73
+ export function dateTimeInTimezoneToIso(
74
+ date: string,
75
+ time: string,
76
+ timezone: string,
77
+ ): string {
78
+ const [year, month, day] = date.split("-").map(Number);
79
+ const [hour, minute] = time.split(":").map(Number);
80
+ const wallClockUtc = Date.UTC(year, month - 1, day, hour, minute, 0);
81
+ const offsets = new Set<number>();
82
+ for (let hours = -36; hours <= 36; hours += 6) {
83
+ offsets.add(
84
+ offsetMsInTimezone(
85
+ new Date(wallClockUtc + hours * 60 * 60 * 1000),
86
+ timezone,
87
+ ),
88
+ );
89
+ }
90
+
91
+ const candidates = [...offsets]
92
+ .map((offset) => new Date(wallClockUtc - offset))
93
+ .map((candidate) => ({
94
+ candidate,
95
+ localWallClock:
96
+ candidate.getTime() + offsetMsInTimezone(candidate, timezone),
97
+ }))
98
+ .sort((a, b) => {
99
+ const aDelta = a.localWallClock - wallClockUtc;
100
+ const bDelta = b.localWallClock - wallClockUtc;
101
+ if (aDelta === 0 && bDelta === 0) {
102
+ return a.candidate.getTime() - b.candidate.getTime();
103
+ }
104
+ if (aDelta >= 0 && bDelta < 0) return -1;
105
+ if (aDelta < 0 && bDelta >= 0) return 1;
106
+ return Math.abs(aDelta) - Math.abs(bDelta);
107
+ });
108
+
109
+ return candidates[0].candidate.toISOString();
110
+ }
111
+
112
+ export function dateKeyInTimezone(date: Date, timezone: string): string {
113
+ const parts = timezoneFormatter(
114
+ timezone,
115
+ { year: "numeric", month: "2-digit", day: "2-digit" },
116
+ "en-CA",
117
+ ).formatToParts(date);
118
+ const value = (type: string) =>
119
+ parts.find((part) => part.type === type)!.value;
120
+ return `${value("year")}-${value("month")}-${value("day")}`;
121
+ }
122
+
123
+ export function addDaysToDateKey(date: string, amount: number): string {
124
+ const [year, month, day] = date.split("-").map(Number);
125
+ const shifted = new Date(Date.UTC(year, month - 1, day + amount));
126
+ return shifted.toISOString().slice(0, 10);
127
+ }
@@ -1,4 +1,5 @@
1
1
  import { defineAction } from "@agent-native/core";
2
+ import { isPostgres } from "@agent-native/core/db";
2
3
  import { buildDeepLink } from "@agent-native/core/server";
3
4
  import { getRequestUserEmail } from "@agent-native/core/server/request-context";
4
5
  import { accessFilter } from "@agent-native/core/sharing";
@@ -13,6 +14,16 @@ function slidesDeepLink(): string {
13
14
  return buildDeepLink({ app: "slides", view: "list" });
14
15
  }
15
16
 
17
+ function parseJsonProjection(value: unknown, label: string): unknown {
18
+ if (value === null || value === undefined) return undefined;
19
+ if (typeof value !== "string") return value;
20
+ try {
21
+ return JSON.parse(value);
22
+ } catch (error) {
23
+ throw new Error(`Invalid ${label} JSON projection`, { cause: error });
24
+ }
25
+ }
26
+
16
27
  export default defineAction({
17
28
  description: "List all decks from the database with metadata.",
18
29
  schema: z.object({
@@ -26,13 +37,20 @@ export default defineAction({
26
37
  .describe(
27
38
  "Set to 'true' for full frontend deck payloads; omitted returns metadata only",
28
39
  ),
40
+ includePreview: z
41
+ .enum(["true", "false"])
42
+ .optional()
43
+ .describe(
44
+ "Set to 'true' with light mode to include only the first slide preview",
45
+ ),
29
46
  light: z
30
47
  .enum(["true", "false"])
31
48
  .optional()
32
49
  .describe(
33
50
  "Set to 'true' for a minimal id/title/updatedAt/visibility listing " +
34
51
  "used for cheap add/remove diffing (e.g. background polling). " +
35
- "Never reads the deck body — no slides, no slideCount.",
52
+ "By default never reads the deck body — no slides, no slideCount. " +
53
+ "Use includePreview for the first slide only.",
36
54
  ),
37
55
  createdBy: z
38
56
  .enum(["all", "me"])
@@ -50,7 +68,7 @@ export default defineAction({
50
68
  const ownerEmail = getRequestUserEmail();
51
69
  const normalizedOwnerEmail = normalizeOwnerEmail(ownerEmail);
52
70
  if (
53
- args.includeSlides === "true" &&
71
+ (args.includeSlides === "true" || args.includePreview === "true") &&
54
72
  ctx?.caller === "frontend" &&
55
73
  normalizedOwnerEmail === null
56
74
  ) {
@@ -75,9 +93,65 @@ export default defineAction({
75
93
  if (args.light === "true") {
76
94
  // Column-projected listing for cheap add/remove diffing (the client's
77
95
  // background poll and SSE-reconnect resync). The `data` column holds
78
- // each deck's entire slide JSON and can be large — never select it
79
- // here. Callers that need slide content use `includeSlides: "true"` or
80
- // fetch the specific deck via `get-deck`.
96
+ // each deck's entire slide JSON and can be large. The home grid opts
97
+ // into the separate preview projection; polling keeps the metadata-only
98
+ // path below.
99
+ if (args.includePreview === "true") {
100
+ // Keep the list bounded at the database boundary. `data` is an opaque
101
+ // full-deck blob, so selecting it and parsing it here scales with every
102
+ // slide even though the caller only needs the first one.
103
+ const previewSlideProjection = isPostgres()
104
+ ? sql<
105
+ string | null
106
+ >`(${schema.decks.data}::jsonb -> 'slides' -> 0)::text`
107
+ : sql<
108
+ string | null
109
+ >`json_extract(${schema.decks.data}, '$.slides[0]')`;
110
+ const aspectRatioProjection = isPostgres()
111
+ ? sql<string | null>`(${schema.decks.data}::jsonb ->> 'aspectRatio')`
112
+ : sql<
113
+ string | null
114
+ >`json_extract(${schema.decks.data}, '$.aspectRatio')`;
115
+ const rows = await db
116
+ .select({
117
+ id: schema.decks.id,
118
+ title: schema.decks.title,
119
+ updatedAt: schema.decks.updatedAt,
120
+ visibility: schema.decks.visibility,
121
+ ownerEmail: schema.decks.ownerEmail,
122
+ previewSlide: previewSlideProjection,
123
+ aspectRatio: aspectRatioProjection,
124
+ })
125
+ .from(schema.decks)
126
+ .where(where)
127
+ .orderBy(desc(schema.decks.updatedAt));
128
+
129
+ return {
130
+ count: rows.length,
131
+ decks: rows.map((row) => {
132
+ const previewSlide = parseJsonProjection(
133
+ row.previewSlide,
134
+ "first slide preview",
135
+ );
136
+ return {
137
+ id: row.id,
138
+ title: row.title,
139
+ updatedAt: row.updatedAt,
140
+ visibility: row.visibility,
141
+ createdByMe:
142
+ normalizedOwnerEmail !== null &&
143
+ normalizeOwnerEmail(row.ownerEmail) === normalizedOwnerEmail,
144
+ ...(previewSlide && typeof previewSlide === "object"
145
+ ? { previewSlide }
146
+ : {}),
147
+ ...(typeof row.aspectRatio === "string"
148
+ ? { aspectRatio: row.aspectRatio }
149
+ : {}),
150
+ };
151
+ }),
152
+ };
153
+ }
154
+
81
155
  const rows = await db
82
156
  .select({
83
157
  id: schema.decks.id,
@@ -8,6 +8,7 @@ import {
8
8
  IconCopy,
9
9
  IconPencil,
10
10
  IconPlus,
11
+ IconShare2,
11
12
  IconStar,
12
13
  IconStarFilled,
13
14
  } from "@tabler/icons-react";
@@ -24,6 +25,7 @@ import {
24
25
  import type { Deck } from "@/context/DeckContext";
25
26
  import { getDeckListingPreviewFrameStyle } from "@/lib/deck-preview-frame";
26
27
 
28
+ import ShareDialog from "../editor/ShareDialog";
27
29
  import SlideRenderer from "./SlideRenderer";
28
30
 
29
31
  interface DeckCardProps {
@@ -48,16 +50,18 @@ export default function DeckCard({
48
50
  onSetWorkspaceDefault,
49
51
  }: DeckCardProps) {
50
52
  const t = useT();
51
- const firstSlide = deck.slides?.[0];
53
+ const firstSlide = deck.previewSlide ?? deck.slides?.[0];
52
54
  const previewFrameStyle = getDeckListingPreviewFrameStyle(deck.aspectRatio);
53
55
  const [isRenaming, setIsRenaming] = useState(false);
54
56
  const [menuOpen, setMenuOpen] = useState(false);
55
57
  const [renameValue, setRenameValue] = useState(deck.title);
56
58
  const [contextOpen, setContextOpen] = useState(false);
59
+ const [shareOpen, setShareOpen] = useState(false);
57
60
  const inputRef = useRef<HTMLInputElement>(null);
58
61
  const pendingRenameRef = useRef(false);
59
62
  const pendingDeleteRef = useRef(false);
60
63
  const pendingWorkspaceDefaultRef = useRef(false);
64
+ const pendingShareRef = useRef(false);
61
65
 
62
66
  useEffect(() => {
63
67
  if (isRenaming) {
@@ -206,6 +210,11 @@ export default function DeckCard({
206
210
  pendingWorkspaceDefaultRef.current = false;
207
211
  onSetWorkspaceDefault?.(deck.id, !isWorkspaceDefault);
208
212
  }
213
+ if (pendingShareRef.current) {
214
+ e.preventDefault();
215
+ pendingShareRef.current = false;
216
+ setTimeout(() => setShareOpen(true), 0);
217
+ }
209
218
  if (pendingDeleteRef.current) {
210
219
  e.preventDefault();
211
220
  pendingDeleteRef.current = false;
@@ -226,6 +235,16 @@ export default function DeckCard({
226
235
  <IconCopy className="w-3.5 h-3.5 me-2" />
227
236
  Duplicate
228
237
  </DropdownMenuItem>
238
+ <DropdownMenuItem
239
+ onSelect={(event) => {
240
+ event.preventDefault();
241
+ pendingShareRef.current = true;
242
+ setMenuOpen(false);
243
+ }}
244
+ >
245
+ <IconShare2 className="w-3.5 h-3.5 me-2" />
246
+ {t("share.title")}
247
+ </DropdownMenuItem>
229
248
  <DropdownMenuItem
230
249
  onSelect={(event) => {
231
250
  event.preventDefault();
@@ -279,6 +298,7 @@ export default function DeckCard({
279
298
  }}
280
299
  canManage={deck.createdByMe}
281
300
  />
301
+ <ShareDialog deck={deck} open={shareOpen} onOpenChange={setShareOpen} />
282
302
  </div>
283
303
  );
284
304
  }
@@ -7,6 +7,7 @@ import {
7
7
  cloneElement,
8
8
  isValidElement,
9
9
  useCallback,
10
+ useEffect,
10
11
  useState,
11
12
  type MouseEvent,
12
13
  type ReactElement,
@@ -22,7 +23,10 @@ import { getDeckShareLinkOrder } from "@/lib/deck-share-links";
22
23
  interface ShareDialogProps {
23
24
  deck: Deck;
24
25
  /** Trigger element rendered as the dialog anchor (usually the Share button). */
25
- children: ReactNode;
26
+ children?: ReactNode;
27
+ /** Controlled opening for menu items that must wait for their parent to close. */
28
+ open?: boolean;
29
+ onOpenChange?: (open: boolean) => void;
26
30
  }
27
31
 
28
32
  function getShareUrls(deckId: string) {
@@ -40,7 +44,12 @@ function getShareUrls(deckId: string) {
40
44
  };
41
45
  }
42
46
 
43
- export default function ShareDialog({ deck, children }: ShareDialogProps) {
47
+ export default function ShareDialog({
48
+ deck,
49
+ children,
50
+ open: requestedOpen,
51
+ onOpenChange,
52
+ }: ShareDialogProps) {
44
53
  const t = useT();
45
54
  const { isLocal } = useDbStatus();
46
55
  const [open, setOpen] = useState(false);
@@ -50,6 +59,14 @@ export default function ShareDialog({ deck, children }: ShareDialogProps) {
50
59
  } | null>(null);
51
60
  const [creatingLink, setCreatingLink] = useState(false);
52
61
 
62
+ const setDialogOpen = useCallback(
63
+ (nextOpen: boolean) => {
64
+ setOpen(nextOpen);
65
+ onOpenChange?.(nextOpen);
66
+ },
67
+ [onOpenChange],
68
+ );
69
+
53
70
  const shareUrls = getShareUrls(deck.id);
54
71
  const shareLinkOrder = getDeckShareLinkOrder(deck.visibility);
55
72
  const shareToken =
@@ -61,11 +78,11 @@ export default function ShareDialog({ deck, children }: ShareDialogProps) {
61
78
 
62
79
  const openShareDialog = useCallback(async () => {
63
80
  if (isLocal) {
64
- setOpen(true);
81
+ setDialogOpen(true);
65
82
  return;
66
83
  }
67
84
  if (shareToken) {
68
- setOpen(true);
85
+ setDialogOpen(true);
69
86
  return;
70
87
  }
71
88
  if (creatingLink) return;
@@ -97,29 +114,40 @@ export default function ShareDialog({ deck, children }: ShareDialogProps) {
97
114
  throw new Error(t("share.createFailed"));
98
115
  }
99
116
  setShareLink({ deckId: deck.id, token: payload.shareToken });
100
- setOpen(true);
117
+ setDialogOpen(true);
101
118
  } catch (error) {
102
119
  toast.error(
103
120
  error instanceof Error ? error.message : t("share.createFailed"),
104
121
  );
122
+ if (requestedOpen !== undefined) setDialogOpen(false);
105
123
  } finally {
106
124
  setCreatingLink(false);
107
125
  }
108
- }, [creatingLink, deck, isLocal, shareToken, t]);
126
+ }, [creatingLink, deck, isLocal, setDialogOpen, shareToken, t]);
127
+
128
+ useEffect(() => {
129
+ if (requestedOpen === undefined) return;
130
+ if (!requestedOpen) {
131
+ setOpen(false);
132
+ return;
133
+ }
134
+ if (!open) void openShareDialog();
135
+ }, [open, openShareDialog, requestedOpen]);
109
136
 
110
- const trigger = isValidElement(children)
111
- ? (() => {
112
- const triggerElement = children as ReactElement<{
113
- onClick?: (event: MouseEvent) => void;
114
- }>;
115
- return cloneElement(triggerElement, {
116
- onClick: (event) => {
117
- triggerElement.props.onClick?.(event);
118
- if (!event.defaultPrevented) void openShareDialog();
119
- },
120
- });
121
- })()
122
- : children;
137
+ const trigger =
138
+ children && isValidElement(children)
139
+ ? (() => {
140
+ const triggerElement = children as ReactElement<{
141
+ onClick?: (event: MouseEvent) => void;
142
+ }>;
143
+ return cloneElement(triggerElement, {
144
+ onClick: (event) => {
145
+ triggerElement.props.onClick?.(event);
146
+ if (!event.defaultPrevented) void openShareDialog();
147
+ },
148
+ });
149
+ })()
150
+ : children;
123
151
 
124
152
  return (
125
153
  <>
@@ -128,12 +156,12 @@ export default function ShareDialog({ deck, children }: ShareDialogProps) {
128
156
  <CloudUpgrade
129
157
  title={t("share.title")}
130
158
  description={t("share.cloudUpgradeDescription")}
131
- onClose={() => setOpen(false)}
159
+ onClose={() => setDialogOpen(false)}
132
160
  />
133
161
  ) : null}
134
162
  <CoreShareDialog
135
163
  open={open && !isLocal}
136
- onClose={() => setOpen(false)}
164
+ onClose={() => setDialogOpen(false)}
137
165
  resourceType="deck"
138
166
  resourceId={deck.id}
139
167
  resourceTitle={deck.title}