@mailoo/blog 0.0.0-stage → 1.33.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 +16 -0
- package/README.md +142 -2
- package/dist/client/article-body.cjs +115 -0
- package/dist/client/article-body.cjs.map +1 -0
- package/dist/client/article-body.d.cts +15 -0
- package/dist/client/article-body.d.ts +15 -0
- package/dist/client/article-body.js +80 -0
- package/dist/client/article-body.js.map +1 -0
- package/dist/client/index.cjs +776 -0
- package/dist/client/index.cjs.map +1 -0
- package/dist/client/index.d.cts +6 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +731 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/linked-links.cjs +125 -0
- package/dist/client/linked-links.cjs.map +1 -0
- package/dist/client/linked-links.d.cts +31 -0
- package/dist/client/linked-links.d.ts +31 -0
- package/dist/client/linked-links.js +100 -0
- package/dist/client/linked-links.js.map +1 -0
- package/dist/client/marks.cjs +300 -0
- package/dist/client/marks.cjs.map +1 -0
- package/dist/client/marks.d.cts +30 -0
- package/dist/client/marks.d.ts +30 -0
- package/dist/client/marks.js +276 -0
- package/dist/client/marks.js.map +1 -0
- package/dist/client/printable.cjs +106 -0
- package/dist/client/printable.cjs.map +1 -0
- package/dist/client/printable.d.cts +12 -0
- package/dist/client/printable.d.ts +12 -0
- package/dist/client/printable.js +82 -0
- package/dist/client/printable.js.map +1 -0
- package/dist/client/share.cjs +234 -0
- package/dist/client/share.cjs.map +1 -0
- package/dist/client/share.d.cts +41 -0
- package/dist/client/share.d.ts +41 -0
- package/dist/client/share.js +205 -0
- package/dist/client/share.js.map +1 -0
- package/dist/index.cjs +89 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/routes/index.cjs +266 -0
- package/dist/routes/index.cjs.map +1 -0
- package/dist/routes/index.d.cts +87 -0
- package/dist/routes/index.d.ts +87 -0
- package/dist/routes/index.js +240 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/server/index.cjs +368 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.cts +132 -0
- package/dist/server/index.d.ts +132 -0
- package/dist/server/index.js +339 -0
- package/dist/server/index.js.map +1 -0
- package/dist/utils-DXbN9MTi.d.cts +120 -0
- package/dist/utils-DXbN9MTi.d.ts +120 -0
- package/package.json +110 -3
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
"use client";
|
|
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/components/blog-article-marks.tsx
|
|
22
|
+
var blog_article_marks_exports = {};
|
|
23
|
+
__export(blog_article_marks_exports, {
|
|
24
|
+
BlogArticleMarks: () => BlogArticleMarks
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(blog_article_marks_exports);
|
|
27
|
+
var import_react = require("react");
|
|
28
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
29
|
+
var STORAGE_KEY = "mailoo_blog_visitor_key";
|
|
30
|
+
var VISITOR_STATE_PREFIX = "mailoo_blog_mark:";
|
|
31
|
+
var BTN = "inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-60";
|
|
32
|
+
var BTN_IDLE = "border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 focus:ring-gray-500 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800";
|
|
33
|
+
var BTN_ACTIVE = "border-blue-600 bg-blue-50 text-blue-800 focus:ring-blue-500 dark:border-blue-400 dark:bg-blue-950 dark:text-blue-200";
|
|
34
|
+
function createVisitorKey() {
|
|
35
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
36
|
+
return crypto.randomUUID();
|
|
37
|
+
}
|
|
38
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
39
|
+
const r = Math.random() * 16 | 0;
|
|
40
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
41
|
+
return v.toString(16);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function readVisitorKey() {
|
|
45
|
+
try {
|
|
46
|
+
const existing = localStorage.getItem(STORAGE_KEY);
|
|
47
|
+
if (existing && existing.length >= 32) return existing;
|
|
48
|
+
const next = createVisitorKey();
|
|
49
|
+
localStorage.setItem(STORAGE_KEY, next);
|
|
50
|
+
return next;
|
|
51
|
+
} catch {
|
|
52
|
+
return createVisitorKey();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function readLocalMark(slug) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = localStorage.getItem(`${VISITOR_STATE_PREFIX}${slug}`);
|
|
58
|
+
if (!raw) return { sentiment: null, hasFeedback: false };
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
return {
|
|
61
|
+
sentiment: parsed.sentiment === "LIKE" || parsed.sentiment === "DISLIKE" ? parsed.sentiment : null,
|
|
62
|
+
hasFeedback: Boolean(parsed.hasFeedback)
|
|
63
|
+
};
|
|
64
|
+
} catch {
|
|
65
|
+
return { sentiment: null, hasFeedback: false };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function writeLocalMark(slug, mark) {
|
|
69
|
+
try {
|
|
70
|
+
localStorage.setItem(
|
|
71
|
+
`${VISITOR_STATE_PREFIX}${slug}`,
|
|
72
|
+
JSON.stringify(mark)
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function postMark(apiBasePath, slug, kind, body) {
|
|
78
|
+
const res = await fetch(
|
|
79
|
+
`${apiBasePath}/slug/${encodeURIComponent(slug)}/${kind}`,
|
|
80
|
+
{
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify(body)
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
const json = await res.json();
|
|
87
|
+
if (!res.ok || !json.data) {
|
|
88
|
+
throw new Error(json.message || json.error || `HTTP ${res.status}`);
|
|
89
|
+
}
|
|
90
|
+
return json.data;
|
|
91
|
+
}
|
|
92
|
+
function BlogArticleMarks({
|
|
93
|
+
slug,
|
|
94
|
+
locale,
|
|
95
|
+
likeCount = 0,
|
|
96
|
+
dislikeCount = 0,
|
|
97
|
+
feedbackCount = 0,
|
|
98
|
+
apiBasePath = "/api/v1/blog",
|
|
99
|
+
labels,
|
|
100
|
+
className
|
|
101
|
+
}) {
|
|
102
|
+
const [visitorKey, setVisitorKey] = (0, import_react.useState)(null);
|
|
103
|
+
const [counts, setCounts] = (0, import_react.useState)({
|
|
104
|
+
likeCount,
|
|
105
|
+
dislikeCount,
|
|
106
|
+
feedbackCount
|
|
107
|
+
});
|
|
108
|
+
const [sentiment, setSentiment] = (0, import_react.useState)(
|
|
109
|
+
null
|
|
110
|
+
);
|
|
111
|
+
const [hasFeedback, setHasFeedback] = (0, import_react.useState)(false);
|
|
112
|
+
const [pending, setPending] = (0, import_react.useState)(false);
|
|
113
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
114
|
+
const [modal, setModal] = (0, import_react.useState)(null);
|
|
115
|
+
const [comment, setComment] = (0, import_react.useState)("");
|
|
116
|
+
(0, import_react.useEffect)(() => {
|
|
117
|
+
const key = readVisitorKey();
|
|
118
|
+
setVisitorKey(key);
|
|
119
|
+
const local = readLocalMark(slug);
|
|
120
|
+
setSentiment(local.sentiment);
|
|
121
|
+
setHasFeedback(local.hasFeedback);
|
|
122
|
+
}, [slug]);
|
|
123
|
+
(0, import_react.useEffect)(() => {
|
|
124
|
+
setCounts({ likeCount, dislikeCount, feedbackCount });
|
|
125
|
+
}, [likeCount, dislikeCount, feedbackCount]);
|
|
126
|
+
const applyState = (0, import_react.useCallback)(
|
|
127
|
+
(state) => {
|
|
128
|
+
setCounts({
|
|
129
|
+
likeCount: state.likeCount,
|
|
130
|
+
dislikeCount: state.dislikeCount,
|
|
131
|
+
feedbackCount: state.feedbackCount
|
|
132
|
+
});
|
|
133
|
+
setSentiment(state.sentiment);
|
|
134
|
+
setHasFeedback(state.hasFeedback);
|
|
135
|
+
writeLocalMark(slug, {
|
|
136
|
+
sentiment: state.sentiment,
|
|
137
|
+
hasFeedback: state.hasFeedback
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
[slug]
|
|
141
|
+
);
|
|
142
|
+
const run = (0, import_react.useCallback)(
|
|
143
|
+
async (kind, body) => {
|
|
144
|
+
if (!visitorKey || pending) return;
|
|
145
|
+
setPending(true);
|
|
146
|
+
setError(null);
|
|
147
|
+
try {
|
|
148
|
+
const state = await postMark(apiBasePath, slug, kind, {
|
|
149
|
+
...body,
|
|
150
|
+
visitorKey,
|
|
151
|
+
...locale ? { locale } : {}
|
|
152
|
+
});
|
|
153
|
+
applyState(state);
|
|
154
|
+
setModal(null);
|
|
155
|
+
setComment("");
|
|
156
|
+
} catch {
|
|
157
|
+
setError(labels.error);
|
|
158
|
+
} finally {
|
|
159
|
+
setPending(false);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
[apiBasePath, applyState, labels.error, locale, pending, slug, visitorKey]
|
|
163
|
+
);
|
|
164
|
+
const onLike = (0, import_react.useCallback)(() => {
|
|
165
|
+
if (sentiment === "LIKE") {
|
|
166
|
+
void run("reaction", { action: "clear" });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
void run("reaction", { action: "set", sentiment: "LIKE" });
|
|
170
|
+
}, [run, sentiment]);
|
|
171
|
+
const onDislikeClick = (0, import_react.useCallback)(() => {
|
|
172
|
+
if (sentiment === "DISLIKE") {
|
|
173
|
+
void run("reaction", { action: "clear" });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (hasFeedback) {
|
|
177
|
+
void run("reaction", { action: "set", sentiment: "DISLIKE" });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
setModal("dislike");
|
|
181
|
+
}, [hasFeedback, run, sentiment]);
|
|
182
|
+
const onSubmitModal = (0, import_react.useCallback)(() => {
|
|
183
|
+
const trimmed = comment.trim();
|
|
184
|
+
if (!trimmed) {
|
|
185
|
+
setError(labels.dislikeRequiresComment);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (modal === "dislike") {
|
|
189
|
+
void run("reaction", {
|
|
190
|
+
action: "set",
|
|
191
|
+
sentiment: "DISLIKE",
|
|
192
|
+
comment: trimmed
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
void run("feedback", { action: "set", comment: trimmed });
|
|
197
|
+
}, [comment, labels.dislikeRequiresComment, modal, run]);
|
|
198
|
+
const rootClass = (0, import_react.useMemo)(
|
|
199
|
+
() => [
|
|
200
|
+
"mt-8 flex flex-col gap-3 border-t border-gray-200 pt-6 dark:border-gray-700",
|
|
201
|
+
className
|
|
202
|
+
].filter(Boolean).join(" "),
|
|
203
|
+
[className]
|
|
204
|
+
);
|
|
205
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: rootClass, children: [
|
|
206
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
207
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
208
|
+
"button",
|
|
209
|
+
{
|
|
210
|
+
type: "button",
|
|
211
|
+
className: `${BTN} ${sentiment === "LIKE" ? BTN_ACTIVE : BTN_IDLE}`,
|
|
212
|
+
disabled: pending || !visitorKey,
|
|
213
|
+
onClick: onLike,
|
|
214
|
+
"aria-pressed": sentiment === "LIKE",
|
|
215
|
+
children: [
|
|
216
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: labels.like }),
|
|
217
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tabular-nums", children: counts.likeCount })
|
|
218
|
+
]
|
|
219
|
+
}
|
|
220
|
+
),
|
|
221
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
222
|
+
"button",
|
|
223
|
+
{
|
|
224
|
+
type: "button",
|
|
225
|
+
className: `${BTN} ${sentiment === "DISLIKE" ? BTN_ACTIVE : BTN_IDLE}`,
|
|
226
|
+
disabled: pending || !visitorKey,
|
|
227
|
+
onClick: onDislikeClick,
|
|
228
|
+
"aria-pressed": sentiment === "DISLIKE",
|
|
229
|
+
children: [
|
|
230
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: labels.dislike }),
|
|
231
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tabular-nums", children: counts.dislikeCount })
|
|
232
|
+
]
|
|
233
|
+
}
|
|
234
|
+
),
|
|
235
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
236
|
+
"button",
|
|
237
|
+
{
|
|
238
|
+
type: "button",
|
|
239
|
+
className: `${BTN} ${hasFeedback ? BTN_ACTIVE : BTN_IDLE}`,
|
|
240
|
+
disabled: pending || !visitorKey,
|
|
241
|
+
onClick: () => {
|
|
242
|
+
setError(null);
|
|
243
|
+
setModal("feedback");
|
|
244
|
+
},
|
|
245
|
+
"aria-pressed": hasFeedback,
|
|
246
|
+
children: [
|
|
247
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: labels.feedback }),
|
|
248
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tabular-nums", children: counts.feedbackCount })
|
|
249
|
+
]
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
] }),
|
|
253
|
+
error ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "text-sm text-red-600 dark:text-red-400", role: "alert", children: error }) : null,
|
|
254
|
+
modal ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "rounded-lg border border-gray-200 p-4 dark:border-gray-700", children: [
|
|
255
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "mb-2 block text-sm font-medium text-gray-800 dark:text-gray-200", children: modal === "dislike" ? labels.dislikeRequiresComment : labels.feedback }),
|
|
256
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
257
|
+
"textarea",
|
|
258
|
+
{
|
|
259
|
+
className: "mb-3 w-full rounded-md border border-gray-300 bg-white p-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100",
|
|
260
|
+
rows: 3,
|
|
261
|
+
maxLength: 1e3,
|
|
262
|
+
value: comment,
|
|
263
|
+
placeholder: labels.feedbackPlaceholder,
|
|
264
|
+
onChange: (e) => setComment(e.target.value)
|
|
265
|
+
}
|
|
266
|
+
),
|
|
267
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "flex gap-2", children: [
|
|
268
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
269
|
+
"button",
|
|
270
|
+
{
|
|
271
|
+
type: "button",
|
|
272
|
+
className: `${BTN} ${BTN_ACTIVE}`,
|
|
273
|
+
disabled: pending,
|
|
274
|
+
onClick: onSubmitModal,
|
|
275
|
+
children: labels.submit
|
|
276
|
+
}
|
|
277
|
+
),
|
|
278
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
279
|
+
"button",
|
|
280
|
+
{
|
|
281
|
+
type: "button",
|
|
282
|
+
className: `${BTN} ${BTN_IDLE}`,
|
|
283
|
+
disabled: pending,
|
|
284
|
+
onClick: () => {
|
|
285
|
+
setModal(null);
|
|
286
|
+
setComment("");
|
|
287
|
+
setError(null);
|
|
288
|
+
},
|
|
289
|
+
children: labels.cancel
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
] })
|
|
293
|
+
] }) : null
|
|
294
|
+
] });
|
|
295
|
+
}
|
|
296
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
297
|
+
0 && (module.exports = {
|
|
298
|
+
BlogArticleMarks
|
|
299
|
+
});
|
|
300
|
+
//# sourceMappingURL=marks.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/blog-article-marks.tsx"],"sourcesContent":["'use client'\n\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type {\n BlogArticleMarkCounts,\n BlogArticleMarkState,\n BlogPostReactionSentiment,\n} from '../types.js'\n\nconst STORAGE_KEY = 'mailoo_blog_visitor_key'\nconst VISITOR_STATE_PREFIX = 'mailoo_blog_mark:'\n\nconst BTN =\n 'inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-60'\nconst BTN_IDLE =\n 'border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 focus:ring-gray-500 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800'\nconst BTN_ACTIVE =\n 'border-blue-600 bg-blue-50 text-blue-800 focus:ring-blue-500 dark:border-blue-400 dark:bg-blue-950 dark:text-blue-200'\n\nfunction createVisitorKey(): string {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n return crypto.randomUUID()\n }\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0\n const v = c === 'x' ? r : (r & 0x3) | 0x8\n return v.toString(16)\n })\n}\n\nfunction readVisitorKey(): string {\n try {\n const existing = localStorage.getItem(STORAGE_KEY)\n if (existing && existing.length >= 32) return existing\n const next = createVisitorKey()\n localStorage.setItem(STORAGE_KEY, next)\n return next\n } catch {\n return createVisitorKey()\n }\n}\n\ntype LocalMark = {\n sentiment: BlogPostReactionSentiment | null\n hasFeedback: boolean\n}\n\nfunction readLocalMark(slug: string): LocalMark {\n try {\n const raw = localStorage.getItem(`${VISITOR_STATE_PREFIX}${slug}`)\n if (!raw) return { sentiment: null, hasFeedback: false }\n const parsed = JSON.parse(raw) as LocalMark\n return {\n sentiment:\n parsed.sentiment === 'LIKE' || parsed.sentiment === 'DISLIKE'\n ? parsed.sentiment\n : null,\n hasFeedback: Boolean(parsed.hasFeedback),\n }\n } catch {\n return { sentiment: null, hasFeedback: false }\n }\n}\n\nfunction writeLocalMark(slug: string, mark: LocalMark): void {\n try {\n localStorage.setItem(\n `${VISITOR_STATE_PREFIX}${slug}`,\n JSON.stringify(mark)\n )\n } catch {\n // ignore quota / private mode\n }\n}\n\nexport type BlogArticleMarksLabels = {\n like: string\n dislike: string\n feedback: string\n feedbackPlaceholder: string\n submit: string\n cancel: string\n dislikeRequiresComment: string\n error: string\n}\n\nexport type BlogArticleMarksProps = {\n slug: string\n locale?: string\n likeCount?: number\n dislikeCount?: number\n feedbackCount?: number\n /** Same-origin BFF base, e.g. `/api/v1/blog` */\n apiBasePath?: string\n labels: BlogArticleMarksLabels\n className?: string\n}\n\nasync function postMark(\n apiBasePath: string,\n slug: string,\n kind: 'reaction' | 'feedback',\n body: Record<string, unknown>\n): Promise<BlogArticleMarkState> {\n const res = await fetch(\n `${apiBasePath}/slug/${encodeURIComponent(slug)}/${kind}`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n }\n )\n const json = (await res.json()) as {\n data?: BlogArticleMarkState\n error?: string\n message?: string\n }\n if (!res.ok || !json.data) {\n throw new Error(json.message || json.error || `HTTP ${res.status}`)\n }\n return json.data\n}\n\n/**\n * Public like / dislike / feedback controls with counts.\n * POSTs to same-origin BFF (never sends API key).\n */\nexport function BlogArticleMarks({\n slug,\n locale,\n likeCount = 0,\n dislikeCount = 0,\n feedbackCount = 0,\n apiBasePath = '/api/v1/blog',\n labels,\n className,\n}: BlogArticleMarksProps) {\n const [visitorKey, setVisitorKey] = useState<string | null>(null)\n const [counts, setCounts] = useState<BlogArticleMarkCounts>({\n likeCount,\n dislikeCount,\n feedbackCount,\n })\n const [sentiment, setSentiment] = useState<BlogPostReactionSentiment | null>(\n null\n )\n const [hasFeedback, setHasFeedback] = useState(false)\n const [pending, setPending] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [modal, setModal] = useState<'dislike' | 'feedback' | null>(null)\n const [comment, setComment] = useState('')\n\n useEffect(() => {\n const key = readVisitorKey()\n setVisitorKey(key)\n const local = readLocalMark(slug)\n setSentiment(local.sentiment)\n setHasFeedback(local.hasFeedback)\n }, [slug])\n\n useEffect(() => {\n setCounts({ likeCount, dislikeCount, feedbackCount })\n }, [likeCount, dislikeCount, feedbackCount])\n\n const applyState = useCallback(\n (state: BlogArticleMarkState) => {\n setCounts({\n likeCount: state.likeCount,\n dislikeCount: state.dislikeCount,\n feedbackCount: state.feedbackCount,\n })\n setSentiment(state.sentiment)\n setHasFeedback(state.hasFeedback)\n writeLocalMark(slug, {\n sentiment: state.sentiment,\n hasFeedback: state.hasFeedback,\n })\n },\n [slug]\n )\n\n const run = useCallback(\n async (kind: 'reaction' | 'feedback', body: Record<string, unknown>) => {\n if (!visitorKey || pending) return\n setPending(true)\n setError(null)\n try {\n const state = await postMark(apiBasePath, slug, kind, {\n ...body,\n visitorKey,\n ...(locale ? { locale } : {}),\n })\n applyState(state)\n setModal(null)\n setComment('')\n } catch {\n setError(labels.error)\n } finally {\n setPending(false)\n }\n },\n [apiBasePath, applyState, labels.error, locale, pending, slug, visitorKey]\n )\n\n const onLike = useCallback(() => {\n if (sentiment === 'LIKE') {\n void run('reaction', { action: 'clear' })\n return\n }\n void run('reaction', { action: 'set', sentiment: 'LIKE' })\n }, [run, sentiment])\n\n const onDislikeClick = useCallback(() => {\n if (sentiment === 'DISLIKE') {\n void run('reaction', { action: 'clear' })\n return\n }\n if (hasFeedback) {\n void run('reaction', { action: 'set', sentiment: 'DISLIKE' })\n return\n }\n setModal('dislike')\n }, [hasFeedback, run, sentiment])\n\n const onSubmitModal = useCallback(() => {\n const trimmed = comment.trim()\n if (!trimmed) {\n setError(labels.dislikeRequiresComment)\n return\n }\n if (modal === 'dislike') {\n void run('reaction', {\n action: 'set',\n sentiment: 'DISLIKE',\n comment: trimmed,\n })\n return\n }\n void run('feedback', { action: 'set', comment: trimmed })\n }, [comment, labels.dislikeRequiresComment, modal, run])\n\n const rootClass = useMemo(\n () =>\n [\n 'mt-8 flex flex-col gap-3 border-t border-gray-200 pt-6 dark:border-gray-700',\n className,\n ]\n .filter(Boolean)\n .join(' '),\n [className]\n )\n\n return (\n <div className={rootClass}>\n <div className=\"flex flex-wrap items-center gap-2\">\n <button\n type=\"button\"\n className={`${BTN} ${sentiment === 'LIKE' ? BTN_ACTIVE : BTN_IDLE}`}\n disabled={pending || !visitorKey}\n onClick={onLike}\n aria-pressed={sentiment === 'LIKE'}\n >\n <span>{labels.like}</span>\n <span className=\"tabular-nums\">{counts.likeCount}</span>\n </button>\n <button\n type=\"button\"\n className={`${BTN} ${sentiment === 'DISLIKE' ? BTN_ACTIVE : BTN_IDLE}`}\n disabled={pending || !visitorKey}\n onClick={onDislikeClick}\n aria-pressed={sentiment === 'DISLIKE'}\n >\n <span>{labels.dislike}</span>\n <span className=\"tabular-nums\">{counts.dislikeCount}</span>\n </button>\n <button\n type=\"button\"\n className={`${BTN} ${hasFeedback ? BTN_ACTIVE : BTN_IDLE}`}\n disabled={pending || !visitorKey}\n onClick={() => {\n setError(null)\n setModal('feedback')\n }}\n aria-pressed={hasFeedback}\n >\n <span>{labels.feedback}</span>\n <span className=\"tabular-nums\">{counts.feedbackCount}</span>\n </button>\n </div>\n\n {error ? (\n <p className=\"text-sm text-red-600 dark:text-red-400\" role=\"alert\">\n {error}\n </p>\n ) : null}\n\n {modal ? (\n <div className=\"rounded-lg border border-gray-200 p-4 dark:border-gray-700\">\n <label className=\"mb-2 block text-sm font-medium text-gray-800 dark:text-gray-200\">\n {modal === 'dislike'\n ? labels.dislikeRequiresComment\n : labels.feedback}\n </label>\n <textarea\n className=\"mb-3 w-full rounded-md border border-gray-300 bg-white p-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100\"\n rows={3}\n maxLength={1000}\n value={comment}\n placeholder={labels.feedbackPlaceholder}\n onChange={(e) => setComment(e.target.value)}\n />\n <div className=\"flex gap-2\">\n <button\n type=\"button\"\n className={`${BTN} ${BTN_ACTIVE}`}\n disabled={pending}\n onClick={onSubmitModal}\n >\n {labels.submit}\n </button>\n <button\n type=\"button\"\n className={`${BTN} ${BTN_IDLE}`}\n disabled={pending}\n onClick={() => {\n setModal(null)\n setComment('')\n setError(null)\n }}\n >\n {labels.cancel}\n </button>\n </div>\n </div>\n ) : null}\n </div>\n )\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
3
|
+
type BlogArticleMarksLabels = {
|
|
4
|
+
like: string;
|
|
5
|
+
dislike: string;
|
|
6
|
+
feedback: string;
|
|
7
|
+
feedbackPlaceholder: string;
|
|
8
|
+
submit: string;
|
|
9
|
+
cancel: string;
|
|
10
|
+
dislikeRequiresComment: string;
|
|
11
|
+
error: string;
|
|
12
|
+
};
|
|
13
|
+
type BlogArticleMarksProps = {
|
|
14
|
+
slug: string;
|
|
15
|
+
locale?: string;
|
|
16
|
+
likeCount?: number;
|
|
17
|
+
dislikeCount?: number;
|
|
18
|
+
feedbackCount?: number;
|
|
19
|
+
/** Same-origin BFF base, e.g. `/api/v1/blog` */
|
|
20
|
+
apiBasePath?: string;
|
|
21
|
+
labels: BlogArticleMarksLabels;
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Public like / dislike / feedback controls with counts.
|
|
26
|
+
* POSTs to same-origin BFF (never sends API key).
|
|
27
|
+
*/
|
|
28
|
+
declare function BlogArticleMarks({ slug, locale, likeCount, dislikeCount, feedbackCount, apiBasePath, labels, className, }: BlogArticleMarksProps): react.JSX.Element;
|
|
29
|
+
|
|
30
|
+
export { BlogArticleMarks, type BlogArticleMarksLabels, type BlogArticleMarksProps };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
3
|
+
type BlogArticleMarksLabels = {
|
|
4
|
+
like: string;
|
|
5
|
+
dislike: string;
|
|
6
|
+
feedback: string;
|
|
7
|
+
feedbackPlaceholder: string;
|
|
8
|
+
submit: string;
|
|
9
|
+
cancel: string;
|
|
10
|
+
dislikeRequiresComment: string;
|
|
11
|
+
error: string;
|
|
12
|
+
};
|
|
13
|
+
type BlogArticleMarksProps = {
|
|
14
|
+
slug: string;
|
|
15
|
+
locale?: string;
|
|
16
|
+
likeCount?: number;
|
|
17
|
+
dislikeCount?: number;
|
|
18
|
+
feedbackCount?: number;
|
|
19
|
+
/** Same-origin BFF base, e.g. `/api/v1/blog` */
|
|
20
|
+
apiBasePath?: string;
|
|
21
|
+
labels: BlogArticleMarksLabels;
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Public like / dislike / feedback controls with counts.
|
|
26
|
+
* POSTs to same-origin BFF (never sends API key).
|
|
27
|
+
*/
|
|
28
|
+
declare function BlogArticleMarks({ slug, locale, likeCount, dislikeCount, feedbackCount, apiBasePath, labels, className, }: BlogArticleMarksProps): react.JSX.Element;
|
|
29
|
+
|
|
30
|
+
export { BlogArticleMarks, type BlogArticleMarksLabels, type BlogArticleMarksProps };
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/components/blog-article-marks.tsx
|
|
4
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
var STORAGE_KEY = "mailoo_blog_visitor_key";
|
|
7
|
+
var VISITOR_STATE_PREFIX = "mailoo_blog_mark:";
|
|
8
|
+
var BTN = "inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-60";
|
|
9
|
+
var BTN_IDLE = "border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 focus:ring-gray-500 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800";
|
|
10
|
+
var BTN_ACTIVE = "border-blue-600 bg-blue-50 text-blue-800 focus:ring-blue-500 dark:border-blue-400 dark:bg-blue-950 dark:text-blue-200";
|
|
11
|
+
function createVisitorKey() {
|
|
12
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
13
|
+
return crypto.randomUUID();
|
|
14
|
+
}
|
|
15
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
16
|
+
const r = Math.random() * 16 | 0;
|
|
17
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
18
|
+
return v.toString(16);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function readVisitorKey() {
|
|
22
|
+
try {
|
|
23
|
+
const existing = localStorage.getItem(STORAGE_KEY);
|
|
24
|
+
if (existing && existing.length >= 32) return existing;
|
|
25
|
+
const next = createVisitorKey();
|
|
26
|
+
localStorage.setItem(STORAGE_KEY, next);
|
|
27
|
+
return next;
|
|
28
|
+
} catch {
|
|
29
|
+
return createVisitorKey();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function readLocalMark(slug) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = localStorage.getItem(`${VISITOR_STATE_PREFIX}${slug}`);
|
|
35
|
+
if (!raw) return { sentiment: null, hasFeedback: false };
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
return {
|
|
38
|
+
sentiment: parsed.sentiment === "LIKE" || parsed.sentiment === "DISLIKE" ? parsed.sentiment : null,
|
|
39
|
+
hasFeedback: Boolean(parsed.hasFeedback)
|
|
40
|
+
};
|
|
41
|
+
} catch {
|
|
42
|
+
return { sentiment: null, hasFeedback: false };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function writeLocalMark(slug, mark) {
|
|
46
|
+
try {
|
|
47
|
+
localStorage.setItem(
|
|
48
|
+
`${VISITOR_STATE_PREFIX}${slug}`,
|
|
49
|
+
JSON.stringify(mark)
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function postMark(apiBasePath, slug, kind, body) {
|
|
55
|
+
const res = await fetch(
|
|
56
|
+
`${apiBasePath}/slug/${encodeURIComponent(slug)}/${kind}`,
|
|
57
|
+
{
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "Content-Type": "application/json" },
|
|
60
|
+
body: JSON.stringify(body)
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
const json = await res.json();
|
|
64
|
+
if (!res.ok || !json.data) {
|
|
65
|
+
throw new Error(json.message || json.error || `HTTP ${res.status}`);
|
|
66
|
+
}
|
|
67
|
+
return json.data;
|
|
68
|
+
}
|
|
69
|
+
function BlogArticleMarks({
|
|
70
|
+
slug,
|
|
71
|
+
locale,
|
|
72
|
+
likeCount = 0,
|
|
73
|
+
dislikeCount = 0,
|
|
74
|
+
feedbackCount = 0,
|
|
75
|
+
apiBasePath = "/api/v1/blog",
|
|
76
|
+
labels,
|
|
77
|
+
className
|
|
78
|
+
}) {
|
|
79
|
+
const [visitorKey, setVisitorKey] = useState(null);
|
|
80
|
+
const [counts, setCounts] = useState({
|
|
81
|
+
likeCount,
|
|
82
|
+
dislikeCount,
|
|
83
|
+
feedbackCount
|
|
84
|
+
});
|
|
85
|
+
const [sentiment, setSentiment] = useState(
|
|
86
|
+
null
|
|
87
|
+
);
|
|
88
|
+
const [hasFeedback, setHasFeedback] = useState(false);
|
|
89
|
+
const [pending, setPending] = useState(false);
|
|
90
|
+
const [error, setError] = useState(null);
|
|
91
|
+
const [modal, setModal] = useState(null);
|
|
92
|
+
const [comment, setComment] = useState("");
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const key = readVisitorKey();
|
|
95
|
+
setVisitorKey(key);
|
|
96
|
+
const local = readLocalMark(slug);
|
|
97
|
+
setSentiment(local.sentiment);
|
|
98
|
+
setHasFeedback(local.hasFeedback);
|
|
99
|
+
}, [slug]);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
setCounts({ likeCount, dislikeCount, feedbackCount });
|
|
102
|
+
}, [likeCount, dislikeCount, feedbackCount]);
|
|
103
|
+
const applyState = useCallback(
|
|
104
|
+
(state) => {
|
|
105
|
+
setCounts({
|
|
106
|
+
likeCount: state.likeCount,
|
|
107
|
+
dislikeCount: state.dislikeCount,
|
|
108
|
+
feedbackCount: state.feedbackCount
|
|
109
|
+
});
|
|
110
|
+
setSentiment(state.sentiment);
|
|
111
|
+
setHasFeedback(state.hasFeedback);
|
|
112
|
+
writeLocalMark(slug, {
|
|
113
|
+
sentiment: state.sentiment,
|
|
114
|
+
hasFeedback: state.hasFeedback
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
[slug]
|
|
118
|
+
);
|
|
119
|
+
const run = useCallback(
|
|
120
|
+
async (kind, body) => {
|
|
121
|
+
if (!visitorKey || pending) return;
|
|
122
|
+
setPending(true);
|
|
123
|
+
setError(null);
|
|
124
|
+
try {
|
|
125
|
+
const state = await postMark(apiBasePath, slug, kind, {
|
|
126
|
+
...body,
|
|
127
|
+
visitorKey,
|
|
128
|
+
...locale ? { locale } : {}
|
|
129
|
+
});
|
|
130
|
+
applyState(state);
|
|
131
|
+
setModal(null);
|
|
132
|
+
setComment("");
|
|
133
|
+
} catch {
|
|
134
|
+
setError(labels.error);
|
|
135
|
+
} finally {
|
|
136
|
+
setPending(false);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
[apiBasePath, applyState, labels.error, locale, pending, slug, visitorKey]
|
|
140
|
+
);
|
|
141
|
+
const onLike = useCallback(() => {
|
|
142
|
+
if (sentiment === "LIKE") {
|
|
143
|
+
void run("reaction", { action: "clear" });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
void run("reaction", { action: "set", sentiment: "LIKE" });
|
|
147
|
+
}, [run, sentiment]);
|
|
148
|
+
const onDislikeClick = useCallback(() => {
|
|
149
|
+
if (sentiment === "DISLIKE") {
|
|
150
|
+
void run("reaction", { action: "clear" });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (hasFeedback) {
|
|
154
|
+
void run("reaction", { action: "set", sentiment: "DISLIKE" });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
setModal("dislike");
|
|
158
|
+
}, [hasFeedback, run, sentiment]);
|
|
159
|
+
const onSubmitModal = useCallback(() => {
|
|
160
|
+
const trimmed = comment.trim();
|
|
161
|
+
if (!trimmed) {
|
|
162
|
+
setError(labels.dislikeRequiresComment);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (modal === "dislike") {
|
|
166
|
+
void run("reaction", {
|
|
167
|
+
action: "set",
|
|
168
|
+
sentiment: "DISLIKE",
|
|
169
|
+
comment: trimmed
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
void run("feedback", { action: "set", comment: trimmed });
|
|
174
|
+
}, [comment, labels.dislikeRequiresComment, modal, run]);
|
|
175
|
+
const rootClass = useMemo(
|
|
176
|
+
() => [
|
|
177
|
+
"mt-8 flex flex-col gap-3 border-t border-gray-200 pt-6 dark:border-gray-700",
|
|
178
|
+
className
|
|
179
|
+
].filter(Boolean).join(" "),
|
|
180
|
+
[className]
|
|
181
|
+
);
|
|
182
|
+
return /* @__PURE__ */ jsxs("div", { className: rootClass, children: [
|
|
183
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
184
|
+
/* @__PURE__ */ jsxs(
|
|
185
|
+
"button",
|
|
186
|
+
{
|
|
187
|
+
type: "button",
|
|
188
|
+
className: `${BTN} ${sentiment === "LIKE" ? BTN_ACTIVE : BTN_IDLE}`,
|
|
189
|
+
disabled: pending || !visitorKey,
|
|
190
|
+
onClick: onLike,
|
|
191
|
+
"aria-pressed": sentiment === "LIKE",
|
|
192
|
+
children: [
|
|
193
|
+
/* @__PURE__ */ jsx("span", { children: labels.like }),
|
|
194
|
+
/* @__PURE__ */ jsx("span", { className: "tabular-nums", children: counts.likeCount })
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
),
|
|
198
|
+
/* @__PURE__ */ jsxs(
|
|
199
|
+
"button",
|
|
200
|
+
{
|
|
201
|
+
type: "button",
|
|
202
|
+
className: `${BTN} ${sentiment === "DISLIKE" ? BTN_ACTIVE : BTN_IDLE}`,
|
|
203
|
+
disabled: pending || !visitorKey,
|
|
204
|
+
onClick: onDislikeClick,
|
|
205
|
+
"aria-pressed": sentiment === "DISLIKE",
|
|
206
|
+
children: [
|
|
207
|
+
/* @__PURE__ */ jsx("span", { children: labels.dislike }),
|
|
208
|
+
/* @__PURE__ */ jsx("span", { className: "tabular-nums", children: counts.dislikeCount })
|
|
209
|
+
]
|
|
210
|
+
}
|
|
211
|
+
),
|
|
212
|
+
/* @__PURE__ */ jsxs(
|
|
213
|
+
"button",
|
|
214
|
+
{
|
|
215
|
+
type: "button",
|
|
216
|
+
className: `${BTN} ${hasFeedback ? BTN_ACTIVE : BTN_IDLE}`,
|
|
217
|
+
disabled: pending || !visitorKey,
|
|
218
|
+
onClick: () => {
|
|
219
|
+
setError(null);
|
|
220
|
+
setModal("feedback");
|
|
221
|
+
},
|
|
222
|
+
"aria-pressed": hasFeedback,
|
|
223
|
+
children: [
|
|
224
|
+
/* @__PURE__ */ jsx("span", { children: labels.feedback }),
|
|
225
|
+
/* @__PURE__ */ jsx("span", { className: "tabular-nums", children: counts.feedbackCount })
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
] }),
|
|
230
|
+
error ? /* @__PURE__ */ jsx("p", { className: "text-sm text-red-600 dark:text-red-400", role: "alert", children: error }) : null,
|
|
231
|
+
modal ? /* @__PURE__ */ jsxs("div", { className: "rounded-lg border border-gray-200 p-4 dark:border-gray-700", children: [
|
|
232
|
+
/* @__PURE__ */ jsx("label", { className: "mb-2 block text-sm font-medium text-gray-800 dark:text-gray-200", children: modal === "dislike" ? labels.dislikeRequiresComment : labels.feedback }),
|
|
233
|
+
/* @__PURE__ */ jsx(
|
|
234
|
+
"textarea",
|
|
235
|
+
{
|
|
236
|
+
className: "mb-3 w-full rounded-md border border-gray-300 bg-white p-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100",
|
|
237
|
+
rows: 3,
|
|
238
|
+
maxLength: 1e3,
|
|
239
|
+
value: comment,
|
|
240
|
+
placeholder: labels.feedbackPlaceholder,
|
|
241
|
+
onChange: (e) => setComment(e.target.value)
|
|
242
|
+
}
|
|
243
|
+
),
|
|
244
|
+
/* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
|
|
245
|
+
/* @__PURE__ */ jsx(
|
|
246
|
+
"button",
|
|
247
|
+
{
|
|
248
|
+
type: "button",
|
|
249
|
+
className: `${BTN} ${BTN_ACTIVE}`,
|
|
250
|
+
disabled: pending,
|
|
251
|
+
onClick: onSubmitModal,
|
|
252
|
+
children: labels.submit
|
|
253
|
+
}
|
|
254
|
+
),
|
|
255
|
+
/* @__PURE__ */ jsx(
|
|
256
|
+
"button",
|
|
257
|
+
{
|
|
258
|
+
type: "button",
|
|
259
|
+
className: `${BTN} ${BTN_IDLE}`,
|
|
260
|
+
disabled: pending,
|
|
261
|
+
onClick: () => {
|
|
262
|
+
setModal(null);
|
|
263
|
+
setComment("");
|
|
264
|
+
setError(null);
|
|
265
|
+
},
|
|
266
|
+
children: labels.cancel
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
] })
|
|
270
|
+
] }) : null
|
|
271
|
+
] });
|
|
272
|
+
}
|
|
273
|
+
export {
|
|
274
|
+
BlogArticleMarks
|
|
275
|
+
};
|
|
276
|
+
//# sourceMappingURL=marks.js.map
|