@fewchurs/react 0.0.0-stage → 1.0.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/LICENSE +41 -0
- package/README.md +25 -2
- package/dist/core.d.mts +213 -0
- package/dist/core.d.ts +213 -0
- package/dist/core.js +425 -0
- package/dist/core.js.map +1 -0
- package/dist/core.mjs +375 -0
- package/dist/core.mjs.map +1 -0
- package/dist/index.d.mts +523 -0
- package/dist/index.d.ts +523 -0
- package/dist/index.js +1823 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1742 -0
- package/dist/index.mjs.map +1 -0
- package/dist/styles.css +484 -0
- package/package.json +113 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1823 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/index.ts
|
|
22
|
+
var src_exports = {};
|
|
23
|
+
__export(src_exports, {
|
|
24
|
+
BoardRoot: () => BoardRoot,
|
|
25
|
+
BoardTabs: () => BoardTabs,
|
|
26
|
+
Branding: () => Branding,
|
|
27
|
+
CommentForm: () => CommentForm,
|
|
28
|
+
CommentList: () => CommentList,
|
|
29
|
+
DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
|
|
30
|
+
DEFAULT_LABELS: () => DEFAULT_LABELS,
|
|
31
|
+
DEFAULT_PAGE_SIZE: () => DEFAULT_PAGE_SIZE,
|
|
32
|
+
DEVICE_ID_COOKIE: () => COOKIE_NAME,
|
|
33
|
+
FeatureCard: () => FeatureCard,
|
|
34
|
+
FeatureList: () => FeatureList,
|
|
35
|
+
FewchursBoard: () => FewchursBoard,
|
|
36
|
+
FewchursBoardDialog: () => FewchursBoardDialog,
|
|
37
|
+
FewchursProvider: () => FewchursProvider,
|
|
38
|
+
FollowForm: () => FollowForm,
|
|
39
|
+
LIMITS: () => LIMITS,
|
|
40
|
+
ROADMAP_STATUSES: () => ROADMAP_STATUSES,
|
|
41
|
+
SDK_HEADER: () => SDK_HEADER,
|
|
42
|
+
SDK_VERSION: () => SDK_VERSION,
|
|
43
|
+
StatusBadge: () => StatusBadge,
|
|
44
|
+
SubmitForm: () => SubmitForm,
|
|
45
|
+
SubmitTrigger: () => SubmitTrigger,
|
|
46
|
+
VoteButton: () => VoteButton,
|
|
47
|
+
commentsStateOf: () => commentsStateOf,
|
|
48
|
+
createDeviceId: () => createDeviceId,
|
|
49
|
+
createFewchursClient: () => createFewchursClient,
|
|
50
|
+
createStore: () => createStore,
|
|
51
|
+
errorFromResponse: () => errorFromResponse,
|
|
52
|
+
featureKey: () => featureKey,
|
|
53
|
+
listKey: () => listKey,
|
|
54
|
+
listStateOf: () => listStateOf,
|
|
55
|
+
mergeLabels: () => mergeLabels,
|
|
56
|
+
networkError: () => networkError,
|
|
57
|
+
normalizeComment: () => normalizeComment,
|
|
58
|
+
normalizeCommentPage: () => normalizeCommentPage,
|
|
59
|
+
normalizeConfig: () => normalizeConfig,
|
|
60
|
+
normalizeFeature: () => normalizeFeature,
|
|
61
|
+
normalizeFeaturePage: () => normalizeFeaturePage,
|
|
62
|
+
normalizeStatus: () => normalizeStatus,
|
|
63
|
+
normalizeVote: () => normalizeVote,
|
|
64
|
+
readDeviceId: () => readDeviceId,
|
|
65
|
+
resolveDeviceId: () => resolveDeviceId,
|
|
66
|
+
useBoard: () => useBoard,
|
|
67
|
+
useComments: () => useComments,
|
|
68
|
+
useFeature: () => useFeature,
|
|
69
|
+
useFeatures: () => useFeatures,
|
|
70
|
+
useFewchurs: () => useFewchurs,
|
|
71
|
+
useFewchursConfig: () => useFewchursConfig,
|
|
72
|
+
useFollow: () => useFollow,
|
|
73
|
+
useSubmitFeature: () => useSubmitFeature,
|
|
74
|
+
useVote: () => useVote,
|
|
75
|
+
userMessage: () => userMessage,
|
|
76
|
+
validateCommentInput: () => validateCommentInput,
|
|
77
|
+
validateEmail: () => validateEmail,
|
|
78
|
+
validateFeatureInput: () => validateFeatureInput,
|
|
79
|
+
validationError: () => validationError
|
|
80
|
+
});
|
|
81
|
+
module.exports = __toCommonJS(src_exports);
|
|
82
|
+
|
|
83
|
+
// src/context/provider.tsx
|
|
84
|
+
var import_react = require("react");
|
|
85
|
+
|
|
86
|
+
// src/client/version.ts
|
|
87
|
+
var SDK_VERSION = "1.0.0";
|
|
88
|
+
|
|
89
|
+
// src/client/constants.ts
|
|
90
|
+
var SDK_HEADER = `react/${SDK_VERSION}`;
|
|
91
|
+
var DEFAULT_BASE_URL = "https://www.fewchurs.shop/api/v1";
|
|
92
|
+
var DEFAULT_PAGE_SIZE = 20;
|
|
93
|
+
var LIMITS = {
|
|
94
|
+
titleMin: 3,
|
|
95
|
+
titleMax: 100,
|
|
96
|
+
descriptionMax: 1e3,
|
|
97
|
+
commentMin: 1,
|
|
98
|
+
commentMax: 1e3,
|
|
99
|
+
authorNameMax: 60
|
|
100
|
+
};
|
|
101
|
+
var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
102
|
+
var ROADMAP_STATUSES = ["planned", "in_progress", "done"];
|
|
103
|
+
|
|
104
|
+
// src/client/errors.ts
|
|
105
|
+
var USER_MESSAGES = {
|
|
106
|
+
invalid_key: "Feedback is unavailable right now.",
|
|
107
|
+
validation_failed: "Please check what you entered and try again.",
|
|
108
|
+
rate_limited: "You have reached today's limit. Try again tomorrow.",
|
|
109
|
+
not_found: "That request no longer exists.",
|
|
110
|
+
limit_reached: "Feedback is unavailable right now.",
|
|
111
|
+
server: "Something went wrong. Please try again.",
|
|
112
|
+
network: "Could not reach the server. Check your connection."
|
|
113
|
+
};
|
|
114
|
+
function userMessage(error) {
|
|
115
|
+
if (error.code === "validation_failed" && error.message) return error.message;
|
|
116
|
+
return USER_MESSAGES[error.code];
|
|
117
|
+
}
|
|
118
|
+
function networkError(message = "Network request failed.") {
|
|
119
|
+
return { code: "network", message };
|
|
120
|
+
}
|
|
121
|
+
function validationError(message) {
|
|
122
|
+
return { code: "validation_failed", status: 400, message };
|
|
123
|
+
}
|
|
124
|
+
function errorFromResponse(status, body) {
|
|
125
|
+
const code = body?.error?.code;
|
|
126
|
+
const message = body?.error?.message ?? `Request failed (${status}).`;
|
|
127
|
+
switch (code) {
|
|
128
|
+
case "invalid_key":
|
|
129
|
+
return { code: "invalid_key", status: 401, message };
|
|
130
|
+
// missing_device_id is a client mistake the caller fixes the same way a bad field is fixed,
|
|
131
|
+
// so it collapses into validation_failed rather than widening the public union.
|
|
132
|
+
case "missing_device_id":
|
|
133
|
+
case "validation_failed":
|
|
134
|
+
return { code: "validation_failed", status: 400, message };
|
|
135
|
+
case "rate_limited":
|
|
136
|
+
return { code: "rate_limited", status: 429, message };
|
|
137
|
+
case "not_found":
|
|
138
|
+
return { code: "not_found", status: 404, message };
|
|
139
|
+
case "limit_reached":
|
|
140
|
+
return { code: "limit_reached", status: 402, message };
|
|
141
|
+
default:
|
|
142
|
+
return status === 401 ? { code: "invalid_key", status: 401, message } : { code: "server", status, message };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/client/normalize.ts
|
|
147
|
+
var KNOWN_STATUSES = /* @__PURE__ */ new Set(["open", "planned", "in_progress", "done", "declined"]);
|
|
148
|
+
function asRecord(value) {
|
|
149
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
150
|
+
}
|
|
151
|
+
function str(value, fallback = "") {
|
|
152
|
+
return typeof value === "string" ? value : fallback;
|
|
153
|
+
}
|
|
154
|
+
function num(value) {
|
|
155
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
156
|
+
}
|
|
157
|
+
function bool(value) {
|
|
158
|
+
return value === true;
|
|
159
|
+
}
|
|
160
|
+
function normalizeStatus(value) {
|
|
161
|
+
return typeof value === "string" && KNOWN_STATUSES.has(value) ? value : "unknown";
|
|
162
|
+
}
|
|
163
|
+
function isMine(raw, ownerKey, deviceId) {
|
|
164
|
+
if (typeof raw.isMine === "boolean") return raw.isMine;
|
|
165
|
+
const owner = raw[ownerKey];
|
|
166
|
+
return typeof owner === "string" && owner.length > 0 && owner === deviceId;
|
|
167
|
+
}
|
|
168
|
+
function normalizeFeature(value, deviceId) {
|
|
169
|
+
const raw = asRecord(value);
|
|
170
|
+
return {
|
|
171
|
+
id: str(raw.id),
|
|
172
|
+
title: str(raw.title),
|
|
173
|
+
description: str(raw.description),
|
|
174
|
+
status: normalizeStatus(raw.status),
|
|
175
|
+
upvoteCount: num(raw.upvoteCount),
|
|
176
|
+
commentCount: num(raw.commentCount),
|
|
177
|
+
followerCount: num(raw.followerCount),
|
|
178
|
+
hasVoted: bool(raw.hasVoted),
|
|
179
|
+
isFollowing: bool(raw.isFollowing),
|
|
180
|
+
isMine: isMine(raw, "authorDeviceId", deviceId),
|
|
181
|
+
createdAt: str(raw.createdAt),
|
|
182
|
+
updatedAt: str(raw.updatedAt, str(raw.createdAt))
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function normalizeComment(value, deviceId) {
|
|
186
|
+
const raw = asRecord(value);
|
|
187
|
+
return {
|
|
188
|
+
id: str(raw.id),
|
|
189
|
+
text: str(raw.text),
|
|
190
|
+
authorName: str(raw.authorName, "App User"),
|
|
191
|
+
isDeveloper: bool(raw.isDeveloper),
|
|
192
|
+
isMine: isMine(raw, "deviceId", deviceId),
|
|
193
|
+
createdAt: str(raw.createdAt)
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function normalizeFeaturePage(value, deviceId) {
|
|
197
|
+
const raw = asRecord(value);
|
|
198
|
+
const features = Array.isArray(raw.features) ? raw.features : [];
|
|
199
|
+
return {
|
|
200
|
+
features: features.map((feature) => normalizeFeature(feature, deviceId)),
|
|
201
|
+
nextCursor: typeof raw.nextCursor === "string" ? raw.nextCursor : null
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function normalizeCommentPage(value, deviceId) {
|
|
205
|
+
const raw = asRecord(value);
|
|
206
|
+
const comments = Array.isArray(raw.comments) ? raw.comments : [];
|
|
207
|
+
return {
|
|
208
|
+
comments: comments.map((comment) => normalizeComment(comment, deviceId)),
|
|
209
|
+
nextCursor: typeof raw.nextCursor === "string" ? raw.nextCursor : null
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function normalizeConfig(value) {
|
|
213
|
+
const raw = asRecord(value);
|
|
214
|
+
return {
|
|
215
|
+
showBranding: raw.showBranding !== false,
|
|
216
|
+
hideVoteCounts: bool(raw.hideVoteCounts),
|
|
217
|
+
appName: typeof raw.appName === "string" && raw.appName ? raw.appName : null
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function normalizeVote(value) {
|
|
221
|
+
const raw = asRecord(value);
|
|
222
|
+
return { upvoteCount: num(raw.upvoteCount), hasVoted: bool(raw.hasVoted) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/client/validate.ts
|
|
226
|
+
function validateFeatureInput(input) {
|
|
227
|
+
const title = input.title.trim();
|
|
228
|
+
if (title.length < LIMITS.titleMin) {
|
|
229
|
+
return validationError(`Title must be at least ${LIMITS.titleMin} characters.`);
|
|
230
|
+
}
|
|
231
|
+
if (title.length > LIMITS.titleMax) {
|
|
232
|
+
return validationError(`Title must be at most ${LIMITS.titleMax} characters.`);
|
|
233
|
+
}
|
|
234
|
+
if ((input.description ?? "").trim().length > LIMITS.descriptionMax) {
|
|
235
|
+
return validationError(`Description must be at most ${LIMITS.descriptionMax} characters.`);
|
|
236
|
+
}
|
|
237
|
+
if (input.email !== void 0 && input.email !== "" && !isEmail(input.email)) {
|
|
238
|
+
return validationError("Enter a valid email address.");
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
function validateCommentInput(input) {
|
|
243
|
+
const text = input.text.trim();
|
|
244
|
+
if (text.length < LIMITS.commentMin) return validationError("Write a comment first.");
|
|
245
|
+
if (text.length > LIMITS.commentMax) {
|
|
246
|
+
return validationError(`Comment must be at most ${LIMITS.commentMax} characters.`);
|
|
247
|
+
}
|
|
248
|
+
if ((input.authorName ?? "").trim().length > LIMITS.authorNameMax) {
|
|
249
|
+
return validationError(`Name must be at most ${LIMITS.authorNameMax} characters.`);
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
function validateEmail(email) {
|
|
254
|
+
return isEmail(email) ? null : validationError("Enter a valid email address.");
|
|
255
|
+
}
|
|
256
|
+
function isEmail(email) {
|
|
257
|
+
return EMAIL_PATTERN.test(email.trim());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/client/client.ts
|
|
261
|
+
function createFewchursClient(options = {}) {
|
|
262
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
263
|
+
const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
264
|
+
const retries = options.retries ?? 1;
|
|
265
|
+
const retryDelayMs = options.retryDelayMs ?? 250;
|
|
266
|
+
function getDeviceId() {
|
|
267
|
+
return typeof options.deviceId === "function" ? options.deviceId() : options.deviceId ?? null;
|
|
268
|
+
}
|
|
269
|
+
function headers(hasBody) {
|
|
270
|
+
const result = {
|
|
271
|
+
Accept: "application/json",
|
|
272
|
+
"X-Fewchurs-Sdk": SDK_HEADER
|
|
273
|
+
};
|
|
274
|
+
if (hasBody) result["Content-Type"] = "application/json";
|
|
275
|
+
if (options.apiKey) result.Authorization = `Bearer ${options.apiKey}`;
|
|
276
|
+
const deviceId = getDeviceId();
|
|
277
|
+
if (deviceId) result["X-Device-Id"] = deviceId;
|
|
278
|
+
if (options.language) result["Accept-Language"] = options.language;
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
function url(path, query) {
|
|
282
|
+
const search = new URLSearchParams();
|
|
283
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
284
|
+
if (value !== void 0 && value !== null && value !== "") search.set(key, String(value));
|
|
285
|
+
}
|
|
286
|
+
const qs = search.toString();
|
|
287
|
+
return `${baseUrl}${path}${qs ? `?${qs}` : ""}`;
|
|
288
|
+
}
|
|
289
|
+
async function request(path, map, { method = "GET", body, query, signal } = {}) {
|
|
290
|
+
if (!doFetch) return { ok: false, error: networkError("No fetch implementation available.") };
|
|
291
|
+
const target = url(path, query);
|
|
292
|
+
const init = { method, headers: headers(body !== void 0) };
|
|
293
|
+
if (body !== void 0) init.body = JSON.stringify(body);
|
|
294
|
+
if (signal) init.signal = signal;
|
|
295
|
+
const attempts = method === "GET" ? retries + 1 : 1;
|
|
296
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
297
|
+
let response;
|
|
298
|
+
try {
|
|
299
|
+
response = await doFetch(target, init);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (signal?.aborted) return { ok: false, error: networkError("Request aborted.") };
|
|
302
|
+
if (attempt < attempts - 1) {
|
|
303
|
+
await delay(retryDelayMs);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
return { ok: false, error: networkError(messageOf(error)) };
|
|
307
|
+
}
|
|
308
|
+
if (response.ok) {
|
|
309
|
+
const payload = await readJson(response);
|
|
310
|
+
return { ok: true, data: map(payload) };
|
|
311
|
+
}
|
|
312
|
+
if (response.status >= 500 && attempt < attempts - 1) {
|
|
313
|
+
await delay(retryDelayMs);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const failure = errorFromResponse(response.status, await readJson(response));
|
|
317
|
+
warnAboutKey(failure.code);
|
|
318
|
+
return { ok: false, error: failure };
|
|
319
|
+
}
|
|
320
|
+
return { ok: false, error: networkError() };
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
baseUrl,
|
|
324
|
+
getDeviceId,
|
|
325
|
+
getConfig: (signal) => request("/config", normalizeConfig, { signal }),
|
|
326
|
+
listFeatures: (params = {}, signal) => request("/features", (body) => normalizeFeaturePage(body, getDeviceId()), {
|
|
327
|
+
query: {
|
|
328
|
+
sort: params.sort ?? "new",
|
|
329
|
+
limit: params.limit ?? DEFAULT_PAGE_SIZE,
|
|
330
|
+
cursor: params.cursor ?? void 0,
|
|
331
|
+
status: params.status?.length ? params.status.join(",") : void 0
|
|
332
|
+
},
|
|
333
|
+
signal
|
|
334
|
+
}),
|
|
335
|
+
getFeature: (featureId, signal) => request(
|
|
336
|
+
`/features/${encodeURIComponent(featureId)}`,
|
|
337
|
+
(body) => normalizeFeature(body, getDeviceId()),
|
|
338
|
+
{ signal }
|
|
339
|
+
),
|
|
340
|
+
submitFeature: async (input) => {
|
|
341
|
+
const invalid = validateFeatureInput(input);
|
|
342
|
+
if (invalid) return { ok: false, error: invalid };
|
|
343
|
+
return request("/features", (body) => normalizeFeature(body, getDeviceId()), {
|
|
344
|
+
method: "POST",
|
|
345
|
+
body: {
|
|
346
|
+
title: input.title.trim(),
|
|
347
|
+
description: (input.description ?? "").trim(),
|
|
348
|
+
...input.email ? { email: input.email.trim() } : {},
|
|
349
|
+
isSubscriber: input.isSubscriber ?? false
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
vote: (featureId) => request(`/features/${encodeURIComponent(featureId)}/vote`, normalizeVote, { method: "POST" }),
|
|
354
|
+
unvote: (featureId) => request(`/features/${encodeURIComponent(featureId)}/vote`, normalizeVote, {
|
|
355
|
+
method: "DELETE"
|
|
356
|
+
}),
|
|
357
|
+
follow: async (featureId, email) => {
|
|
358
|
+
const invalid = validateEmail(email);
|
|
359
|
+
if (invalid) return { ok: false, error: invalid };
|
|
360
|
+
return request(
|
|
361
|
+
`/features/${encodeURIComponent(featureId)}/follow`,
|
|
362
|
+
() => ({ following: true }),
|
|
363
|
+
{
|
|
364
|
+
method: "POST",
|
|
365
|
+
body: { email: email.trim() }
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
},
|
|
369
|
+
unfollow: (featureId) => request(`/features/${encodeURIComponent(featureId)}/follow`, () => ({ following: false }), {
|
|
370
|
+
method: "DELETE"
|
|
371
|
+
}),
|
|
372
|
+
listComments: (featureId, params = {}, signal) => request(
|
|
373
|
+
`/features/${encodeURIComponent(featureId)}/comments`,
|
|
374
|
+
(body) => normalizeCommentPage(body, getDeviceId()),
|
|
375
|
+
{
|
|
376
|
+
query: { limit: params.limit ?? DEFAULT_PAGE_SIZE, cursor: params.cursor ?? void 0 },
|
|
377
|
+
signal
|
|
378
|
+
}
|
|
379
|
+
),
|
|
380
|
+
// The server takes the feature id in the body here; POST /features/{id}/comments is a 405.
|
|
381
|
+
createComment: async (input) => {
|
|
382
|
+
const invalid = validateCommentInput(input);
|
|
383
|
+
if (invalid) return { ok: false, error: invalid };
|
|
384
|
+
return request("/comments", (body) => normalizeComment(body, getDeviceId()), {
|
|
385
|
+
method: "POST",
|
|
386
|
+
body: {
|
|
387
|
+
featureId: input.featureId,
|
|
388
|
+
text: input.text.trim(),
|
|
389
|
+
...input.authorName?.trim() ? { authorName: input.authorName.trim() } : {}
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
async function readJson(response) {
|
|
396
|
+
try {
|
|
397
|
+
return await response.json();
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function delay(ms) {
|
|
403
|
+
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
|
|
404
|
+
}
|
|
405
|
+
function messageOf(error) {
|
|
406
|
+
return error instanceof Error ? error.message : "Network request failed.";
|
|
407
|
+
}
|
|
408
|
+
var warnedAboutKey = false;
|
|
409
|
+
function warnAboutKey(code) {
|
|
410
|
+
if (code !== "invalid_key" || warnedAboutKey) return;
|
|
411
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return;
|
|
412
|
+
warnedAboutKey = true;
|
|
413
|
+
console.error(
|
|
414
|
+
"[fewchurs] The API key was rejected. Check FEWCHURS_API_KEY (server) or the apiKey passed to FewchursProvider, and that the key is active in your Fewchurs dashboard."
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/identity/deviceId.ts
|
|
419
|
+
var COOKIE_NAME = "fewchurs_did";
|
|
420
|
+
var STORAGE_KEY = "fewchurs_device_id";
|
|
421
|
+
var COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
|
422
|
+
function createDeviceId() {
|
|
423
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
424
|
+
return crypto.randomUUID();
|
|
425
|
+
}
|
|
426
|
+
return `fw-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
427
|
+
}
|
|
428
|
+
function readDeviceId() {
|
|
429
|
+
if (typeof document === "undefined") return null;
|
|
430
|
+
return readCookie() ?? readStorage();
|
|
431
|
+
}
|
|
432
|
+
function resolveDeviceId() {
|
|
433
|
+
const existing = readDeviceId();
|
|
434
|
+
if (existing) {
|
|
435
|
+
persist(existing);
|
|
436
|
+
return existing;
|
|
437
|
+
}
|
|
438
|
+
const created = createDeviceId();
|
|
439
|
+
persist(created);
|
|
440
|
+
return created;
|
|
441
|
+
}
|
|
442
|
+
function persist(deviceId) {
|
|
443
|
+
if (typeof document === "undefined") return;
|
|
444
|
+
try {
|
|
445
|
+
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(deviceId)}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
|
|
446
|
+
} catch {
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
localStorage.setItem(STORAGE_KEY, deviceId);
|
|
450
|
+
} catch {
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function readCookie() {
|
|
454
|
+
const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_NAME}=([^;]*)`));
|
|
455
|
+
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
|
456
|
+
}
|
|
457
|
+
function readStorage() {
|
|
458
|
+
try {
|
|
459
|
+
return localStorage.getItem(STORAGE_KEY);
|
|
460
|
+
} catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/store/keys.ts
|
|
466
|
+
function listKey({ sort, status }) {
|
|
467
|
+
return status?.length ? `${sort}:${[...status].sort().join(",")}` : sort;
|
|
468
|
+
}
|
|
469
|
+
function featureKey(featureId) {
|
|
470
|
+
return `feature:${featureId}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/store/store.ts
|
|
474
|
+
var EMPTY_LIST = { features: [], cursor: null, status: "idle", error: null };
|
|
475
|
+
var EMPTY_COMMENTS = { comments: [], cursor: null, status: "idle", error: null };
|
|
476
|
+
var MAX_EMPTY_PAGES = 5;
|
|
477
|
+
function createStore(client, { pageSize = 20, initialState, onEvent } = {}) {
|
|
478
|
+
let state = {
|
|
479
|
+
config: initialState?.config ?? null,
|
|
480
|
+
lists: initialState?.lists ?? {},
|
|
481
|
+
comments: initialState?.comments ?? {}
|
|
482
|
+
};
|
|
483
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
484
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
485
|
+
function setState(next) {
|
|
486
|
+
state = { ...state, ...next };
|
|
487
|
+
for (const listener of listeners) listener();
|
|
488
|
+
}
|
|
489
|
+
function setList(key, patch) {
|
|
490
|
+
const current = state.lists[key] ?? EMPTY_LIST;
|
|
491
|
+
setState({ lists: { ...state.lists, [key]: { ...current, ...patch } } });
|
|
492
|
+
}
|
|
493
|
+
function setComments(featureId, patch) {
|
|
494
|
+
const current = state.comments[featureId] ?? EMPTY_COMMENTS;
|
|
495
|
+
setState({ comments: { ...state.comments, [featureId]: { ...current, ...patch } } });
|
|
496
|
+
}
|
|
497
|
+
function emit(event) {
|
|
498
|
+
onEvent?.(event);
|
|
499
|
+
}
|
|
500
|
+
function fail(error) {
|
|
501
|
+
emit({ name: "error", error });
|
|
502
|
+
}
|
|
503
|
+
function dedupe(key, run) {
|
|
504
|
+
const existing = inFlight.get(key);
|
|
505
|
+
if (existing) return existing;
|
|
506
|
+
const promise = run().finally(() => inFlight.delete(key));
|
|
507
|
+
inFlight.set(key, promise);
|
|
508
|
+
return promise;
|
|
509
|
+
}
|
|
510
|
+
function mapFeature(featureId, update) {
|
|
511
|
+
const lists = {};
|
|
512
|
+
let changed = false;
|
|
513
|
+
for (const [key, list] of Object.entries(state.lists)) {
|
|
514
|
+
const index = list.features.findIndex((feature) => feature.id === featureId);
|
|
515
|
+
if (index === -1) {
|
|
516
|
+
lists[key] = list;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
changed = true;
|
|
520
|
+
const features = [...list.features];
|
|
521
|
+
features[index] = update(features[index]);
|
|
522
|
+
lists[key] = { ...list, features };
|
|
523
|
+
}
|
|
524
|
+
if (changed) setState({ lists });
|
|
525
|
+
}
|
|
526
|
+
async function fetchFeatures(query, append, size) {
|
|
527
|
+
const key = listKey(query);
|
|
528
|
+
const current = state.lists[key] ?? EMPTY_LIST;
|
|
529
|
+
if (append && !current.cursor) return;
|
|
530
|
+
setList(key, { status: append ? "loadingMore" : "loading", error: null });
|
|
531
|
+
const result = await client.listFeatures({
|
|
532
|
+
sort: query.sort,
|
|
533
|
+
status: query.status,
|
|
534
|
+
limit: size,
|
|
535
|
+
cursor: append ? current.cursor : null
|
|
536
|
+
});
|
|
537
|
+
if (!result.ok) {
|
|
538
|
+
setList(key, { status: "error", error: result.error });
|
|
539
|
+
fail(result.error);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const previous = append ? state.lists[key]?.features ?? [] : [];
|
|
543
|
+
setList(key, {
|
|
544
|
+
features: dedupeById([...previous, ...result.data.features]),
|
|
545
|
+
cursor: result.data.nextCursor,
|
|
546
|
+
status: "ready",
|
|
547
|
+
error: null
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
async function fetchFeature(featureId) {
|
|
551
|
+
const key = featureKey(featureId);
|
|
552
|
+
setList(key, { status: "loading", error: null });
|
|
553
|
+
const result = await client.getFeature(featureId);
|
|
554
|
+
if (!result.ok) {
|
|
555
|
+
setList(key, { status: "error", error: result.error });
|
|
556
|
+
fail(result.error);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
setList(key, { features: [result.data], cursor: null, status: "ready", error: null });
|
|
560
|
+
}
|
|
561
|
+
async function fetchComments(featureId, append) {
|
|
562
|
+
const current = state.comments[featureId] ?? EMPTY_COMMENTS;
|
|
563
|
+
if (append && !current.cursor) return;
|
|
564
|
+
setComments(featureId, { status: append ? "loadingMore" : "loading", error: null });
|
|
565
|
+
let cursor = append ? current.cursor : null;
|
|
566
|
+
const collected = [];
|
|
567
|
+
for (let page = 0; page < MAX_EMPTY_PAGES; page += 1) {
|
|
568
|
+
const result = await client.listComments(featureId, { limit: pageSize, cursor });
|
|
569
|
+
if (!result.ok) {
|
|
570
|
+
setComments(featureId, { status: "error", error: result.error });
|
|
571
|
+
fail(result.error);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
collected.push(...result.data.comments);
|
|
575
|
+
cursor = result.data.nextCursor;
|
|
576
|
+
if (collected.length > 0 || !cursor) break;
|
|
577
|
+
}
|
|
578
|
+
const previous = append ? state.comments[featureId]?.comments ?? [] : [];
|
|
579
|
+
setComments(featureId, {
|
|
580
|
+
comments: dedupeById([...previous, ...collected]),
|
|
581
|
+
cursor,
|
|
582
|
+
status: "ready",
|
|
583
|
+
error: null
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
subscribe(listener) {
|
|
588
|
+
listeners.add(listener);
|
|
589
|
+
return () => listeners.delete(listener);
|
|
590
|
+
},
|
|
591
|
+
getState: () => state,
|
|
592
|
+
emit,
|
|
593
|
+
ensureConfig() {
|
|
594
|
+
if (state.config) return;
|
|
595
|
+
void dedupe("config", async () => {
|
|
596
|
+
const result = await client.getConfig();
|
|
597
|
+
if (result.ok) setState({ config: result.data });
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
ensureFeatures(query, size = pageSize) {
|
|
601
|
+
const key = listKey(query);
|
|
602
|
+
if ((state.lists[key] ?? EMPTY_LIST).status !== "idle") return;
|
|
603
|
+
void dedupe(`features:${key}`, () => fetchFeatures(query, false, size));
|
|
604
|
+
},
|
|
605
|
+
refresh(query, size = pageSize) {
|
|
606
|
+
return dedupe(`features:${listKey(query)}`, () => fetchFeatures(query, false, size));
|
|
607
|
+
},
|
|
608
|
+
loadMore(query, size = pageSize) {
|
|
609
|
+
return dedupe(`features:more:${listKey(query)}`, () => fetchFeatures(query, true, size));
|
|
610
|
+
},
|
|
611
|
+
ensureFeature(featureId) {
|
|
612
|
+
if ((state.lists[featureKey(featureId)] ?? EMPTY_LIST).status !== "idle") return;
|
|
613
|
+
void dedupe(`feature:${featureId}`, () => fetchFeature(featureId));
|
|
614
|
+
},
|
|
615
|
+
refreshFeature(featureId) {
|
|
616
|
+
return dedupe(`feature:${featureId}`, () => fetchFeature(featureId));
|
|
617
|
+
},
|
|
618
|
+
ensureComments(featureId) {
|
|
619
|
+
if ((state.comments[featureId] ?? EMPTY_COMMENTS).status !== "idle") return;
|
|
620
|
+
void dedupe(`comments:${featureId}`, () => fetchComments(featureId, false));
|
|
621
|
+
},
|
|
622
|
+
refreshComments(featureId) {
|
|
623
|
+
return dedupe(`comments:${featureId}`, () => fetchComments(featureId, false));
|
|
624
|
+
},
|
|
625
|
+
loadMoreComments(featureId) {
|
|
626
|
+
return dedupe(`comments:more:${featureId}`, () => fetchComments(featureId, true));
|
|
627
|
+
},
|
|
628
|
+
async toggleVote(featureId) {
|
|
629
|
+
const before = findFeature(state, featureId);
|
|
630
|
+
if (!before)
|
|
631
|
+
return {
|
|
632
|
+
ok: false,
|
|
633
|
+
error: { code: "not_found", status: 404, message: "Unknown feature." }
|
|
634
|
+
};
|
|
635
|
+
const nextVoted = !before.hasVoted;
|
|
636
|
+
mapFeature(featureId, (feature) => ({
|
|
637
|
+
...feature,
|
|
638
|
+
hasVoted: nextVoted,
|
|
639
|
+
upvoteCount: Math.max(0, feature.upvoteCount + (nextVoted ? 1 : -1))
|
|
640
|
+
}));
|
|
641
|
+
const result = nextVoted ? await client.vote(featureId) : await client.unvote(featureId);
|
|
642
|
+
if (!result.ok) {
|
|
643
|
+
mapFeature(featureId, (feature) => ({
|
|
644
|
+
...feature,
|
|
645
|
+
hasVoted: before.hasVoted,
|
|
646
|
+
upvoteCount: before.upvoteCount
|
|
647
|
+
}));
|
|
648
|
+
fail(result.error);
|
|
649
|
+
return result;
|
|
650
|
+
}
|
|
651
|
+
mapFeature(featureId, (feature) => ({
|
|
652
|
+
...feature,
|
|
653
|
+
hasVoted: result.data.hasVoted,
|
|
654
|
+
upvoteCount: result.data.upvoteCount
|
|
655
|
+
}));
|
|
656
|
+
emit({ name: "voted", featureId, hasVoted: result.data.hasVoted });
|
|
657
|
+
return result;
|
|
658
|
+
},
|
|
659
|
+
async submitFeature(input) {
|
|
660
|
+
const result = await client.submitFeature(input);
|
|
661
|
+
if (!result.ok) {
|
|
662
|
+
fail(result.error);
|
|
663
|
+
return result;
|
|
664
|
+
}
|
|
665
|
+
const lists = {};
|
|
666
|
+
for (const [key, list] of Object.entries(state.lists)) {
|
|
667
|
+
lists[key] = key.startsWith("new") ? { ...list, features: dedupeById([result.data, ...list.features]) } : list;
|
|
668
|
+
}
|
|
669
|
+
setState({ lists });
|
|
670
|
+
emit({ name: "featureSubmitted", featureId: result.data.id });
|
|
671
|
+
return result;
|
|
672
|
+
},
|
|
673
|
+
async addComment(input) {
|
|
674
|
+
const result = await client.createComment(input);
|
|
675
|
+
if (!result.ok) {
|
|
676
|
+
fail(result.error);
|
|
677
|
+
return result;
|
|
678
|
+
}
|
|
679
|
+
const current = state.comments[input.featureId] ?? EMPTY_COMMENTS;
|
|
680
|
+
setComments(input.featureId, { comments: [...current.comments, result.data] });
|
|
681
|
+
mapFeature(input.featureId, (feature) => ({
|
|
682
|
+
...feature,
|
|
683
|
+
commentCount: feature.commentCount + 1
|
|
684
|
+
}));
|
|
685
|
+
emit({ name: "commented", featureId: input.featureId });
|
|
686
|
+
return result;
|
|
687
|
+
},
|
|
688
|
+
async setFollowing(featureId, email) {
|
|
689
|
+
const result = email ? await client.follow(featureId, email) : await client.unfollow(featureId);
|
|
690
|
+
if (!result.ok) {
|
|
691
|
+
fail(result.error);
|
|
692
|
+
return result;
|
|
693
|
+
}
|
|
694
|
+
mapFeature(featureId, (feature) => ({
|
|
695
|
+
...feature,
|
|
696
|
+
isFollowing: result.data.following,
|
|
697
|
+
followerCount: Math.max(0, feature.followerCount + (result.data.following ? 1 : -1))
|
|
698
|
+
}));
|
|
699
|
+
emit({ name: "followed", featureId, following: result.data.following });
|
|
700
|
+
return result;
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function listStateOf(state, key) {
|
|
705
|
+
return state.lists[key] ?? EMPTY_LIST;
|
|
706
|
+
}
|
|
707
|
+
function commentsStateOf(state, featureId) {
|
|
708
|
+
return state.comments[featureId] ?? EMPTY_COMMENTS;
|
|
709
|
+
}
|
|
710
|
+
function findFeature(state, featureId) {
|
|
711
|
+
for (const list of Object.values(state.lists)) {
|
|
712
|
+
const found = list.features.find((feature) => feature.id === featureId);
|
|
713
|
+
if (found) return found;
|
|
714
|
+
}
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
function dedupeById(items) {
|
|
718
|
+
const seen = /* @__PURE__ */ new Set();
|
|
719
|
+
const result = [];
|
|
720
|
+
for (const item of items) {
|
|
721
|
+
if (seen.has(item.id)) continue;
|
|
722
|
+
seen.add(item.id);
|
|
723
|
+
result.push(item);
|
|
724
|
+
}
|
|
725
|
+
return result;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/context/provider.tsx
|
|
729
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
730
|
+
var FewchursContext = (0, import_react.createContext)(null);
|
|
731
|
+
function FewchursProvider({
|
|
732
|
+
apiKey,
|
|
733
|
+
baseUrl,
|
|
734
|
+
deviceId: deviceIdProp,
|
|
735
|
+
language,
|
|
736
|
+
pageSize = 20,
|
|
737
|
+
initialState,
|
|
738
|
+
onEvent,
|
|
739
|
+
client: clientProp,
|
|
740
|
+
children
|
|
741
|
+
}) {
|
|
742
|
+
const [deviceId, setDeviceId] = (0, import_react.useState)(() => deviceIdProp ?? readDeviceId());
|
|
743
|
+
const deviceIdRef = (0, import_react.useRef)(deviceId);
|
|
744
|
+
deviceIdRef.current = deviceIdProp ?? deviceId;
|
|
745
|
+
const eventRef = (0, import_react.useRef)(onEvent);
|
|
746
|
+
eventRef.current = onEvent;
|
|
747
|
+
(0, import_react.useEffect)(() => {
|
|
748
|
+
if (deviceIdProp) return;
|
|
749
|
+
setDeviceId(resolveDeviceId());
|
|
750
|
+
}, [deviceIdProp]);
|
|
751
|
+
const client = (0, import_react.useMemo)(
|
|
752
|
+
() => clientProp ?? createFewchursClient({
|
|
753
|
+
apiKey,
|
|
754
|
+
baseUrl,
|
|
755
|
+
language,
|
|
756
|
+
deviceId: () => deviceIdRef.current
|
|
757
|
+
}),
|
|
758
|
+
[clientProp, apiKey, baseUrl, language]
|
|
759
|
+
);
|
|
760
|
+
const store = (0, import_react.useMemo)(
|
|
761
|
+
() => createStore(client, {
|
|
762
|
+
pageSize,
|
|
763
|
+
initialState,
|
|
764
|
+
onEvent: (event) => eventRef.current?.(event)
|
|
765
|
+
}),
|
|
766
|
+
// initialState is a first-paint seed; a new object identity must not wipe live state.
|
|
767
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
768
|
+
[client, pageSize]
|
|
769
|
+
);
|
|
770
|
+
const value = (0, import_react.useMemo)(
|
|
771
|
+
() => ({
|
|
772
|
+
client,
|
|
773
|
+
store,
|
|
774
|
+
deviceId: deviceIdProp ?? deviceId,
|
|
775
|
+
isReady: Boolean(deviceIdProp ?? deviceId) || !apiKey
|
|
776
|
+
}),
|
|
777
|
+
[client, store, deviceIdProp, deviceId, apiKey]
|
|
778
|
+
);
|
|
779
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FewchursContext.Provider, { value, children });
|
|
780
|
+
}
|
|
781
|
+
function useFewchurs() {
|
|
782
|
+
const value = (0, import_react.useContext)(FewchursContext);
|
|
783
|
+
if (!value) {
|
|
784
|
+
throw new Error(
|
|
785
|
+
"Fewchurs components must be rendered inside <FewchursProvider>. In Next.js, <FewchursBoard> from @fewchurs/next provides one for you."
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
return value;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// src/components/Board.tsx
|
|
792
|
+
var import_react15 = require("react");
|
|
793
|
+
|
|
794
|
+
// src/hooks/useFewchursConfig.ts
|
|
795
|
+
var import_react3 = require("react");
|
|
796
|
+
|
|
797
|
+
// src/hooks/useStoreState.ts
|
|
798
|
+
var import_react2 = require("react");
|
|
799
|
+
function useStoreState() {
|
|
800
|
+
const { store } = useFewchurs();
|
|
801
|
+
return (0, import_react2.useSyncExternalStore)(store.subscribe, store.getState, store.getState);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// src/hooks/useFewchursConfig.ts
|
|
805
|
+
function useFewchursConfig() {
|
|
806
|
+
const { store } = useFewchurs();
|
|
807
|
+
const state = useStoreState();
|
|
808
|
+
(0, import_react3.useEffect)(() => {
|
|
809
|
+
store.ensureConfig();
|
|
810
|
+
}, [store]);
|
|
811
|
+
return {
|
|
812
|
+
showBranding: state.config?.showBranding ?? false,
|
|
813
|
+
hideVoteCounts: state.config?.hideVoteCounts ?? false,
|
|
814
|
+
appName: state.config?.appName ?? null,
|
|
815
|
+
isLoading: state.config === null
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/components/BoardContext.tsx
|
|
820
|
+
var import_react4 = require("react");
|
|
821
|
+
|
|
822
|
+
// src/components/labels.ts
|
|
823
|
+
var DEFAULT_LABELS = {
|
|
824
|
+
title: "Feature requests",
|
|
825
|
+
tabTop: "Top",
|
|
826
|
+
tabNew: "New",
|
|
827
|
+
tabRoadmap: "Roadmap",
|
|
828
|
+
newRequest: "New request",
|
|
829
|
+
cancel: "Cancel",
|
|
830
|
+
submit: "Submit request",
|
|
831
|
+
submitting: "Submitting\u2026",
|
|
832
|
+
submitted: "Thanks \u2014 your request is in.",
|
|
833
|
+
fieldTitle: "Title",
|
|
834
|
+
fieldTitlePlaceholder: "Add dark mode",
|
|
835
|
+
fieldDescription: "Details",
|
|
836
|
+
fieldDescriptionPlaceholder: "What would this let you do?",
|
|
837
|
+
fieldEmail: "Email",
|
|
838
|
+
fieldEmailHint: "Only used to tell you when this ships.",
|
|
839
|
+
fieldName: "Your name",
|
|
840
|
+
emptyTitle: "No feature requests yet",
|
|
841
|
+
emptyBody: "Be the first to suggest something.",
|
|
842
|
+
loading: "Loading\u2026",
|
|
843
|
+
loadMore: "Load more",
|
|
844
|
+
retry: "Try again",
|
|
845
|
+
comments: "Comments",
|
|
846
|
+
commentPlaceholder: "Add a comment",
|
|
847
|
+
postComment: "Post",
|
|
848
|
+
noComments: "No comments yet.",
|
|
849
|
+
follow: "Notify me",
|
|
850
|
+
following: "You'll be notified",
|
|
851
|
+
followPlaceholder: "you@example.com",
|
|
852
|
+
developer: "Team",
|
|
853
|
+
poweredBy: "Powered by Fewchurs",
|
|
854
|
+
upvote: "Upvote",
|
|
855
|
+
status: {
|
|
856
|
+
open: "Open",
|
|
857
|
+
planned: "Planned",
|
|
858
|
+
in_progress: "In progress",
|
|
859
|
+
done: "Done",
|
|
860
|
+
declined: "Declined",
|
|
861
|
+
unknown: "Open"
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
function mergeLabels(overrides) {
|
|
865
|
+
if (!overrides) return DEFAULT_LABELS;
|
|
866
|
+
return {
|
|
867
|
+
...DEFAULT_LABELS,
|
|
868
|
+
...overrides,
|
|
869
|
+
status: { ...DEFAULT_LABELS.status, ...overrides.status }
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// src/components/BoardContext.tsx
|
|
874
|
+
var BoardContext = (0, import_react4.createContext)(null);
|
|
875
|
+
var BoardContextProvider = BoardContext.Provider;
|
|
876
|
+
function useBoard() {
|
|
877
|
+
const value = (0, import_react4.useContext)(BoardContext);
|
|
878
|
+
if (!value) {
|
|
879
|
+
throw new Error("Fewchurs board parts must be rendered inside <FewchursBoard.Root>.");
|
|
880
|
+
}
|
|
881
|
+
return value;
|
|
882
|
+
}
|
|
883
|
+
function makeClassName(unstyled) {
|
|
884
|
+
return (...names) => unstyled ? void 0 : names.filter(Boolean).join(" ");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// src/components/Branding.tsx
|
|
888
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
889
|
+
function Branding() {
|
|
890
|
+
const { labels, cx } = useBoard();
|
|
891
|
+
const { showBranding } = useFewchursConfig();
|
|
892
|
+
if (!showBranding) return null;
|
|
893
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: cx("fw-branding"), children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
894
|
+
"a",
|
|
895
|
+
{
|
|
896
|
+
className: cx("fw-link"),
|
|
897
|
+
href: "https://www.fewchurs.shop",
|
|
898
|
+
target: "_blank",
|
|
899
|
+
rel: "noreferrer noopener",
|
|
900
|
+
children: labels.poweredBy
|
|
901
|
+
}
|
|
902
|
+
) });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// src/hooks/useFeature.ts
|
|
906
|
+
var import_react5 = require("react");
|
|
907
|
+
function useFeature(featureId, { enabled = true } = {}) {
|
|
908
|
+
const { store, isReady } = useFewchurs();
|
|
909
|
+
const state = useStoreState();
|
|
910
|
+
const active = Boolean(featureId) && enabled;
|
|
911
|
+
const list = listStateOf(state, featureKey(featureId ?? ""));
|
|
912
|
+
(0, import_react5.useEffect)(() => {
|
|
913
|
+
if (!active || !isReady || !featureId) return;
|
|
914
|
+
store.ensureFeature(featureId);
|
|
915
|
+
}, [store, featureId, active, isReady]);
|
|
916
|
+
const refresh = (0, import_react5.useCallback)(
|
|
917
|
+
() => featureId ? store.refreshFeature(featureId) : Promise.resolve(),
|
|
918
|
+
[store, featureId]
|
|
919
|
+
);
|
|
920
|
+
return {
|
|
921
|
+
feature: list.features[0] ?? null,
|
|
922
|
+
isLoading: active && (list.status === "loading" || list.status === "idle"),
|
|
923
|
+
error: list.error,
|
|
924
|
+
refresh
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// src/hooks/useFeatures.ts
|
|
929
|
+
var import_react6 = require("react");
|
|
930
|
+
function useFeatures({
|
|
931
|
+
sort = "new",
|
|
932
|
+
status,
|
|
933
|
+
pageSize,
|
|
934
|
+
enabled = true
|
|
935
|
+
} = {}) {
|
|
936
|
+
const { store, isReady } = useFewchurs();
|
|
937
|
+
const state = useStoreState();
|
|
938
|
+
const statusKey = status?.length ? [...status].sort().join(",") : "";
|
|
939
|
+
const query = (0, import_react6.useMemo)(
|
|
940
|
+
() => ({ sort, status: statusKey ? statusKey.split(",") : void 0 }),
|
|
941
|
+
[sort, statusKey]
|
|
942
|
+
);
|
|
943
|
+
const key = listKey(query);
|
|
944
|
+
const list = listStateOf(state, key);
|
|
945
|
+
(0, import_react6.useEffect)(() => {
|
|
946
|
+
if (!enabled || !isReady) return;
|
|
947
|
+
store.ensureFeatures(query, pageSize);
|
|
948
|
+
}, [store, query, pageSize, enabled, isReady]);
|
|
949
|
+
const loadMore = (0, import_react6.useCallback)(() => store.loadMore(query, pageSize), [store, query, pageSize]);
|
|
950
|
+
const refresh = (0, import_react6.useCallback)(() => store.refresh(query, pageSize), [store, query, pageSize]);
|
|
951
|
+
return {
|
|
952
|
+
features: list.features,
|
|
953
|
+
isLoading: list.status === "loading" || list.status === "idle" && enabled,
|
|
954
|
+
isLoadingMore: list.status === "loadingMore",
|
|
955
|
+
error: list.error,
|
|
956
|
+
hasMore: list.cursor !== null,
|
|
957
|
+
loadMore,
|
|
958
|
+
refresh
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/hooks/useFollow.ts
|
|
963
|
+
var import_react7 = require("react");
|
|
964
|
+
function useFollow(featureId) {
|
|
965
|
+
const { store } = useFewchurs();
|
|
966
|
+
const state = useStoreState();
|
|
967
|
+
const [isPending, setIsPending] = (0, import_react7.useState)(false);
|
|
968
|
+
const [error, setError] = (0, import_react7.useState)(null);
|
|
969
|
+
const feature = Object.values(state.lists).flatMap((list) => list.features).find((candidate) => candidate.id === featureId);
|
|
970
|
+
const run = (0, import_react7.useCallback)(
|
|
971
|
+
async (email) => {
|
|
972
|
+
setIsPending(true);
|
|
973
|
+
setError(null);
|
|
974
|
+
const result = await store.setFollowing(featureId, email);
|
|
975
|
+
if (!result.ok) setError(result.error);
|
|
976
|
+
setIsPending(false);
|
|
977
|
+
return result.ok;
|
|
978
|
+
},
|
|
979
|
+
[store, featureId]
|
|
980
|
+
);
|
|
981
|
+
return {
|
|
982
|
+
isFollowing: feature?.isFollowing ?? false,
|
|
983
|
+
isPending,
|
|
984
|
+
error,
|
|
985
|
+
follow: (0, import_react7.useCallback)((email) => run(email), [run]),
|
|
986
|
+
unfollow: (0, import_react7.useCallback)(() => run(null), [run])
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// src/hooks/useVote.ts
|
|
991
|
+
var import_react8 = require("react");
|
|
992
|
+
function useVote(featureId) {
|
|
993
|
+
const { store } = useFewchurs();
|
|
994
|
+
const state = useStoreState();
|
|
995
|
+
const [isPending, setIsPending] = (0, import_react8.useState)(false);
|
|
996
|
+
const [error, setError] = (0, import_react8.useState)(null);
|
|
997
|
+
const feature = Object.values(state.lists).flatMap((list) => list.features).find((candidate) => candidate.id === featureId);
|
|
998
|
+
const toggle = (0, import_react8.useCallback)(async () => {
|
|
999
|
+
setIsPending(true);
|
|
1000
|
+
setError(null);
|
|
1001
|
+
const result = await store.toggleVote(featureId);
|
|
1002
|
+
if (!result.ok) setError(result.error);
|
|
1003
|
+
setIsPending(false);
|
|
1004
|
+
}, [store, featureId]);
|
|
1005
|
+
const vote = (0, import_react8.useCallback)(async () => {
|
|
1006
|
+
if (feature?.hasVoted) return;
|
|
1007
|
+
await toggle();
|
|
1008
|
+
}, [feature?.hasVoted, toggle]);
|
|
1009
|
+
const unvote = (0, import_react8.useCallback)(async () => {
|
|
1010
|
+
if (!feature?.hasVoted) return;
|
|
1011
|
+
await toggle();
|
|
1012
|
+
}, [feature?.hasVoted, toggle]);
|
|
1013
|
+
return {
|
|
1014
|
+
hasVoted: feature?.hasVoted ?? false,
|
|
1015
|
+
upvoteCount: feature?.upvoteCount ?? 0,
|
|
1016
|
+
isPending,
|
|
1017
|
+
error,
|
|
1018
|
+
toggle,
|
|
1019
|
+
vote,
|
|
1020
|
+
unvote
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/components/FeatureCard.tsx
|
|
1025
|
+
var import_react12 = require("react");
|
|
1026
|
+
|
|
1027
|
+
// src/components/Comments.tsx
|
|
1028
|
+
var import_react10 = require("react");
|
|
1029
|
+
|
|
1030
|
+
// src/hooks/useComments.ts
|
|
1031
|
+
var import_react9 = require("react");
|
|
1032
|
+
function useComments(featureId, { enabled = true } = {}) {
|
|
1033
|
+
const { store, isReady } = useFewchurs();
|
|
1034
|
+
const state = useStoreState();
|
|
1035
|
+
const [isSubmitting, setIsSubmitting] = (0, import_react9.useState)(false);
|
|
1036
|
+
const [submitError, setSubmitError] = (0, import_react9.useState)(null);
|
|
1037
|
+
const comments = commentsStateOf(state, featureId);
|
|
1038
|
+
(0, import_react9.useEffect)(() => {
|
|
1039
|
+
if (!enabled || !isReady) return;
|
|
1040
|
+
store.ensureComments(featureId);
|
|
1041
|
+
}, [store, featureId, enabled, isReady]);
|
|
1042
|
+
const loadMore = (0, import_react9.useCallback)(() => store.loadMoreComments(featureId), [store, featureId]);
|
|
1043
|
+
const add = (0, import_react9.useCallback)(
|
|
1044
|
+
async (text, authorName) => {
|
|
1045
|
+
setIsSubmitting(true);
|
|
1046
|
+
setSubmitError(null);
|
|
1047
|
+
const result = await store.addComment({ featureId, text, authorName });
|
|
1048
|
+
if (!result.ok) setSubmitError(result.error);
|
|
1049
|
+
setIsSubmitting(false);
|
|
1050
|
+
return result.ok;
|
|
1051
|
+
},
|
|
1052
|
+
[store, featureId]
|
|
1053
|
+
);
|
|
1054
|
+
return {
|
|
1055
|
+
comments: comments.comments,
|
|
1056
|
+
isLoading: comments.status === "loading" || comments.status === "idle" && enabled,
|
|
1057
|
+
isLoadingMore: comments.status === "loadingMore",
|
|
1058
|
+
isSubmitting,
|
|
1059
|
+
error: comments.error,
|
|
1060
|
+
submitError,
|
|
1061
|
+
hasMore: comments.cursor !== null,
|
|
1062
|
+
loadMore,
|
|
1063
|
+
add
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// src/components/time.ts
|
|
1068
|
+
var FORMATTER = new Intl.DateTimeFormat("en", {
|
|
1069
|
+
month: "short",
|
|
1070
|
+
day: "numeric",
|
|
1071
|
+
timeZone: "UTC"
|
|
1072
|
+
});
|
|
1073
|
+
function formatDate(iso) {
|
|
1074
|
+
const date = new Date(iso);
|
|
1075
|
+
return Number.isNaN(date.getTime()) ? "" : FORMATTER.format(date);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// src/components/Comments.tsx
|
|
1079
|
+
var import_jsx_runtime3 = require("react/jsx-runtime");
|
|
1080
|
+
function CommentList({ featureId }) {
|
|
1081
|
+
const { labels, cx } = useBoard();
|
|
1082
|
+
const { comments, isLoading, isLoadingMore, error, hasMore, loadMore } = useComments(featureId);
|
|
1083
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("fw-comments"), children: [
|
|
1084
|
+
isLoading && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-muted"), children: labels.loading }),
|
|
1085
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
|
|
1086
|
+
!isLoading && !error && comments.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-muted"), children: labels.noComments }),
|
|
1087
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("ul", { className: cx("fw-comment-list"), children: comments.map((comment) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("li", { className: cx("fw-comment"), children: [
|
|
1088
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("fw-comment__author"), children: comment.authorName }),
|
|
1089
|
+
comment.isDeveloper && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: cx("fw-badge"), children: [
|
|
1090
|
+
" ",
|
|
1091
|
+
labels.developer
|
|
1092
|
+
] }),
|
|
1093
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: cx("fw-muted"), children: [
|
|
1094
|
+
" \xB7 ",
|
|
1095
|
+
formatDate(comment.createdAt)
|
|
1096
|
+
] }),
|
|
1097
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-comment__text"), children: comment.text })
|
|
1098
|
+
] }, comment.id)) }),
|
|
1099
|
+
hasMore && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
1100
|
+
"button",
|
|
1101
|
+
{
|
|
1102
|
+
type: "button",
|
|
1103
|
+
className: cx("fw-btn", "fw-btn--ghost"),
|
|
1104
|
+
disabled: isLoadingMore,
|
|
1105
|
+
onClick: () => void loadMore(),
|
|
1106
|
+
children: isLoadingMore ? labels.loading : labels.loadMore
|
|
1107
|
+
}
|
|
1108
|
+
),
|
|
1109
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(CommentForm, { featureId })
|
|
1110
|
+
] });
|
|
1111
|
+
}
|
|
1112
|
+
function CommentForm({ featureId }) {
|
|
1113
|
+
const { labels, cx } = useBoard();
|
|
1114
|
+
const { add, isSubmitting, submitError } = useComments(featureId);
|
|
1115
|
+
const [text, setText] = (0, import_react10.useState)("");
|
|
1116
|
+
const [authorName, setAuthorName] = (0, import_react10.useState)("");
|
|
1117
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
1118
|
+
"form",
|
|
1119
|
+
{
|
|
1120
|
+
className: cx("fw-form", "fw-form--inline"),
|
|
1121
|
+
onSubmit: async (event) => {
|
|
1122
|
+
event.preventDefault();
|
|
1123
|
+
const posted = await add(text, authorName);
|
|
1124
|
+
if (posted) setText("");
|
|
1125
|
+
},
|
|
1126
|
+
children: [
|
|
1127
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("fw-field"), children: [
|
|
1128
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { className: cx("fw-sr-only"), htmlFor: `fw-comment-${featureId}`, children: labels.commentPlaceholder }),
|
|
1129
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
1130
|
+
"input",
|
|
1131
|
+
{
|
|
1132
|
+
id: `fw-comment-${featureId}`,
|
|
1133
|
+
className: cx("fw-input"),
|
|
1134
|
+
value: text,
|
|
1135
|
+
maxLength: LIMITS.commentMax,
|
|
1136
|
+
placeholder: labels.commentPlaceholder,
|
|
1137
|
+
onChange: (event) => setText(event.target.value),
|
|
1138
|
+
required: true
|
|
1139
|
+
}
|
|
1140
|
+
)
|
|
1141
|
+
] }),
|
|
1142
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("fw-field"), children: [
|
|
1143
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { className: cx("fw-sr-only"), htmlFor: `fw-comment-name-${featureId}`, children: labels.fieldName }),
|
|
1144
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
1145
|
+
"input",
|
|
1146
|
+
{
|
|
1147
|
+
id: `fw-comment-name-${featureId}`,
|
|
1148
|
+
className: cx("fw-input"),
|
|
1149
|
+
value: authorName,
|
|
1150
|
+
maxLength: LIMITS.authorNameMax,
|
|
1151
|
+
placeholder: labels.fieldName,
|
|
1152
|
+
onChange: (event) => setAuthorName(event.target.value)
|
|
1153
|
+
}
|
|
1154
|
+
)
|
|
1155
|
+
] }),
|
|
1156
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting, children: labels.postComment }),
|
|
1157
|
+
submitError && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(submitError) })
|
|
1158
|
+
]
|
|
1159
|
+
}
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// src/components/FollowForm.tsx
|
|
1164
|
+
var import_react11 = require("react");
|
|
1165
|
+
var import_jsx_runtime4 = require("react/jsx-runtime");
|
|
1166
|
+
function FollowForm({ feature }) {
|
|
1167
|
+
const { labels, userEmail, cx } = useBoard();
|
|
1168
|
+
const { isFollowing, isPending, error, follow, unfollow } = useFollow(feature.id);
|
|
1169
|
+
const [email, setEmail] = (0, import_react11.useState)(userEmail ?? "");
|
|
1170
|
+
const [isOpen, setIsOpen] = (0, import_react11.useState)(false);
|
|
1171
|
+
if (isFollowing) {
|
|
1172
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
1173
|
+
"button",
|
|
1174
|
+
{
|
|
1175
|
+
type: "button",
|
|
1176
|
+
className: cx("fw-link"),
|
|
1177
|
+
disabled: isPending,
|
|
1178
|
+
onClick: () => void unfollow(),
|
|
1179
|
+
children: labels.following
|
|
1180
|
+
}
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
if (!isOpen) {
|
|
1184
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", className: cx("fw-link"), onClick: () => setIsOpen(true), children: labels.follow });
|
|
1185
|
+
}
|
|
1186
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
|
|
1187
|
+
"form",
|
|
1188
|
+
{
|
|
1189
|
+
className: cx("fw-form", "fw-form--inline"),
|
|
1190
|
+
onSubmit: async (event) => {
|
|
1191
|
+
event.preventDefault();
|
|
1192
|
+
const ok = await follow(email);
|
|
1193
|
+
if (ok) setIsOpen(false);
|
|
1194
|
+
},
|
|
1195
|
+
children: [
|
|
1196
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("label", { className: cx("fw-sr-only"), htmlFor: `fw-follow-${feature.id}`, children: labels.fieldEmail }),
|
|
1197
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
1198
|
+
"input",
|
|
1199
|
+
{
|
|
1200
|
+
id: `fw-follow-${feature.id}`,
|
|
1201
|
+
className: cx("fw-input"),
|
|
1202
|
+
type: "email",
|
|
1203
|
+
value: email,
|
|
1204
|
+
placeholder: labels.followPlaceholder,
|
|
1205
|
+
onChange: (event) => setEmail(event.target.value),
|
|
1206
|
+
required: true
|
|
1207
|
+
}
|
|
1208
|
+
),
|
|
1209
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: labels.follow }),
|
|
1210
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
|
|
1211
|
+
]
|
|
1212
|
+
}
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// src/components/StatusBadge.tsx
|
|
1217
|
+
var import_jsx_runtime5 = require("react/jsx-runtime");
|
|
1218
|
+
function StatusBadge({ status }) {
|
|
1219
|
+
const { labels, cx } = useBoard();
|
|
1220
|
+
if (status === "open" || status === "unknown") return null;
|
|
1221
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: cx("fw-status", `fw-status--${status}`), children: labels.status[status] });
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// src/components/VoteButton.tsx
|
|
1225
|
+
var import_jsx_runtime6 = require("react/jsx-runtime");
|
|
1226
|
+
function VoteButton({ feature, isPending, onToggle }) {
|
|
1227
|
+
const { labels, hideVoteCounts, cx } = useBoard();
|
|
1228
|
+
return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
|
|
1229
|
+
"button",
|
|
1230
|
+
{
|
|
1231
|
+
type: "button",
|
|
1232
|
+
className: cx("fw-vote"),
|
|
1233
|
+
"aria-pressed": feature.hasVoted,
|
|
1234
|
+
"aria-label": `${labels.upvote} ${feature.title}`,
|
|
1235
|
+
disabled: isPending,
|
|
1236
|
+
onClick: onToggle,
|
|
1237
|
+
children: [
|
|
1238
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: cx("fw-vote__arrow"), "aria-hidden": "true", children: "\u25B2" }),
|
|
1239
|
+
!hideVoteCounts && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: cx("fw-vote__count"), children: feature.upvoteCount })
|
|
1240
|
+
]
|
|
1241
|
+
}
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// src/components/FeatureCard.tsx
|
|
1246
|
+
var import_jsx_runtime7 = require("react/jsx-runtime");
|
|
1247
|
+
function FeatureCard({
|
|
1248
|
+
feature,
|
|
1249
|
+
defaultCommentsOpen = false
|
|
1250
|
+
}) {
|
|
1251
|
+
const { labels, allowComments, cx } = useBoard();
|
|
1252
|
+
const { isPending, toggle } = useVote(feature.id);
|
|
1253
|
+
const [showComments, setShowComments] = (0, import_react12.useState)(defaultCommentsOpen);
|
|
1254
|
+
return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("li", { className: cx("fw-card"), children: [
|
|
1255
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(VoteButton, { feature, isPending, onToggle: () => void toggle() }),
|
|
1256
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: cx("fw-card__body"), children: [
|
|
1257
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("h3", { className: cx("fw-card__title"), children: feature.title }),
|
|
1258
|
+
feature.description && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: cx("fw-card__desc"), children: feature.description }),
|
|
1259
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: cx("fw-meta"), children: [
|
|
1260
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(StatusBadge, { status: feature.status }),
|
|
1261
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { children: formatDate(feature.createdAt) }),
|
|
1262
|
+
allowComments && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
|
|
1263
|
+
"button",
|
|
1264
|
+
{
|
|
1265
|
+
type: "button",
|
|
1266
|
+
className: cx("fw-link"),
|
|
1267
|
+
"aria-expanded": showComments,
|
|
1268
|
+
"aria-controls": `fw-comments-${feature.id}`,
|
|
1269
|
+
onClick: () => setShowComments((open) => !open),
|
|
1270
|
+
children: [
|
|
1271
|
+
labels.comments,
|
|
1272
|
+
" (",
|
|
1273
|
+
feature.commentCount,
|
|
1274
|
+
")"
|
|
1275
|
+
]
|
|
1276
|
+
}
|
|
1277
|
+
),
|
|
1278
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(FollowForm, { feature })
|
|
1279
|
+
] }),
|
|
1280
|
+
allowComments && showComments && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { id: `fw-comments-${feature.id}`, children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(CommentList, { featureId: feature.id }) })
|
|
1281
|
+
] })
|
|
1282
|
+
] });
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// src/components/Skeleton.tsx
|
|
1286
|
+
var import_jsx_runtime8 = require("react/jsx-runtime");
|
|
1287
|
+
var ROW_COUNT = 3;
|
|
1288
|
+
var ROADMAP_ROW_COUNT = 2;
|
|
1289
|
+
function FeatureListSkeleton({
|
|
1290
|
+
roadmap = false,
|
|
1291
|
+
rows = ROW_COUNT
|
|
1292
|
+
}) {
|
|
1293
|
+
const { labels, cx } = useBoard();
|
|
1294
|
+
return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: cx("fw-skeleton"), role: "status", "aria-busy": "true", children: [
|
|
1295
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: cx("fw-sr-only"), children: labels.loading }),
|
|
1296
|
+
roadmap ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: cx("fw-roadmap"), "aria-hidden": "true", children: ROADMAP_STATUSES.map((status) => /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: cx("fw-roadmap__column"), children: [
|
|
1297
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Bar, { width: "40%" }),
|
|
1298
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Rows, { count: ROADMAP_ROW_COUNT })
|
|
1299
|
+
] }, status)) }) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Rows, { count: rows }) })
|
|
1300
|
+
] });
|
|
1301
|
+
}
|
|
1302
|
+
function Rows({ count }) {
|
|
1303
|
+
const { cx } = useBoard();
|
|
1304
|
+
return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: cx("fw-list"), children: Array.from({ length: count }, (_, index) => /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: cx("fw-card", "fw-skeleton__card"), children: [
|
|
1305
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: cx("fw-skeleton__vote") }),
|
|
1306
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: cx("fw-card__body"), children: [
|
|
1307
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Bar, { width: "62%" }),
|
|
1308
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Bar, { width: "88%", height: "0.6rem" }),
|
|
1309
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Bar, { width: "34%", height: "0.6rem" })
|
|
1310
|
+
] })
|
|
1311
|
+
] }, index)) });
|
|
1312
|
+
}
|
|
1313
|
+
function Bar({ width, height }) {
|
|
1314
|
+
const { cx } = useBoard();
|
|
1315
|
+
return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: cx("fw-skeleton__bar"), style: { width, height } });
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// src/components/FeatureList.tsx
|
|
1319
|
+
var import_jsx_runtime9 = require("react/jsx-runtime");
|
|
1320
|
+
function FeatureList() {
|
|
1321
|
+
const { initialFeatureId } = useBoard();
|
|
1322
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
|
|
1323
|
+
initialFeatureId && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(PinnedFeature, { featureId: initialFeatureId }),
|
|
1324
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Listing, { excludeId: initialFeatureId })
|
|
1325
|
+
] });
|
|
1326
|
+
}
|
|
1327
|
+
function PinnedFeature({ featureId }) {
|
|
1328
|
+
const { cx } = useBoard();
|
|
1329
|
+
const { feature, isLoading } = useFeature(featureId);
|
|
1330
|
+
if (isLoading) return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureListSkeleton, { rows: 1 });
|
|
1331
|
+
if (!feature) return null;
|
|
1332
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: cx("fw-pinned"), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("ul", { className: cx("fw-list"), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureCard, { feature, defaultCommentsOpen: true }) }) });
|
|
1333
|
+
}
|
|
1334
|
+
function Listing({ excludeId }) {
|
|
1335
|
+
const { tab, showDeclined, labels, cx, renderEmpty, pageSize } = useBoard();
|
|
1336
|
+
const sort = tab === "roadmap" ? "top" : tab;
|
|
1337
|
+
const isRoadmap = tab === "roadmap";
|
|
1338
|
+
const { features, isLoading, isLoadingMore, error, hasMore, loadMore, refresh } = useFeatures({
|
|
1339
|
+
sort,
|
|
1340
|
+
status: isRoadmap ? ROADMAP_STATUSES : void 0,
|
|
1341
|
+
pageSize
|
|
1342
|
+
});
|
|
1343
|
+
const visible = features.filter((feature) => {
|
|
1344
|
+
if (feature.id === excludeId) return false;
|
|
1345
|
+
if (isRoadmap) return ROADMAP_STATUSES.includes(feature.status);
|
|
1346
|
+
return showDeclined || feature.status !== "declined";
|
|
1347
|
+
});
|
|
1348
|
+
if (error && visible.length === 0) {
|
|
1349
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: cx("fw-empty"), role: "alert", children: [
|
|
1350
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { children: userMessage(error) }),
|
|
1351
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
|
|
1352
|
+
"button",
|
|
1353
|
+
{
|
|
1354
|
+
type: "button",
|
|
1355
|
+
className: cx("fw-btn", "fw-btn--ghost"),
|
|
1356
|
+
onClick: () => void refresh(),
|
|
1357
|
+
children: labels.retry
|
|
1358
|
+
}
|
|
1359
|
+
)
|
|
1360
|
+
] });
|
|
1361
|
+
}
|
|
1362
|
+
if (isLoading && visible.length === 0) {
|
|
1363
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureListSkeleton, { roadmap: isRoadmap });
|
|
1364
|
+
}
|
|
1365
|
+
if (visible.length === 0) {
|
|
1366
|
+
if (renderEmpty) return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_jsx_runtime9.Fragment, { children: renderEmpty() });
|
|
1367
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: cx("fw-empty"), children: [
|
|
1368
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: cx("fw-empty__title"), children: labels.emptyTitle }),
|
|
1369
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { children: labels.emptyBody })
|
|
1370
|
+
] });
|
|
1371
|
+
}
|
|
1372
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
|
|
1373
|
+
isRoadmap ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(RoadmapColumns, { features: visible }) : /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Rows2, { features: visible }),
|
|
1374
|
+
hasMore && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: cx("fw-pagination"), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
|
|
1375
|
+
"button",
|
|
1376
|
+
{
|
|
1377
|
+
type: "button",
|
|
1378
|
+
className: cx("fw-btn", "fw-btn--ghost"),
|
|
1379
|
+
disabled: isLoadingMore,
|
|
1380
|
+
onClick: () => void loadMore(),
|
|
1381
|
+
children: isLoadingMore ? labels.loading : labels.loadMore
|
|
1382
|
+
}
|
|
1383
|
+
) })
|
|
1384
|
+
] });
|
|
1385
|
+
}
|
|
1386
|
+
function Rows2({ features }) {
|
|
1387
|
+
const { cx } = useBoard();
|
|
1388
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("ul", { className: cx("fw-list"), children: features.map((feature) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureRow, { feature }, feature.id)) });
|
|
1389
|
+
}
|
|
1390
|
+
function RoadmapColumns({ features }) {
|
|
1391
|
+
const { labels, cx } = useBoard();
|
|
1392
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: cx("fw-roadmap"), children: ROADMAP_STATUSES.map((status) => {
|
|
1393
|
+
const column = features.filter((feature) => feature.status === status);
|
|
1394
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(
|
|
1395
|
+
"section",
|
|
1396
|
+
{
|
|
1397
|
+
className: cx("fw-roadmap__column"),
|
|
1398
|
+
"aria-label": labels.status[status],
|
|
1399
|
+
children: [
|
|
1400
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("h3", { className: cx("fw-roadmap__heading"), children: labels.status[status] }),
|
|
1401
|
+
column.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: cx("fw-muted"), children: "\u2014" }) : /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("ul", { className: cx("fw-list"), children: column.map((feature) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureRow, { feature }, feature.id)) })
|
|
1402
|
+
]
|
|
1403
|
+
},
|
|
1404
|
+
status
|
|
1405
|
+
);
|
|
1406
|
+
}) });
|
|
1407
|
+
}
|
|
1408
|
+
function FeatureRow({ feature }) {
|
|
1409
|
+
const { renderFeature } = useBoard();
|
|
1410
|
+
if (!renderFeature) return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureCard, { feature });
|
|
1411
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureSlot, { feature });
|
|
1412
|
+
}
|
|
1413
|
+
function FeatureSlot({ feature }) {
|
|
1414
|
+
const { renderFeature } = useBoard();
|
|
1415
|
+
const { toggle } = useVote(feature.id);
|
|
1416
|
+
const { follow, unfollow } = useFollow(feature.id);
|
|
1417
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_jsx_runtime9.Fragment, { children: renderFeature?.(feature, { toggleVote: toggle, follow, unfollow }) });
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// src/components/SubmitForm.tsx
|
|
1421
|
+
var import_react14 = require("react");
|
|
1422
|
+
|
|
1423
|
+
// src/hooks/useSubmitFeature.ts
|
|
1424
|
+
var import_react13 = require("react");
|
|
1425
|
+
function useSubmitFeature() {
|
|
1426
|
+
const { store } = useFewchurs();
|
|
1427
|
+
const [isPending, setIsPending] = (0, import_react13.useState)(false);
|
|
1428
|
+
const [error, setError] = (0, import_react13.useState)(null);
|
|
1429
|
+
const submit = (0, import_react13.useCallback)(
|
|
1430
|
+
async (input) => {
|
|
1431
|
+
setIsPending(true);
|
|
1432
|
+
setError(null);
|
|
1433
|
+
const result = await store.submitFeature(input);
|
|
1434
|
+
setIsPending(false);
|
|
1435
|
+
if (!result.ok) {
|
|
1436
|
+
setError(result.error);
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
return result.data;
|
|
1440
|
+
},
|
|
1441
|
+
[store]
|
|
1442
|
+
);
|
|
1443
|
+
const reset = (0, import_react13.useCallback)(() => setError(null), []);
|
|
1444
|
+
return { submit, isPending, error, reset };
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// src/components/SubmitForm.tsx
|
|
1448
|
+
var import_jsx_runtime10 = require("react/jsx-runtime");
|
|
1449
|
+
function SubmitForm() {
|
|
1450
|
+
const { labels, emailField, userEmail, isSubscriber, setFormOpen, setJustSubmitted, cx } = useBoard();
|
|
1451
|
+
const { submit, isPending, error } = useSubmitFeature();
|
|
1452
|
+
const [title, setTitle] = (0, import_react14.useState)("");
|
|
1453
|
+
const [description, setDescription] = (0, import_react14.useState)("");
|
|
1454
|
+
const [email, setEmail] = (0, import_react14.useState)(userEmail ?? "");
|
|
1455
|
+
return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
|
|
1456
|
+
"form",
|
|
1457
|
+
{
|
|
1458
|
+
className: cx("fw-form"),
|
|
1459
|
+
onSubmit: async (event) => {
|
|
1460
|
+
event.preventDefault();
|
|
1461
|
+
const created = await submit({
|
|
1462
|
+
title,
|
|
1463
|
+
description,
|
|
1464
|
+
...emailField !== "off" && email ? { email } : {},
|
|
1465
|
+
isSubscriber
|
|
1466
|
+
});
|
|
1467
|
+
if (!created) return;
|
|
1468
|
+
setTitle("");
|
|
1469
|
+
setDescription("");
|
|
1470
|
+
setJustSubmitted(true);
|
|
1471
|
+
setFormOpen(false);
|
|
1472
|
+
},
|
|
1473
|
+
children: [
|
|
1474
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field"), children: [
|
|
1475
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("label", { className: cx("fw-label"), htmlFor: "fw-title", children: labels.fieldTitle }),
|
|
1476
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
|
|
1477
|
+
"input",
|
|
1478
|
+
{
|
|
1479
|
+
id: "fw-title",
|
|
1480
|
+
className: cx("fw-input"),
|
|
1481
|
+
value: title,
|
|
1482
|
+
minLength: LIMITS.titleMin,
|
|
1483
|
+
maxLength: LIMITS.titleMax,
|
|
1484
|
+
placeholder: labels.fieldTitlePlaceholder,
|
|
1485
|
+
onChange: (event) => setTitle(event.target.value),
|
|
1486
|
+
required: true
|
|
1487
|
+
}
|
|
1488
|
+
)
|
|
1489
|
+
] }),
|
|
1490
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field"), children: [
|
|
1491
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("label", { className: cx("fw-label"), htmlFor: "fw-description", children: labels.fieldDescription }),
|
|
1492
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
|
|
1493
|
+
"textarea",
|
|
1494
|
+
{
|
|
1495
|
+
id: "fw-description",
|
|
1496
|
+
className: cx("fw-input", "fw-textarea"),
|
|
1497
|
+
value: description,
|
|
1498
|
+
maxLength: LIMITS.descriptionMax,
|
|
1499
|
+
placeholder: labels.fieldDescriptionPlaceholder,
|
|
1500
|
+
onChange: (event) => setDescription(event.target.value)
|
|
1501
|
+
}
|
|
1502
|
+
)
|
|
1503
|
+
] }),
|
|
1504
|
+
emailField !== "off" && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field"), children: [
|
|
1505
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("label", { className: cx("fw-label"), htmlFor: "fw-email", children: labels.fieldEmail }),
|
|
1506
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
|
|
1507
|
+
"input",
|
|
1508
|
+
{
|
|
1509
|
+
id: "fw-email",
|
|
1510
|
+
className: cx("fw-input"),
|
|
1511
|
+
type: "email",
|
|
1512
|
+
value: email,
|
|
1513
|
+
placeholder: labels.followPlaceholder,
|
|
1514
|
+
"aria-describedby": "fw-email-hint",
|
|
1515
|
+
onChange: (event) => setEmail(event.target.value),
|
|
1516
|
+
required: emailField === "required"
|
|
1517
|
+
}
|
|
1518
|
+
),
|
|
1519
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { id: "fw-email-hint", className: cx("fw-muted"), children: labels.fieldEmailHint })
|
|
1520
|
+
] }),
|
|
1521
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
|
|
1522
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-form__actions"), children: [
|
|
1523
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: isPending ? labels.submitting : labels.submit }),
|
|
1524
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
|
|
1525
|
+
"button",
|
|
1526
|
+
{
|
|
1527
|
+
type: "button",
|
|
1528
|
+
className: cx("fw-btn", "fw-btn--ghost"),
|
|
1529
|
+
onClick: () => setFormOpen(false),
|
|
1530
|
+
children: labels.cancel
|
|
1531
|
+
}
|
|
1532
|
+
)
|
|
1533
|
+
] })
|
|
1534
|
+
]
|
|
1535
|
+
}
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// src/components/theme.ts
|
|
1540
|
+
var VARIABLE_BY_KEY = {
|
|
1541
|
+
primary: "--fw-primary",
|
|
1542
|
+
radius: "--fw-radius",
|
|
1543
|
+
surface: "--fw-surface",
|
|
1544
|
+
border: "--fw-border",
|
|
1545
|
+
text: "--fw-text",
|
|
1546
|
+
subtle: "--fw-subtle",
|
|
1547
|
+
font: "--fw-font"
|
|
1548
|
+
};
|
|
1549
|
+
function themeToStyle(theme) {
|
|
1550
|
+
if (!theme) return void 0;
|
|
1551
|
+
const style = {};
|
|
1552
|
+
for (const [key, value] of Object.entries(theme)) {
|
|
1553
|
+
if (value === void 0 || value === null) continue;
|
|
1554
|
+
const variable = VARIABLE_BY_KEY[key];
|
|
1555
|
+
style[variable] = typeof value === "number" ? `${value}px` : value;
|
|
1556
|
+
}
|
|
1557
|
+
return style;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// src/components/Board.tsx
|
|
1561
|
+
var import_jsx_runtime11 = require("react/jsx-runtime");
|
|
1562
|
+
var DEFAULT_TABS = ["top", "new"];
|
|
1563
|
+
function BoardRoot({
|
|
1564
|
+
tabs = DEFAULT_TABS,
|
|
1565
|
+
defaultTab,
|
|
1566
|
+
initialFeatureId,
|
|
1567
|
+
showDeclined = false,
|
|
1568
|
+
allowComments = true,
|
|
1569
|
+
emailField = "optional",
|
|
1570
|
+
pageSize,
|
|
1571
|
+
labels,
|
|
1572
|
+
theme,
|
|
1573
|
+
unstyled = false,
|
|
1574
|
+
colorScheme = "system",
|
|
1575
|
+
className,
|
|
1576
|
+
style,
|
|
1577
|
+
userEmail,
|
|
1578
|
+
isSubscriber = false,
|
|
1579
|
+
renderHeader,
|
|
1580
|
+
renderEmpty,
|
|
1581
|
+
renderFeature,
|
|
1582
|
+
children
|
|
1583
|
+
}) {
|
|
1584
|
+
const { store } = useFewchurs();
|
|
1585
|
+
const { hideVoteCounts } = useFewchursConfig();
|
|
1586
|
+
const [tab, setTab] = (0, import_react15.useState)(defaultTab ?? tabs[0] ?? "top");
|
|
1587
|
+
const [isFormOpen, setFormOpen] = (0, import_react15.useState)(false);
|
|
1588
|
+
const [justSubmitted, setJustSubmitted] = (0, import_react15.useState)(false);
|
|
1589
|
+
(0, import_react15.useEffect)(() => {
|
|
1590
|
+
store.emit({ name: "boardOpened" });
|
|
1591
|
+
}, [store]);
|
|
1592
|
+
const value = (0, import_react15.useMemo)(
|
|
1593
|
+
() => ({
|
|
1594
|
+
labels: mergeLabels(labels),
|
|
1595
|
+
unstyled,
|
|
1596
|
+
allowComments,
|
|
1597
|
+
emailField,
|
|
1598
|
+
showDeclined,
|
|
1599
|
+
hideVoteCounts,
|
|
1600
|
+
pageSize,
|
|
1601
|
+
userEmail,
|
|
1602
|
+
isSubscriber,
|
|
1603
|
+
tab,
|
|
1604
|
+
setTab,
|
|
1605
|
+
tabs,
|
|
1606
|
+
initialFeatureId,
|
|
1607
|
+
isFormOpen,
|
|
1608
|
+
setFormOpen,
|
|
1609
|
+
justSubmitted,
|
|
1610
|
+
setJustSubmitted,
|
|
1611
|
+
renderFeature,
|
|
1612
|
+
renderEmpty,
|
|
1613
|
+
renderHeader,
|
|
1614
|
+
cx: makeClassName(unstyled)
|
|
1615
|
+
}),
|
|
1616
|
+
[
|
|
1617
|
+
labels,
|
|
1618
|
+
unstyled,
|
|
1619
|
+
allowComments,
|
|
1620
|
+
emailField,
|
|
1621
|
+
showDeclined,
|
|
1622
|
+
hideVoteCounts,
|
|
1623
|
+
pageSize,
|
|
1624
|
+
userEmail,
|
|
1625
|
+
isSubscriber,
|
|
1626
|
+
tab,
|
|
1627
|
+
tabs,
|
|
1628
|
+
initialFeatureId,
|
|
1629
|
+
isFormOpen,
|
|
1630
|
+
justSubmitted,
|
|
1631
|
+
renderFeature,
|
|
1632
|
+
renderEmpty,
|
|
1633
|
+
renderHeader
|
|
1634
|
+
]
|
|
1635
|
+
);
|
|
1636
|
+
return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(BoardContextProvider, { value, children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
|
|
1637
|
+
"section",
|
|
1638
|
+
{
|
|
1639
|
+
className: [unstyled ? "" : "fewchurs", className].filter(Boolean).join(" ") || void 0,
|
|
1640
|
+
style: { ...themeToStyle(theme), ...style },
|
|
1641
|
+
"data-theme": colorScheme === "system" ? void 0 : colorScheme,
|
|
1642
|
+
children: children ?? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(DefaultBoard, {})
|
|
1643
|
+
}
|
|
1644
|
+
) });
|
|
1645
|
+
}
|
|
1646
|
+
function DefaultBoard() {
|
|
1647
|
+
const { isFormOpen, justSubmitted, labels, renderHeader, cx } = useBoard();
|
|
1648
|
+
return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_jsx_runtime11.Fragment, { children: [
|
|
1649
|
+
renderHeader ? renderHeader() : /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(BoardHeading, {}),
|
|
1650
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: cx("fw-toolbar"), children: [
|
|
1651
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(BoardTabs, {}),
|
|
1652
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(SubmitTrigger, {})
|
|
1653
|
+
] }),
|
|
1654
|
+
justSubmitted && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("p", { className: cx("fw-alert"), role: "status", children: labels.submitted }),
|
|
1655
|
+
isFormOpen && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(SubmitForm, {}),
|
|
1656
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(FeatureList, {}),
|
|
1657
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Branding, {})
|
|
1658
|
+
] });
|
|
1659
|
+
}
|
|
1660
|
+
function BoardHeading() {
|
|
1661
|
+
const { labels, cx } = useBoard();
|
|
1662
|
+
return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("h2", { className: cx("fw-heading"), children: labels.title });
|
|
1663
|
+
}
|
|
1664
|
+
function BoardTabs() {
|
|
1665
|
+
const { tabs, tab, setTab, labels, cx } = useBoard();
|
|
1666
|
+
if (tabs.length < 2) return null;
|
|
1667
|
+
const labelOf = {
|
|
1668
|
+
top: labels.tabTop,
|
|
1669
|
+
new: labels.tabNew,
|
|
1670
|
+
roadmap: labels.tabRoadmap
|
|
1671
|
+
};
|
|
1672
|
+
return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: cx("fw-tabs"), role: "tablist", "aria-label": labels.title, children: tabs.map((name) => /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
|
|
1673
|
+
"button",
|
|
1674
|
+
{
|
|
1675
|
+
type: "button",
|
|
1676
|
+
role: "tab",
|
|
1677
|
+
id: `fw-tab-${name}`,
|
|
1678
|
+
className: cx("fw-tab"),
|
|
1679
|
+
"aria-selected": tab === name,
|
|
1680
|
+
tabIndex: tab === name ? 0 : -1,
|
|
1681
|
+
onClick: () => setTab(name),
|
|
1682
|
+
onKeyDown: (event) => {
|
|
1683
|
+
const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
|
|
1684
|
+
if (step === 0) return;
|
|
1685
|
+
event.preventDefault();
|
|
1686
|
+
const next = tabs[(tabs.indexOf(tab) + step + tabs.length) % tabs.length];
|
|
1687
|
+
if (next) {
|
|
1688
|
+
setTab(next);
|
|
1689
|
+
document.getElementById(`fw-tab-${next}`)?.focus();
|
|
1690
|
+
}
|
|
1691
|
+
},
|
|
1692
|
+
children: labelOf[name]
|
|
1693
|
+
},
|
|
1694
|
+
name
|
|
1695
|
+
)) });
|
|
1696
|
+
}
|
|
1697
|
+
function SubmitTrigger() {
|
|
1698
|
+
const { isFormOpen, setFormOpen, setJustSubmitted, labels, cx } = useBoard();
|
|
1699
|
+
return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
|
|
1700
|
+
"button",
|
|
1701
|
+
{
|
|
1702
|
+
type: "button",
|
|
1703
|
+
className: cx("fw-btn"),
|
|
1704
|
+
"aria-expanded": isFormOpen,
|
|
1705
|
+
onClick: () => {
|
|
1706
|
+
if (!isFormOpen) setJustSubmitted(false);
|
|
1707
|
+
setFormOpen(!isFormOpen);
|
|
1708
|
+
},
|
|
1709
|
+
children: isFormOpen ? labels.cancel : labels.newRequest
|
|
1710
|
+
}
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
var FewchursBoard = Object.assign(BoardRoot, {
|
|
1714
|
+
Root: BoardRoot,
|
|
1715
|
+
Tabs: BoardTabs,
|
|
1716
|
+
List: FeatureList,
|
|
1717
|
+
SubmitTrigger,
|
|
1718
|
+
SubmitForm,
|
|
1719
|
+
Branding
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
// src/components/BoardDialog.tsx
|
|
1723
|
+
var import_react16 = require("react");
|
|
1724
|
+
var import_jsx_runtime12 = require("react/jsx-runtime");
|
|
1725
|
+
function FewchursBoardDialog({
|
|
1726
|
+
open,
|
|
1727
|
+
onClose,
|
|
1728
|
+
ariaLabel = "Feature requests",
|
|
1729
|
+
...boardProps
|
|
1730
|
+
}) {
|
|
1731
|
+
const dialogRef = (0, import_react16.useRef)(null);
|
|
1732
|
+
const openerRef = (0, import_react16.useRef)(null);
|
|
1733
|
+
(0, import_react16.useEffect)(() => {
|
|
1734
|
+
if (!open) return;
|
|
1735
|
+
openerRef.current = document.activeElement;
|
|
1736
|
+
dialogRef.current?.focus();
|
|
1737
|
+
function onKeyDown(event) {
|
|
1738
|
+
if (event.key === "Escape") onClose();
|
|
1739
|
+
}
|
|
1740
|
+
document.addEventListener("keydown", onKeyDown);
|
|
1741
|
+
return () => {
|
|
1742
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
1743
|
+
openerRef.current?.focus?.();
|
|
1744
|
+
};
|
|
1745
|
+
}, [open, onClose]);
|
|
1746
|
+
if (!open) return null;
|
|
1747
|
+
return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "fw-overlay", onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(
|
|
1748
|
+
"div",
|
|
1749
|
+
{
|
|
1750
|
+
ref: dialogRef,
|
|
1751
|
+
className: "fw-dialog",
|
|
1752
|
+
role: "dialog",
|
|
1753
|
+
"aria-modal": "true",
|
|
1754
|
+
"aria-label": ariaLabel,
|
|
1755
|
+
tabIndex: -1,
|
|
1756
|
+
onClick: (event) => event.stopPropagation(),
|
|
1757
|
+
children: [
|
|
1758
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("button", { type: "button", className: "fw-dialog__close", "aria-label": "Close", onClick: onClose, children: "\xD7" }),
|
|
1759
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)(BoardRoot, { ...boardProps })
|
|
1760
|
+
]
|
|
1761
|
+
}
|
|
1762
|
+
) });
|
|
1763
|
+
}
|
|
1764
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1765
|
+
0 && (module.exports = {
|
|
1766
|
+
BoardRoot,
|
|
1767
|
+
BoardTabs,
|
|
1768
|
+
Branding,
|
|
1769
|
+
CommentForm,
|
|
1770
|
+
CommentList,
|
|
1771
|
+
DEFAULT_BASE_URL,
|
|
1772
|
+
DEFAULT_LABELS,
|
|
1773
|
+
DEFAULT_PAGE_SIZE,
|
|
1774
|
+
DEVICE_ID_COOKIE,
|
|
1775
|
+
FeatureCard,
|
|
1776
|
+
FeatureList,
|
|
1777
|
+
FewchursBoard,
|
|
1778
|
+
FewchursBoardDialog,
|
|
1779
|
+
FewchursProvider,
|
|
1780
|
+
FollowForm,
|
|
1781
|
+
LIMITS,
|
|
1782
|
+
ROADMAP_STATUSES,
|
|
1783
|
+
SDK_HEADER,
|
|
1784
|
+
SDK_VERSION,
|
|
1785
|
+
StatusBadge,
|
|
1786
|
+
SubmitForm,
|
|
1787
|
+
SubmitTrigger,
|
|
1788
|
+
VoteButton,
|
|
1789
|
+
commentsStateOf,
|
|
1790
|
+
createDeviceId,
|
|
1791
|
+
createFewchursClient,
|
|
1792
|
+
createStore,
|
|
1793
|
+
errorFromResponse,
|
|
1794
|
+
featureKey,
|
|
1795
|
+
listKey,
|
|
1796
|
+
listStateOf,
|
|
1797
|
+
mergeLabels,
|
|
1798
|
+
networkError,
|
|
1799
|
+
normalizeComment,
|
|
1800
|
+
normalizeCommentPage,
|
|
1801
|
+
normalizeConfig,
|
|
1802
|
+
normalizeFeature,
|
|
1803
|
+
normalizeFeaturePage,
|
|
1804
|
+
normalizeStatus,
|
|
1805
|
+
normalizeVote,
|
|
1806
|
+
readDeviceId,
|
|
1807
|
+
resolveDeviceId,
|
|
1808
|
+
useBoard,
|
|
1809
|
+
useComments,
|
|
1810
|
+
useFeature,
|
|
1811
|
+
useFeatures,
|
|
1812
|
+
useFewchurs,
|
|
1813
|
+
useFewchursConfig,
|
|
1814
|
+
useFollow,
|
|
1815
|
+
useSubmitFeature,
|
|
1816
|
+
useVote,
|
|
1817
|
+
userMessage,
|
|
1818
|
+
validateCommentInput,
|
|
1819
|
+
validateEmail,
|
|
1820
|
+
validateFeatureInput,
|
|
1821
|
+
validationError
|
|
1822
|
+
});
|
|
1823
|
+
//# sourceMappingURL=index.js.map
|