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,6 +1,6 @@
1
1
  {
2
2
  "name": "feedbax-installation",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -27,18 +27,20 @@
27
27
  "sonner": "2.0.8",
28
28
  "tailwindcss": "4.3.3",
29
29
  "zod": "4.4.3",
30
- "@base-ui/react": "1.7.0",
30
+ "@base-ui/react": "1.9.0",
31
31
  "@fontsource-variable/geist": "5.3.0",
32
- "@shadcn/react": "0.3.0",
32
+ "@shadcn/react": "0.3.1",
33
33
  "class-variance-authority": "0.7.1",
34
34
  "clsx": "2.1.1",
35
35
  "cmdk": "^1.1.1",
36
36
  "shadcn": "latest",
37
37
  "tailwind-merge": "3.6.0",
38
- "tw-animate-css": "1.4.0"
38
+ "tw-animate-css": "1.4.0",
39
+ "drizzle-orm": "0.45.2"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@cloudflare/vite-plugin": "1.53.0",
43
+ "@cloudflare/workers-types": "^5.20261014.1",
42
44
  "@tanstack/react-router-devtools": "1.167.1",
43
45
  "@tanstack/router-cli": "1.167.32",
44
46
  "@testing-library/dom": "10.4.1",
@@ -48,6 +50,7 @@
48
50
  "@types/react-dom": "19.2.4",
49
51
  "@vitejs/plugin-react": "6.1.0",
50
52
  "jsdom": "29.1.1",
53
+ "miniflare": "4.20260730.0",
51
54
  "typescript": "6.0.3",
52
55
  "vite": "8.2.2",
53
56
  "web-vitals": "5.3.0",
@@ -56,8 +59,9 @@
56
59
  "tailwindcss": "4.3.3",
57
60
  "vitest": "4.1.11",
58
61
  "@eslint/js": "10.0.1",
62
+ "@shadcn/lint": "0.2.1",
59
63
  "@types/node": "22.20.1",
60
- "feedbax": "0.2.0",
64
+ "feedbax": "0.2.1",
61
65
  "eslint": "10.8.1",
62
66
  "globals": "17.11.0",
63
67
  "prettier": "3.9.6",
@@ -146,6 +146,7 @@ export function AuthorizedDraftPost({ slug }: { slug: string }) {
146
146
  ) : (
147
147
  <PublicPostDetail
148
148
  post={post}
149
+ published={false}
149
150
  display={overlaid ? "overlay" : "page"}
150
151
  features={{ voting: false, comments: false, changelog: true }}
151
152
  summaryMarker={<Badge variant="outline">Draft</Badge>}
@@ -3,9 +3,8 @@ import type {
3
3
  PublicChangelogEntry,
4
4
  } from "@feedbax/changelog";
5
5
  import { Badge } from "@feedbax/ui/components/badge";
6
- import { Button } from "@feedbax/ui/components/button";
6
+ import { Button, buttonVariants } from "@feedbax/ui/components/button";
7
7
  import { ButtonGroup } from "@feedbax/ui/components/button-group";
8
- import { cn } from "@feedbax/ui/lib/utils";
9
8
  import { useRouterState } from "@tanstack/react-router";
10
9
  import { ChevronDown, Link2 } from "lucide-react";
11
10
  import { useEffect, useMemo, useState } from "react";
@@ -80,21 +79,24 @@ export function ChangelogPage({
80
79
  ]);
81
80
 
82
81
  return (
83
- <main className="changelog-page">
84
- <header className="changelog-heading">
82
+ <main className="mx-auto w-full max-w-[96rem] px-6 py-10 lg:px-10">
83
+ <header className="flex flex-wrap items-start justify-between gap-5">
85
84
  <div>
86
- <h1>Changelog</h1>
87
- <p>Product updates, fixes, and improvements.</p>
85
+ <h1 className="text-2xl font-semibold tracking-tight">Changelog</h1>
86
+ <p className="mt-2 text-sm text-muted-foreground">
87
+ Product updates, fixes, and improvements.
88
+ </p>
88
89
  </div>
90
+ <CommandPaletteTrigger className="h-10" />
89
91
  </header>
90
- <div className="changelog-toolbar">
91
- <ButtonGroup className="changelog-filters" aria-label="Filter updates">
92
+ {labels.length ? (
93
+ <ButtonGroup className="mt-6 flex-wrap" aria-label="Filter updates">
92
94
  {[undefined, ...labels].map((label) => (
93
95
  <Button
94
- className="h-11 md:h-8"
95
96
  key={label ?? "all"}
96
97
  type="button"
97
- variant="outline"
98
+ size="sm"
99
+ variant={selectedLabel === label ? "secondary" : "outline"}
98
100
  aria-pressed={selectedLabel === label}
99
101
  disabled={loading}
100
102
  onClick={() => void replaceForLabel(label)}
@@ -103,23 +105,30 @@ export function ChangelogPage({
103
105
  </Button>
104
106
  ))}
105
107
  </ButtonGroup>
106
- <CommandPaletteTrigger className="h-11 md:h-10" />
107
- </div>
108
- <ol className="changelog-timeline" aria-label="Product updates">
108
+ ) : null}
109
+ <ol
110
+ className="changelog-timeline"
111
+ aria-label="Product updates"
112
+ aria-busy={loading}
113
+ >
109
114
  {page.items.map((entry) => (
110
115
  <TimelineEntry entry={entry} key={entry.slug} />
111
116
  ))}
112
117
  </ol>
118
+ {!page.items.length && !loading ? (
119
+ <p className="py-12 text-sm text-muted-foreground">
120
+ No updates published yet.
121
+ </p>
122
+ ) : null}
113
123
  {page.nextCursor ? (
114
- <div className="changelog-load-more">
124
+ <div className="mx-auto mt-8 flex max-w-5xl justify-center">
115
125
  <Button
116
- className="h-11 min-w-64 gap-2 text-sm md:h-10"
117
126
  type="button"
118
127
  variant="outline"
119
128
  disabled={loading}
120
129
  onClick={() => void loadMore()}
121
130
  >
122
- Load more updates
131
+ {loading ? "Loading updates…" : "Load more updates"}
123
132
  <ChevronDown aria-hidden="true" />
124
133
  </Button>
125
134
  </div>
@@ -135,28 +144,46 @@ function TimelineEntry({ entry }: { entry: PublicChangelogEntry }) {
135
144
  <time dateTime={entry.date}>{formatDate(entry.date)}</time>
136
145
  <span className="changelog-timeline-marker" aria-hidden="true" />
137
146
  <article id={entry.slug} tabIndex={-1}>
138
- <div className={cn("changelog-entry-layout", image && "has-image")}>
139
- <div className="changelog-entry-copy">
140
- <div className="changelog-labels" aria-label="Labels">
141
- {entry.labels.map((label) => (
142
- <Label key={label} label={label} />
143
- ))}
144
- </div>
145
- <div className="changelog-entry-title">
146
- <h2>{entry.title}</h2>
147
- <a href={`#${entry.slug}`} aria-label={`Link to ${entry.title}`}>
148
- <Link2 aria-hidden="true" />
149
- </a>
150
- </div>
151
- <p className="changelog-entry-summary">{entry.summary}</p>
152
- <div className="changelog-entry-body">
153
- <p>{entry.body}</p>
154
- </div>
147
+ {entry.labels.length ? (
148
+ <div className="mb-3 flex flex-wrap gap-2" aria-label="Labels">
149
+ {entry.labels.map((label) => (
150
+ <Badge key={label} variant="outline">
151
+ {label}
152
+ </Badge>
153
+ ))}
155
154
  </div>
156
- {image ? (
157
- <img src={image.src} alt={image.alt} width={760} height={420} />
158
- ) : null}
155
+ ) : null}
156
+ <div className="flex items-start justify-between gap-3">
157
+ <h2 className="text-lg font-semibold tracking-tight">
158
+ {entry.title}
159
+ </h2>
160
+ <a
161
+ className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
162
+ href={`#${entry.slug}`}
163
+ aria-label={`Link to ${entry.title}`}
164
+ >
165
+ <Link2 aria-hidden="true" />
166
+ </a>
167
+ </div>
168
+ <p className="mt-3 text-sm leading-relaxed">{entry.summary}</p>
169
+ <div className="changelog-entry-body">
170
+ {entry.body
171
+ .trim()
172
+ .split(/\n\s*\n/)
173
+ .filter(Boolean)
174
+ .map((paragraph, index) => (
175
+ <p key={index}>{paragraph}</p>
176
+ ))}
159
177
  </div>
178
+ {image ? (
179
+ <img
180
+ className="mt-6 w-full border"
181
+ src={image.src}
182
+ alt={image.alt}
183
+ width={760}
184
+ height={420}
185
+ />
186
+ ) : null}
160
187
  </article>
161
188
  </li>
162
189
  );
@@ -190,24 +217,6 @@ function useUnexpiredImage(image: PublicChangelogEntry["image"]) {
190
217
  : undefined;
191
218
  }
192
219
 
193
- function Label({ label }: { label: string }) {
194
- const tone = label.toLowerCase();
195
- return (
196
- <Badge
197
- className={cn(
198
- "changelog-label",
199
- tone === "new feature" && "is-new",
200
- tone === "improved" && "is-improved",
201
- tone === "fixed" && "is-fixed",
202
- )}
203
- variant="outline"
204
- >
205
- <span aria-hidden="true" />
206
- {label}
207
- </Badge>
208
- );
209
- }
210
-
211
220
  function formatDate(date: string) {
212
221
  const [year, month, day] = date.split("-").map(Number);
213
222
  return new Intl.DateTimeFormat("en-US", {
@@ -6,6 +6,12 @@ declare module "cloudflare:workers" {
6
6
  }>;
7
7
  };
8
8
  export const env: {
9
+ FEEDBAX_UPDATES?: import("@cloudflare/workers-types").Queue<string>;
10
+ FEEDBAX_EMAIL?: import("@feedbax/feedback").CloudflareEmailBinding;
11
+ EMAIL_PROVIDER_SECRET?: string;
12
+ FEEDBAX_IMAGES?: import("@cloudflare/workers-types").R2Bucket;
13
+ FEEDBAX_DB?: import("@feedbax/feedback").ConnectedDatabase;
14
+ IDENTITY_HANDOFF_SECRET?: string;
9
15
  TRUSTED_SUBMISSIONS_RATE_LIMITER: {
10
16
  limit(options: { key: string }): Promise<{ success: boolean }>;
11
17
  };
@@ -41,7 +41,7 @@ describe("Comment creation", () => {
41
41
  });
42
42
  expect(composer.classList.contains("comment-composer")).toBe(true);
43
43
  expect(screen.getByLabelText("Add a comment").getAttribute("rows")).toBe(
44
- "4",
44
+ "3",
45
45
  );
46
46
  expect(
47
47
  screen.getByPlaceholderText("Share context, an example, or a question…"),
@@ -147,6 +147,47 @@ describe("Comment creation", () => {
147
147
  expect(await screen.findByLabelText("Add a comment")).toBeTruthy();
148
148
  });
149
149
 
150
+ it("does not duplicate a native Comment that arrives before its submission confirmation", async () => {
151
+ saveDeviceProfile(localStorage, {
152
+ name: "Ari",
153
+ email: "private@example.com",
154
+ });
155
+ const confirmed = {
156
+ id: "native-comment",
157
+ discussionId: "native-thread",
158
+ body: "One comment",
159
+ author: { kind: "participant" as const, displayName: "Ari" },
160
+ createdAt: new Date(),
161
+ };
162
+ let finish!: (value: typeof confirmed) => void;
163
+ const submitComment = vi.fn(
164
+ () =>
165
+ new Promise<typeof confirmed>((resolve) => {
166
+ finish = resolve;
167
+ }),
168
+ );
169
+ const view = renderCommentDetail({ submitComment });
170
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
171
+ target: { value: confirmed.body },
172
+ });
173
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
174
+ view.rerender(
175
+ <DeviceProfileProvider>
176
+ <PublicPostDetail
177
+ post={post}
178
+ submitComment={submitComment}
179
+ comments={{
180
+ items: [{ id: confirmed.discussionId, comments: [confirmed] }],
181
+ }}
182
+ />
183
+ </DeviceProfileProvider>,
184
+ );
185
+ finish(confirmed);
186
+ await waitFor(() =>
187
+ expect(screen.getAllByText("One comment")).toHaveLength(1),
188
+ );
189
+ });
190
+
150
191
  it("rolls back a rejected Comment and shows an actionable error", async () => {
151
192
  saveDeviceProfile(localStorage, {
152
193
  name: "Ari",
@@ -10,7 +10,17 @@ export function mergeCommentThreads(
10
10
  merged.set(
11
11
  thread.id,
12
12
  existing
13
- ? { ...existing, comments: [...existing.comments, ...thread.comments] }
13
+ ? {
14
+ ...existing,
15
+ comments: [
16
+ ...new Map(
17
+ [...existing.comments, ...thread.comments].map((comment) => [
18
+ comment.id,
19
+ comment,
20
+ ]),
21
+ ).values(),
22
+ ],
23
+ }
14
24
  : thread,
15
25
  );
16
26
  }
@@ -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,108 @@
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
+ error?: string;
23
+ signOut: () => Promise<void>;
24
+ };
25
+ const IdentityContext = createContext<IdentityState>({
26
+ enabled: Boolean(feedbax.connected),
27
+ ready: !feedbax.connected,
28
+ signOut: async () => {},
29
+ });
30
+ export function useConnectedIdentity() {
31
+ return useContext(IdentityContext);
32
+ }
33
+ export function ConnectedIdentityProvider({
34
+ children,
35
+ }: {
36
+ children: ReactNode;
37
+ }) {
38
+ const [state, setState] = useState<{
39
+ ready: boolean;
40
+ displayName?: string;
41
+ error?: string;
42
+ }>({ ready: !feedbax.connected });
43
+ useEffect(() => {
44
+ if (!feedbax.connected) return;
45
+ const controller = new AbortController();
46
+ fetch("/internal/identity/session", {
47
+ signal: controller.signal,
48
+ cache: "no-store",
49
+ })
50
+ .then(async (response) => {
51
+ if (!response.ok) throw new Error();
52
+ const value = await response.json();
53
+ setState({ ready: true, displayName: value.participant?.displayName });
54
+ })
55
+ .catch(() => {
56
+ if (!controller.signal.aborted)
57
+ setState({
58
+ ready: true,
59
+ error: "Sign-in is temporarily unavailable.",
60
+ });
61
+ });
62
+ return () => controller.abort();
63
+ }, []);
64
+ async function signOut() {
65
+ const response = await fetch("/internal/identity/logout", {
66
+ method: "POST",
67
+ });
68
+ if (!response.ok) {
69
+ setState((current) => ({
70
+ ...current,
71
+ error: "Sign-out failed. Try again.",
72
+ }));
73
+ return;
74
+ }
75
+ window.location.reload();
76
+ }
77
+ return (
78
+ <IdentityContext.Provider
79
+ value={{ enabled: Boolean(feedbax.connected), ...state, signOut }}
80
+ >
81
+ {children}
82
+ </IdentityContext.Provider>
83
+ );
84
+ }
85
+ export function ConnectedSignInPrompt() {
86
+ const identity = useConnectedIdentity();
87
+ return (
88
+ <Item variant="muted" size="sm" className="my-4">
89
+ <ItemContent className="min-w-36">
90
+ <ItemTitle>Join the conversation</ItemTitle>
91
+ <ItemDescription className="line-clamp-none">
92
+ {identity.error ?? "Sign in with your product account to comment."}
93
+ </ItemDescription>
94
+ </ItemContent>
95
+ <ItemActions>
96
+ <Button
97
+ nativeButton={false}
98
+ render={<a href="/internal/identity/start" />}
99
+ variant="outline"
100
+ size="sm"
101
+ disabled={!identity.ready}
102
+ >
103
+ Sign in
104
+ </Button>
105
+ </ItemActions>
106
+ </Item>
107
+ );
108
+ }