create-feedbax 0.2.0 → 0.2.2

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 (143) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +2610 -291
  3. package/dist/index.js +2375 -200
  4. package/package.json +7 -7
  5. package/template/.feedbax-template.sha256 +1 -1
  6. package/template/.gitignore.template +1 -0
  7. package/template/.npmignore.template +1 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/AGENTS.md +13 -0
  10. package/template/CONNECTED.md +418 -0
  11. package/template/README.md +97 -0
  12. package/template/components.json +1 -1
  13. package/template/eslint.config.mjs +23 -0
  14. package/template/feedbax-vite.ts +79 -0
  15. package/template/migrations/0001_connected.sql +6 -0
  16. package/template/migrations/0002_participation.sql +8 -0
  17. package/template/migrations/0003_images.sql +3 -0
  18. package/template/migrations/0004_followed_status.sql +9 -0
  19. package/template/migrations/0005_github.sql +4 -0
  20. package/template/migrations/0006_integration.sql +3 -0
  21. package/template/migrations/0007_ownership.sql +2 -0
  22. package/template/migrations/0008_integration_recovery.sql +5 -0
  23. package/template/migrations/0009_observed_history.sql +3 -0
  24. package/template/package.json +39 -34
  25. package/template/src/authorized-draft-post.tsx +1 -0
  26. package/template/src/changelog-page.tsx +63 -54
  27. package/template/src/changelog-runtime.ts +2 -1
  28. package/template/src/cloudflare-workers.d.ts +9 -0
  29. package/template/src/comment-creation.test.tsx +95 -7
  30. package/template/src/comment-thread-page.ts +11 -1
  31. package/template/src/components/command-palette.tsx +59 -30
  32. package/template/src/components/connected-identity-provider.tsx +135 -0
  33. package/template/src/components/device-profile-provider.tsx +41 -26
  34. package/template/src/components/follow-post-button.tsx +78 -0
  35. package/template/src/components/header.tsx +2 -0
  36. package/template/src/components/image-attachments.tsx +187 -0
  37. package/template/src/components/loader.tsx +48 -5
  38. package/template/src/components/notification-inbox.tsx +232 -0
  39. package/template/src/components/portal-footer.tsx +14 -0
  40. package/template/src/components/post-history.tsx +101 -0
  41. package/template/src/components/profile-menu.tsx +37 -0
  42. package/template/src/components/ui/components/command.tsx +30 -29
  43. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  44. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  45. package/template/src/components/ui/components/switch.tsx +29 -0
  46. package/template/src/components/ui/styles/globals.css +1 -1
  47. package/template/src/configured-feedback.test.ts +82 -0
  48. package/template/src/configured-feedback.ts +61 -0
  49. package/template/src/connected-identity.test.ts +148 -0
  50. package/template/src/connected-identity.ts +127 -0
  51. package/template/src/connected-participation.test.tsx +67 -0
  52. package/template/src/connected-runtime.test.ts +244 -0
  53. package/template/src/connected-runtime.ts +176 -0
  54. package/template/src/connected-social.test.tsx +158 -0
  55. package/template/src/feedback-cache-invalidation.ts +3 -0
  56. package/template/src/feedback-runtime.ts +123 -13
  57. package/template/src/feedbax/changelog/index.test.ts +35 -0
  58. package/template/src/feedbax/changelog/index.ts +5 -1
  59. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  60. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  61. package/template/src/feedbax/config/index.test.ts +73 -1
  62. package/template/src/feedbax/config/index.ts +205 -9
  63. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  64. package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
  65. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  66. package/template/src/feedbax/feedback/connected.ts +225 -0
  67. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  68. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  69. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  70. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  71. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  72. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  73. package/template/src/feedbax/feedback/github-request.ts +120 -0
  74. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  75. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  76. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  77. package/template/src/feedbax/feedback/image-feedback.ts +114 -0
  78. package/template/src/feedbax/feedback/images.test.ts +223 -0
  79. package/template/src/feedbax/feedback/images.ts +350 -0
  80. package/template/src/feedbax/feedback/index.ts +226 -16
  81. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  82. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  83. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  84. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  85. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  86. package/template/src/feedbax/feedback/ownership.ts +170 -0
  87. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  88. package/template/src/feedbax/feedback/participation.ts +502 -0
  89. package/template/src/history-runtime.test.ts +80 -0
  90. package/template/src/history-runtime.ts +37 -0
  91. package/template/src/images-runtime.test.ts +133 -0
  92. package/template/src/images-runtime.ts +125 -0
  93. package/template/src/index.css +103 -440
  94. package/template/src/integration-runtime.test.ts +365 -0
  95. package/template/src/integration-runtime.ts +289 -0
  96. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  97. package/template/src/my-feedback-page.test.tsx +152 -0
  98. package/template/src/my-feedback-page.tsx +398 -0
  99. package/template/src/my-feedback-runtime.test.ts +93 -0
  100. package/template/src/my-feedback-runtime.ts +154 -0
  101. package/template/src/overlay-post-detail.test.tsx +59 -8
  102. package/template/src/overlay-post-detail.tsx +20 -69
  103. package/template/src/participation-client.ts +63 -0
  104. package/template/src/participation-runtime.test.ts +35 -0
  105. package/template/src/participation-runtime.ts +255 -0
  106. package/template/src/portal-comments.ts +35 -5
  107. package/template/src/portal-feedback-form.tsx +29 -6
  108. package/template/src/portal-feedback-runtime.ts +3 -0
  109. package/template/src/portal-feedback-server-function.ts +6 -3
  110. package/template/src/portal-feedback-submission.ts +19 -0
  111. package/template/src/portal-server-entry.ts +13 -0
  112. package/template/src/post-creation-flow.tsx +2 -2
  113. package/template/src/post-detail-content.test.tsx +233 -0
  114. package/template/src/post-detail-content.tsx +162 -0
  115. package/template/src/post-detail-query.ts +34 -0
  116. package/template/src/post-results.tsx +1 -1
  117. package/template/src/public-cache-policy.test.ts +11 -4
  118. package/template/src/public-cache-policy.ts +1 -1
  119. package/template/src/public-feedback-index.tsx +9 -39
  120. package/template/src/public-feedback-page.test.tsx +65 -0
  121. package/template/src/public-feedback-page.ts +12 -1
  122. package/template/src/public-portal-error.tsx +1 -1
  123. package/template/src/public-post-detail-skeleton.tsx +54 -0
  124. package/template/src/public-post-detail.tsx +437 -271
  125. package/template/src/public-post-route.test.tsx +39 -3
  126. package/template/src/public-post-route.tsx +15 -28
  127. package/template/src/public-release-links.test.ts +120 -0
  128. package/template/src/public-release-links.ts +130 -0
  129. package/template/src/public-roadmap-route.test.tsx +38 -2
  130. package/template/src/public-roadmap-view.tsx +56 -23
  131. package/template/src/public-shell.test.tsx +26 -0
  132. package/template/src/routeTree.gen.ts +70 -3
  133. package/template/src/router.tsx +2 -0
  134. package/template/src/routes/__root.tsx +3 -5
  135. package/template/src/routes/email-preferences.tsx +77 -0
  136. package/template/src/routes/my-feedback.tsx +16 -0
  137. package/template/src/routes/submit.tsx +1 -1
  138. package/template/src/safe-public-failure.ts +3 -1
  139. package/template/src/server.ts +87 -2
  140. package/template/src/submission-intent.ts +26 -0
  141. package/template/src/trusted-feedback-handler.test.ts +1 -1
  142. package/template/src/vote-toggle.tsx +42 -9
  143. package/template/vite.config.ts +2 -0
