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.
- package/README.md +17 -5
- package/dist/cli.js +2610 -291
- package/dist/index.js +2375 -200
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +418 -0
- package/template/README.md +97 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/feedbax-vite.ts +79 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +39 -34
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +9 -0
- package/template/src/comment-creation.test.tsx +95 -7
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +135 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +78 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +37 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +148 -0
- package/template/src/connected-identity.ts +127 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +244 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +3 -0
- package/template/src/feedback-runtime.ts +123 -13
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +73 -1
- package/template/src/feedbax/config/index.ts +205 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +225 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +114 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +350 -0
- package/template/src/feedbax/feedback/index.ts +226 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +103 -440
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.ts +35 -5
- package/template/src/portal-feedback-form.tsx +29 -6
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +19 -0
- package/template/src/portal-server-entry.ts +13 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +437 -271
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +70 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +87 -2
- package/template/src/submission-intent.ts +26 -0
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
- 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 {
|
|
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 &&
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
{
|
|
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-
|
|
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="
|
|
168
|
-
<
|
|
169
|
-
<
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
} from "@feedbax/ui/components/
|
|
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
|
|
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
|
-
<
|
|
172
|
-
<
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
<
|
|
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
|
-
</
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
</
|
|
190
|
-
</
|
|
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
|
+
}
|