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.
- package/dist/cli.js +2042 -16
- package/dist/index.js +1997 -5
- package/package.json +1 -1
- package/template/.feedbax-template.sha256 +1 -1
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/README.md +58 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -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/package.json +9 -5
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/cloudflare-workers.d.ts +6 -0
- package/template/src/comment-creation.test.tsx +42 -1
- 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 +108 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +89 -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/profile-menu.tsx +30 -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/connected-identity.test.ts +144 -0
- package/template/src/connected-identity.ts +122 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +241 -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 +1 -0
- package/template/src/feedback-runtime.ts +79 -12
- package/template/src/feedbax/config/index.test.ts +28 -0
- package/template/src/feedbax/config/index.ts +93 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +215 -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/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +101 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +324 -0
- package/template/src/feedbax/feedback/index.ts +55 -1
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +97 -440
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- 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 +24 -5
- package/template/src/portal-feedback-form.tsx +18 -3
- 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 +10 -0
- package/template/src/portal-server-entry.ts +4 -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-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +298 -208
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- 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 +49 -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/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +41 -1
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- 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
|
-
|
|
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,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 {
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
}
|