@@ -1,10 +1,12 @@
1
1
  import { useEffect, useState } from "react";
2
- import { useInfiniteQuery } from "@tanstack/react-query";
2
+ import { useInfiniteQuery, keepPreviousData } from "@tanstack/react-query";
3
3
  import { useRouter } from "@tanstack/react-router";
4
4
  import type { PublicPostPage } from "@feedbax/feedback";
5
5
  import type { PortalFeatures } from "@feedbax/config";
6
6
 
7
- import { Badge } from "@feedbax/ui/components/badge";
7
+ import { Button } from "@feedbax/ui/components/button";
8
+ import { Kbd, KbdGroup } from "@feedbax/ui/components/kbd";
9
+ import { Spinner } from "@feedbax/ui/components/spinner";
8
10
  import {
9
11
  Command,
10
12
  CommandDialog,
@@ -13,16 +15,8 @@ import {
13
15
  CommandInput,
14
16
  CommandItem,
15
17
  CommandList,
16
- CommandSeparator,
17
18
  } from "@feedbax/ui/components/command";
18
- import {
19
- FileText,
20
- LayoutList,
21
- LoaderCircle,
22
- Map,
23
- Newspaper,
24
- Plus,
25
- } from "lucide-react";
19
+ import { FileText, LayoutList, Map, Newspaper, Plus } from "lucide-react";
26
20
  import { publicPostsQuery } from "../post-queries";
27
21
  import { useCommandPalette } from "./command-palette-context";
28
22
  import feedbax from "../feedbax";
@@ -64,14 +58,19 @@ export function CommandPalette({
64
58
  (search) => searchPosts(search.search ?? ""),
65
59
  ),
66
60
  enabled: open && deferredTerm.trim().length > 0,
61
+ placeholderData: keepPreviousData,
67
62
  });
