@fewchurs/react 1.4.0 → 1.6.0
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/core.d.mts +5 -4
- package/dist/core.d.ts +5 -4
- package/dist/core.js +44 -24
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +43 -24
- package/dist/core.mjs.map +1 -1
- package/dist/index.d.mts +14 -4
- package/dist/index.d.ts +14 -4
- package/dist/index.js +235 -67
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +246 -79
- package/dist/index.mjs.map +1 -1
- package/dist/styles.css +22 -0
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
5
5
|
|
|
6
6
|
// src/client/version.ts
|
|
7
|
-
var SDK_VERSION = "1.
|
|
7
|
+
var SDK_VERSION = "1.6.0";
|
|
8
8
|
|
|
9
9
|
// src/client/constants.ts
|
|
10
10
|
var SDK_HEADER = `react/${SDK_VERSION}`;
|
|
@@ -22,17 +22,28 @@ var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
22
22
|
var ROADMAP_STATUSES = ["planned", "in_progress", "done"];
|
|
23
23
|
|
|
24
24
|
// src/client/errors.ts
|
|
25
|
+
var NOT_CONFIGURED_MESSAGE = "Fewchurs is not configured.";
|
|
25
26
|
var USER_MESSAGES = {
|
|
26
|
-
invalid_key: "
|
|
27
|
-
validation_failed: "
|
|
28
|
-
rate_limited: "You
|
|
29
|
-
not_found: "
|
|
30
|
-
limit_reached: "
|
|
31
|
-
server: "Something went wrong.
|
|
32
|
-
network: "
|
|
27
|
+
invalid_key: "Invalid API key. Check the key in your Fewchurs dashboard.",
|
|
28
|
+
validation_failed: "Invalid input.",
|
|
29
|
+
rate_limited: "You've reached today's limit. Try again tomorrow.",
|
|
30
|
+
not_found: "This request no longer exists.",
|
|
31
|
+
limit_reached: "This board isn't accepting requests right now.",
|
|
32
|
+
server: "Something went wrong. Try again.",
|
|
33
|
+
network: "You're offline."
|
|
33
34
|
};
|
|
35
|
+
function isWrongApp(error) {
|
|
36
|
+
return error.code === "invalid_key" && error.status === 403;
|
|
37
|
+
}
|
|
38
|
+
function isNotConfigured(error) {
|
|
39
|
+
return error.code === "server" && error.message === NOT_CONFIGURED_MESSAGE;
|
|
40
|
+
}
|
|
41
|
+
function notConfiguredError() {
|
|
42
|
+
return { code: "server", status: 500, message: NOT_CONFIGURED_MESSAGE };
|
|
43
|
+
}
|
|
34
44
|
function userMessage(error) {
|
|
35
45
|
if (error.code === "validation_failed" && error.message) return error.message;
|
|
46
|
+
if (isWrongApp(error) && error.message) return error.message;
|
|
36
47
|
return USER_MESSAGES[error.code];
|
|
37
48
|
}
|
|
38
49
|
function networkError(message = "Network request failed.") {
|
|
@@ -43,17 +54,17 @@ function validationError(message) {
|
|
|
43
54
|
}
|
|
44
55
|
function errorFromResponse(status, body) {
|
|
45
56
|
const code = body?.error?.code;
|
|
46
|
-
const
|
|
57
|
+
const serverMessage = body?.error?.message;
|
|
58
|
+
const message = serverMessage ?? `Request failed (${status}).`;
|
|
47
59
|
switch (code) {
|
|
48
60
|
case "invalid_key":
|
|
49
61
|
return { code: "invalid_key", status: 401, message };
|
|
50
62
|
case "wrong_app":
|
|
51
63
|
return { code: "invalid_key", status: 403, message };
|
|
52
|
-
// missing_device_id is a client mistake the caller fixes the same way a bad field is fixed,
|
|
53
|
-
// so it collapses into validation_failed rather than widening the public union.
|
|
54
64
|
case "missing_device_id":
|
|
65
|
+
return { code: "server", status: 400, message };
|
|
55
66
|
case "validation_failed":
|
|
56
|
-
return { code: "validation_failed", status: 400, message };
|
|
67
|
+
return { code: "validation_failed", status: 400, message: serverMessage ?? "Invalid input." };
|
|
57
68
|
case "rate_limited":
|
|
58
69
|
return { code: "rate_limited", status: 429, message };
|
|
59
70
|
case "not_found":
|
|
@@ -147,11 +158,8 @@ function normalizeVote(value) {
|
|
|
147
158
|
// src/client/validate.ts
|
|
148
159
|
function validateFeatureInput(input) {
|
|
149
160
|
const title = input.title.trim();
|
|
150
|
-
if (title.length < LIMITS.titleMin) {
|
|
151
|
-
return validationError(`Title must be
|
|
152
|
-
}
|
|
153
|
-
if (title.length > LIMITS.titleMax) {
|
|
154
|
-
return validationError(`Title must be at most ${LIMITS.titleMax} characters.`);
|
|
161
|
+
if (title.length < LIMITS.titleMin || title.length > LIMITS.titleMax) {
|
|
162
|
+
return validationError(`Title must be ${LIMITS.titleMin}\u2013${LIMITS.titleMax} characters.`);
|
|
155
163
|
}
|
|
156
164
|
if ((input.description ?? "").trim().length > LIMITS.descriptionMax) {
|
|
157
165
|
return validationError(`Description must be at most ${LIMITS.descriptionMax} characters.`);
|
|
@@ -163,9 +171,8 @@ function validateFeatureInput(input) {
|
|
|
163
171
|
}
|
|
164
172
|
function validateCommentInput(input) {
|
|
165
173
|
const text = input.text.trim();
|
|
166
|
-
if (text.length < LIMITS.commentMin
|
|
167
|
-
|
|
168
|
-
return validationError(`Comment must be at most ${LIMITS.commentMax} characters.`);
|
|
174
|
+
if (text.length < LIMITS.commentMin || text.length > LIMITS.commentMax) {
|
|
175
|
+
return validationError(`Comment must be ${LIMITS.commentMin}\u2013${LIMITS.commentMax} characters.`);
|
|
169
176
|
}
|
|
170
177
|
if ((input.authorName ?? "").trim().length > LIMITS.authorNameMax) {
|
|
171
178
|
return validationError(`Name must be at most ${LIMITS.authorNameMax} characters.`);
|
|
@@ -185,6 +192,7 @@ function createFewchursClient(options = {}) {
|
|
|
185
192
|
const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
186
193
|
const retries = options.retries ?? 1;
|
|
187
194
|
const retryDelayMs = options.retryDelayMs ?? 250;
|
|
195
|
+
if (options.apiKey && !options.apiKey.startsWith("fr_")) logOnce(BAD_KEY_PREFIX);
|
|
188
196
|
function getDeviceId() {
|
|
189
197
|
return typeof options.deviceId === "function" ? options.deviceId() : options.deviceId ?? null;
|
|
190
198
|
}
|
|
@@ -211,6 +219,11 @@ function createFewchursClient(options = {}) {
|
|
|
211
219
|
}
|
|
212
220
|
async function request(path, map, { method = "GET", body, query, signal } = {}) {
|
|
213
221
|
if (!doFetch) return { ok: false, error: networkError("No fetch implementation available.") };
|
|
222
|
+
if (!options.apiKey && options.baseUrl === void 0) {
|
|
223
|
+
const failure = notConfiguredError();
|
|
224
|
+
logError(failure);
|
|
225
|
+
return { ok: false, error: failure };
|
|
226
|
+
}
|
|
214
227
|
const target = url(path, query);
|
|
215
228
|
const init = { method, headers: headers(body !== void 0) };
|
|
216
229
|
if (body !== void 0) init.body = JSON.stringify(body);
|
|
@@ -330,16 +343,21 @@ function delay(ms) {
|
|
|
330
343
|
function messageOf(error) {
|
|
331
344
|
return error instanceof Error ? error.message : "Network request failed.";
|
|
332
345
|
}
|
|
333
|
-
var INVALID_KEY_HELP = "
|
|
346
|
+
var INVALID_KEY_HELP = "Fewchurs rejected the API key. Check the key in your dashboard (Apps \u2192 API keys).";
|
|
347
|
+
var NOT_CONFIGURED_HELP = "Fewchurs is not configured. Pass apiKey to FewchursProvider, or set FEWCHURS_API_KEY on your server.";
|
|
348
|
+
var BAD_KEY_PREFIX = 'The Fewchurs API key should start with "fr_". Copy it from your dashboard.';
|
|
334
349
|
var loggedErrors = /* @__PURE__ */ new Set();
|
|
335
|
-
function
|
|
350
|
+
function logOnce(detail) {
|
|
336
351
|
if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return;
|
|
337
|
-
const isInvalidKey = error.code === "invalid_key" && error.status === 401;
|
|
338
|
-
const detail = isInvalidKey ? INVALID_KEY_HELP : error.message;
|
|
339
352
|
if (loggedErrors.has(detail)) return;
|
|
340
353
|
loggedErrors.add(detail);
|
|
341
354
|
console.warn(`[fewchurs] : ${detail}`);
|
|
342
355
|
}
|
|
356
|
+
function logError(error) {
|
|
357
|
+
if (isNotConfigured(error)) return logOnce(NOT_CONFIGURED_HELP);
|
|
358
|
+
const isInvalidKey = error.code === "invalid_key" && error.status === 401;
|
|
359
|
+
logOnce(isInvalidKey ? INVALID_KEY_HELP : error.message);
|
|
360
|
+
}
|
|
343
361
|
|
|
344
362
|
// src/identity/deviceId.ts
|
|
345
363
|
var COOKIE_NAME = "fewchurs_did";
|
|
@@ -715,7 +733,8 @@ function useFewchurs() {
|
|
|
715
733
|
}
|
|
716
734
|
|
|
717
735
|
// src/components/Board.tsx
|
|
718
|
-
import { useEffect as useEffect6, useMemo as useMemo3, useState as useState10 } from "react";
|
|
736
|
+
import { useEffect as useEffect6, useMemo as useMemo3, useRef as useRef2, useState as useState10 } from "react";
|
|
737
|
+
import { flushSync } from "react-dom";
|
|
719
738
|
|
|
720
739
|
// src/hooks/useFewchursConfig.ts
|
|
721
740
|
import { useEffect as useEffect2 } from "react";
|
|
@@ -769,11 +788,19 @@ var DEFAULT_LABELS = {
|
|
|
769
788
|
loadMore: "Load more",
|
|
770
789
|
retry: "Try again",
|
|
771
790
|
errorInvalidKeyTitle: "Invalid API key",
|
|
772
|
-
errorInvalidKeyDetail: "
|
|
773
|
-
errorWrongAppTitle: "
|
|
774
|
-
errorWrongAppDetail: "
|
|
775
|
-
errorTitle: "
|
|
776
|
-
errorDetail: "
|
|
791
|
+
errorInvalidKeyDetail: "Copy the key from your Fewchurs dashboard (Apps \u2192 API keys) and pass it to FewchursProvider, or set FEWCHURS_API_KEY on your server.",
|
|
792
|
+
errorWrongAppTitle: "Invalid API key",
|
|
793
|
+
errorWrongAppDetail: "",
|
|
794
|
+
errorTitle: "Couldn't load requests",
|
|
795
|
+
errorDetail: "Something went wrong. Try again.",
|
|
796
|
+
errorOfflineTitle: "You're offline",
|
|
797
|
+
errorOfflineDetail: "Check your connection and try again.",
|
|
798
|
+
errorNotSetUpTitle: "Fewchurs isn't set up",
|
|
799
|
+
errorNotSetUpDetail: "Pass apiKey to FewchursProvider, or set FEWCHURS_API_KEY on your server.",
|
|
800
|
+
errorLoadMore: "Couldn't load more.",
|
|
801
|
+
errorComments: "Couldn't load comments.",
|
|
802
|
+
fieldTitleTooShort: "At least 3 characters.",
|
|
803
|
+
fieldEmailInvalid: "Enter a valid email address.",
|
|
777
804
|
comments: "Comments",
|
|
778
805
|
commentPlaceholder: "Add a comment",
|
|
779
806
|
postComment: "Post",
|
|
@@ -972,6 +999,7 @@ function useComments(featureId, { enabled = true } = {}) {
|
|
|
972
999
|
store.ensureComments(featureId);
|
|
973
1000
|
}, [store, featureId, enabled, isReady]);
|
|
974
1001
|
const loadMore = useCallback5(() => store.loadMoreComments(featureId), [store, featureId]);
|
|
1002
|
+
const refresh = useCallback5(() => store.refreshComments(featureId), [store, featureId]);
|
|
975
1003
|
const add = useCallback5(
|
|
976
1004
|
async (text, authorName) => {
|
|
977
1005
|
setIsSubmitting(true);
|
|
@@ -992,6 +1020,7 @@ function useComments(featureId, { enabled = true } = {}) {
|
|
|
992
1020
|
submitError,
|
|
993
1021
|
hasMore: comments.cursor !== null,
|
|
994
1022
|
loadMore,
|
|
1023
|
+
refresh,
|
|
995
1024
|
add
|
|
996
1025
|
};
|
|
997
1026
|
}
|
|
@@ -1011,10 +1040,15 @@ function formatDate(iso) {
|
|
|
1011
1040
|
import { jsx as jsx3, jsxs } from "react/jsx-runtime";
|
|
1012
1041
|
function CommentList({ featureId }) {
|
|
1013
1042
|
const { labels, cx } = useBoard();
|
|
1014
|
-
const { comments, isLoading, isLoadingMore, error, hasMore, loadMore } = useComments(featureId);
|
|
1043
|
+
const { comments, isLoading, isLoadingMore, error, hasMore, loadMore, refresh } = useComments(featureId);
|
|
1015
1044
|
return /* @__PURE__ */ jsxs("div", { className: cx("fw-comments"), children: [
|
|
1016
1045
|
isLoading && /* @__PURE__ */ jsx3("p", { className: cx("fw-muted"), children: labels.loading }),
|
|
1017
|
-
error && /* @__PURE__ */
|
|
1046
|
+
error && comments.length === 0 && /* @__PURE__ */ jsxs("div", { className: cx("fw-empty", "fw-empty--compact"), role: "alert", children: [
|
|
1047
|
+
/* @__PURE__ */ jsx3("p", { className: cx("fw-empty__title"), children: labels.errorComments }),
|
|
1048
|
+
/* @__PURE__ */ jsx3("p", { className: cx("fw-empty__detail"), children: isNotConfigured(error) ? labels.errorNotSetUpTitle : userMessage(error) }),
|
|
1049
|
+
/* @__PURE__ */ jsx3("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
|
|
1050
|
+
] }),
|
|
1051
|
+
error && comments.length > 0 && /* @__PURE__ */ jsx3("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
|
|
1018
1052
|
!isLoading && !error && comments.length === 0 && /* @__PURE__ */ jsx3("p", { className: cx("fw-muted"), children: labels.noComments }),
|
|
1019
1053
|
/* @__PURE__ */ jsx3("ul", { className: cx("fw-comment-list"), children: comments.map((comment) => /* @__PURE__ */ jsxs("li", { className: cx("fw-comment"), children: [
|
|
1020
1054
|
/* @__PURE__ */ jsx3("span", { className: cx("fw-comment__author"), children: comment.authorName }),
|
|
@@ -1085,7 +1119,7 @@ function CommentForm({ featureId }) {
|
|
|
1085
1119
|
}
|
|
1086
1120
|
)
|
|
1087
1121
|
] }),
|
|
1088
|
-
/* @__PURE__ */ jsx3("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting, children: labels.postComment }),
|
|
1122
|
+
/* @__PURE__ */ jsx3("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting || !text.trim(), children: labels.postComment }),
|
|
1089
1123
|
submitError && /* @__PURE__ */ jsx3("p", { className: cx("fw-error"), role: "alert", children: userMessage(submitError) })
|
|
1090
1124
|
]
|
|
1091
1125
|
}
|
|
@@ -1094,27 +1128,31 @@ function CommentForm({ featureId }) {
|
|
|
1094
1128
|
|
|
1095
1129
|
// src/components/FollowForm.tsx
|
|
1096
1130
|
import { useState as useState6 } from "react";
|
|
1097
|
-
import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
1131
|
+
import { Fragment, jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
1098
1132
|
function FollowForm({ feature }) {
|
|
1099
1133
|
const { labels, userEmail, cx } = useBoard();
|
|
1100
1134
|
const { isFollowing, isPending, error, follow, unfollow } = useFollow(feature.id);
|
|
1101
1135
|
const [email, setEmail] = useState6(userEmail ?? "");
|
|
1102
1136
|
const [isOpen, setIsOpen] = useState6(false);
|
|
1103
1137
|
if (isFollowing) {
|
|
1104
|
-
return /* @__PURE__ */
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1138
|
+
return /* @__PURE__ */ jsxs2(Fragment, { children: [
|
|
1139
|
+
/* @__PURE__ */ jsx4(
|
|
1140
|
+
"button",
|
|
1141
|
+
{
|
|
1142
|
+
type: "button",
|
|
1143
|
+
className: cx("fw-link"),
|
|
1144
|
+
disabled: isPending,
|
|
1145
|
+
onClick: () => void unfollow(),
|
|
1146
|
+
children: labels.following
|
|
1147
|
+
}
|
|
1148
|
+
),
|
|
1149
|
+
error && /* @__PURE__ */ jsx4("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
|
|
1150
|
+
] });
|
|
1114
1151
|
}
|
|
1115
1152
|
if (!isOpen) {
|
|
1116
1153
|
return /* @__PURE__ */ jsx4("button", { type: "button", className: cx("fw-link"), onClick: () => setIsOpen(true), children: labels.follow });
|
|
1117
1154
|
}
|
|
1155
|
+
const isEmailValid = EMAIL_PATTERN.test(email.trim());
|
|
1118
1156
|
return /* @__PURE__ */ jsxs2(
|
|
1119
1157
|
"form",
|
|
1120
1158
|
{
|
|
@@ -1138,7 +1176,8 @@ function FollowForm({ feature }) {
|
|
|
1138
1176
|
required: true
|
|
1139
1177
|
}
|
|
1140
1178
|
),
|
|
1141
|
-
/* @__PURE__ */ jsx4("
|
|
1179
|
+
email !== "" && !isEmailValid && /* @__PURE__ */ jsx4("p", { className: cx("fw-error"), children: labels.fieldEmailInvalid }),
|
|
1180
|
+
/* @__PURE__ */ jsx4("button", { type: "submit", className: cx("fw-btn"), disabled: isPending || !isEmailValid, children: labels.follow }),
|
|
1142
1181
|
error && /* @__PURE__ */ jsx4("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
|
|
1143
1182
|
]
|
|
1144
1183
|
}
|
|
@@ -1181,7 +1220,7 @@ function FeatureCard({
|
|
|
1181
1220
|
defaultCommentsOpen = false
|
|
1182
1221
|
}) {
|
|
1183
1222
|
const { labels, allowComments, cx } = useBoard();
|
|
1184
|
-
const { isPending, toggle } = useVote(feature.id);
|
|
1223
|
+
const { isPending, toggle, error: voteError } = useVote(feature.id);
|
|
1185
1224
|
const [showComments, setShowComments] = useState7(defaultCommentsOpen);
|
|
1186
1225
|
return /* @__PURE__ */ jsxs4("li", { className: cx("fw-card"), children: [
|
|
1187
1226
|
/* @__PURE__ */ jsx7(VoteButton, { feature, isPending, onToggle: () => void toggle() }),
|
|
@@ -1209,6 +1248,7 @@ function FeatureCard({
|
|
|
1209
1248
|
),
|
|
1210
1249
|
/* @__PURE__ */ jsx7(FollowForm, { feature })
|
|
1211
1250
|
] }),
|
|
1251
|
+
voteError && /* @__PURE__ */ jsx7("p", { className: cx("fw-error"), role: "alert", children: userMessage(voteError) }),
|
|
1212
1252
|
allowComments && showComments && /* @__PURE__ */ jsx7("div", { id: `fw-comments-${feature.id}`, children: /* @__PURE__ */ jsx7(CommentList, { featureId: feature.id }) })
|
|
1213
1253
|
] })
|
|
1214
1254
|
] });
|
|
@@ -1248,24 +1288,42 @@ function Bar({ width, height }) {
|
|
|
1248
1288
|
}
|
|
1249
1289
|
|
|
1250
1290
|
// src/components/FeatureList.tsx
|
|
1251
|
-
import { Fragment, jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
1291
|
+
import { Fragment as Fragment2, jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
1252
1292
|
function errorCopy(error, labels) {
|
|
1253
|
-
if (error
|
|
1254
|
-
return { title: labels.
|
|
1293
|
+
if (isNotConfigured(error)) {
|
|
1294
|
+
return { title: labels.errorNotSetUpTitle, detail: labels.errorNotSetUpDetail };
|
|
1295
|
+
}
|
|
1296
|
+
if (error.code === "network") {
|
|
1297
|
+
return { title: labels.errorOfflineTitle, detail: labels.errorOfflineDetail };
|
|
1298
|
+
}
|
|
1299
|
+
if (isWrongApp(error)) {
|
|
1300
|
+
return {
|
|
1301
|
+
title: labels.errorWrongAppTitle,
|
|
1302
|
+
detail: labels.errorWrongAppDetail || userMessage(error)
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
if (error.code === "invalid_key") {
|
|
1306
|
+
return { title: labels.errorInvalidKeyTitle, detail: labels.errorInvalidKeyDetail };
|
|
1255
1307
|
}
|
|
1256
|
-
return
|
|
1308
|
+
return {
|
|
1309
|
+
title: labels.errorTitle,
|
|
1310
|
+
detail: error.code === "server" ? labels.errorDetail : userMessage(error)
|
|
1311
|
+
};
|
|
1257
1312
|
}
|
|
1258
1313
|
function FeatureList() {
|
|
1259
1314
|
const { initialFeatureId } = useBoard();
|
|
1260
|
-
return /* @__PURE__ */ jsxs6(
|
|
1315
|
+
return /* @__PURE__ */ jsxs6(Fragment2, { children: [
|
|
1261
1316
|
initialFeatureId && /* @__PURE__ */ jsx9(PinnedFeature, { featureId: initialFeatureId }),
|
|
1262
1317
|
/* @__PURE__ */ jsx9(Listing, { excludeId: initialFeatureId })
|
|
1263
1318
|
] });
|
|
1264
1319
|
}
|
|
1265
1320
|
function PinnedFeature({ featureId }) {
|
|
1266
1321
|
const { cx } = useBoard();
|
|
1267
|
-
const { feature, isLoading } = useFeature(featureId);
|
|
1322
|
+
const { feature, isLoading, error } = useFeature(featureId);
|
|
1268
1323
|
if (isLoading) return /* @__PURE__ */ jsx9(FeatureListSkeleton, { rows: 1 });
|
|
1324
|
+
if (!feature && error) {
|
|
1325
|
+
return /* @__PURE__ */ jsx9("p", { className: cx("fw-error", "fw-pinned"), role: "alert", children: userMessage(error) });
|
|
1326
|
+
}
|
|
1269
1327
|
if (!feature) return null;
|
|
1270
1328
|
return /* @__PURE__ */ jsx9("div", { className: cx("fw-pinned"), children: /* @__PURE__ */ jsx9("ul", { className: cx("fw-list"), children: /* @__PURE__ */ jsx9(FeatureCard, { feature, defaultCommentsOpen: true }) }) });
|
|
1271
1329
|
}
|
|
@@ -1288,22 +1346,26 @@ function Listing({ excludeId }) {
|
|
|
1288
1346
|
return /* @__PURE__ */ jsxs6("div", { className: cx("fw-empty"), role: "alert", children: [
|
|
1289
1347
|
/* @__PURE__ */ jsx9("p", { className: cx("fw-empty__title"), children: title }),
|
|
1290
1348
|
/* @__PURE__ */ jsx9("p", { className: cx("fw-empty__detail"), children: detail }),
|
|
1291
|
-
/* @__PURE__ */ jsx9("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
|
|
1349
|
+
!isNotConfigured(error) && /* @__PURE__ */ jsx9("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
|
|
1292
1350
|
] });
|
|
1293
1351
|
}
|
|
1294
1352
|
if (isLoading && visible.length === 0) {
|
|
1295
1353
|
return /* @__PURE__ */ jsx9(FeatureListSkeleton, { roadmap: isRoadmap });
|
|
1296
1354
|
}
|
|
1297
1355
|
if (visible.length === 0) {
|
|
1298
|
-
if (renderEmpty) return /* @__PURE__ */ jsx9(
|
|
1356
|
+
if (renderEmpty) return /* @__PURE__ */ jsx9(Fragment2, { children: renderEmpty() });
|
|
1299
1357
|
return /* @__PURE__ */ jsxs6("div", { className: cx("fw-empty"), children: [
|
|
1300
1358
|
/* @__PURE__ */ jsx9("p", { className: cx("fw-empty__title"), children: labels.emptyTitle }),
|
|
1301
1359
|
/* @__PURE__ */ jsx9("p", { children: labels.emptyBody })
|
|
1302
1360
|
] });
|
|
1303
1361
|
}
|
|
1304
|
-
return /* @__PURE__ */ jsxs6(
|
|
1362
|
+
return /* @__PURE__ */ jsxs6(Fragment2, { children: [
|
|
1305
1363
|
isRoadmap ? /* @__PURE__ */ jsx9(RoadmapColumns, { features: visible }) : /* @__PURE__ */ jsx9(Rows2, { features: visible }),
|
|
1306
|
-
hasMore && /* @__PURE__ */
|
|
1364
|
+
hasMore && error && /* @__PURE__ */ jsxs6("div", { className: cx("fw-pagination"), role: "alert", children: [
|
|
1365
|
+
/* @__PURE__ */ jsx9("span", { children: labels.errorLoadMore }),
|
|
1366
|
+
/* @__PURE__ */ jsx9("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void loadMore(), children: labels.retry })
|
|
1367
|
+
] }),
|
|
1368
|
+
hasMore && !error && /* @__PURE__ */ jsx9("div", { className: cx("fw-pagination"), children: /* @__PURE__ */ jsx9(
|
|
1307
1369
|
"button",
|
|
1308
1370
|
{
|
|
1309
1371
|
type: "button",
|
|
@@ -1346,7 +1408,7 @@ function FeatureSlot({ feature }) {
|
|
|
1346
1408
|
const { renderFeature } = useBoard();
|
|
1347
1409
|
const { toggle } = useVote(feature.id);
|
|
1348
1410
|
const { follow, unfollow } = useFollow(feature.id);
|
|
1349
|
-
return /* @__PURE__ */ jsx9(
|
|
1411
|
+
return /* @__PURE__ */ jsx9(Fragment2, { children: renderFeature?.(feature, { toggleVote: toggle, follow, unfollow }) });
|
|
1350
1412
|
}
|
|
1351
1413
|
|
|
1352
1414
|
// src/components/SubmitForm.tsx
|
|
@@ -1379,11 +1441,24 @@ function useSubmitFeature() {
|
|
|
1379
1441
|
// src/components/SubmitForm.tsx
|
|
1380
1442
|
import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
1381
1443
|
function SubmitForm() {
|
|
1382
|
-
const {
|
|
1444
|
+
const {
|
|
1445
|
+
labels,
|
|
1446
|
+
emailField,
|
|
1447
|
+
userEmail,
|
|
1448
|
+
isSubscriber,
|
|
1449
|
+
isFormOpen,
|
|
1450
|
+
setFormOpen,
|
|
1451
|
+
setJustSubmitted,
|
|
1452
|
+
cx
|
|
1453
|
+
} = useBoard();
|
|
1383
1454
|
const { submit, isPending, error } = useSubmitFeature();
|
|
1384
1455
|
const [title, setTitle] = useState9("");
|
|
1385
1456
|
const [description, setDescription] = useState9("");
|
|
1386
1457
|
const [email, setEmail] = useState9(userEmail ?? "");
|
|
1458
|
+
const isTitleValid = title.trim().length >= LIMITS.titleMin;
|
|
1459
|
+
const isEmailInvalid = emailField !== "off" && email !== "" && !EMAIL_PATTERN.test(email.trim());
|
|
1460
|
+
const isEmailMissing = emailField === "required" && !email.trim();
|
|
1461
|
+
const canSubmit = isTitleValid && !isEmailInvalid && !isEmailMissing;
|
|
1387
1462
|
return /* @__PURE__ */ jsxs7(
|
|
1388
1463
|
"form",
|
|
1389
1464
|
{
|
|
@@ -1397,8 +1472,10 @@ function SubmitForm() {
|
|
|
1397
1472
|
isSubscriber
|
|
1398
1473
|
});
|
|
1399
1474
|
if (!created) return;
|
|
1400
|
-
|
|
1401
|
-
|
|
1475
|
+
if (!isFormOpen) {
|
|
1476
|
+
setTitle("");
|
|
1477
|
+
setDescription("");
|
|
1478
|
+
}
|
|
1402
1479
|
setJustSubmitted(true);
|
|
1403
1480
|
setFormOpen(false);
|
|
1404
1481
|
},
|
|
@@ -1417,7 +1494,15 @@ function SubmitForm() {
|
|
|
1417
1494
|
onChange: (event) => setTitle(event.target.value),
|
|
1418
1495
|
required: true
|
|
1419
1496
|
}
|
|
1420
|
-
)
|
|
1497
|
+
),
|
|
1498
|
+
/* @__PURE__ */ jsxs7("div", { className: cx("fw-field__foot"), children: [
|
|
1499
|
+
/* @__PURE__ */ jsx10("span", { className: cx("fw-error"), children: title !== "" && !isTitleValid ? labels.fieldTitleTooShort : "" }),
|
|
1500
|
+
/* @__PURE__ */ jsxs7("span", { className: cx("fw-muted"), children: [
|
|
1501
|
+
title.length,
|
|
1502
|
+
"/",
|
|
1503
|
+
LIMITS.titleMax
|
|
1504
|
+
] })
|
|
1505
|
+
] })
|
|
1421
1506
|
] }),
|
|
1422
1507
|
/* @__PURE__ */ jsxs7("div", { className: cx("fw-field"), children: [
|
|
1423
1508
|
/* @__PURE__ */ jsx10("label", { className: cx("fw-label"), htmlFor: "fw-description", children: labels.fieldDescription }),
|
|
@@ -1431,7 +1516,15 @@ function SubmitForm() {
|
|
|
1431
1516
|
placeholder: labels.fieldDescriptionPlaceholder,
|
|
1432
1517
|
onChange: (event) => setDescription(event.target.value)
|
|
1433
1518
|
}
|
|
1434
|
-
)
|
|
1519
|
+
),
|
|
1520
|
+
/* @__PURE__ */ jsxs7("div", { className: cx("fw-field__foot"), children: [
|
|
1521
|
+
/* @__PURE__ */ jsx10("span", {}),
|
|
1522
|
+
/* @__PURE__ */ jsxs7("span", { className: cx("fw-muted"), children: [
|
|
1523
|
+
description.length,
|
|
1524
|
+
"/",
|
|
1525
|
+
LIMITS.descriptionMax
|
|
1526
|
+
] })
|
|
1527
|
+
] })
|
|
1435
1528
|
] }),
|
|
1436
1529
|
emailField !== "off" && /* @__PURE__ */ jsxs7("div", { className: cx("fw-field"), children: [
|
|
1437
1530
|
/* @__PURE__ */ jsx10("label", { className: cx("fw-label"), htmlFor: "fw-email", children: labels.fieldEmail }),
|
|
@@ -1448,11 +1541,11 @@ function SubmitForm() {
|
|
|
1448
1541
|
required: emailField === "required"
|
|
1449
1542
|
}
|
|
1450
1543
|
),
|
|
1451
|
-
/* @__PURE__ */ jsx10("p", { id: "fw-email-hint", className: cx("fw-muted"), children: labels.fieldEmailHint })
|
|
1544
|
+
isEmailInvalid ? /* @__PURE__ */ jsx10("p", { id: "fw-email-hint", className: cx("fw-error"), children: labels.fieldEmailInvalid }) : /* @__PURE__ */ jsx10("p", { id: "fw-email-hint", className: cx("fw-muted"), children: labels.fieldEmailHint })
|
|
1452
1545
|
] }),
|
|
1453
1546
|
error && /* @__PURE__ */ jsx10("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
|
|
1454
1547
|
/* @__PURE__ */ jsxs7("div", { className: cx("fw-form__actions"), children: [
|
|
1455
|
-
/* @__PURE__ */ jsx10("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: isPending ? labels.submitting : labels.submit }),
|
|
1548
|
+
/* @__PURE__ */ jsx10("button", { type: "submit", className: cx("fw-btn"), disabled: isPending || !canSubmit, children: isPending ? labels.submitting : labels.submit }),
|
|
1456
1549
|
/* @__PURE__ */ jsx10(
|
|
1457
1550
|
"button",
|
|
1458
1551
|
{
|
|
@@ -1490,7 +1583,7 @@ function themeToStyle(theme) {
|
|
|
1490
1583
|
}
|
|
1491
1584
|
|
|
1492
1585
|
// src/components/Board.tsx
|
|
1493
|
-
import { Fragment as
|
|
1586
|
+
import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
|
|
1494
1587
|
var DEFAULT_TABS = ["top", "new"];
|
|
1495
1588
|
function BoardRoot({
|
|
1496
1589
|
tabs = DEFAULT_TABS,
|
|
@@ -1576,19 +1669,92 @@ function BoardRoot({
|
|
|
1576
1669
|
) });
|
|
1577
1670
|
}
|
|
1578
1671
|
function DefaultBoard() {
|
|
1579
|
-
const {
|
|
1580
|
-
return /* @__PURE__ */ jsxs8(
|
|
1672
|
+
const { renderHeader } = useBoard();
|
|
1673
|
+
return /* @__PURE__ */ jsxs8(Fragment3, { children: [
|
|
1581
1674
|
renderHeader ? renderHeader() : /* @__PURE__ */ jsx11(BoardHeading, {}),
|
|
1582
|
-
/* @__PURE__ */
|
|
1583
|
-
/* @__PURE__ */ jsx11(BoardTabs, {}),
|
|
1584
|
-
/* @__PURE__ */ jsx11(SubmitTrigger, {})
|
|
1585
|
-
] }),
|
|
1586
|
-
justSubmitted && /* @__PURE__ */ jsx11("p", { className: cx("fw-alert"), role: "status", children: labels.submitted }),
|
|
1587
|
-
isFormOpen && /* @__PURE__ */ jsx11(SubmitForm, {}),
|
|
1588
|
-
/* @__PURE__ */ jsx11(FeatureList, {}),
|
|
1675
|
+
/* @__PURE__ */ jsx11(BoardPanels, {}),
|
|
1589
1676
|
/* @__PURE__ */ jsx11(Branding, {})
|
|
1590
1677
|
] });
|
|
1591
1678
|
}
|
|
1679
|
+
var LEAVE_MS = 110;
|
|
1680
|
+
var ENTER_MS = 300;
|
|
1681
|
+
var ENTER_EASING = "cubic-bezier(0.22, 1, 0.36, 1)";
|
|
1682
|
+
function canAnimate(element) {
|
|
1683
|
+
if (!element || typeof element.animate !== "function") return false;
|
|
1684
|
+
return !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
1685
|
+
}
|
|
1686
|
+
function BoardPanels() {
|
|
1687
|
+
const { isFormOpen, setFormOpen, justSubmitted, setJustSubmitted, labels, unstyled, cx } = useBoard();
|
|
1688
|
+
const [showForm, setShowForm] = useState10(isFormOpen);
|
|
1689
|
+
const panel = useRef2(null);
|
|
1690
|
+
const trigger = useRef2(null);
|
|
1691
|
+
const entering = useRef2(null);
|
|
1692
|
+
const hasSwapped = useRef2(false);
|
|
1693
|
+
useEffect6(() => {
|
|
1694
|
+
if (isFormOpen === showForm) return;
|
|
1695
|
+
hasSwapped.current = true;
|
|
1696
|
+
const element = panel.current;
|
|
1697
|
+
if (unstyled || !canAnimate(element)) {
|
|
1698
|
+
setShowForm(isFormOpen);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
entering.current?.cancel();
|
|
1702
|
+
const leaving = element.animate(
|
|
1703
|
+
{ opacity: [1, 0], transform: ["translateY(0)", "translateY(-4px)"] },
|
|
1704
|
+
{ duration: LEAVE_MS, easing: "ease-in", fill: "forwards" }
|
|
1705
|
+
);
|
|
1706
|
+
leaving.onfinish = () => {
|
|
1707
|
+
const from = element.offsetHeight;
|
|
1708
|
+
flushSync(() => setShowForm(isFormOpen));
|
|
1709
|
+
leaving.cancel();
|
|
1710
|
+
const to = element.offsetHeight;
|
|
1711
|
+
element.style.overflow = "hidden";
|
|
1712
|
+
const enter = element.animate(
|
|
1713
|
+
{
|
|
1714
|
+
opacity: [0, 1],
|
|
1715
|
+
transform: ["translateY(6px)", "translateY(0)"],
|
|
1716
|
+
height: [`${from}px`, `${to}px`]
|
|
1717
|
+
},
|
|
1718
|
+
{ duration: ENTER_MS, easing: ENTER_EASING }
|
|
1719
|
+
);
|
|
1720
|
+
const finish = () => {
|
|
1721
|
+
element.style.overflow = "";
|
|
1722
|
+
};
|
|
1723
|
+
enter.onfinish = finish;
|
|
1724
|
+
enter.oncancel = finish;
|
|
1725
|
+
entering.current = enter;
|
|
1726
|
+
};
|
|
1727
|
+
return () => leaving.cancel();
|
|
1728
|
+
}, [isFormOpen, showForm, unstyled]);
|
|
1729
|
+
useEffect6(() => {
|
|
1730
|
+
if (!hasSwapped.current) return;
|
|
1731
|
+
const target = showForm ? panel.current?.querySelector("#fw-title") : trigger.current;
|
|
1732
|
+
target?.focus({ preventScroll: true });
|
|
1733
|
+
}, [showForm]);
|
|
1734
|
+
return /* @__PURE__ */ jsxs8("div", { ref: panel, children: [
|
|
1735
|
+
/* @__PURE__ */ jsxs8("div", { hidden: showForm, children: [
|
|
1736
|
+
/* @__PURE__ */ jsxs8("div", { className: cx("fw-toolbar"), children: [
|
|
1737
|
+
/* @__PURE__ */ jsx11(BoardTabs, {}),
|
|
1738
|
+
/* @__PURE__ */ jsx11(
|
|
1739
|
+
"button",
|
|
1740
|
+
{
|
|
1741
|
+
ref: trigger,
|
|
1742
|
+
type: "button",
|
|
1743
|
+
className: cx("fw-btn"),
|
|
1744
|
+
onClick: () => {
|
|
1745
|
+
setJustSubmitted(false);
|
|
1746
|
+
setFormOpen(true);
|
|
1747
|
+
},
|
|
1748
|
+
children: labels.newRequest
|
|
1749
|
+
}
|
|
1750
|
+
)
|
|
1751
|
+
] }),
|
|
1752
|
+
justSubmitted && /* @__PURE__ */ jsx11("p", { className: cx("fw-alert"), role: "status", children: labels.submitted }),
|
|
1753
|
+
/* @__PURE__ */ jsx11(FeatureList, {})
|
|
1754
|
+
] }),
|
|
1755
|
+
showForm && /* @__PURE__ */ jsx11(SubmitForm, {})
|
|
1756
|
+
] });
|
|
1757
|
+
}
|
|
1592
1758
|
function BoardHeading() {
|
|
1593
1759
|
const { labels, cx } = useBoard();
|
|
1594
1760
|
return /* @__PURE__ */ jsx11("h2", { className: cx("fw-heading"), children: labels.title });
|
|
@@ -1652,7 +1818,7 @@ var FewchursBoard = Object.assign(BoardRoot, {
|
|
|
1652
1818
|
});
|
|
1653
1819
|
|
|
1654
1820
|
// src/components/BoardDialog.tsx
|
|
1655
|
-
import { useEffect as useEffect7, useRef as
|
|
1821
|
+
import { useEffect as useEffect7, useRef as useRef3 } from "react";
|
|
1656
1822
|
import { jsx as jsx12, jsxs as jsxs9 } from "react/jsx-runtime";
|
|
1657
1823
|
function FewchursBoardDialog({
|
|
1658
1824
|
open,
|
|
@@ -1661,8 +1827,8 @@ function FewchursBoardDialog({
|
|
|
1661
1827
|
variant = "dialog",
|
|
1662
1828
|
...boardProps
|
|
1663
1829
|
}) {
|
|
1664
|
-
const dialogRef =
|
|
1665
|
-
const openerRef =
|
|
1830
|
+
const dialogRef = useRef3(null);
|
|
1831
|
+
const openerRef = useRef3(null);
|
|
1666
1832
|
useEffect7(() => {
|
|
1667
1833
|
if (!open) return;
|
|
1668
1834
|
openerRef.current = document.activeElement;
|
|
@@ -1701,7 +1867,7 @@ function FewchursBoardDialog({
|
|
|
1701
1867
|
// src/components/BoardButton.tsx
|
|
1702
1868
|
import { useCallback as useCallback7, useState as useState11 } from "react";
|
|
1703
1869
|
import { createPortal } from "react-dom";
|
|
1704
|
-
import { Fragment as
|
|
1870
|
+
import { Fragment as Fragment4, jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
|
|
1705
1871
|
function launcherClasses(floating) {
|
|
1706
1872
|
if (!floating) return "fw-launcher";
|
|
1707
1873
|
const side = floating === "left" ? " fw-launcher--left" : "";
|
|
@@ -1718,7 +1884,7 @@ function FewchursButton({
|
|
|
1718
1884
|
const [open, setOpen] = useState11(false);
|
|
1719
1885
|
const close = useCallback7(() => setOpen(false), []);
|
|
1720
1886
|
const classes = [board.unstyled ? "" : launcherClasses(floating), className].filter(Boolean).join(" ");
|
|
1721
|
-
return /* @__PURE__ */ jsxs10(
|
|
1887
|
+
return /* @__PURE__ */ jsxs10(Fragment4, { children: [
|
|
1722
1888
|
/* @__PURE__ */ jsx13(
|
|
1723
1889
|
"button",
|
|
1724
1890
|
{
|
|
@@ -1764,6 +1930,7 @@ export {
|
|
|
1764
1930
|
FewchursProvider,
|
|
1765
1931
|
FollowForm,
|
|
1766
1932
|
LIMITS,
|
|
1933
|
+
NOT_CONFIGURED_MESSAGE,
|
|
1767
1934
|
ROADMAP_STATUSES,
|
|
1768
1935
|
SDK_HEADER,
|
|
1769
1936
|
SDK_VERSION,
|