create-feedbax 0.1.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/LICENSE +201 -21
- package/README.md +11 -1
- package/dist/cli.js +3283 -22
- package/dist/index.js +3394 -160
- package/package.json +40 -14
- package/template/.feedbax-template.sha256 +1 -0
- package/template/.gitignore.template +6 -0
- package/template/.npmignore.template +5 -0
- package/template/.prettierignore.template +4 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/LICENSE +201 -0
- package/template/README.md +56 -5
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +48 -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 +74 -14
- package/template/public/feedbax-mark.svg +4 -0
- package/template/public/robots.txt +3 -0
- package/template/src/authorized-draft-post.test.tsx +212 -0
- package/template/src/authorized-draft-post.tsx +205 -0
- package/template/src/authorized-draft-query.ts +74 -0
- package/template/src/browser-post-state.test.ts +120 -0
- package/template/src/browser-post-state.ts +134 -0
- package/template/src/browser-vote-state.test.ts +14 -0
- package/template/src/browser-vote-state.ts +27 -0
- package/template/src/capability-authorization-error.ts +3 -0
- package/template/src/changelog-page.test.tsx +139 -0
- package/template/src/changelog-page.tsx +228 -0
- package/template/src/changelog-query.ts +13 -0
- package/template/src/changelog-runtime.ts +27 -0
- package/template/src/cloudflare-turnstile.ts +31 -0
- package/template/src/cloudflare-workers-test.ts +3 -0
- package/template/src/cloudflare-workers.d.ts +24 -0
- package/template/src/comment-creation.test.tsx +311 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +28 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +275 -0
- package/template/src/components/connected-identity-provider.tsx +108 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +324 -0
- package/template/src/components/follow-post-button.tsx +89 -0
- package/template/src/components/header.tsx +97 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +53 -0
- 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 +119 -0
- package/template/src/components/ui/components/alert-dialog.tsx +185 -0
- package/template/src/components/ui/components/attachment.tsx +207 -0
- package/template/src/components/ui/components/avatar.tsx +107 -0
- package/template/src/components/ui/components/badge.tsx +52 -0
- package/template/src/components/ui/components/bubble.tsx +128 -0
- package/template/src/components/ui/components/button-group.tsx +87 -0
- package/template/src/components/ui/components/button.tsx +56 -0
- package/template/src/components/ui/components/card.tsx +103 -0
- package/template/src/components/ui/components/checkbox.tsx +26 -0
- package/template/src/components/ui/components/command.tsx +195 -0
- package/template/src/components/ui/components/dialog.tsx +154 -0
- package/template/src/components/ui/components/drawer.tsx +229 -0
- package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
- package/template/src/components/ui/components/empty.tsx +101 -0
- package/template/src/components/ui/components/field.tsx +236 -0
- package/template/src/components/ui/components/input-group.tsx +155 -0
- package/template/src/components/ui/components/input.tsx +20 -0
- package/template/src/components/ui/components/item.tsx +201 -0
- package/template/src/components/ui/components/kbd.tsx +26 -0
- package/template/src/components/ui/components/label.tsx +18 -0
- package/template/src/components/ui/components/marker.tsx +71 -0
- package/template/src/components/ui/components/message-scroller.tsx +128 -0
- package/template/src/components/ui/components/message.tsx +92 -0
- package/template/src/components/ui/components/native-select.tsx +65 -0
- package/template/src/components/ui/components/popover.tsx +88 -0
- package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
- package/template/src/components/ui/components/radio-group.tsx +36 -0
- package/template/src/components/ui/components/scroll-area.tsx +51 -0
- package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
- package/template/src/components/ui/components/select.tsx +199 -0
- package/template/src/components/ui/components/separator.tsx +23 -0
- package/template/src/components/ui/components/sheet.tsx +135 -0
- package/template/src/components/ui/components/skeleton.tsx +13 -0
- package/template/src/components/ui/components/sonner.tsx +43 -0
- package/template/src/components/ui/components/spinner.tsx +16 -0
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/components/tabs.tsx +80 -0
- package/template/src/components/ui/components/textarea.tsx +18 -0
- package/template/src/components/ui/components/tooltip.tsx +64 -0
- package/template/src/components/ui/hooks/.gitkeep +0 -0
- package/template/src/components/ui/lib/utils.ts +6 -0
- package/template/src/components/ui/styles/globals.css +151 -0
- 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/create-post-overlay.tsx +82 -0
- package/template/src/draft-post-list.tsx +39 -0
- package/template/src/feedback-cache-invalidation.test.ts +185 -0
- package/template/src/feedback-cache-invalidation.ts +98 -0
- package/template/src/feedback-runtime.ts +105 -0
- package/template/src/feedbax/changelog/index.test.ts +156 -0
- package/template/src/feedbax/changelog/index.ts +206 -0
- package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
- package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
- package/template/src/feedbax/config/index.test.ts +139 -0
- package/template/src/feedbax/config/index.ts +364 -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/feedback-storage.ts +43 -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.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +996 -0
- package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
- package/template/src/feedbax/feedback/notion-comments.ts +231 -0
- package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
- package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
- package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
- package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/notion-request.ts +74 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +1795 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +21 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +181 -0
- package/template/src/overlay-post-detail.tsx +43 -0
- 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.test.ts +375 -0
- package/template/src/portal-comments.ts +403 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +530 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +58 -0
- package/template/src/portal-feedback-server-function.ts +96 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +224 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +68 -0
- package/template/src/portal-voting.test.ts +106 -0
- package/template/src/portal-voting.ts +149 -0
- package/template/src/post-badges.tsx +27 -0
- package/template/src/post-creation-flow.test.tsx +230 -0
- package/template/src/post-creation-flow.tsx +88 -0
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-feed-controls.test.tsx +239 -0
- package/template/src/post-feed-controls.tsx +298 -0
- package/template/src/post-presentation.ts +37 -0
- package/template/src/post-queries.test.ts +158 -0
- package/template/src/post-queries.ts +26 -0
- package/template/src/post-results.tsx +165 -0
- package/template/src/public-cache-policy.test.ts +148 -0
- package/template/src/public-cache-policy.ts +119 -0
- package/template/src/public-cache-tags.ts +7 -0
- package/template/src/public-changelog-page.ts +15 -0
- package/template/src/public-changelog-server-function.ts +27 -0
- package/template/src/public-date.ts +11 -0
- package/template/src/public-failure-state.test.tsx +69 -0
- package/template/src/public-feedback-index.tsx +94 -0
- package/template/src/public-feedback-page.test.tsx +355 -0
- package/template/src/public-feedback-page.ts +86 -0
- package/template/src/public-feedback-server-function.ts +22 -0
- package/template/src/public-portal-error.tsx +27 -0
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +772 -0
- package/template/src/public-post-page.test.tsx +173 -0
- package/template/src/public-post-page.ts +15 -0
- package/template/src/public-post-route.test.tsx +250 -0
- package/template/src/public-post-route.tsx +158 -0
- package/template/src/public-post-server-function.ts +48 -0
- package/template/src/public-post-unavailable.tsx +10 -0
- package/template/src/public-roadmap-page.test.tsx +139 -0
- package/template/src/public-roadmap-page.ts +24 -0
- package/template/src/public-roadmap-route.test.tsx +254 -0
- package/template/src/public-roadmap-route.tsx +50 -0
- package/template/src/public-roadmap-server-function.ts +44 -0
- package/template/src/public-roadmap-view.tsx +411 -0
- package/template/src/public-shell.test.tsx +544 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +177 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +27 -0
- package/template/src/routes/__root.tsx +114 -0
- package/template/src/routes/changelog.tsx +39 -0
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/index.tsx +61 -0
- package/template/src/routes/p.$slug.tsx +16 -0
- package/template/src/routes/roadmap.tsx +11 -0
- package/template/src/routes/submit.tsx +28 -0
- package/template/src/safe-public-failure.ts +47 -0
- package/template/src/server.ts +151 -0
- package/template/src/similar-posts.tsx +45 -0
- package/template/src/submit-route.test.tsx +134 -0
- package/template/src/trusted-feedback-handler.test.ts +425 -0
- package/template/src/trusted-feedback-handler.ts +181 -0
- package/template/src/trusted-feedback-runtime.ts +17 -0
- package/template/src/turnstile-configuration.test.ts +37 -0
- package/template/src/turnstile-configuration.ts +19 -0
- package/template/src/use-media-query.ts +17 -0
- package/template/src/vote-toggle.test.tsx +171 -0
- package/template/src/vote-toggle.tsx +244 -0
- package/template/tsconfig.json +45 -0
- package/template/vite.config.ts +20 -0
- package/template/vitest.config.ts +4 -0
- package/template/vitest.setup.ts +3 -0
- package/template/wrangler.jsonc +35 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/template/.env.example +0 -4
- package/template/apps/portal/components.json +0 -20
- package/template/apps/portal/package.json +0 -31
- package/template/apps/portal/src/lib/utils.ts +0 -6
- package/template/apps/portal/src/routeTree.gen.ts +0 -59
- package/template/apps/portal/src/router.tsx +0 -10
- package/template/apps/portal/src/routes/__root.tsx +0 -31
- package/template/apps/portal/src/routes/index.tsx +0 -35
- package/template/apps/portal/src/styles.css +0 -91
- package/template/apps/portal/tsconfig.json +0 -15
- package/template/apps/portal/vite.config.ts +0 -13
- package/template/feedbax.config.ts +0 -13
- package/template/feedbax.jsonc +0 -11
- package/template/pnpm-workspace.yaml +0 -3
- package/template/turbo.json +0 -8
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Skeleton } from "@feedbax/ui/components/skeleton";
|
|
2
|
+
|
|
3
|
+
export default function Loader({
|
|
4
|
+
label = "Loading content",
|
|
5
|
+
}: { label?: string } = {}) {
|
|
6
|
+
return (
|
|
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>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function PortalFooter() {
|
|
2
|
+
return (
|
|
3
|
+
<footer className="mt-auto flex justify-end border-t px-6 py-4 text-xs text-muted-foreground lg:px-10">
|
|
4
|
+
<a
|
|
5
|
+
href="https://github.com/jessequartey/feedbax"
|
|
6
|
+
target="_blank"
|
|
7
|
+
rel="noopener noreferrer"
|
|
8
|
+
className="transition-colors hover:text-foreground"
|
|
9
|
+
>
|
|
10
|
+
Powered by Feedbax
|
|
11
|
+
</a>
|
|
12
|
+
</footer>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useConnectedIdentity } from "./connected-identity-provider";
|
|
2
|
+
import { useTheme } from "next-themes";
|
|
3
|
+
import { UserCircle } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import { Avatar, AvatarFallback } from "@feedbax/ui/components/avatar";
|
|
6
|
+
import { Button } from "@feedbax/ui/components/button";
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuGroup,
|
|
11
|
+
DropdownMenuItem,
|
|
12
|
+
DropdownMenuLabel,
|
|
13
|
+
DropdownMenuRadioGroup,
|
|
14
|
+
DropdownMenuRadioItem,
|
|
15
|
+
DropdownMenuSeparator,
|
|
16
|
+
DropdownMenuTrigger,
|
|
17
|
+
} from "@feedbax/ui/components/dropdown-menu";
|
|
18
|
+
import { deriveDeviceProfileInitials } from "../browser-post-state";
|
|
19
|
+
import { useDeviceProfile } from "./device-profile-provider";
|
|
20
|
+
|
|
21
|
+
export function ProfileMenu() {
|
|
22
|
+
const { profile, openProfileSetup, clearProfile } = useDeviceProfile();
|
|
23
|
+
|
|
24
|
+
const identity = useConnectedIdentity();
|
|
25
|
+
|
|
26
|
+
const initials = profile ? deriveDeviceProfileInitials(profile) : undefined;
|
|
27
|
+
|
|
28
|
+
if (identity.enabled)
|
|
29
|
+
return (
|
|
30
|
+
<div>
|
|
31
|
+
<Button variant="ghost" onClick={openProfileSetup}>
|
|
32
|
+
Contact profile
|
|
33
|
+
</Button>
|
|
34
|
+
{identity.displayName ? (
|
|
35
|
+
<>
|
|
36
|
+
<span>{identity.displayName}</span>
|
|
37
|
+
<Button variant="outline" onClick={() => void identity.signOut()}>
|
|
38
|
+
Sign out
|
|
39
|
+
</Button>
|
|
40
|
+
</>
|
|
41
|
+
) : (
|
|
42
|
+
<Button
|
|
43
|
+
variant="outline"
|
|
44
|
+
disabled={!identity.ready}
|
|
45
|
+
onClick={() => {
|
|
46
|
+
window.location.href = "/internal/identity/start";
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
Sign in
|
|
50
|
+
</Button>
|
|
51
|
+
)}
|
|
52
|
+
{identity.error ? <p role="alert">{identity.error}</p> : null}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
return profile ? (
|
|
56
|
+
<DropdownMenu>
|
|
57
|
+
<DropdownMenuTrigger
|
|
58
|
+
render={
|
|
59
|
+
<Button
|
|
60
|
+
aria-label="Profile"
|
|
61
|
+
className="h-11 gap-2 px-3 md:h-10"
|
|
62
|
+
variant="outline"
|
|
63
|
+
type="button"
|
|
64
|
+
>
|
|
65
|
+
<Avatar className="size-5">
|
|
66
|
+
<AvatarFallback>{initials}</AvatarFallback>
|
|
67
|
+
</Avatar>
|
|
68
|
+
<span className="max-w-32 truncate">{profile.name}</span>
|
|
69
|
+
</Button>
|
|
70
|
+
}
|
|
71
|
+
/>
|
|
72
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
73
|
+
<DropdownMenuGroup>
|
|
74
|
+
<DropdownMenuLabel>
|
|
75
|
+
<span className="block truncate">{profile.name}</span>
|
|
76
|
+
{profile.email ? (
|
|
77
|
+
<span className="block truncate text-xs font-normal text-muted-foreground">
|
|
78
|
+
{profile.email}
|
|
79
|
+
</span>
|
|
80
|
+
) : null}
|
|
81
|
+
</DropdownMenuLabel>
|
|
82
|
+
</DropdownMenuGroup>
|
|
83
|
+
<ThemeMenuItems />
|
|
84
|
+
<DropdownMenuSeparator />
|
|
85
|
+
<DropdownMenuItem onClick={openProfileSetup}>
|
|
86
|
+
Edit profile
|
|
87
|
+
</DropdownMenuItem>
|
|
88
|
+
<DropdownMenuItem onClick={clearProfile}>
|
|
89
|
+
Clear profile
|
|
90
|
+
</DropdownMenuItem>
|
|
91
|
+
</DropdownMenuContent>
|
|
92
|
+
</DropdownMenu>
|
|
93
|
+
) : (
|
|
94
|
+
<Button
|
|
95
|
+
aria-label="Profile"
|
|
96
|
+
className="h-11 gap-2 px-3 md:h-10"
|
|
97
|
+
variant="outline"
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={openProfileSetup}
|
|
100
|
+
>
|
|
101
|
+
<UserCircle aria-hidden="true" />
|
|
102
|
+
<span className="hidden sm:inline">Add profile</span>
|
|
103
|
+
</Button>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ThemeMenuItems() {
|
|
108
|
+
const { theme, setTheme } = useTheme();
|
|
109
|
+
return (
|
|
110
|
+
<DropdownMenuRadioGroup
|
|
111
|
+
value={theme ?? "system"}
|
|
112
|
+
onValueChange={(value) => setTheme(value as string)}
|
|
113
|
+
>
|
|
114
|
+
<DropdownMenuRadioItem value="system">System</DropdownMenuRadioItem>
|
|
115
|
+
<DropdownMenuRadioItem value="light">Light</DropdownMenuRadioItem>
|
|
116
|
+
<DropdownMenuRadioItem value="dark">Dark</DropdownMenuRadioItem>
|
|
117
|
+
</DropdownMenuRadioGroup>
|
|
118
|
+
);
|
|
119
|
+
}
|