68
63
  const posts = query.data?.pages.flatMap((page) => page.items) ?? [];
64
+ const hasTerm = term.trim().length > 0;
65
+ const settling = term.trim() !== deferredTerm.trim();
69
66
  const showEmpty =
70
67
  open &&
71
68
  deferredTerm.trim().length > 0 &&
72
69
  !query.isPending &&
70
+ !query.isError &&
71
+ !settling &&
73
72
  posts.length === 0;
74
- const searching = open && deferredTerm.trim().length > 0 && query.isFetching;
73
+ const searching = open && hasTerm && (settling || query.isFetching);
75
74
  const searchStatus = searching
76
75
  ? "Searching Posts…"
77
76
  : showEmpty
@@ -83,7 +82,7 @@ export function CommandPalette({
83
82
  useEffect(() => {
84
83
  const firstPost = posts[0];
85
84
  if (firstPost) setSelection(`post-${firstPost.slug}`);
86
- }, [posts]);
85
+ }, [posts[0]?.slug]);
87
86
 
88
87
  function getCurrentRouteOptions() {
89
88
  const currentRoute = router.state.matches.at(-1);
@@ -136,24 +135,45 @@ export function CommandPalette({
136
135
  onValueChange={setTerm}
137
136
  placeholder="Search feedback…"
138
137
  />
139
- <CommandList aria-busy={searching || undefined} className="max-h-96">
140
- <CommandEmpty>No Posts match this search.</CommandEmpty>
141
- {searching && posts.length === 0 ? (
142
- <CommandGroup heading="Posts">
143
- <CommandItem disabled value="searching-posts">
144
- <LoaderCircle className="animate-spin" aria-hidden="true" />
145
- Searching Posts…
146
- </CommandItem>
147
- </CommandGroup>
138
+ <CommandList
139
+ aria-busy={searching || undefined}
140
+ className="min-h-28 max-h-96 p-2"
141
+ >
142
+ {showEmpty ? (
143
+ <CommandEmpty>No Posts match this search.</CommandEmpty>
148
144
  ) : null}
149
- {posts.length > 0 ? (
145
+ {query.isError && hasTerm ? (
146
+ <div
147
+ role="alert"
148
+ className="space-y-3 px-4 py-8 text-center text-sm text-muted-foreground"
149
+ >
150
+ <p>Search is unavailable. Try again.</p>
151
+ <Button variant="outline" onClick={() => void query.refetch()}>
152
+ Retry search
153
+ </Button>
154
+ </div>
155
+ ) : null}
156
+ {searching ? (
157
+ <div
158
+ role="status"
159
+ className={
160
+ posts.length
161
+ ? "flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground"
162
+ : "flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground"
163
+ }
164
+ >
165
+ <Spinner aria-hidden="true" />
166
+ Searching Posts…
167
+ </div>
168
+ ) : null}
169
+ {hasTerm && !query.isError && posts.length > 0 ? (
150
170
  <CommandGroup heading="Posts">
151
171
  {posts.map((post) => (
152
172
  <CommandItem
153
173
  key={post.slug}
154
174
  value={`post-${post.slug}`}
155
175
  onSelect={() => openPost(post.slug)}
156
- className="min-h-11 gap-3 py-2.5"
176
+ className="min-h-14 gap-3"
157
177
  >
158
178
  <FileText aria-hidden="true" />
159
179
  <div className="min-w-0 flex-1">
@@ -164,17 +184,14 @@ export function CommandPalette({
164
184
  {post.description}
165
185
  </div>
166
186
  </div>
167
- <div className="flex shrink-0 items-center gap-1.5">
168
- <Badge variant="outline">{post.type}</Badge>
169
- <Badge className="hidden sm:inline-flex" variant="outline">
170
- {post.status}
171
- </Badge>
187
+ <div className="shrink-0 text-right text-xs text-muted-foreground">
188
+ <span className="block">{post.status}</span>
189
+ <span className="mt-1 hidden sm:block">{post.type}</span>
172
190
  </div>
173
191
  </CommandItem>
174
192
  ))}
175
193
  </CommandGroup>
176
194
  ) : null}
177
- {!term.trim() ? <CommandSeparator /> : null}
178
195
  {!term.trim() ? (
179
196
  <CommandGroup heading="Navigate">
180
197
  <CommandItem
@@ -228,6 +245,18 @@ export function CommandPalette({
228
245
  </CommandGroup>
229
246
  ) : null}
230
247
  </CommandList>
248
+ <div className="flex items-center justify-between gap-4 border-t px-4 py-3 text-xs text-muted-foreground">
249
+ <span className="flex items-center gap-1.5">
250
+ <KbdGroup>
251
+ <Kbd>↑</Kbd>
252
+ <Kbd>↓</Kbd>
253
+ </KbdGroup>{" "}
254
+ to navigate <Kbd>↵</Kbd> to open
255
+ </span>
256
+ <span className="flex items-center gap-1.5">
257
+ <Kbd>Esc</Kbd> to close
258
+ </span>
259
+ </div>
231
260
  <div aria-label="Search status" className="sr-only" role="status">
232
261
  {searchStatus}
233
262
  </div>
@@ -0,0 +1,135 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useState,
6
+ type ReactNode,
7
+ } from "react";
8
+ import feedbax from "../feedbax";
9
+ import { Button } from "@feedbax/ui/components/button";
10
+ import {
11
+ Item,
12
+ ItemActions,
13
+ ItemContent,
14
+ ItemDescription,
15
+ ItemTitle,
16
+ } from "@feedbax/ui/components/item";
17
+
18
+ type IdentityState = {
19
+ enabled: boolean;
20
+ ready: boolean;
21
+ displayName?: string;
22
+ sessionKey?: string;
23
+ error?: string;
24
+ signOut: () => Promise<void>;
25
+ };
26
+ const IdentityContext = createContext<IdentityState>({
27
+ enabled: Boolean(feedbax.connected),
28
+ ready: !feedbax.connected,
29
+ signOut: async () => {},
30
+ });
31
+ export function useConnectedIdentity() {
32
+ return useContext(IdentityContext);
33
+ }
34
+ export function ConnectedIdentityProvider({
35
+ children,
36
+ }: {
37
+ children: ReactNode;
38
+ }) {
39
+ const [state, setState] = useState<{
40
+ ready: boolean;
41
+ displayName?: string;
42
+ sessionKey?: string;
43
+ error?: string;
44
+ }>({ ready: !feedbax.connected });
45
+ useEffect(() => {
46
+ if (!feedbax.connected) return;
47
+ let controller: AbortController | undefined;
48
+ const refresh = () => {
49
+ controller?.abort();
50
+ const active = new AbortController();
51
+ controller = active;
52
+ // Unmount private views before fetching a possibly different session.
53
+ setState({ ready: false });
54
+ fetch("/internal/identity/session", {
55
+ signal: active.signal,
56
+ cache: "no-store",
57
+ })
58
+ .then(async (response) => {
59
+ if (!response.ok) throw new Error();
60
+ const value = await response.json();
61
+ if (!active.signal.aborted)
62
+ setState({
63
+ ready: true,
64
+ displayName: value.participant?.displayName,
65
+ sessionKey: value.participant?.sessionKey,
66
+ });
67
+ })
68
+ .catch(() => {
69
+ if (!active.signal.aborted)
70
+ setState({
71
+ ready: true,
72
+ error: "Sign-in is temporarily unavailable.",
73
+ });
74
+ });
75
+ };
76
+ const visibility = () => {
77
+ if (document.visibilityState === "hidden") {
78
+ controller?.abort();
79
+ setState({ ready: false });
80
+ } else refresh();
81
+ };
82
+ refresh();
83
+ window.addEventListener("focus", refresh);
84
+ document.addEventListener("visibilitychange", visibility);
85
+ return () => {
86
+ controller?.abort();
87
+ window.removeEventListener("focus", refresh);
88
+ document.removeEventListener("visibilitychange", visibility);
89
+ };
90
+ }, []);
91
+ async function signOut() {
92
+ const response = await fetch("/internal/identity/logout", {
93
+ method: "POST",
94
+ });
95
+ if (!response.ok) {
96
+ setState((current) => ({
97
+ ...current,
98
+ error: "Sign-out failed. Try again.",
99
+ }));
100
+ return;
101
+ }
102
+ window.location.reload();
103
+ }
104
+ return (
105
+ <IdentityContext.Provider
106
+ value={{ enabled: Boolean(feedbax.connected), ...state, signOut }}
107
+ >
108
+ {children}
109
+ </IdentityContext.Provider>
110
+ );
111
+ }
112
+ export function ConnectedSignInPrompt() {
113
+ const identity = useConnectedIdentity();
114
+ return (
115
+ <Item variant="muted" size="sm" className="my-4">
116
+ <ItemContent className="min-w-36">
117
+ <ItemTitle>Join the conversation</ItemTitle>
118
+ <ItemDescription className="line-clamp-none">
119
+ {identity.error ?? "Sign in with your product account to comment."}
120
+ </ItemDescription>
121
+ </ItemContent>
122
+ <ItemActions>
123
+ <Button
124
+ nativeButton={false}
125
+ render={<a href="/internal/identity/start" />}
126
+ variant="outline"
127
+ size="sm"
128
+ disabled={!identity.ready}
129
+ >
130
+ Sign in
131
+ </Button>
132
+ </ItemActions>
133
+ </Item>
134
+ );
135
+ }
@@ -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,78 @@
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 || !identity.displayName) 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 || !identity.displayName) return null;
24
+ async function toggle() {
25
+ setPending(true);
26
+ setError(undefined);
27
+ try {
28
+ const result = await participationRequest<FollowState>("follow", {
29
+ slug,
30
+ following: !state.data?.following,
31
+ });
32
+ queryClient.setQueryData(followQuery(slug).queryKey, result);
33
+ } catch (cause) {
34
+ setError(
35
+ cause instanceof Error ? cause.message : "Couldn’t update Follow.",
36
+ );
37
+ } finally {
38
+ setPending(false);
39
+ }
40
+ }
41
+ const following = state.data?.following;
42
+ return (
43
+ <div className="space-y-2">
44
+ <Button
45
+ variant={following ? "secondary" : "outline"}
46
+ size="sm"
47
+ onClick={state.isError ? () => void state.refetch() : toggle}
48
+ disabled={pending || state.isPending}
49
+ aria-pressed={Boolean(following)}
50
+ >
51
+ {following ? (
52
+ <BellRing aria-hidden="true" />
53
+ ) : (
54
+ <Bell aria-hidden="true" />
55
+ )}
56
+ {state.isError
57
+ ? "Retry follow status"
58
+ : pending
59
+ ? "Updating…"
60
+ : following
61
+ ? state.data?.initializing
62
+ ? "Cancel preparation"
63
+ : "Following"
64
+ : "Follow updates"}
65
+ </Button>
66
+ {state.data?.initializing ? (
67
+ <p className="text-xs text-muted-foreground">
68
+ Preparing updates for this Post…
69
+ </p>
70
+ ) : null}
71
+ {error ? (
72
+ <p role="alert" className="text-sm text-destructive">
73
+ {error}
74
+ </p>
75
+ ) : null}
76
+ </div>
77
+ );
78
+ }
@@ -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
+ }