create-feedbax 0.2.0 → 0.2.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.
Files changed (99) hide show
  1. package/dist/cli.js +2042 -16
  2. package/dist/index.js +1997 -5
  3. package/package.json +1 -1
  4. package/template/.feedbax-template.sha256 +1 -1
  5. package/template/AGENTS.md +13 -0
  6. package/template/CONNECTED.md +404 -0
  7. package/template/README.md +58 -0
  8. package/template/components.json +1 -1
  9. package/template/eslint.config.mjs +23 -0
  10. package/template/migrations/0001_connected.sql +6 -0
  11. package/template/migrations/0002_participation.sql +8 -0
  12. package/template/migrations/0003_images.sql +3 -0
  13. package/template/migrations/0004_followed_status.sql +9 -0
  14. package/template/package.json +9 -5
  15. package/template/src/authorized-draft-post.tsx +1 -0
  16. package/template/src/changelog-page.tsx +63 -54
  17. package/template/src/cloudflare-workers.d.ts +6 -0
  18. package/template/src/comment-creation.test.tsx +42 -1
  19. package/template/src/comment-thread-page.ts +11 -1
  20. package/template/src/components/command-palette.tsx +59 -30
  21. package/template/src/components/connected-identity-provider.tsx +108 -0
  22. package/template/src/components/device-profile-provider.tsx +41 -26
  23. package/template/src/components/follow-post-button.tsx +89 -0
  24. package/template/src/components/header.tsx +2 -0
  25. package/template/src/components/image-attachments.tsx +187 -0
  26. package/template/src/components/loader.tsx +48 -5
  27. package/template/src/components/notification-inbox.tsx +232 -0
  28. package/template/src/components/portal-footer.tsx +14 -0
  29. package/template/src/components/profile-menu.tsx +30 -0
  30. package/template/src/components/ui/components/command.tsx +30 -29
  31. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  32. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  33. package/template/src/components/ui/components/switch.tsx +29 -0
  34. package/template/src/components/ui/styles/globals.css +1 -1
  35. package/template/src/connected-identity.test.ts +144 -0
  36. package/template/src/connected-identity.ts +122 -0
  37. package/template/src/connected-participation.test.tsx +67 -0
  38. package/template/src/connected-runtime.test.ts +241 -0
  39. package/template/src/connected-runtime.ts +176 -0
  40. package/template/src/connected-social.test.tsx +158 -0
  41. package/template/src/feedback-cache-invalidation.ts +1 -0
  42. package/template/src/feedback-runtime.ts +79 -12
  43. package/template/src/feedbax/config/index.test.ts +28 -0
  44. package/template/src/feedbax/config/index.ts +93 -0
  45. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  46. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  47. package/template/src/feedbax/feedback/connected.ts +215 -0
  48. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  49. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  50. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  51. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  52. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  53. package/template/src/feedbax/feedback/images.test.ts +223 -0
  54. package/template/src/feedbax/feedback/images.ts +324 -0
  55. package/template/src/feedbax/feedback/index.ts +55 -1
  56. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  57. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  58. package/template/src/feedbax/feedback/participation.ts +502 -0
  59. package/template/src/images-runtime.test.ts +133 -0
  60. package/template/src/images-runtime.ts +125 -0
  61. package/template/src/index.css +97 -440
  62. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  63. package/template/src/overlay-post-detail.test.tsx +59 -8
  64. package/template/src/overlay-post-detail.tsx +20 -69
  65. package/template/src/participation-client.ts +63 -0
  66. package/template/src/participation-runtime.test.ts +35 -0
  67. package/template/src/participation-runtime.ts +255 -0
  68. package/template/src/portal-comments.ts +24 -5
  69. package/template/src/portal-feedback-form.tsx +18 -3
  70. package/template/src/portal-feedback-runtime.ts +3 -0
  71. package/template/src/portal-feedback-server-function.ts +6 -3
  72. package/template/src/portal-feedback-submission.ts +10 -0
  73. package/template/src/portal-server-entry.ts +4 -0
  74. package/template/src/post-creation-flow.tsx +2 -2
  75. package/template/src/post-detail-content.test.tsx +233 -0
  76. package/template/src/post-detail-content.tsx +162 -0
  77. package/template/src/post-detail-query.ts +34 -0
  78. package/template/src/post-results.tsx +1 -1
  79. package/template/src/public-cache-policy.test.ts +11 -4
  80. package/template/src/public-cache-policy.ts +1 -1
  81. package/template/src/public-feedback-index.tsx +9 -39
  82. package/template/src/public-feedback-page.test.tsx +65 -0
  83. package/template/src/public-feedback-page.ts +12 -1
  84. package/template/src/public-post-detail-skeleton.tsx +54 -0
  85. package/template/src/public-post-detail.tsx +298 -208
  86. package/template/src/public-post-route.test.tsx +39 -3
  87. package/template/src/public-post-route.tsx +15 -28
  88. package/template/src/public-roadmap-route.test.tsx +38 -2
  89. package/template/src/public-roadmap-view.tsx +56 -23
  90. package/template/src/public-shell.test.tsx +26 -0
  91. package/template/src/routeTree.gen.ts +49 -3
  92. package/template/src/router.tsx +2 -0
  93. package/template/src/routes/__root.tsx +3 -5
  94. package/template/src/routes/email-preferences.tsx +77 -0
  95. package/template/src/routes/submit.tsx +1 -1
  96. package/template/src/safe-public-failure.ts +3 -1
  97. package/template/src/server.ts +41 -1
  98. package/template/src/trusted-feedback-handler.test.ts +1 -1
  99. package/template/src/vote-toggle.tsx +42 -9
