@webx-ui/module-faq 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 webx-ui
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @webx-ui/module-faq
2
+
3
+ The front end of the FAQ section of the [WebX UI](https://github.com/webx-ui/webx-ui) admin panel:
4
+ questions and answers, and the categories they are filed under.
5
+
6
+ The other half is the Composer package `webx-ui/module-faq`, which owns the questions, their order,
7
+ their anchors, the FAQ block and the API. A section appears in the panel when both halves are
8
+ installed.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @webx-ui/module-faq
14
+ ```
15
+
16
+ ```ts
17
+ import { createAdmin } from '@webx-ui/module-admin'
18
+ import { faq } from '@webx-ui/module-faq'
19
+ import '@webx-ui/module-faq/style.css'
20
+
21
+ createAdmin({
22
+ basePath: '/cms',
23
+ modules: [...faq()],
24
+ }).mount()
25
+ ```
26
+
27
+ `faq()` answers with two sections — the questions and their categories — because the panel draws
28
+ one entry per module. The server puts both in the `faq` group. `faq({ path: '/help' })` puts them
29
+ somewhere else inside the panel.
30
+
31
+ On the server, the block type the module offers is installed once:
32
+
33
+ ```bash
34
+ php artisan webx:blocks:offered --install --module=faq
35
+ ```
36
+
37
+ ## Questions
38
+
39
+ A list and the form of one question side by side, without pages: a FAQ has dozens of questions,
40
+ and a drag cannot cross a page boundary. The open question is in the address (`?question=12`), so a
41
+ link to it is a link to the list around it too. **New question** is a row that opens an empty form;
42
+ the question is created by its first save. On a phone the form slides over the list and draws its
43
+ own «Back».
44
+
45
+ The list has two orders, and the filter decides which one you are dragging:
46
+
47
+ - **no category chosen** — the order of the whole list;
48
+ - **a category chosen** — the order inside that category only. Every other category keeps its own.
49
+
50
+ While a search narrows the list, the grips disappear. A published question with no language that
51
+ has both a question and an answer is marked: a reader sees it nowhere.
52
+
53
+ The form is the `faq.form` screen, described in JSON on the server: the question and the answer in
54
+ every language of the site, the categories, **Published**, the anchor — read-only, with a button
55
+ that copies `#anchor` for a link — and a card for the project's own fields. Save with the button or
56
+ `Ctrl+S`; leaving with unsaved changes asks first. A question has no draft: a save is on the site.
57
+
58
+ ## Categories
59
+
60
+ The panel's shared category screens with this module's words, without addresses: a category is a
61
+ button in a block's filter and a way to pick questions for a block.
62
+
63
+ ## On the site
64
+
65
+ The module has no page of its own. The site's FAQ page is an ordinary page with the **FAQ** block
66
+ on it; the same block with categories chosen is «the questions about X» anywhere else. See the
67
+ [guide](https://webx-ui.github.io/webx-ui/guide/faq.html).
68
+
69
+ ## Licence
70
+
71
+ MIT
@@ -0,0 +1,2 @@
1
+ declare const _default: import('vue').DefineComponent<{}, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, HTMLDivElement>;
2
+ export default _default;
@@ -0,0 +1,24 @@
1
+ import { QuestionRow } from './types';
2
+ type __VLS_Props = {
3
+ /** `null` — a question not written yet. */
4
+ id: number | null;
5
+ /** The category the list is narrowed to: a new question starts in it. */
6
+ category?: number | null;
7
+ /** False in the drawer a narrow screen opens this in. */
8
+ inline?: boolean;
9
+ };
10
+ declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
11
+ saved: (row: QuestionRow) => any;
12
+ back: () => any;
13
+ created: (row: QuestionRow) => any;
14
+ removed: (id: number) => any;
15
+ }, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
16
+ onSaved?: ((row: QuestionRow) => any) | undefined;
17
+ onBack?: (() => any) | undefined;
18
+ onCreated?: ((row: QuestionRow) => any) | undefined;
19
+ onRemoved?: ((id: number) => any) | undefined;
20
+ }>, {
21
+ category: number | null;
22
+ inline: boolean;
23
+ }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
24
+ export default _default;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The questions: the whole FAQ on the left, the one being written on the right (§4.5).
3
+ *
4
+ * The list and the form side by side rather than a page per question, because a question is
5
+ * written in a minute and the next one is usually the one under it: a round trip through a
6
+ * page of its own for every question is most of the time spent. On a phone the form is the
7
+ * whole screen, in the drawer `WxListDetail` raises, and draws its own way back.
8
+ *
9
+ * The order is decision 4, as with services: without a filter a drag moves the common order;
10
+ * narrowed to one category, the category's own; a search or the bin shows a selection, so the
11
+ * grips go away and the line under the list says why.
12
+ *
13
+ * Everything the screen is looking at lives in the address — the view, the category, the
14
+ * search and the open question — so a link to a question is a link to it in its list.
15
+ */
16
+ type __VLS_Props = {
17
+ base?: string;
18
+ };
19
+ declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
20
+ base: string;
21
+ }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
22
+ export default _default;
package/dist/api.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { AdminContext } from '@webx-ui/module-admin';
2
+ import { ScreenModel } from '@webx-ui/schema';
3
+ import { QuestionDetail, QuestionQuery, QuestionRow, QuestionsList } from './types';
4
+ export interface FaqApi {
5
+ /** Every question, with the categories the list can be narrowed to. */
6
+ list(query?: QuestionQuery): Promise<QuestionsList>;
7
+ get(id: number): Promise<QuestionDetail>;
8
+ /**
9
+ * A new question out of the values of its form, in one request: the form is filled in first
10
+ * and the record made on the first save, so nobody leaves an empty question behind.
11
+ */
12
+ create(values: ScreenModel): Promise<QuestionDetail>;
13
+ /** Refused with a 422 under the name of the field. */
14
+ save(id: number, values: ScreenModel): Promise<QuestionDetail>;
15
+ remove(id: number): Promise<void>;
16
+ restore(id: number): Promise<QuestionRow>;
17
+ /** The order on screen: of the whole list, or — with `category` — of that category alone. */
18
+ reorder(ids: number[], category?: number | null): Promise<void>;
19
+ }
20
+ /** The path of the list under the panel's API, and of its order (`CategoryRoutes::items()`). */
21
+ export declare const FAQ_API = "faq/questions";
22
+ /**
23
+ * Everything under `/faq/questions`, below the panel's API path — except the categories, which
24
+ * are the panel's shared ones and are asked for with `createCategoriesApi(admin, 'faq/categories')`.
25
+ */
26
+ export declare function createFaqApi(admin: AdminContext): FaqApi;
@@ -0,0 +1,16 @@
1
+ import { InjectionKey, Ref } from 'vue';
2
+ import { QuestionRow } from './types';
3
+ /**
4
+ * What the form knows and the nodes of `faq.form` do not: the question being edited.
5
+ *
6
+ * The anchor is not a value of the screen — nobody writes it, it is made once on the server and
7
+ * never changes (decision 10) — so the node that shows it (`wx-faq-anchor`) reads the record.
8
+ */
9
+ export interface QuestionEditorContext {
10
+ /** The question as the server last answered it; `null` while it is new and not yet saved. */
11
+ question: Ref<QuestionRow | null>;
12
+ }
13
+ export declare const questionEditorKey: InjectionKey<QuestionEditorContext>;
14
+ export declare function provideQuestionEditor(editor: QuestionEditorContext): void;
15
+ /** The form above this node, or `null` outside one — a demo or a test draws the empty state. */
16
+ export declare function useQuestionEditor(): QuestionEditorContext | null;
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Puts this package's English under the panel's dictionary, once per panel. */
2
+ export declare function useFaqMessages(): void;
@@ -0,0 +1,8 @@
1
+ export { faq, faqCategoriesOptions, type FaqOptions } from './module';
2
+ export { createFaqApi, FAQ_API, type FaqApi } from './api';
3
+ export { faqMessages } from './messages';
4
+ export { provideQuestionEditor, questionEditorKey, useQuestionEditor, type QuestionEditorContext, } from './editor';
5
+ export { default as WxFaqQuestionsPage } from './QuestionsPage';
6
+ export { default as WxFaqQuestionPane } from './QuestionPane';
7
+ export { default as WxFaqAnchor } from './AnchorField';
8
+ export type { QuestionCategoryRef, QuestionDetail, QuestionQuery, QuestionRow, QuestionsList, } from './types';
package/dist/index.js ADDED
@@ -0,0 +1,727 @@
1
+ import { useAdmin as ee, useTranslate as te, reorderItems as we, useErrorText as ce, WxScreenHead as be, WxScreen as xe, WxSaveState as ke, useItemOrder as _e, WxListScreen as Se, WxRowMenu as Te, categoryRoutes as We } from "@webx-ui/module-admin";
2
+ import { provide as $e, inject as Ce, defineComponent as ae, computed as q, openBlock as v, createElementBlock as A, Fragment as K, createVNode as y, unref as t, withCtx as d, createTextVNode as $, toDisplayString as _, createBlock as b, ref as S, onMounted as Ae, onBeforeUnmount as de, createSlots as Be, createCommentVNode as L, watch as ie, createElementVNode as j, normalizeClass as G, resolveDynamicComponent as Ne, renderList as Le } from "vue";
3
+ import { WxText as le, WxButton as ve, WxIcon as X, toast as w, useLocales as Qe, localizedValue as Ve, WxSkeleton as fe, WxBadge as O, WxActionBar as Fe, confirm as Y, WxCard as ze, WxListDetail as Ie, WxEmpty as re, WxInput as Pe, WxSelect as Me, WxSortableList as Re, WxTooltip as Ue, WxAlert as Ee } from "@webx-ui/core";
4
+ import { onBeforeRouteUpdate as De, onBeforeRouteLeave as Oe, useRoute as Ke, useRouter as He } from "vue-router";
5
+ const qe = /* @__PURE__ */ Symbol("wx-faq-question");
6
+ function Je(l) {
7
+ $e(qe, l);
8
+ }
9
+ function je() {
10
+ return Ce(qe, null);
11
+ }
12
+ const Ge = {
13
+ module: {
14
+ group: "FAQ",
15
+ questions: "Questions",
16
+ categories: "Categories"
17
+ },
18
+ question: {
19
+ new: "New question",
20
+ untitled: "Untitled",
21
+ search: "Search the questions",
22
+ empty: "No questions yet.",
23
+ "empty-help": "A question reaches the site in a FAQ block, on any page.",
24
+ "empty-search": "Nothing matches that.",
25
+ "empty-bin": "The bin is empty.",
26
+ all: "All",
27
+ bin: "Bin",
28
+ "filter-category": "Category",
29
+ "any-category": "Any category",
30
+ "not-published": "Not published",
31
+ "seen-nowhere": "Not seen in any language: it needs a question and an answer in one.",
32
+ choose: "Choose a question",
33
+ "choose-help": "Or write a new one: the first line of the list.",
34
+ save: "Save",
35
+ saved: "The question is saved.",
36
+ "save-failed": "The question was not saved.",
37
+ cancel: "Cancel",
38
+ delete: "Delete",
39
+ "delete-title": "Delete “:title”?",
40
+ "delete-text": "It goes to the bin and leaves every block on the site. Restored, it comes back with the same link.",
41
+ deleted: "The question is in the bin.",
42
+ restore: "Restore",
43
+ restored: "The question is back.",
44
+ "copy-link": "Copy the link",
45
+ "link-copied": "The link is on the clipboard.",
46
+ "anchor-later": "Made from the question when it is first saved.",
47
+ "reorder-failed": "The new order was not saved.",
48
+ "leave-title": "Leave without saving?",
49
+ "leave-text": "What you wrote in this question is not on the server.",
50
+ leave: "Leave",
51
+ back: "Back to the list"
52
+ },
53
+ screen: {
54
+ question: "Question",
55
+ answer: "Answer",
56
+ "answer-help": "A question is shown in a language only when it has both the question and the answer in it.",
57
+ settings: "Settings",
58
+ published: "Published",
59
+ "published-help": "On the site in every block that shows its categories, from the moment it is saved.",
60
+ categories: "Categories",
61
+ "categories-help": "What a block picks the question by, and the buttons of its filter.",
62
+ anchor: "Link",
63
+ "anchor-help": "Made once from the question and never changed, so links to it keep working."
64
+ },
65
+ category: {
66
+ "field-title": "Title",
67
+ visible: "Shown on the site",
68
+ "visible-help": "Hidden, it drops out of every filter; its questions stay in the blocks that show them.",
69
+ new: "New category",
70
+ empty: "No categories yet.",
71
+ "empty-help": "A category groups questions: a block shows the ones you pick, and its filter has a button for each.",
72
+ order: "Drag to change the order of the filter buttons on the site.",
73
+ hidden: "Hidden from the site",
74
+ questions: "Questions: :count",
75
+ "show-questions": "Show its questions",
76
+ "delete-blocked": "This category still holds questions. Move them first.",
77
+ "delete-text": "It goes to the bin and its button leaves every filter. Its questions stay.",
78
+ deleted: "The category is in the bin.",
79
+ saved: "The category is saved."
80
+ }
81
+ }, ue = /* @__PURE__ */ new WeakSet();
82
+ function ne() {
83
+ try {
84
+ const { i18n: l } = ee();
85
+ ue.has(l) || (l.defaults("webx-faq", Ge), ue.add(l));
86
+ } catch {
87
+ }
88
+ }
89
+ const Xe = { class: "wx-faq-anchor" }, Ye = /* @__PURE__ */ ae({
90
+ name: "WxFaqAnchor",
91
+ __name: "AnchorField",
92
+ setup(l) {
93
+ const r = je();
94
+ ne();
95
+ const s = te("webx-faq"), a = q(() => r?.question.value?.anchor ?? ""), c = q(() => a.value === "" ? "" : `#${a.value}`);
96
+ async function n() {
97
+ try {
98
+ await navigator.clipboard.writeText(c.value), w.success(s("question.link-copied"));
99
+ } catch {
100
+ }
101
+ }
102
+ return (B, u) => (v(), A("div", Xe, [
103
+ c.value !== "" ? (v(), A(K, { key: 0 }, [
104
+ y(t(le), {
105
+ class: "wx-faq-anchor__value",
106
+ mono: "",
107
+ truncate: ""
108
+ }, {
109
+ default: d(() => [
110
+ $(_(c.value), 1)
111
+ ]),
112
+ _: 1
113
+ }),
114
+ y(t(ve), {
115
+ variant: "outline",
116
+ size: "sm",
117
+ onClick: n
118
+ }, {
119
+ icon: d(() => [
120
+ y(t(X), { name: "copy" })
121
+ ]),
122
+ default: d(() => [
123
+ $(" " + _(t(s)("question.copy-link")), 1)
124
+ ]),
125
+ _: 1
126
+ })
127
+ ], 64)) : (v(), b(t(le), {
128
+ key: 1,
129
+ size: "sm",
130
+ tone: "muted"
131
+ }, {
132
+ default: d(() => [
133
+ $(_(t(s)("question.anchor-later")), 1)
134
+ ]),
135
+ _: 1
136
+ }))
137
+ ]));
138
+ }
139
+ }), oe = (l, r) => {
140
+ const s = l.__vccOpts || l;
141
+ for (const [a, c] of r)
142
+ s[a] = c;
143
+ return s;
144
+ }, Ze = /* @__PURE__ */ oe(Ye, [["__scopeId", "data-v-e6091b48"]]), Z = "faq/questions";
145
+ function he(l) {
146
+ const r = `${l.apiPath}/${Z}`, s = (a) => a.data;
147
+ return {
148
+ list: (a = {}) => {
149
+ const c = new URLSearchParams();
150
+ return a.search && c.set("search", a.search), a.category != null && c.set("category", String(a.category)), a.trashed && c.set("trashed", "1"), l.http.get(c.size > 0 ? `${r}?${c}` : r);
151
+ },
152
+ get: (a) => l.http.get(`${r}/${a}`).then(s),
153
+ create: (a) => l.http.post(r, { values: a }).then(s),
154
+ save: (a, c) => l.http.put(`${r}/${a}`, { values: c }).then(s),
155
+ remove: (a) => l.http.delete(`${r}/${a}`).then(() => {
156
+ }),
157
+ restore: (a) => l.http.post(`${r}/${a}/restore`, {}).then(s),
158
+ reorder: (a, c = null) => we(l, Z, a, c)
159
+ };
160
+ }
161
+ const et = /* @__PURE__ */ ae({
162
+ name: "WxFaqQuestionPane",
163
+ __name: "QuestionPane",
164
+ props: {
165
+ id: {},
166
+ category: { default: null },
167
+ inline: { type: Boolean, default: !0 }
168
+ },
169
+ emits: ["back", "saved", "created", "removed"],
170
+ setup(l, { emit: r }) {
171
+ const s = l, a = r, c = ee(), n = he(c), B = Qe();
172
+ ne();
173
+ const u = te("webx-faq"), Q = ce(), x = S(null), g = S({}), N = S(""), C = S(s.id !== null), f = S(!1), T = S({}), k = q(() => c.can("faq.manage")), V = q(() => JSON.stringify(g.value)), m = q(() => N.value !== "" && V.value !== N.value), R = q(() => f.value ? "saving" : m.value || s.id === null ? "unsaved" : "saved"), H = q(() => {
174
+ const o = g.value.question, h = s.id === null ? u("question.new") : x.value?.question ?? "";
175
+ return Ve(o, B.active.value, h) || u("question.untitled");
176
+ });
177
+ Je({ question: x });
178
+ function F(o) {
179
+ x.value = o.question, g.value = o.values, N.value = JSON.stringify(o.values);
180
+ }
181
+ if (s.id === null) {
182
+ const o = {
183
+ question: {},
184
+ answer: {},
185
+ published: !1,
186
+ categories: s.category === null ? [] : [s.category]
187
+ };
188
+ g.value = o, N.value = JSON.stringify(o);
189
+ }
190
+ async function U() {
191
+ if (s.id !== null) {
192
+ C.value = !0;
193
+ try {
194
+ F(await n.get(s.id));
195
+ } catch (o) {
196
+ w.danger(Q(o)), a("back");
197
+ } finally {
198
+ C.value = !1;
199
+ }
200
+ }
201
+ }
202
+ async function E() {
203
+ if (!(f.value || !k.value)) {
204
+ f.value = !0, T.value = {};
205
+ try {
206
+ const o = s.id === null, h = o ? await n.create(g.value) : await n.save(s.id, g.value);
207
+ F(h), w.success(u("question.saved")), a(o ? "created" : "saved", h.question);
208
+ } catch (o) {
209
+ const h = o.body;
210
+ h?.errors ? (T.value = h.errors, w.danger(u("question.save-failed"))) : w.danger(Q(o));
211
+ } finally {
212
+ f.value = !1;
213
+ }
214
+ }
215
+ }
216
+ async function W() {
217
+ const o = x.value;
218
+ if (!(!o || !await Y({
219
+ title: u("question.delete-title", { title: o.question }),
220
+ message: u("question.delete-text"),
221
+ confirmText: u("question.delete"),
222
+ cancelText: u("question.cancel"),
223
+ tone: "danger"
224
+ })))
225
+ try {
226
+ await n.remove(o.id), w.success(u("question.deleted")), N.value = V.value, a("removed", o.id);
227
+ } catch (P) {
228
+ w.danger(Q(P));
229
+ }
230
+ }
231
+ function z(o) {
232
+ m.value && o.preventDefault();
233
+ }
234
+ function J(o) {
235
+ (o.ctrlKey || o.metaKey) && o.key.toLowerCase() === "s" && (o.preventDefault(), (m.value || s.id === null) && E());
236
+ }
237
+ Ae(() => {
238
+ U(), window.addEventListener("beforeunload", z);
239
+ }), de(() => {
240
+ window.removeEventListener("beforeunload", z);
241
+ });
242
+ async function I() {
243
+ return !m.value || !k.value ? !0 : await Y({
244
+ title: u("question.leave-title"),
245
+ message: u("question.leave-text"),
246
+ confirmText: u("question.leave"),
247
+ cancelText: u("question.cancel"),
248
+ tone: "danger"
249
+ });
250
+ }
251
+ De(
252
+ async (o, h) => o.query.question === h.query.question ? !0 : await I()
253
+ ), Oe(I);
254
+ const D = q(
255
+ () => k.value && x.value !== null ? [
256
+ {
257
+ key: "delete",
258
+ label: u("question.delete"),
259
+ icon: "trash",
260
+ danger: !0,
261
+ menu: !0,
262
+ run: () => {
263
+ W();
264
+ }
265
+ }
266
+ ] : []
267
+ );
268
+ return (o, h) => (v(), A("div", {
269
+ class: "wx-faq-question",
270
+ onKeydown: J
271
+ }, [
272
+ C.value ? (v(), b(t(fe), {
273
+ key: 0,
274
+ class: "wx-faq-question__ghost",
275
+ title: "",
276
+ rows: 6
277
+ })) : (v(), A(K, { key: 1 }, [
278
+ y(t(be), {
279
+ level: 3,
280
+ back: !s.inline,
281
+ "back-label": t(u)("question.back"),
282
+ title: H.value,
283
+ actions: D.value,
284
+ "collapse-below": 480,
285
+ onBack: h[0] || (h[0] = (P) => a("back"))
286
+ }, Be({ _: 2 }, [
287
+ x.value && !x.value.published ? {
288
+ name: "title-after",
289
+ fn: d(() => [
290
+ y(t(O), { dot: "" }, {
291
+ default: d(() => [
292
+ $(_(t(u)("question.not-published")), 1)
293
+ ]),
294
+ _: 1
295
+ })
296
+ ]),
297
+ key: "0"
298
+ } : void 0
299
+ ]), 1032, ["back", "back-label", "title", "actions"]),
300
+ y(t(xe), {
301
+ modelValue: g.value,
302
+ "onUpdate:modelValue": h[1] || (h[1] = (P) => g.value = P),
303
+ name: "faq.form",
304
+ errors: T.value,
305
+ disabled: !k.value || f.value
306
+ }, null, 8, ["modelValue", "errors", "disabled"]),
307
+ k.value ? (v(), b(t(Fe), { key: 0 }, {
308
+ state: d(() => [
309
+ y(t(ke), { state: R.value }, null, 8, ["state"])
310
+ ]),
311
+ default: d(() => [
312
+ y(t(ve), {
313
+ type: "primary",
314
+ loading: f.value,
315
+ disabled: !m.value && s.id !== null,
316
+ onClick: E
317
+ }, {
318
+ default: d(() => [
319
+ $(_(t(u)("question.save")), 1)
320
+ ]),
321
+ _: 1
322
+ }, 8, ["loading", "disabled"])
323
+ ]),
324
+ _: 1
325
+ })) : L("", !0)
326
+ ], 64))
327
+ ], 32));
328
+ }
329
+ }), tt = /* @__PURE__ */ oe(et, [["__scopeId", "data-v-0589ef85"]]), at = { class: "wx-faq__bar" }, nt = { class: "wx-faq-row__question" }, ot = { class: "wx-faq-row__facts" }, st = {
330
+ key: 2,
331
+ class: "wx-faq-row__locales"
332
+ }, it = /* @__PURE__ */ ae({
333
+ __name: "QuestionsPage",
334
+ props: {
335
+ base: { default: "/faq" }
336
+ },
337
+ setup(l) {
338
+ const r = ee(), s = he(r), a = Ke(), c = He();
339
+ ne();
340
+ const n = te("webx-faq"), B = ce(), u = S([]), Q = S([]), x = S(!0), g = q(() => r.can("faq.manage")), N = q(
341
+ () => r.state.manifest?.modules.find((e) => e.id === "faq")?.title ?? n("module.questions")
342
+ ), C = q({
343
+ get: () => a.query.view === "trashed" ? "trashed" : "",
344
+ set: (e) => {
345
+ const p = {
346
+ ...a.query,
347
+ view: e === "" ? void 0 : String(e)
348
+ };
349
+ delete p.question, c.replace({ query: p });
350
+ }
351
+ }), f = q(() => C.value === "trashed"), T = q(() => {
352
+ const e = a.query.category;
353
+ return typeof e == "string" && e !== "" && Number(e) || null;
354
+ }), k = q(() => typeof a.query.q == "string" ? a.query.q : ""), V = S(k.value), m = q(() => {
355
+ const e = a.query.question;
356
+ return e === "new" ? g.value ? "new" : null : typeof e == "string" && e !== "" && Number(e) || null;
357
+ }), R = q({
358
+ get: () => m.value !== null,
359
+ set: (e) => {
360
+ e || D();
361
+ }
362
+ }), H = q(() => [
363
+ { value: "", label: n("question.all") },
364
+ { value: "trashed", label: n("question.bin"), icon: "trash" }
365
+ ]), F = _e(Z, () => ({
366
+ q: k.value,
367
+ category: T.value,
368
+ filtered: f.value
369
+ })), U = q(() => g.value && F.sortable.value), E = q(() => k.value !== "" ? n("question.empty-search") : f.value ? n("question.empty-bin") : n("question.empty"));
370
+ async function W() {
371
+ x.value = !0;
372
+ try {
373
+ const e = await s.list({
374
+ search: k.value,
375
+ category: f.value ? null : T.value,
376
+ trashed: f.value
377
+ });
378
+ u.value = e.data, Q.value = e.filters.categories;
379
+ } catch (e) {
380
+ w.danger(B(e));
381
+ } finally {
382
+ x.value = !1;
383
+ }
384
+ }
385
+ ie(
386
+ () => [C.value, T.value, k.value],
387
+ () => {
388
+ W();
389
+ }
390
+ ), W();
391
+ let z;
392
+ ie(V, (e) => {
393
+ clearTimeout(z), z = setTimeout(() => {
394
+ c.replace({ query: { ...a.query, q: e.trim() === "" ? void 0 : e } });
395
+ }, 300);
396
+ }), de(() => clearTimeout(z));
397
+ function J(e) {
398
+ c.replace({
399
+ query: { ...a.query, category: typeof e == "number" ? String(e) : void 0 }
400
+ });
401
+ }
402
+ function I(e) {
403
+ c.replace({ query: { ...a.query, question: String(e) } });
404
+ }
405
+ function D() {
406
+ const e = { ...a.query };
407
+ delete e.question, c.replace({ query: e });
408
+ }
409
+ function o(e) {
410
+ I(e.id), W();
411
+ }
412
+ async function h() {
413
+ try {
414
+ await F.move(u.value.map((e) => e.id));
415
+ } catch (e) {
416
+ w.danger(B(e, n("question.reorder-failed"))), await W();
417
+ }
418
+ }
419
+ function P(e) {
420
+ return g.value ? f.value ? [
421
+ {
422
+ key: "restore",
423
+ icon: "refresh",
424
+ label: n("question.restore"),
425
+ run: () => {
426
+ pe(e);
427
+ }
428
+ }
429
+ ] : [
430
+ {
431
+ key: "copy",
432
+ icon: "copy",
433
+ label: n("question.copy-link"),
434
+ run: () => {
435
+ ge(e);
436
+ }
437
+ },
438
+ {
439
+ key: "delete",
440
+ icon: "trash",
441
+ label: n("question.delete"),
442
+ danger: !0,
443
+ run: () => {
444
+ ye(e);
445
+ }
446
+ }
447
+ ] : [];
448
+ }
449
+ async function ye(e) {
450
+ if (await Y({
451
+ title: n("question.delete-title", { title: e.question }),
452
+ message: n("question.delete-text"),
453
+ confirmText: n("question.delete"),
454
+ cancelText: n("question.cancel"),
455
+ tone: "danger"
456
+ }))
457
+ try {
458
+ await s.remove(e.id), w.success(n("question.deleted")), se(e.id);
459
+ } catch (i) {
460
+ w.danger(B(i));
461
+ }
462
+ }
463
+ function se(e) {
464
+ m.value === e && D(), W();
465
+ }
466
+ async function pe(e) {
467
+ try {
468
+ await s.restore(e.id), w.success(n("question.restored"));
469
+ } catch (p) {
470
+ w.danger(B(p));
471
+ } finally {
472
+ await W();
473
+ }
474
+ }
475
+ async function ge(e) {
476
+ try {
477
+ await navigator.clipboard.writeText(`#${e.anchor}`), w.success(n("question.link-copied"));
478
+ } catch {
479
+ }
480
+ }
481
+ function me(e) {
482
+ return e.published && e.locales.length === 0;
483
+ }
484
+ return (e, p) => (v(), b(t(Se), {
485
+ view: C.value,
486
+ "onUpdate:view": p[4] || (p[4] = (i) => C.value = i),
487
+ title: N.value,
488
+ views: H.value,
489
+ card: !1
490
+ }, {
491
+ default: d(() => [
492
+ y(t(ze), {
493
+ class: "wx-faq",
494
+ padding: "none"
495
+ }, {
496
+ default: d(() => [
497
+ y(t(Ie), {
498
+ open: R.value,
499
+ "onUpdate:open": p[3] || (p[3] = (i) => R.value = i),
500
+ class: "wx-faq__panes",
501
+ "list-width": 360,
502
+ "detail-min": 460,
503
+ "detail-label": t(n)("module.questions")
504
+ }, {
505
+ list: d(() => [
506
+ j("div", at, [
507
+ y(t(Pe), {
508
+ modelValue: V.value,
509
+ "onUpdate:modelValue": p[0] || (p[0] = (i) => V.value = i),
510
+ class: "wx-faq__search",
511
+ size: "sm",
512
+ clearable: "",
513
+ placeholder: t(n)("question.search"),
514
+ "aria-label": t(n)("question.search")
515
+ }, {
516
+ prefix: d(() => [
517
+ y(t(X), {
518
+ name: "search",
519
+ size: "sm"
520
+ })
521
+ ]),
522
+ _: 1
523
+ }, 8, ["modelValue", "placeholder", "aria-label"]),
524
+ f.value ? L("", !0) : (v(), b(t(Me), {
525
+ key: 0,
526
+ "model-value": T.value,
527
+ options: Q.value.map((i) => ({ value: i.id, label: i.title })),
528
+ placeholder: t(n)("question.any-category"),
529
+ "aria-label": t(n)("question.filter-category"),
530
+ clearable: "",
531
+ size: "sm",
532
+ "onUpdate:modelValue": J
533
+ }, null, 8, ["model-value", "options", "placeholder", "aria-label"]))
534
+ ]),
535
+ g.value && !f.value ? (v(), A("button", {
536
+ key: 0,
537
+ type: "button",
538
+ class: G(["wx-faq__new", { "is-current": m.value === "new" }]),
539
+ onClick: p[1] || (p[1] = (i) => I("new"))
540
+ }, [
541
+ y(t(X), {
542
+ name: "plus",
543
+ size: "sm"
544
+ }),
545
+ $(" " + _(t(n)("question.new")), 1)
546
+ ], 2)) : L("", !0),
547
+ x.value && u.value.length === 0 ? (v(), b(t(fe), {
548
+ key: 1,
549
+ class: "wx-faq__loading",
550
+ rows: 5
551
+ })) : u.value.length === 0 ? (v(), b(t(re), {
552
+ key: 2,
553
+ title: E.value,
554
+ description: !f.value && k.value === "" ? t(n)("question.empty-help") : void 0
555
+ }, null, 8, ["title", "description"])) : (v(), A(K, { key: 3 }, [
556
+ y(t(Re), {
557
+ modelValue: u.value,
558
+ "onUpdate:modelValue": p[2] || (p[2] = (i) => u.value = i),
559
+ class: G(["wx-faq__list", { "is-loading": x.value }]),
560
+ plain: "",
561
+ "item-key": "id",
562
+ handle: U.value ? "grip" : "row",
563
+ "item-label": (i) => i.question,
564
+ disabled: !U.value,
565
+ onMove: h
566
+ }, {
567
+ default: d(({ item: i }) => [
568
+ (v(), b(Ne(f.value ? "div" : "button"), {
569
+ type: f.value ? void 0 : "button",
570
+ class: G(["wx-faq-row", { "is-current": m.value === i.id }]),
571
+ onClick: (M) => f.value ? void 0 : I(i.id)
572
+ }, {
573
+ default: d(() => [
574
+ j("span", nt, _(i.question), 1),
575
+ j("span", ot, [
576
+ i.published ? me(i) ? (v(), b(t(Ue), {
577
+ key: 1,
578
+ content: t(n)("question.seen-nowhere")
579
+ }, {
580
+ default: d(() => [
581
+ y(t(O), {
582
+ type: "warning",
583
+ dot: "",
584
+ size: "sm"
585
+ }, {
586
+ default: d(() => [
587
+ $(_(t(n)("question.not-published")), 1)
588
+ ]),
589
+ _: 1
590
+ })
591
+ ]),
592
+ _: 1
593
+ }, 8, ["content"])) : L("", !0) : (v(), b(t(O), {
594
+ key: 0,
595
+ dot: "",
596
+ size: "sm"
597
+ }, {
598
+ default: d(() => [
599
+ $(_(t(n)("question.not-published")), 1)
600
+ ]),
601
+ _: 1
602
+ })),
603
+ (v(!0), A(K, null, Le(i.categories, (M) => (v(), b(t(O), {
604
+ key: M.id,
605
+ size: "sm",
606
+ round: ""
607
+ }, {
608
+ default: d(() => [
609
+ $(_(M.title), 1)
610
+ ]),
611
+ _: 2
612
+ }, 1024))), 128)),
613
+ i.locales.length > 0 ? (v(), A("span", st, _(i.locales.join(" · ")), 1)) : L("", !0)
614
+ ])
615
+ ]),
616
+ _: 2
617
+ }, 1032, ["type", "class", "onClick"]))
618
+ ]),
619
+ actions: d(({ item: i }) => [
620
+ y(t(Te), {
621
+ actions: P(i),
622
+ label: i.question
623
+ }, null, 8, ["actions", "label"])
624
+ ]),
625
+ _: 1
626
+ }, 8, ["modelValue", "class", "handle", "item-label", "disabled"]),
627
+ g.value && !f.value ? (v(), b(t(Ee), {
628
+ key: 0,
629
+ class: "wx-faq__note",
630
+ type: "info",
631
+ description: t(F).hint.value
632
+ }, null, 8, ["description"])) : L("", !0)
633
+ ], 64))
634
+ ]),
635
+ detail: d(({ inline: i, back: M }) => [
636
+ m.value !== null ? (v(), b(tt, {
637
+ id: m.value === "new" ? null : m.value,
638
+ key: String(m.value),
639
+ category: T.value,
640
+ inline: i,
641
+ onBack: M,
642
+ onSaved: W,
643
+ onCreated: o,
644
+ onRemoved: se
645
+ }, null, 8, ["id", "category", "inline", "onBack"])) : L("", !0)
646
+ ]),
647
+ empty: d(() => [
648
+ y(t(re), {
649
+ title: t(n)("question.choose"),
650
+ description: g.value ? t(n)("question.choose-help") : void 0
651
+ }, null, 8, ["title", "description"])
652
+ ]),
653
+ _: 1
654
+ }, 8, ["open", "detail-label"])
655
+ ]),
656
+ _: 1
657
+ })
658
+ ]),
659
+ _: 1
660
+ }, 8, ["view", "title", "views"]));
661
+ }
662
+ }), lt = /* @__PURE__ */ oe(it, [["__scopeId", "data-v-6b3bda78"]]);
663
+ function rt(l = "/faq") {
664
+ return {
665
+ api: "faq/categories",
666
+ path: `${l}/categories`,
667
+ name: "webx.faq.categories",
668
+ module: "faq-categories",
669
+ screen: "faq.category-form",
670
+ manage: "faq.categories.manage",
671
+ count: "questions_count",
672
+ // The list narrowed to one category is also where its own order is dragged.
673
+ items: (r) => ({ path: l, query: { category: String(r) } }),
674
+ words: {
675
+ new: "webx-faq::category.new",
676
+ empty: "webx-faq::category.empty",
677
+ "empty-help": "webx-faq::category.empty-help",
678
+ order: "webx-faq::category.order",
679
+ hidden: "webx-faq::category.hidden",
680
+ count: "webx-faq::category.questions",
681
+ "show-items": "webx-faq::category.show-questions",
682
+ "delete-blocked": "webx-faq::category.delete-blocked",
683
+ "delete-text": "webx-faq::category.delete-text",
684
+ deleted: "webx-faq::category.deleted",
685
+ saved: "webx-faq::category.saved",
686
+ "field-title": "webx-faq::category.field-title"
687
+ }
688
+ };
689
+ }
690
+ function ft(l = {}) {
691
+ const r = l.path ?? "/faq";
692
+ return [
693
+ {
694
+ id: "faq",
695
+ path: r,
696
+ // One route: the open question is in the address (`?question=`), beside the list it was
697
+ // opened from, so a link to it is a link to both.
698
+ routes: [{ path: r, name: "webx.faq", component: lt, props: { base: r } }],
699
+ /*
700
+ * The one node of `faq.form` only this module can draw. A field in the screen's eyes — it
701
+ * gets the label and the help of its node — but without a name, so nothing is bound.
702
+ */
703
+ types: {
704
+ "wx-faq-anchor": { component: Ze, kind: "field" }
705
+ }
706
+ },
707
+ {
708
+ id: "faq-categories",
709
+ path: `${r}/categories`,
710
+ routes: We(rt(r))
711
+ }
712
+ ];
713
+ }
714
+ export {
715
+ Z as FAQ_API,
716
+ Ze as WxFaqAnchor,
717
+ tt as WxFaqQuestionPane,
718
+ lt as WxFaqQuestionsPage,
719
+ he as createFaqApi,
720
+ ft as faq,
721
+ rt as faqCategoriesOptions,
722
+ Ge as faqMessages,
723
+ Je as provideQuestionEditor,
724
+ qe as questionEditorKey,
725
+ je as useQuestionEditor
726
+ };
727
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../src/editor.ts","../src/messages.ts","../src/i18n.ts","../src/AnchorField.vue","../src/api.ts","../src/QuestionPane.vue","../src/QuestionsPage.vue","../src/module.ts"],"sourcesContent":["import { inject, provide, type InjectionKey, type Ref } from 'vue'\nimport type { QuestionRow } from './types'\n\n/**\n * What the form knows and the nodes of `faq.form` do not: the question being edited.\n *\n * The anchor is not a value of the screen — nobody writes it, it is made once on the server and\n * never changes (decision 10) — so the node that shows it (`wx-faq-anchor`) reads the record.\n */\nexport interface QuestionEditorContext {\n /** The question as the server last answered it; `null` while it is new and not yet saved. */\n question: Ref<QuestionRow | null>\n}\n\nexport const questionEditorKey: InjectionKey<QuestionEditorContext> = Symbol('wx-faq-question')\n\nexport function provideQuestionEditor(editor: QuestionEditorContext): void {\n provide(questionEditorKey, editor)\n}\n\n/** The form above this node, or `null` outside one — a demo or a test draws the empty state. */\nexport function useQuestionEditor(): QuestionEditorContext | null {\n return inject(questionEditorKey, null)\n}\n","import type { Messages } from '@webx-ui/module-admin'\n\n/**\n * The English this package speaks on its own, before the server's dictionary arrives, or without\n * one. The server ships the same lines in ten languages under `webx-faq::` and overrides these;\n * `messages.test.ts` keeps the two sets of keys equal.\n */\nexport const faqMessages: Record<string, Messages> = {\n module: {\n group: 'FAQ',\n questions: 'Questions',\n categories: 'Categories',\n },\n question: {\n new: 'New question',\n untitled: 'Untitled',\n search: 'Search the questions',\n empty: 'No questions yet.',\n 'empty-help': 'A question reaches the site in a FAQ block, on any page.',\n 'empty-search': 'Nothing matches that.',\n 'empty-bin': 'The bin is empty.',\n all: 'All',\n bin: 'Bin',\n 'filter-category': 'Category',\n 'any-category': 'Any category',\n 'not-published': 'Not published',\n 'seen-nowhere': 'Not seen in any language: it needs a question and an answer in one.',\n choose: 'Choose a question',\n 'choose-help': 'Or write a new one: the first line of the list.',\n save: 'Save',\n saved: 'The question is saved.',\n 'save-failed': 'The question was not saved.',\n cancel: 'Cancel',\n delete: 'Delete',\n 'delete-title': 'Delete “:title”?',\n 'delete-text':\n 'It goes to the bin and leaves every block on the site. Restored, it comes back with the same link.',\n deleted: 'The question is in the bin.',\n restore: 'Restore',\n restored: 'The question is back.',\n 'copy-link': 'Copy the link',\n 'link-copied': 'The link is on the clipboard.',\n 'anchor-later': 'Made from the question when it is first saved.',\n 'reorder-failed': 'The new order was not saved.',\n 'leave-title': 'Leave without saving?',\n 'leave-text': 'What you wrote in this question is not on the server.',\n leave: 'Leave',\n back: 'Back to the list',\n },\n screen: {\n question: 'Question',\n answer: 'Answer',\n 'answer-help':\n 'A question is shown in a language only when it has both the question and the answer in it.',\n settings: 'Settings',\n published: 'Published',\n 'published-help':\n 'On the site in every block that shows its categories, from the moment it is saved.',\n categories: 'Categories',\n 'categories-help': 'What a block picks the question by, and the buttons of its filter.',\n anchor: 'Link',\n 'anchor-help': 'Made once from the question and never changed, so links to it keep working.',\n },\n category: {\n 'field-title': 'Title',\n visible: 'Shown on the site',\n 'visible-help':\n 'Hidden, it drops out of every filter; its questions stay in the blocks that show them.',\n new: 'New category',\n empty: 'No categories yet.',\n 'empty-help':\n 'A category groups questions: a block shows the ones you pick, and its filter has a button for each.',\n order: 'Drag to change the order of the filter buttons on the site.',\n hidden: 'Hidden from the site',\n questions: 'Questions: :count',\n 'show-questions': 'Show its questions',\n 'delete-blocked': 'This category still holds questions. Move them first.',\n 'delete-text': 'It goes to the bin and its button leaves every filter. Its questions stay.',\n deleted: 'The category is in the bin.',\n saved: 'The category is saved.',\n },\n}\n","import { useAdmin } from '@webx-ui/module-admin'\nimport { faqMessages } from './messages'\n\nconst seeded = new WeakSet<object>()\n\n/** Puts this package's English under the panel's dictionary, once per panel. */\nexport function useFaqMessages(): void {\n try {\n const { i18n } = useAdmin()\n\n if (!seeded.has(i18n)) {\n i18n.defaults('webx-faq', faqMessages)\n seeded.add(i18n)\n }\n } catch {\n // Outside a panel there is no dictionary to seed, and `useTranslate` falls back on its own.\n }\n}\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { toast, WxButton, WxIcon, WxText } from '@webx-ui/core'\nimport { useTranslate } from '@webx-ui/module-admin'\nimport { useQuestionEditor } from './editor'\nimport { useFaqMessages } from './i18n'\n\n/**\n * The anchor of a question: shown, never written (decision 10).\n *\n * Registered as a field only so that the screen draws it the way it draws a field — the label and\n * the help of its node around it. It has no name, so there is no value to bind, and it reads the\n * anchor off the question the form is editing.\n *\n * What is copied is the fragment, `#anchor`, and not an address: a question has no page of its\n * own, and the page it is linked on is whichever page the editor put the block on.\n */\ndefineOptions({ name: 'WxFaqAnchor' })\n\nconst editor = useQuestionEditor()\nuseFaqMessages()\n\nconst t = useTranslate('webx-faq')\n\nconst anchor = computed(() => editor?.question.value?.anchor ?? '')\nconst fragment = computed(() => (anchor.value === '' ? '' : `#${anchor.value}`))\n\nasync function copy(): Promise<void> {\n try {\n await navigator.clipboard.writeText(fragment.value)\n toast.success(t('question.link-copied'))\n } catch {\n // A browser that refuses the clipboard without a gesture it trusts; the anchor is on screen.\n }\n}\n</script>\n\n<template>\n <div class=\"wx-faq-anchor\">\n <template v-if=\"fragment !== ''\">\n <wx-text class=\"wx-faq-anchor__value\" mono truncate>{{ fragment }}</wx-text>\n <wx-button variant=\"outline\" size=\"sm\" @click=\"copy\">\n <template #icon><wx-icon name=\"copy\" /></template>\n {{ t('question.copy-link') }}\n </wx-button>\n </template>\n\n <wx-text v-else size=\"sm\" tone=\"muted\">{{ t('question.anchor-later') }}</wx-text>\n </div>\n</template>\n\n<style scoped>\n.wx-faq-anchor {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: var(--wx-space-8);\n min-width: 0;\n}\n\n.wx-faq-anchor__value {\n flex: 1 1 auto;\n min-width: 0;\n}\n</style>\n","import { reorderItems, type AdminContext } from '@webx-ui/module-admin'\nimport type { ScreenModel } from '@webx-ui/schema'\nimport type { QuestionDetail, QuestionQuery, QuestionRow, QuestionsList } from './types'\n\nexport interface FaqApi {\n /** Every question, with the categories the list can be narrowed to. */\n list(query?: QuestionQuery): Promise<QuestionsList>\n get(id: number): Promise<QuestionDetail>\n /**\n * A new question out of the values of its form, in one request: the form is filled in first\n * and the record made on the first save, so nobody leaves an empty question behind.\n */\n create(values: ScreenModel): Promise<QuestionDetail>\n /** Refused with a 422 under the name of the field. */\n save(id: number, values: ScreenModel): Promise<QuestionDetail>\n remove(id: number): Promise<void>\n restore(id: number): Promise<QuestionRow>\n /** The order on screen: of the whole list, or — with `category` — of that category alone. */\n reorder(ids: number[], category?: number | null): Promise<void>\n}\n\n/** The path of the list under the panel's API, and of its order (`CategoryRoutes::items()`). */\nexport const FAQ_API = 'faq/questions'\n\n/**\n * Everything under `/faq/questions`, below the panel's API path — except the categories, which\n * are the panel's shared ones and are asked for with `createCategoriesApi(admin, 'faq/categories')`.\n */\nexport function createFaqApi(admin: AdminContext): FaqApi {\n const base = `${admin.apiPath}/${FAQ_API}`\n const data = <T>(body: { data: T }): T => body.data\n\n return {\n list: (query = {}) => {\n const search = new URLSearchParams()\n\n // Only what was asked for: `category=` would ask for the questions of no category.\n if (query.search) search.set('search', query.search)\n if (query.category != null) search.set('category', String(query.category))\n if (query.trashed) search.set('trashed', '1')\n\n return admin.http.get<QuestionsList>(search.size > 0 ? `${base}?${search}` : base)\n },\n get: (id) => admin.http.get<{ data: QuestionDetail }>(`${base}/${id}`).then(data),\n create: (values) => admin.http.post<{ data: QuestionDetail }>(base, { values }).then(data),\n save: (id, values) =>\n admin.http.put<{ data: QuestionDetail }>(`${base}/${id}`, { values }).then(data),\n remove: (id) => admin.http.delete<void>(`${base}/${id}`).then(() => undefined),\n restore: (id) => admin.http.post<{ data: QuestionRow }>(`${base}/${id}/restore`, {}).then(data),\n reorder: (ids, category = null) => reorderItems(admin, FAQ_API, ids, category),\n }\n}\n","<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref } from 'vue'\nimport { onBeforeRouteLeave, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'\nimport {\n confirm,\n localizedValue,\n toast,\n useLocales,\n WxActionBar,\n WxBadge,\n WxButton,\n WxSkeleton,\n type LocalizedValue,\n} from '@webx-ui/core'\nimport {\n useAdmin,\n useErrorText,\n useTranslate,\n WxSaveState,\n WxScreen,\n WxScreenHead,\n type ScreenAction,\n} from '@webx-ui/module-admin'\nimport type { ScreenModel } from '@webx-ui/schema'\nimport { createFaqApi } from './api'\nimport { provideQuestionEditor } from './editor'\nimport { useFaqMessages } from './i18n'\nimport type { QuestionDetail, QuestionRow } from './types'\n\n/**\n * One question: the pane beside the list, and the whole screen on a phone.\n *\n * The described screen `faq.form` with a head over it and one button under it. One explicit\n * save, and not the autosave of an article: a question has no draft (decision 12), so what is\n * saved is on the site at once — the button is the moment somebody decides it is ready.\n *\n * A new question is a form first and a record on the first save: an empty row made by pressing\n * \"New question\" and walking away would be a question with nothing in it on the list for ever.\n *\n * The way back is drawn here and not by the pane around it: the drawer a narrow screen opens\n * this in has no close of its own on purpose (CLAUDE.md §4).\n */\ndefineOptions({ name: 'WxFaqQuestionPane' })\n\nconst props = withDefaults(\n defineProps<{\n /** `null` — a question not written yet. */\n id: number | null\n /** The category the list is narrowed to: a new question starts in it. */\n category?: number | null\n /** False in the drawer a narrow screen opens this in. */\n inline?: boolean\n }>(),\n { category: null, inline: true },\n)\n\nconst emit = defineEmits<{\n back: []\n /** The question was written; the list redraws its row. */\n saved: [row: QuestionRow]\n /** The first save of a new question: it is a record now, with an id to be opened by. */\n created: [row: QuestionRow]\n removed: [id: number]\n}>()\n\nconst context = useAdmin()\nconst api = createFaqApi(context)\nconst locales = useLocales()\nuseFaqMessages()\n\nconst t = useTranslate('webx-faq')\n/* Not the server's `message`: the panel says how a request failed in its own words. */\nconst message = useErrorText()\n\nconst question = ref<QuestionRow | null>(null)\nconst values = ref<ScreenModel>({})\nconst snapshot = ref('')\n\nconst loading = ref(props.id !== null)\nconst saving = ref(false)\nconst errors = ref<Record<string, string[]>>({})\n\nconst canManage = computed(() => context.can('faq.manage'))\n\nconst current = computed(() => JSON.stringify(values.value))\nconst dirty = computed(() => snapshot.value !== '' && current.value !== snapshot.value)\n\nconst state = computed<'saving' | 'unsaved' | 'saved'>(() => {\n if (saving.value) return 'saving'\n\n return dirty.value || props.id === null ? 'unsaved' : 'saved'\n})\n\n/** The question follows the field rather than the answer: an edit shows at the top as it is typed. */\nconst title = computed(() => {\n const written = values.value.question as LocalizedValue | string | null | undefined\n const fallback = props.id === null ? t('question.new') : (question.value?.question ?? '')\n\n return localizedValue(written, locales.active.value, fallback) || t('question.untitled')\n})\n\nprovideQuestionEditor({ question })\n\nfunction take(detail: QuestionDetail): void {\n question.value = detail.question\n values.value = detail.values\n snapshot.value = JSON.stringify(detail.values)\n}\n\nif (props.id === null) {\n // What the server would answer for a question with nothing in it yet.\n const blank: ScreenModel = {\n question: {},\n answer: {},\n published: false,\n categories: props.category === null ? [] : [props.category],\n }\n\n values.value = blank\n snapshot.value = JSON.stringify(blank)\n}\n\nasync function load(): Promise<void> {\n if (props.id === null) return\n\n loading.value = true\n\n try {\n take(await api.get(props.id))\n } catch (error) {\n toast.danger(message(error))\n emit('back')\n } finally {\n loading.value = false\n }\n}\n\nasync function save(): Promise<void> {\n if (saving.value || !canManage.value) return\n\n saving.value = true\n errors.value = {}\n\n try {\n const made = props.id === null\n const detail = made\n ? await api.create(values.value)\n : await api.save(props.id as number, values.value)\n\n take(detail)\n toast.success(t('question.saved'))\n if (made) emit('created', detail.question)\n else emit('saved', detail.question)\n } catch (error) {\n const body = (error as { body?: { errors?: Record<string, string[]> } }).body\n\n if (body?.errors) {\n errors.value = body.errors\n toast.danger(t('question.save-failed'))\n } else {\n toast.danger(message(error))\n }\n } finally {\n saving.value = false\n }\n}\n\nasync function remove(): Promise<void> {\n const row = question.value\n\n if (!row) return\n\n const agreed = await confirm({\n title: t('question.delete-title', { title: row.question }),\n message: t('question.delete-text'),\n confirmText: t('question.delete'),\n cancelText: t('question.cancel'),\n tone: 'danger',\n })\n\n if (!agreed) return\n\n try {\n await api.remove(row.id)\n toast.success(t('question.deleted'))\n // Nothing left to protect: the record is in the bin.\n snapshot.value = current.value\n emit('removed', row.id)\n } catch (error) {\n toast.danger(message(error))\n }\n}\n\n/* The browser's own guard: it cannot wait for a request, so all it can do is ask. */\nfunction leaveGuard(event: BeforeUnloadEvent): void {\n if (dirty.value) event.preventDefault()\n}\n\n/* Ctrl+S saves, as it does everywhere else that has a save button. */\nfunction onKeydown(event: KeyboardEvent): void {\n if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {\n event.preventDefault()\n if (dirty.value || props.id === null) void save()\n }\n}\n\nonMounted(() => {\n void load()\n window.addEventListener('beforeunload', leaveGuard)\n})\n\nonBeforeUnmount(() => {\n window.removeEventListener('beforeunload', leaveGuard)\n})\n\nasync function mayLeave(): Promise<boolean> {\n if (!dirty.value || !canManage.value) return true\n\n return await confirm({\n title: t('question.leave-title'),\n message: t('question.leave-text'),\n confirmText: t('question.leave'),\n cancelText: t('question.cancel'),\n tone: 'danger',\n })\n}\n\n/*\n * The list and this form are one route, so another question is an update of it rather than a\n * way out — and so is every keystroke in the search above the list, which goes into the address\n * too. Only a change of the question asks.\n */\nonBeforeRouteUpdate(async (to: RouteLocationNormalized, from: RouteLocationNormalized) =>\n to.query.question === from.query.question ? true : await mayLeave(),\n)\n\nonBeforeRouteLeave(mayLeave)\n\n/* What leads away from the question. On a narrow pane the head folds them into the ···. */\nconst actions = computed<ScreenAction[]>(() =>\n canManage.value && question.value !== null\n ? [\n {\n key: 'delete',\n label: t('question.delete'),\n icon: 'trash',\n danger: true,\n menu: true,\n run: () => void remove(),\n },\n ]\n : [],\n)\n</script>\n\n<template>\n <div class=\"wx-faq-question\" @keydown=\"onKeydown\">\n <wx-skeleton v-if=\"loading\" class=\"wx-faq-question__ghost\" title :rows=\"6\" />\n\n <template v-else>\n <wx-screen-head\n :level=\"3\"\n :back=\"!props.inline\"\n :back-label=\"t('question.back')\"\n :title=\"title\"\n :actions=\"actions\"\n :collapse-below=\"480\"\n @back=\"emit('back')\"\n >\n <template v-if=\"question && !question.published\" #title-after>\n <wx-badge dot>{{ t('question.not-published') }}</wx-badge>\n </template>\n </wx-screen-head>\n\n <wx-screen\n v-model=\"values\"\n name=\"faq.form\"\n :errors=\"errors\"\n :disabled=\"!canManage || saving\"\n />\n\n <wx-action-bar v-if=\"canManage\">\n <template #state>\n <wx-save-state :state=\"state\" />\n </template>\n\n <wx-button\n type=\"primary\"\n :loading=\"saving\"\n :disabled=\"!dirty && props.id !== null\"\n @click=\"save\"\n >\n {{ t('question.save') }}\n </wx-button>\n </wx-action-bar>\n </template>\n </div>\n</template>\n\n<style scoped>\n.wx-faq-question {\n display: flex;\n flex-direction: column;\n gap: var(--wx-gap, var(--wx-space-16));\n min-width: 0;\n padding: var(--wx-gap, var(--wx-space-16));\n container-type: inline-size;\n}\n\n.wx-faq-question__ghost {\n max-width: 480px;\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'\nimport {\n confirm,\n toast,\n WxAlert,\n WxBadge,\n WxCard,\n WxEmpty,\n WxIcon,\n WxInput,\n WxListDetail,\n WxSelect,\n WxSkeleton,\n WxSortableList,\n WxTooltip,\n type TabItem,\n type TabValue,\n} from '@webx-ui/core'\nimport {\n useAdmin,\n useErrorText,\n useItemOrder,\n useTranslate,\n WxListScreen,\n WxRowMenu,\n type RowAction,\n} from '@webx-ui/module-admin'\nimport { createFaqApi, FAQ_API } from './api'\nimport { useFaqMessages } from './i18n'\nimport QuestionPane from './QuestionPane.vue'\nimport type { QuestionCategoryRef, QuestionRow } from './types'\n\n/**\n * The questions: the whole FAQ on the left, the one being written on the right (§4.5).\n *\n * The list and the form side by side rather than a page per question, because a question is\n * written in a minute and the next one is usually the one under it: a round trip through a\n * page of its own for every question is most of the time spent. On a phone the form is the\n * whole screen, in the drawer `WxListDetail` raises, and draws its own way back.\n *\n * The order is decision 4, as with services: without a filter a drag moves the common order;\n * narrowed to one category, the category's own; a search or the bin shows a selection, so the\n * grips go away and the line under the list says why.\n *\n * Everything the screen is looking at lives in the address — the view, the category, the\n * search and the open question — so a link to a question is a link to it in its list.\n */\nwithDefaults(defineProps<{ base?: string }>(), { base: '/faq' })\n\nconst context = useAdmin()\nconst api = createFaqApi(context)\nconst route = useRoute()\nconst router = useRouter()\nuseFaqMessages()\n\nconst t = useTranslate('webx-faq')\n/* Not the server's `message`: the panel says how a request failed in its own words. */\nconst message = useErrorText()\n\nconst rows = ref<QuestionRow[]>([])\nconst categories = ref<QuestionCategoryRef[]>([])\nconst loading = ref(true)\n\nconst canManage = computed(() => context.can('faq.manage'))\n\nconst title = computed(\n () =>\n context.state.manifest?.modules.find((module) => module.id === 'faq')?.title ??\n t('module.questions'),\n)\n\nconst view = computed<TabValue>({\n get: () => (route.query.view === 'trashed' ? 'trashed' : ''),\n set: (value) => {\n const query: LocationQueryRaw = {\n ...route.query,\n view: value === '' ? undefined : String(value),\n }\n\n // A question from the list is not a question from the bin: the form closes with the view.\n delete query.question\n void router.replace({ query })\n },\n})\n\nconst inBin = computed(() => view.value === 'trashed')\n\nconst category = computed<number | null>(() => {\n const raw = route.query.category\n\n return typeof raw === 'string' && raw !== '' ? Number(raw) || null : null\n})\n\nconst q = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''))\n\n/* What is typed, ahead of the address: the address follows it after a pause. */\nconst typed = ref(q.value)\n\n/** The open question: a number, `'new'` for one not yet saved, or nothing. */\nconst selected = computed<number | 'new' | null>(() => {\n const raw = route.query.question\n\n if (raw === 'new') return canManage.value ? 'new' : null\n\n return typeof raw === 'string' && raw !== '' ? Number(raw) || null : null\n})\n\n/*\n * Open is \"a question is chosen\", and closing is un-choosing it — so the address, the list's\n * highlight and the drawer on a phone can never disagree about it.\n */\nconst open = computed({\n get: () => selected.value !== null,\n set: (value) => {\n if (!value) close()\n },\n})\n\nconst views = computed<TabItem[]>(() => [\n { value: '', label: t('question.all') },\n { value: 'trashed', label: t('question.bin'), icon: 'trash' },\n])\n\nconst order = useItemOrder(FAQ_API, () => ({\n q: q.value,\n category: category.value,\n filtered: inBin.value,\n}))\n\n/* Dragged only by somebody who may write, and only in a list that is a whole order. */\nconst sortable = computed(() => canManage.value && order.sortable.value)\n\nconst emptyText = computed(() => {\n if (q.value !== '') return t('question.empty-search')\n\n return inBin.value ? t('question.empty-bin') : t('question.empty')\n})\n\nasync function load(): Promise<void> {\n loading.value = true\n\n try {\n const answer = await api.list({\n search: q.value,\n category: inBin.value ? null : category.value,\n trashed: inBin.value,\n })\n\n rows.value = answer.data\n categories.value = answer.filters.categories\n } catch (error) {\n toast.danger(message(error))\n } finally {\n loading.value = false\n }\n}\n\nwatch(\n () => [view.value, category.value, q.value] as const,\n () => void load(),\n)\nvoid load()\n\nlet pause: ReturnType<typeof setTimeout> | undefined\n\nwatch(typed, (value) => {\n clearTimeout(pause)\n pause = setTimeout(() => {\n void router.replace({ query: { ...route.query, q: value.trim() === '' ? undefined : value } })\n }, 300)\n})\n\nonBeforeUnmount(() => clearTimeout(pause))\n\nfunction narrow(value: unknown): void {\n void router.replace({\n query: { ...route.query, category: typeof value === 'number' ? String(value) : undefined },\n })\n}\n\n/* `replace` and not `push`: going through twenty questions is not twenty steps of history. */\nfunction choose(id: number | 'new'): void {\n void router.replace({ query: { ...route.query, question: String(id) } })\n}\n\nfunction close(): void {\n const query = { ...route.query }\n\n delete query.question\n void router.replace({ query })\n}\n\n/** A new question was saved: it is a record now, and the list has it. */\nfunction created(row: QuestionRow): void {\n choose(row.id)\n void load()\n}\n\n/**\n * The order on screen, written as a whole — the list or the category's, whichever this is. The\n * list is already in its new order (`WxSortableList` reorders the model before it says so), so a\n * failure has to put it back rather than leave the screen disagreeing with the database.\n */\nasync function reorder(): Promise<void> {\n try {\n await order.move(rows.value.map((row) => row.id))\n } catch (error) {\n toast.danger(message(error, t('question.reorder-failed')))\n await load()\n }\n}\n\nfunction actionsFor(row: QuestionRow): RowAction[] {\n if (!canManage.value) return []\n\n if (inBin.value) {\n return [\n {\n key: 'restore',\n icon: 'refresh',\n label: t('question.restore'),\n run: () => void restore(row),\n },\n ]\n }\n\n return [\n {\n key: 'copy',\n icon: 'copy',\n label: t('question.copy-link'),\n run: () => void copyLink(row),\n },\n {\n key: 'delete',\n icon: 'trash',\n label: t('question.delete'),\n danger: true,\n run: () => void remove(row),\n },\n ]\n}\n\nasync function remove(row: QuestionRow): Promise<void> {\n const agreed = await confirm({\n title: t('question.delete-title', { title: row.question }),\n message: t('question.delete-text'),\n confirmText: t('question.delete'),\n cancelText: t('question.cancel'),\n tone: 'danger',\n })\n\n if (!agreed) return\n\n try {\n await api.remove(row.id)\n toast.success(t('question.deleted'))\n removed(row.id)\n } catch (error) {\n toast.danger(message(error))\n }\n}\n\n/** Gone from the list; and if it was the open one, the form goes with it. */\nfunction removed(id: number): void {\n if (selected.value === id) close()\n\n void load()\n}\n\nasync function restore(row: QuestionRow): Promise<void> {\n try {\n await api.restore(row.id)\n toast.success(t('question.restored'))\n } catch (error) {\n toast.danger(message(error))\n } finally {\n await load()\n }\n}\n\nasync function copyLink(row: QuestionRow): Promise<void> {\n try {\n await navigator.clipboard.writeText(`#${row.anchor}`)\n toast.success(t('question.link-copied'))\n } catch {\n // A browser that refuses the clipboard without a gesture it trusts.\n }\n}\n\n/* Published and yet shown in no language: the one state the list has to point out (decision 9). */\nfunction unseen(row: QuestionRow): boolean {\n return row.published && row.locales.length === 0\n}\n</script>\n\n<template>\n <wx-list-screen v-model:view=\"view\" :title=\"title\" :views=\"views\" :card=\"false\">\n <wx-card class=\"wx-faq\" padding=\"none\">\n <wx-list-detail\n v-model:open=\"open\"\n class=\"wx-faq__panes\"\n :list-width=\"360\"\n :detail-min=\"460\"\n :detail-label=\"t('module.questions')\"\n >\n <template #list>\n <div class=\"wx-faq__bar\">\n <wx-input\n v-model=\"typed\"\n class=\"wx-faq__search\"\n size=\"sm\"\n clearable\n :placeholder=\"t('question.search')\"\n :aria-label=\"t('question.search')\"\n >\n <template #prefix><wx-icon name=\"search\" size=\"sm\" /></template>\n </wx-input>\n\n <!-- Always in sight rather than behind a funnel: it is the one filter that decides\n which order a drag writes, and the list says so right under it. -->\n <wx-select\n v-if=\"!inBin\"\n :model-value=\"category\"\n :options=\"categories.map((item) => ({ value: item.id, label: item.title }))\"\n :placeholder=\"t('question.any-category')\"\n :aria-label=\"t('question.filter-category')\"\n clearable\n size=\"sm\"\n @update:model-value=\"narrow\"\n />\n </div>\n\n <!--\n A line of the list rather than a button in the head: the new question is written\n where it will stand, beside the ones it joins — and the form opens the same way any\n other question's does.\n -->\n <button\n v-if=\"canManage && !inBin\"\n type=\"button\"\n class=\"wx-faq__new\"\n :class=\"{ 'is-current': selected === 'new' }\"\n @click=\"choose('new')\"\n >\n <wx-icon name=\"plus\" size=\"sm\" />\n {{ t('question.new') }}\n </button>\n\n <wx-skeleton v-if=\"loading && rows.length === 0\" class=\"wx-faq__loading\" :rows=\"5\" />\n\n <wx-empty\n v-else-if=\"rows.length === 0\"\n :title=\"emptyText\"\n :description=\"!inBin && q === '' ? t('question.empty-help') : undefined\"\n />\n\n <template v-else>\n <!-- A grip and not the whole row: the row opens a question, and a row that both\n opens and drags is one where one of the two happens by accident. -->\n <wx-sortable-list\n v-model=\"rows\"\n class=\"wx-faq__list\"\n :class=\"{ 'is-loading': loading }\"\n plain\n item-key=\"id\"\n :handle=\"sortable ? 'grip' : 'row'\"\n :item-label=\"(item: QuestionRow) => item.question\"\n :disabled=\"!sortable\"\n @move=\"reorder\"\n >\n <template #default=\"{ item }\">\n <component\n :is=\"inBin ? 'div' : 'button'\"\n :type=\"inBin ? undefined : 'button'\"\n class=\"wx-faq-row\"\n :class=\"{ 'is-current': selected === item.id }\"\n @click=\"inBin ? undefined : choose(item.id)\"\n >\n <span class=\"wx-faq-row__question\">{{ item.question }}</span>\n\n <span class=\"wx-faq-row__facts\">\n <wx-badge v-if=\"!item.published\" dot size=\"sm\">\n {{ t('question.not-published') }}\n </wx-badge>\n <wx-tooltip v-else-if=\"unseen(item)\" :content=\"t('question.seen-nowhere')\">\n <wx-badge type=\"warning\" dot size=\"sm\">\n {{ t('question.not-published') }}\n </wx-badge>\n </wx-tooltip>\n\n <wx-badge v-for=\"chip in item.categories\" :key=\"chip.id\" size=\"sm\" round>\n {{ chip.title }}\n </wx-badge>\n\n <span v-if=\"item.locales.length > 0\" class=\"wx-faq-row__locales\">\n {{ item.locales.join(' · ') }}\n </span>\n </span>\n </component>\n </template>\n\n <template #actions=\"{ item }\">\n <wx-row-menu :actions=\"actionsFor(item)\" :label=\"item.question\" />\n </template>\n </wx-sortable-list>\n\n <!-- What a drag does here, or why there is nothing to drag. -->\n <wx-alert\n v-if=\"canManage && !inBin\"\n class=\"wx-faq__note\"\n type=\"info\"\n :description=\"order.hint.value\"\n />\n </template>\n </template>\n\n <template #detail=\"{ inline, back }\">\n <question-pane\n v-if=\"selected !== null\"\n :id=\"selected === 'new' ? null : selected\"\n :key=\"String(selected)\"\n :category=\"category\"\n :inline=\"inline\"\n @back=\"back\"\n @saved=\"load\"\n @created=\"created\"\n @removed=\"removed\"\n />\n </template>\n\n <template #empty>\n <wx-empty\n :title=\"t('question.choose')\"\n :description=\"canManage ? t('question.choose-help') : undefined\"\n />\n </template>\n </wx-list-detail>\n </wx-card>\n </wx-list-screen>\n</template>\n\n<style>\n/*\n * What scrolls here is the page, the way it does on every other list of the panel. The card's\n * corners are the screen's, and the panes paint their own background to the edge: `clip`, not\n * `hidden`, which would make this a scroll container and pin everything sticky inside to a box\n * that never moves (CLAUDE.md §4).\n */\n.wx-faq > .wx-card__body {\n display: flex;\n flex-direction: column;\n overflow: clip;\n border-radius: inherit;\n}\n</style>\n\n<style scoped>\n.wx-faq__bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--wx-space-8);\n padding: var(--wx-space-12) var(--wx-space-12) var(--wx-space-8);\n}\n\n.wx-faq__search {\n flex: 1 1 180px;\n min-width: 0;\n}\n\n/* The select is a fragment (it carries its dropdown beside it), so our scope attribute never\n reaches it: styled from our own wrapper (CLAUDE.md §4). */\n.wx-faq__bar > :deep(.wx-select) {\n flex: 1 1 140px;\n min-width: 0;\n}\n\n.wx-faq__new {\n display: flex;\n align-items: center;\n gap: var(--wx-space-8);\n width: calc(100% - 2 * var(--wx-space-8));\n margin: 0 var(--wx-space-8) var(--wx-space-4);\n padding: var(--wx-space-8);\n border: 1px dashed var(--wx-border-default);\n border-radius: var(--wx-radius-sm);\n background: none;\n font: inherit;\n color: var(--wx-color-primary);\n text-align: start;\n cursor: pointer;\n}\n\n.wx-faq__new:hover,\n.wx-faq__new.is-current {\n background: var(--wx-bg-subtle);\n}\n\n.wx-faq__loading {\n padding: var(--wx-space-12);\n}\n\n.wx-faq__list {\n padding-inline: var(--wx-space-8);\n}\n\n.wx-faq__list.is-loading {\n opacity: 0.6;\n}\n\n.wx-faq__list :deep(.wx-sortable-list__row) {\n gap: var(--wx-space-4);\n padding-inline: var(--wx-space-4);\n border-radius: var(--wx-radius-sm);\n}\n\n.wx-faq__list :deep(.wx-sortable-list__row:hover),\n.wx-faq__list :deep(.wx-sortable-list__row:has(.is-current)) {\n background: var(--wx-bg-subtle);\n}\n\n.wx-faq__note {\n margin: var(--wx-space-8);\n}\n\n.wx-faq-row {\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: var(--wx-space-4);\n /* The row is the target: a short question should not leave half the line dead — and a\n button, unlike a link, is only as wide as what is in it until it is told otherwise. */\n flex: 1 1 auto;\n width: 100%;\n min-width: 0;\n padding-block: var(--wx-space-8);\n padding-inline: 0;\n border: 0;\n background: none;\n font: inherit;\n color: inherit;\n text-align: start;\n}\n\nbutton.wx-faq-row {\n cursor: pointer;\n}\n\n.wx-faq-row.is-current .wx-faq-row__question {\n color: var(--wx-color-primary);\n}\n\n/* Two lines, then an ellipsis: a question is a sentence, and one line of it is rarely enough to\n tell two apart. */\n.wx-faq-row__question {\n display: -webkit-box;\n overflow: hidden;\n font-weight: var(--wx-font-weight-medium);\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow-wrap: anywhere;\n}\n\n.wx-faq-row__facts {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: var(--wx-space-4);\n min-width: 0;\n}\n\n.wx-faq-row__facts:empty {\n display: none;\n}\n\n.wx-faq-row__locales {\n color: var(--wx-text-muted);\n font-family: var(--wx-font-family-mono);\n font-size: var(--wx-font-size-xs);\n text-transform: uppercase;\n}\n</style>\n","import { categoryRoutes, type AdminModule, type CategoriesOptions } from '@webx-ui/module-admin'\nimport AnchorField from './AnchorField.vue'\nimport QuestionsPage from './QuestionsPage.vue'\n\nexport interface FaqOptions {\n /** Where the questions live inside the panel. The categories sit under it. */\n path?: string\n}\n\n/**\n * The categories of the FAQ as the panel's shared category screens see them. Exported so that a\n * panel mounting the screens somewhere of its own does not have to repeat the words.\n *\n * No words about addresses: a category here has none (decision 2) — it is a button of the filter\n * on the site, and the shared list leaves the address line out when the server says so.\n */\nexport function faqCategoriesOptions(path = '/faq'): CategoriesOptions {\n return {\n api: 'faq/categories',\n path: `${path}/categories`,\n name: 'webx.faq.categories',\n module: 'faq-categories',\n screen: 'faq.category-form',\n manage: 'faq.categories.manage',\n count: 'questions_count',\n // The list narrowed to one category is also where its own order is dragged.\n items: (id) => ({ path, query: { category: String(id) } }),\n words: {\n new: 'webx-faq::category.new',\n empty: 'webx-faq::category.empty',\n 'empty-help': 'webx-faq::category.empty-help',\n order: 'webx-faq::category.order',\n hidden: 'webx-faq::category.hidden',\n count: 'webx-faq::category.questions',\n 'show-items': 'webx-faq::category.show-questions',\n 'delete-blocked': 'webx-faq::category.delete-blocked',\n 'delete-text': 'webx-faq::category.delete-text',\n deleted: 'webx-faq::category.deleted',\n saved: 'webx-faq::category.saved',\n 'field-title': 'webx-faq::category.field-title',\n },\n }\n}\n\n/**\n * The FAQ as sections of the panel: questions, and their categories (§4.5).\n *\n * Two modules rather than one, because the navigation is one entry per module; the server puts\n * both in the `faq` group, which is what draws them under one heading. A section whose server\n * half is not installed never appears — the entry is built from the manifest.\n */\nexport function faq(options: FaqOptions = {}): AdminModule[] {\n const path = options.path ?? '/faq'\n\n return [\n {\n id: 'faq',\n path,\n // One route: the open question is in the address (`?question=`), beside the list it was\n // opened from, so a link to it is a link to both.\n routes: [{ path, name: 'webx.faq', component: QuestionsPage, props: { base: path } }],\n /*\n * The one node of `faq.form` only this module can draw. A field in the screen's eyes — it\n * gets the label and the help of its node — but without a name, so nothing is bound.\n */\n types: {\n 'wx-faq-anchor': { component: AnchorField, kind: 'field' },\n },\n },\n {\n id: 'faq-categories',\n path: `${path}/categories`,\n routes: categoryRoutes(faqCategoriesOptions(path)),\n },\n ]\n}\n"],"names":["questionEditorKey","provideQuestionEditor","editor","provide","useQuestionEditor","inject","faqMessages","seeded","useFaqMessages","i18n","useAdmin","t","useTranslate","anchor","computed","fragment","copy","toast","_openBlock","_createElementBlock","_hoisted_1","_Fragment","_createVNode","_unref","WxText","WxButton","WxIcon","_createTextVNode","_createBlock","FAQ_API","createFaqApi","admin","base","data","body","query","search","id","values","ids","category","reorderItems","props","__props","emit","__emit","context","api","locales","useLocales","message","useErrorText","question","ref","snapshot","loading","saving","errors","canManage","current","dirty","state","title","written","fallback","localizedValue","take","detail","blank","load","error","save","made","remove","row","confirm","leaveGuard","event","onKeydown","onMounted","onBeforeUnmount","mayLeave","onBeforeRouteUpdate","to","from","onBeforeRouteLeave","actions","WxSkeleton","WxScreenHead","WxBadge","WxScreen","$event","WxActionBar","WxSaveState","route","useRoute","router","useRouter","rows","categories","module","view","value","inBin","raw","q","typed","selected","open","close","views","order","useItemOrder","sortable","emptyText","answer","watch","pause","narrow","choose","created","reorder","actionsFor","restore","copyLink","removed","unseen","WxListScreen","WxCard","WxListDetail","_createElementVNode","WxInput","WxSelect","item","_normalizeClass","WxEmpty","WxSortableList","_withCtx","_resolveDynamicComponent","_hoisted_2","_toDisplayString","_hoisted_3","WxTooltip","_renderList","chip","_hoisted_4","WxRowMenu","WxAlert","inline","back","QuestionPane","faqCategoriesOptions","path","faq","options","QuestionsPage","AnchorField","categoryRoutes"],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ import { Messages } from '@webx-ui/module-admin';
2
+ /**
3
+ * The English this package speaks on its own, before the server's dictionary arrives, or without
4
+ * one. The server ships the same lines in ten languages under `webx-faq::` and overrides these;
5
+ * `messages.test.ts` keeps the two sets of keys equal.
6
+ */
7
+ export declare const faqMessages: Record<string, Messages>;
@@ -0,0 +1,21 @@
1
+ import { AdminModule, CategoriesOptions } from '@webx-ui/module-admin';
2
+ export interface FaqOptions {
3
+ /** Where the questions live inside the panel. The categories sit under it. */
4
+ path?: string;
5
+ }
6
+ /**
7
+ * The categories of the FAQ as the panel's shared category screens see them. Exported so that a
8
+ * panel mounting the screens somewhere of its own does not have to repeat the words.
9
+ *
10
+ * No words about addresses: a category here has none (decision 2) — it is a button of the filter
11
+ * on the site, and the shared list leaves the address line out when the server says so.
12
+ */
13
+ export declare function faqCategoriesOptions(path?: string): CategoriesOptions;
14
+ /**
15
+ * The FAQ as sections of the panel: questions, and their categories (§4.5).
16
+ *
17
+ * Two modules rather than one, because the navigation is one entry per module; the server puts
18
+ * both in the `faq` group, which is what draws them under one heading. A section whose server
19
+ * half is not installed never appears — the entry is built from the manifest.
20
+ */
21
+ export declare function faq(options?: FaqOptions): AdminModule[];
package/dist/style.css ADDED
@@ -0,0 +1 @@
1
+ .wx-faq-anchor[data-v-e6091b48]{display:flex;flex-wrap:wrap;align-items:center;gap:var(--wx-space-8);min-width:0}.wx-faq-anchor__value[data-v-e6091b48]{flex:1 1 auto;min-width:0}.wx-faq-question[data-v-0589ef85]{display:flex;flex-direction:column;gap:var(--wx-gap, var(--wx-space-16));min-width:0;padding:var(--wx-gap, var(--wx-space-16));container-type:inline-size}.wx-faq-question__ghost[data-v-0589ef85]{max-width:480px}.wx-faq>.wx-card__body{display:flex;flex-direction:column;overflow:clip;border-radius:inherit}.wx-faq__bar[data-v-6b3bda78]{display:flex;flex-wrap:wrap;gap:var(--wx-space-8);padding:var(--wx-space-12) var(--wx-space-12) var(--wx-space-8)}.wx-faq__search[data-v-6b3bda78]{flex:1 1 180px;min-width:0}.wx-faq__bar[data-v-6b3bda78]>.wx-select{flex:1 1 140px;min-width:0}.wx-faq__new[data-v-6b3bda78]{display:flex;align-items:center;gap:var(--wx-space-8);width:calc(100% - 2 * var(--wx-space-8));margin:0 var(--wx-space-8) var(--wx-space-4);padding:var(--wx-space-8);border:1px dashed var(--wx-border-default);border-radius:var(--wx-radius-sm);background:none;font:inherit;color:var(--wx-color-primary);text-align:start;cursor:pointer}.wx-faq__new[data-v-6b3bda78]:hover,.wx-faq__new.is-current[data-v-6b3bda78]{background:var(--wx-bg-subtle)}.wx-faq__loading[data-v-6b3bda78]{padding:var(--wx-space-12)}.wx-faq__list[data-v-6b3bda78]{padding-inline:var(--wx-space-8)}.wx-faq__list.is-loading[data-v-6b3bda78]{opacity:.6}.wx-faq__list[data-v-6b3bda78] .wx-sortable-list__row{gap:var(--wx-space-4);padding-inline:var(--wx-space-4);border-radius:var(--wx-radius-sm)}.wx-faq__list[data-v-6b3bda78] .wx-sortable-list__row:hover,.wx-faq__list[data-v-6b3bda78] .wx-sortable-list__row:has(.is-current){background:var(--wx-bg-subtle)}.wx-faq__note[data-v-6b3bda78]{margin:var(--wx-space-8)}.wx-faq-row[data-v-6b3bda78]{display:flex;flex-direction:column;align-items:stretch;gap:var(--wx-space-4);flex:1 1 auto;width:100%;min-width:0;padding-block:var(--wx-space-8);padding-inline:0;border:0;background:none;font:inherit;color:inherit;text-align:start}button.wx-faq-row[data-v-6b3bda78]{cursor:pointer}.wx-faq-row.is-current .wx-faq-row__question[data-v-6b3bda78]{color:var(--wx-color-primary)}.wx-faq-row__question[data-v-6b3bda78]{display:-webkit-box;overflow:hidden;font-weight:var(--wx-font-weight-medium);-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow-wrap:anywhere}.wx-faq-row__facts[data-v-6b3bda78]{display:flex;flex-wrap:wrap;align-items:center;gap:var(--wx-space-4);min-width:0}.wx-faq-row__facts[data-v-6b3bda78]:empty{display:none}.wx-faq-row__locales[data-v-6b3bda78]{color:var(--wx-text-muted);font-family:var(--wx-font-family-mono);font-size:var(--wx-font-size-xs);text-transform:uppercase}
@@ -0,0 +1,47 @@
1
+ import { ScreenModel } from '@webx-ui/schema';
2
+ /** A category as a row of the list names one: the filter, and the chips on the row. */
3
+ export interface QuestionCategoryRef {
4
+ id: number;
5
+ title: string;
6
+ }
7
+ /**
8
+ * One question as the section lists it (`QuestionResource`).
9
+ *
10
+ * `locales` is where it can be seen — the languages it has both a question and an answer in —
11
+ * and it is a separate thing from `published` on purpose: "published, and seen nowhere" is the
12
+ * row the list has to point out (decision 9).
13
+ */
14
+ export interface QuestionRow {
15
+ id: number;
16
+ /** In the language the panel is open in, in another where there is none, `#id` where neither. */
17
+ question: string;
18
+ /** Made once, from the question, and never changed (decision 10). */
19
+ anchor: string;
20
+ published: boolean;
21
+ /** Its place in the whole list. */
22
+ position: number;
23
+ locales: string[];
24
+ categories: QuestionCategoryRef[];
25
+ updated_at: string | null;
26
+ deleted_at: string | null;
27
+ }
28
+ /** The whole FAQ — no pages: the list is where questions are put in order (§4.6). */
29
+ export interface QuestionsList {
30
+ data: QuestionRow[];
31
+ filters: {
32
+ categories: QuestionCategoryRef[];
33
+ };
34
+ }
35
+ export interface QuestionQuery {
36
+ /** The question in any language the site has, or the anchor. */
37
+ search?: string;
38
+ /** One category: the list comes in its own order, and a drag writes that order. */
39
+ category?: number | null;
40
+ /** What was deleted. The only way back to a question in the bin is through this list. */
41
+ trashed?: boolean;
42
+ }
43
+ /** One question as its form opens it: the row, and the values of `faq.form` by field name. */
44
+ export interface QuestionDetail {
45
+ question: QuestionRow;
46
+ values: ScreenModel;
47
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@webx-ui/module-faq",
3
+ "version": "0.1.0",
4
+ "description": "FAQ for the WebX UI admin panel: questions and answers with flat categories, ordered by hand, shown on the site by a block.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": [
8
+ "*.css"
9
+ ],
10
+ "keywords": [
11
+ "webx-ui",
12
+ "vue",
13
+ "admin",
14
+ "cms",
15
+ "faq"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/webx-ui/webx-ui.git",
20
+ "directory": "packages/module-faq"
21
+ },
22
+ "homepage": "https://webx-ui.github.io/webx-ui/guide/faq.html",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "main": "./dist/index.js",
30
+ "module": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js"
36
+ },
37
+ "./style.css": "./dist/style.css",
38
+ "./dist/style.css": "./dist/style.css",
39
+ "./package.json": "./package.json"
40
+ },
41
+ "peerDependencies": {
42
+ "vue": "^3.5.0",
43
+ "vue-router": "^4.5.0"
44
+ },
45
+ "dependencies": {
46
+ "@webx-ui/core": "^0.33.2",
47
+ "@webx-ui/module-admin": "^0.17.0",
48
+ "@webx-ui/schema": "^0.6.1"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^24.10.1",
52
+ "@vitejs/plugin-vue": "^6.0.1",
53
+ "typescript": "^5.9.3",
54
+ "vite": "^7.1.14",
55
+ "vite-plugin-dts": "^4.5.4",
56
+ "vue": "^3.5.24",
57
+ "vue-router": "^4.5.1",
58
+ "vue-tsc": "^3.1.3"
59
+ },
60
+ "scripts": {
61
+ "build": "vite build",
62
+ "dev": "vite build --watch",
63
+ "typecheck": "vue-tsc -p tsconfig.json --noEmit"
64
+ }
65
+ }