@@ -1,3 +1,4 @@
1
+ import { ConnectedIdentityProvider } from "./connected-identity-provider";
1
2
  import {
2
3
  createContext,
3
4
  useContext,
@@ -21,13 +22,14 @@ import {
21
22
  DialogTitle,
22
23
  } from "@feedbax/ui/components/dialog";
23
24
  import {
24
- Empty,
25
- EmptyContent,
26
- EmptyDescription,
27
- EmptyHeader,
28
- EmptyMedia,
29
- EmptyTitle,
30
- } from "@feedbax/ui/components/empty";
25
+ Item,
26
+ ItemActions,
27
+ ItemContent,
28
+ ItemDescription,
29
+ ItemMedia,
30
+ ItemTitle,
31
+ } from "@feedbax/ui/components/item";
32
+ import { Skeleton } from "@feedbax/ui/components/skeleton";
31
33
  import {
32
34
  Field,
33
35
  FieldDescription,
@@ -101,7 +103,7 @@ export function DeviceProfileProvider({ children }: { children: ReactNode }) {
101
103
 
102
104
  return (
103
105
  <DeviceProfileContext.Provider value={value}>
104
- {children}
106
+ <ConnectedIdentityProvider>{children}</ConnectedIdentityProvider>
105
107
  {setupOpen ? (
106
108
  <ProfileSetupDialog
107
109
  initialProfile={profile}
@@ -163,31 +165,44 @@ export function DeviceProfilePrompt({
163
165
 
164
166
  if (!ready)
165
167
  return (
166
- <div className="profile-gate" aria-label="Loading profile" aria-busy />
168
+ <div
169
+ className="my-4 flex items-center gap-3"
170
+ aria-label="Loading profile"
171
+ aria-busy
172
+ >
173
+ <Skeleton className="size-8" />
174
+ <Skeleton className="h-4 w-48" />
175
+ </div>
167
176
  );
168
177
 
169
178
  const incomplete = Boolean(profile);
170
179
  return (
171
- <Empty className="profile-gate" aria-labelledby={id}>
172
- <EmptyHeader>
173
- <EmptyMedia variant="icon">
174
- <UserRound aria-hidden="true" />
175
- </EmptyMedia>
176
- <EmptyTitle id={id}>
180
+ <Item variant="muted" size="sm" className="my-4" aria-labelledby={id}>
181
+ <ItemMedia variant="icon">
182
+ <UserRound aria-hidden="true" />
183
+ </ItemMedia>
184
+ <ItemContent className="min-w-36">
185
+ <ItemTitle id={id}>
177
186
  {incomplete ? "Complete your profile" : "Add your profile"}
178
- </EmptyTitle>
179
- <EmptyDescription>
180
- A display name and valid email are required to{" "}
181
- {purpose === "comment" ? "join the conversation" : "create a Post"}.
182
- Your email stays private and is not verified.
183
- </EmptyDescription>
184
- </EmptyHeader>
185
- <EmptyContent>
186
- <Button type="button" onClick={openProfileSetup}>
187
+ </ItemTitle>
188
+ <ItemDescription className="line-clamp-none">
189
+ {purpose === "comment"
190
+ ? "Add a name and email to comment."
191
+ : "Add a name and email to create a Post."}{" "}
192
+ Email stays private. Details are unverified.
193
+ </ItemDescription>
194
+ </ItemContent>
195
+ <ItemActions>
196
+ <Button
197
+ type="button"
198
+ variant="outline"
199
+ size="sm"
200
+ onClick={openProfileSetup}
201
+ >
187
202
  {incomplete ? "Complete profile" : "Add profile"}
188
203
  </Button>
189
- </EmptyContent>
190
- </Empty>
204
+ </ItemActions>
205
+ </Item>
191
206
  );
192
207
  }
193
208
 
@@ -0,0 +1,89 @@
1
+ import { Bell, BellRing } from "lucide-react";
2
+ import { useState } from "react";
3
+ import type { FollowState } from "@feedbax/feedback";
4
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { Button } from "@feedbax/ui/components/button";
6
+ import { useConnectedIdentity } from "./connected-identity-provider";
7
+ import { followQuery, participationRequest } from "../participation-client";
8
+
9
+ export function FollowPostButton({ slug }: { slug: string }) {
10
+ const identity = useConnectedIdentity();
11
+ if (!identity.enabled) return null;
12
+ return <ConnectedFollowPostButton slug={slug} />;
13
+ }
14
+ function ConnectedFollowPostButton({ slug }: { slug: string }) {
15
+ const identity = useConnectedIdentity();
16
+ const queryClient = useQueryClient();
17
+ const state = useQuery({
18
+ ...followQuery(slug),
19
+ enabled: identity.enabled && Boolean(identity.displayName),
20
+ });
21
+ const [pending, setPending] = useState(false);
22
+ const [error, setError] = useState<string>();
23
+ if (!identity.enabled) return null;
24
+ if (!identity.displayName)
25
+ return (
26
+ <Button
27
+ variant="outline"
28
+ size="sm"
29
+ nativeButton={false}
30
+ render={<a href="/internal/identity/start" />}
31
+ >
32
+ Sign in to follow
33
+ </Button>
34
+ );
35
+ async function toggle() {
36
+ setPending(true);
37
+ setError(undefined);
38
+ try {
39
+ const result = await participationRequest<FollowState>("follow", {
40
+ slug,
41
+ following: !state.data?.following,
42
+ });
43
+ queryClient.setQueryData(followQuery(slug).queryKey, result);
44
+ } catch (cause) {
45
+ setError(
46
+ cause instanceof Error ? cause.message : "Couldn’t update Follow.",
47
+ );
48
+ } finally {
49
+ setPending(false);
50
+ }
51
+ }
52
+ const following = state.data?.following;
53
+ return (
54
+ <div className="space-y-2">
55
+ <Button
56
+ variant={following ? "secondary" : "outline"}
57
+ size="sm"
58
+ onClick={state.isError ? () => void state.refetch() : toggle}
59
+ disabled={pending || state.isPending}
60
+ aria-pressed={Boolean(following)}
61
+ >
62
+ {following ? (
63
+ <BellRing aria-hidden="true" />
64
+ ) : (
65
+ <Bell aria-hidden="true" />
66
+ )}
67
+ {state.isError
68
+ ? "Retry follow status"
69
+ : pending
70
+ ? "Updating…"
71
+ : following
72
+ ? state.data?.initializing
73
+ ? "Cancel preparation"
74
+ : "Following"
75
+ : "Follow updates"}
76
+ </Button>
77
+ {state.data?.initializing ? (
78
+ <p className="text-xs text-muted-foreground">
79
+ Preparing updates for this Post…
80
+ </p>
81
+ ) : null}
82
+ {error ? (
83
+ <p role="alert" className="text-sm text-destructive">
84
+ {error}
85
+ </p>
86
+ ) : null}
87
+ </div>
88
+ );
89
+ }
@@ -2,6 +2,7 @@ import { Link, useRouterState } from "@tanstack/react-router";
2
2
  import { cn } from "@feedbax/ui/lib/utils";
3
3
 
4
4
  import { CommandPaletteTrigger } from "./command-palette-context";
5
+ import { NotificationInbox } from "./notification-inbox";
5
6
  import { ProfileMenu } from "./profile-menu";
6
7
  import type { PortalFeatures, ProductConfiguration } from "@feedbax/config";
7
8
  import feedbax from "../feedbax";
@@ -87,6 +88,7 @@ export default function Header({
87
88
  compact
88
89
  />
89
90
  ) : null}
91
+ <NotificationInbox />
90
92
  <ProfileMenu />
91
93
  </div>
92
94
  </header>
@@ -0,0 +1,187 @@
1
+ import { ImagePlus, Loader2, X } from "lucide-react";
2
+ import { useId, useRef, useState } from "react";
3
+ import type { ImageAttachment } from "@feedbax/feedback";
4
+ import { Button } from "@feedbax/ui/components/button";
5
+ import { Input } from "@feedbax/ui/components/input";
6
+ import { useConnectedIdentity } from "./connected-identity-provider";
7
+ import feedbax from "../feedbax";
8
+
9
+ export function ImageAttachments({
10
+ value,
11
+ onChange,
12
+ onUploadingChange,
13
+ }: {
14
+ value: ImageAttachment[];
15
+ onChange: (images: ImageAttachment[]) => void;
16
+ onUploadingChange?: (pending: boolean) => void;
17
+ }) {
18
+ const identity = useConnectedIdentity();
19
+ if (!feedbax.connected?.images || !identity.displayName) return null;
20
+ return (
21
+ <ImageAttachmentInput
22
+ value={value}
23
+ onChange={onChange}
24
+ onUploadingChange={onUploadingChange}
25
+ />
26
+ );
27
+ }
28
+ function ImageAttachmentInput({
29
+ value,
30
+ onChange,
31
+ onUploadingChange,
32
+ }: {
33
+ value: ImageAttachment[];
34
+ onChange: (images: ImageAttachment[]) => void;
35
+ onUploadingChange?: (pending: boolean) => void;
36
+ }) {
37
+ const input = useRef<HTMLInputElement>(null);
38
+ const hintId = useId();
39
+ const [pending, setPending] = useState(false);
40
+ const [error, setError] = useState<string>();
41
+ async function upload(files: FileList | null) {
42
+ if (!files?.length) return;
43
+ setError(undefined);
44
+ const selected = Array.from(files);
45
+ if (selected.length + value.length > 4) {
46
+ setError("Choose up to four images.");
47
+ if (input.current) input.current.value = "";
48
+ return;
49
+ }
50
+ setPending(true);
51
+ onUploadingChange?.(true);
52
+ const next = [...value];
53
+ try {
54
+ for (const file of selected) {
55
+ if (file.size > 5 * 1024 * 1024)
56
+ throw new Error("Each image must be 5 MiB or smaller.");
57
+ if (!["image/jpeg", "image/png", "image/webp"].includes(file.type))
58
+ throw new Error("Choose a JPEG, PNG, or WebP image.");
59
+ const response = await fetch("/internal/images", {
60
+ method: "POST",
61
+ headers: { "Content-Type": file.type },
62
+ body: file,
63
+ });
64
+ const result = (await response.json()) as ImageAttachment & {
65
+ error?: string;
66
+ };
67
+ if (!response.ok)
68
+ throw new Error(result.error ?? "Couldn’t upload image.");
69
+ next.push(result);
70
+ onChange([...next]);
71
+ }
72
+ } catch (cause) {
73
+ setError(
74
+ cause instanceof Error ? cause.message : "Couldn’t upload image.",
75
+ );
76
+ } finally {
77
+ setPending(false);
78
+ onUploadingChange?.(false);
79
+ if (input.current) input.current.value = "";
80
+ }
81
+ }
82
+ async function remove(image: ImageAttachment) {
83
+ setError(undefined);
84
+ setPending(true);
85
+ onUploadingChange?.(true);
86
+ try {
87
+ const response = await fetch(`/internal/images/${image.id}`, {
88
+ method: "DELETE",
89
+ });
90
+ if (!response.ok) throw new Error("Couldn’t remove image. Try again.");
91
+ onChange(value.filter((item) => item.id !== image.id));
92
+ } catch (cause) {
93
+ setError(
94
+ cause instanceof Error ? cause.message : "Couldn’t remove image.",
95
+ );
96
+ } finally {
97
+ setPending(false);
98
+ onUploadingChange?.(false);
99
+ }
100
+ }
101
+ return (
102
+ <div className="space-y-3">
103
+ <Input
104
+ ref={input}
105
+ type="file"
106
+ accept="image/jpeg,image/png,image/webp"
107
+ multiple
108
+ className="sr-only"
109
+ tabIndex={-1}
110
+ aria-label="Choose image attachments"
111
+ onChange={(event) => void upload(event.currentTarget.files)}
112
+ />
113
+ <Button
114
+ type="button"
115
+ variant="outline"
116
+ size="sm"
117
+ disabled={pending || value.length >= 4}
118
+ aria-describedby={hintId}
119
+ onClick={() => input.current?.click()}
120
+ >
121
+ {pending ? (
122
+ <Loader2 className="animate-spin" aria-hidden="true" />
123
+ ) : (
124
+ <ImagePlus aria-hidden="true" />
125
+ )}
126
+ {pending ? "Uploading…" : "Add images"}
127
+ </Button>
128
+ <p id={hintId} className="text-xs text-muted-foreground">
129
+ Up to 4 images · JPEG, PNG, or WebP · 5 MiB each
130
+ </p>
131
+ {value.length ? (
132
+ <ul className="flex flex-wrap gap-3">
133
+ {value.map((image, index) => (
134
+ <li key={image.id} className="relative">
135
+ <img
136
+ src={image.src}
137
+ alt={`Attached image ${index + 1}`}
138
+ className="size-24 border bg-muted object-cover"
139
+ />
140
+ <Button
141
+ type="button"
142
+ variant="secondary"
143
+ size="icon-xs"
144
+ className="absolute top-1 right-1"
145
+ aria-label={`Remove image ${index + 1}`}
146
+ disabled={pending}
147
+ onClick={() => void remove(image)}
148
+ >
149
+ <X aria-hidden="true" />
150
+ </Button>
151
+ </li>
152
+ ))}
153
+ </ul>
154
+ ) : null}
155
+ {error ? (
156
+ <p role="alert" className="text-sm text-destructive">
157
+ {error}
158
+ </p>
159
+ ) : null}
160
+ </div>
161
+ );
162
+ }
163
+ export function ImageGallery({ images }: { images?: ImageAttachment[] }) {
164
+ if (!images?.length) return null;
165
+ return (
166
+ <ul className="my-4 grid grid-cols-2 gap-3" aria-label="Attached images">
167
+ {images.map((image, index) => (
168
+ <li key={image.id}>
169
+ <a
170
+ href={image.src}
171
+ target="_blank"
172
+ rel="noopener noreferrer"
173
+ aria-label={`Open attached image ${index + 1}`}
174
+ className="block focus-visible:outline-2 focus-visible:outline-ring"
175
+ >
176
+ <img
177
+ src={image.src}
178
+ alt={`Attached image ${index + 1}`}
179
+ loading="lazy"
180
+ className="max-h-64 w-full border bg-muted/30 object-contain"
181
+ />
182
+ </a>
183
+ </li>
184
+ ))}
185
+ </ul>
186
+ );
187
+ }
@@ -1,10 +1,53 @@
1
- import { Loader2 } from "lucide-react";
1
+ import { Skeleton } from "@feedbax/ui/components/skeleton";
2
2
 
3
- export default function Loader() {
3
+ export default function Loader({
4
+ label = "Loading content",
5
+ }: { label?: string } = {}) {
4
6
  return (
5
- <div className="portal-loading" role="status">
6
- <Loader2 aria-hidden="true" />
7
- <span>Loading published feedback…</span>
7
+ <div
8
+ className="portal-loading"
9
+ role="status"
10
+ aria-label={label}
11
+ aria-busy="true"
12
+ >
13
+ <span className="sr-only">{label}…</span>
14
+ <div
15
+ aria-hidden="true"
16
+ className="mx-auto grid w-full max-w-7xl gap-10 px-6 py-10 md:grid-cols-[1fr_16rem] md:px-10"
17
+ >
18
+ <div className="space-y-8">
19
+ <div className="space-y-3">
20
+ <Skeleton className="h-8 w-40" />
21
+ <Skeleton className="h-4 w-64 max-w-full" />
22
+ </div>
23
+ <div className="flex gap-3">
24
+ <Skeleton className="h-10 w-24" />
25
+ <Skeleton className="h-10 w-24" />
26
+ </div>
27
+ <div className="divide-y border-y">
28
+ {[0, 1, 2].map((row) => (
29
+ <div
30
+ key={row}
31
+ data-post-skeleton-row
32
+ className="flex items-start gap-5 py-7"
33
+ >
34
+ <Skeleton className="size-10 shrink-0" />
35
+ <div className="flex-1 space-y-3">
36
+ <Skeleton className="h-4 w-3/5" />
37
+ <Skeleton className="h-3 w-4/5" />
38
+ <Skeleton className="h-3 w-24" />
39
+ </div>
40
+ </div>
41
+ ))}
42
+ </div>
43
+ </div>
44
+ <div className="hidden space-y-6 pt-3 md:block">
45
+ <Skeleton className="h-4 w-20" />
46
+ <Skeleton className="h-40 w-full" />
47
+ <Skeleton className="h-4 w-20" />
48
+ <Skeleton className="h-28 w-full" />
49
+ </div>
50
+ </div>
8
51
  </div>
9
52
  );
10
53
  }
@@ -0,0 +1,232 @@
1
+ import { Bell, Loader2 } from "lucide-react";
2
+ import { useState } from "react";
3
+ import {
4
+ useInfiniteQuery,
5
+ useQuery,
6
+ useQueryClient,
7
+ } from "@tanstack/react-query";
8
+ import { Button } from "@feedbax/ui/components/button";
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ DialogTrigger,
16
+ } from "@feedbax/ui/components/dialog";
17
+ import { Label } from "@feedbax/ui/components/label";
18
+ import { Switch } from "@feedbax/ui/components/switch";
19
+ import { useConnectedIdentity } from "./connected-identity-provider";
20
+ import {
21
+ notificationQuery,
22
+ participationRequest,
23
+ preferencesQuery,
24
+ type EmailPreferences,
25
+ } from "../participation-client";
26
+ import { formatPublicDate } from "../public-date";
27
+
28
+ export function NotificationInbox() {
29
+ const identity = useConnectedIdentity();
30
+ if (!identity.enabled || !identity.displayName) return null;
31
+ return <ConnectedNotificationInbox />;
32
+ }
33
+ function ConnectedNotificationInbox() {
34
+ const identity = useConnectedIdentity();
35
+ const [open, setOpen] = useState(false);
36
+ const [error, setError] = useState<string>();
37
+ const [pending, setPending] = useState(false);
38
+ const queryClient = useQueryClient();
39
+ const enabled = identity.enabled && Boolean(identity.displayName);
40
+ const inbox = useInfiniteQuery({
41
+ ...notificationQuery,
42
+ enabled,
43
+ refetchInterval: enabled ? 60000 : false,
44
+ });
45
+ const preferences = useQuery({
46
+ ...preferencesQuery(),
47
+ enabled: enabled && open,
48
+ });
49
+ if (!enabled) return null;
50
+ const items = inbox.data?.pages.flatMap((page) => page.items) ?? [];
51
+ const unread = inbox.data?.pages[0]?.unreadCount ?? 0;
52
+ async function read(id?: string) {
53
+ setError(undefined);
54
+ try {
55
+ await participationRequest("notifications", id ? { id } : {});
56
+ await queryClient.invalidateQueries({
57
+ queryKey: notificationQuery.queryKey,
58
+ });
59
+ } catch (cause) {
60
+ setError(
61
+ cause instanceof Error
62
+ ? cause.message
63
+ : "Couldn’t mark updates as read.",
64
+ );
65
+ }
66
+ }
67
+ async function email(enabled: boolean) {
68
+ setError(undefined);
69
+ setPending(true);
70
+ try {
71
+ const next = await participationRequest<EmailPreferences>("preferences", {
72
+ emailEnabled: enabled,
73
+ });
74
+ queryClient.setQueryData(preferencesQuery().queryKey, next);
75
+ } catch (cause) {
76
+ setError(
77
+ cause instanceof Error
78
+ ? cause.message
79
+ : "Couldn’t save email preferences.",
80
+ );
81
+ } finally {
82
+ setPending(false);
83
+ }
84
+ }
85
+ return (
86
+ <Dialog open={open} onOpenChange={setOpen}>
87
+ <DialogTrigger
88
+ render={
89
+ <Button
90
+ variant="ghost"
91
+ size="icon"
92
+ aria-label={
93
+ unread ? `Notifications, ${unread} unread` : "Notifications"
94
+ }
95
+ className="relative"
96
+ />
97
+ }
98
+ >
99
+ <Bell aria-hidden="true" />
100
+ {unread ? (
101
+ <span className="absolute top-1 right-1 size-2 rounded-full bg-primary" />
102
+ ) : null}
103
+ </DialogTrigger>
104
+ <DialogContent className="max-h-[85svh] gap-0 overflow-hidden p-0 sm:max-w-lg">
105
+ <DialogHeader className="border-b p-5">
106
+ <DialogTitle className="text-lg">Notifications</DialogTitle>
107
+ <DialogDescription className="text-sm">
108
+ Updates on Posts you follow.
109
+ </DialogDescription>
110
+ </DialogHeader>
111
+ <div className="flex items-center justify-between gap-3 border-b px-5 py-3">
112
+ <span className="text-sm text-muted-foreground">{unread} unread</span>
113
+ <Button
114
+ size="sm"
115
+ variant="ghost"
116
+ disabled={!unread}
117
+ onClick={() => void read()}
118
+ >
119
+ Mark all read
120
+ </Button>
121
+ </div>
122
+ <div className="max-h-[45svh] overflow-y-auto">
123
+ {inbox.isPending ? (
124
+ <p role="status" className="flex items-center gap-2 p-5 text-sm">
125
+ <Loader2 className="size-4 animate-spin" />
126
+ Loading updates…
127
+ </p>
128
+ ) : inbox.isError ? (
129
+ <div className="space-y-3 p-5">
130
+ <p className="text-sm">Couldn’t load updates.</p>
131
+ <Button
132
+ size="sm"
133
+ variant="outline"
134
+ onClick={() => void inbox.refetch()}
135
+ >
136
+ Try again
137
+ </Button>
138
+ </div>
139
+ ) : items.length ? (
140
+ <ul className="divide-y">
141
+ {items.map((item) => (
142
+ <li
143
+ key={item.id}
144
+ className={item.read ? "p-5" : "bg-muted/40 p-5"}
145
+ >
146
+ <a
147
+ href={`/p/${encodeURIComponent(item.slug)}`}
148
+ className="block space-y-1 text-sm hover:underline"
149
+ onClick={() => {
150
+ void read(item.id);
151
+ setOpen(false);
152
+ }}
153
+ >
154
+ <span className="block font-medium">{item.title}</span>
155
+ <span className="block text-muted-foreground">
156
+ {item.message}
157
+ </span>
158
+ <time
159
+ className="block text-xs text-muted-foreground"
160
+ dateTime={new Date(item.createdAt).toISOString()}
161
+ >
162
+ {formatPublicDate(new Date(item.createdAt))}
163
+ </time>
164
+ </a>
165
+ {!item.read ? (
166
+ <Button
167
+ size="sm"
168
+ variant="ghost"
169
+ className="mt-2"
170
+ onClick={() => void read(item.id)}
171
+ >
172
+ Mark read
173
+ </Button>
174
+ ) : null}
175
+ </li>
176
+ ))}
177
+ </ul>
178
+ ) : (
179
+ <p className="p-5 text-sm text-muted-foreground">
180
+ Follow a Post to receive updates here.
181
+ </p>
182
+ )}
183
+ {inbox.hasNextPage ? (
184
+ <Button
185
+ variant="outline"
186
+ size="sm"
187
+ className="m-5"
188
+ disabled={inbox.isFetchingNextPage}
189
+ onClick={() => void inbox.fetchNextPage()}
190
+ >
191
+ {inbox.isFetchingNextPage ? "Loading…" : "View more"}
192
+ </Button>
193
+ ) : null}
194
+ </div>
195
+ <div className="space-y-2 border-t p-5">
196
+ <div className="flex items-center justify-between gap-4">
197
+ <Label htmlFor="notification-email">Email notifications</Label>
198
+ <Switch
199
+ id="notification-email"
200
+ checked={preferences.data?.emailEnabled ?? false}
201
+ disabled={pending || !preferences.data?.emailAvailable}
202
+ onCheckedChange={email}
203
+ />
204
+ </div>
205
+ {preferences.isError ? (
206
+ <Button
207
+ size="sm"
208
+ variant="ghost"
209
+ onClick={() => void preferences.refetch()}
210
+ >
211
+ Retry email preferences
212
+ </Button>
213
+ ) : !preferences.data?.emailAvailable && !preferences.isPending ? (
214
+ <p className="text-xs text-muted-foreground">
215
+ Email requires a verified address from your product account and a
216
+ configured sender.
217
+ </p>
218
+ ) : (
219
+ <p className="text-xs text-muted-foreground">
220
+ You can unsubscribe at any time.
221
+ </p>
222
+ )}
223
+ {error ? (
224
+ <p role="alert" className="text-sm text-destructive">
225
+ {error}
226
+ </p>
227
+ ) : null}
228
+ </div>
229
+ </DialogContent>
230
+ </Dialog>
231
+ );
232
+ }