@ansiversa/components 0.0.137 → 0.0.138

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/index.ts CHANGED
@@ -39,6 +39,7 @@ export { default as ResumeBuilderSummary } from './src/Summary/ResumeBuilderSumm
39
39
  export { default as PortfolioCreatorSummary } from './src/Summary/PortfolioCreatorSummary.astro';
40
40
  export { default as AvImageUploader } from "./src/components/media/AvImageUploader.astro";
41
41
  export { default as AvAiAssist } from "./src/components/Ai/AvAiAssist.astro";
42
+ export { default as FaqManager } from "./src/components/Admin/FaqManager.astro";
42
43
  export { AppLogo } from "./src/Logo";
43
44
  export type { AppLogoProps } from "./src/Logo";
44
45
  export { default as ResumeBuilderShell } from './src/resume-templates/ResumeBuilderShell.astro';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansiversa/components",
3
- "version": "0.0.137",
3
+ "version": "0.0.138",
4
4
  "description": "Shared UI components and layouts for the Ansiversa ecosystem",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,631 @@
1
+ ---
2
+ import AvButton from "../../AvButton.astro";
3
+ import AvCard from "../../AvCard.astro";
4
+ import AvDrawer from "../../AvDrawer.astro";
5
+ import AvInput from "../../AvInput.astro";
6
+ import AvLoading from "../../AvLoading.astro";
7
+ import AvSelect from "../../AvSelect.astro";
8
+ import AvTable from "../../AvTable.astro";
9
+ import AvTableToolbar from "../../AvTableToolbar.astro";
10
+ import AvTextarea from "../../AvTextarea.astro";
11
+
12
+ interface Props {
13
+ basePath?: string;
14
+ defaultAudience?: "user" | "admin";
15
+ showAudienceToggle?: boolean;
16
+ title?: string;
17
+ }
18
+
19
+ const {
20
+ basePath = "",
21
+ defaultAudience = "user",
22
+ showAudienceToggle = true,
23
+ title = "FAQs",
24
+ } = Astro.props as Props;
25
+
26
+ const normalizedBasePath = String(basePath).replace(/\/$/, "");
27
+ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
28
+ ---
29
+
30
+ <div
31
+ class="av-admin-section av-auth-stack-lg"
32
+ x-data={`avFaqManager(${JSON.stringify({
33
+ basePath: normalizedBasePath,
34
+ defaultAudience: initialAudience,
35
+ showAudienceToggle,
36
+ title,
37
+ })})`}
38
+ x-init="init()"
39
+ >
40
+ <AvCard variant="soft" className="av-auth-stack-sm">
41
+ <AvTableToolbar title={title}>
42
+ <div slot="title" class="av-admin-titlewrap">
43
+ <h1 class="av-table-toolbar__title" x-text="title"></h1>
44
+ <span class="av-admin-count-inline" x-text="`(${faqs.length})`"></span>
45
+ </div>
46
+
47
+ <div slot="search">
48
+ <div class="av-admin-toolbar-left">
49
+ <div class="av-admin-toolbar-top">
50
+ <p class="av-table-toolbar__subtitle av-m-0">Manage frequently asked questions.</p>
51
+ <div class="av-admin-toolbar-actions">
52
+ <AvButton
53
+ size="sm"
54
+ variant="ghost"
55
+ type="button"
56
+ @click.prevent="fetchFaqs()"
57
+ :disabled="loading || saving"
58
+ >
59
+ Refresh
60
+ </AvButton>
61
+ <AvButton
62
+ size="sm"
63
+ variant="primary"
64
+ type="button"
65
+ @click.prevent="openCreate()"
66
+ :disabled="loading || saving"
67
+ >
68
+ Add FAQ
69
+ </AvButton>
70
+ </div>
71
+ </div>
72
+
73
+ <div class="av-admin-filters" x-show="showAudienceToggle" x-cloak>
74
+ <div class="av-admin-filter av-admin-filter--sort">
75
+ <AvSelect label="Audience" name="faq-audience" x-model="audience" @change="setAudience($event.target.value)">
76
+ <option value="user">User</option>
77
+ <option value="admin">Admin</option>
78
+ </AvSelect>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </AvTableToolbar>
84
+
85
+ <template x-if="error">
86
+ <div class="av-alert av-alert-danger" role="status" x-text="error"></div>
87
+ </template>
88
+
89
+ <template x-if="notice">
90
+ <div class="av-alert av-alert-success" role="status" x-text="notice"></div>
91
+ </template>
92
+ </AvCard>
93
+
94
+ <template x-if="!loading && !faqs.length">
95
+ <AvCard variant="soft" className="av-auth-stack-sm">
96
+ <p class="av-text-soft av-m-0">No FAQs found for this audience.</p>
97
+ </AvCard>
98
+ </template>
99
+
100
+ <template x-if="faqs.length">
101
+ <AvCard variant="soft" className="av-auth-stack-sm">
102
+ <AvTable stickyHeader>
103
+ <thead>
104
+ <tr>
105
+ <th class="av-table__th-order">Order</th>
106
+ <th x-show="showAudienceColumn" x-cloak>Audience</th>
107
+ <th x-show="showCategoryColumn" x-cloak>Category</th>
108
+ <th>Question</th>
109
+ <th>Answer</th>
110
+ <th>Published</th>
111
+ <th x-show="showUpdatedColumn" x-cloak>Updated</th>
112
+ <th class="av-table__th-actions">Actions</th>
113
+ </tr>
114
+ </thead>
115
+
116
+ <tbody>
117
+ <template x-for="(faq, index) in faqs" :key="faq.id || index">
118
+ <tr>
119
+ <td>
120
+ <div class="av-faq-manager__order-cell">
121
+ <input
122
+ class="av-input av-faq-manager__order-input"
123
+ type="number"
124
+ min="0"
125
+ x-model.number="faq.sort_order"
126
+ :disabled="loading || saving"
127
+ aria-label="Sort order"
128
+ />
129
+ <AvButton
130
+ size="sm"
131
+ variant="ghost"
132
+ type="button"
133
+ @click.prevent="saveSort(faq)"
134
+ :disabled="loading || saving || !faq.id"
135
+ >
136
+ Save
137
+ </AvButton>
138
+ </div>
139
+ </td>
140
+ <td x-show="showAudienceColumn" x-cloak>
141
+ <span class="av-table__cell-muted" x-text="faq.audience || '-'" ></span>
142
+ </td>
143
+ <td x-show="showCategoryColumn" x-cloak>
144
+ <span class="av-table__cell-muted" x-text="faq.category || '-'" ></span>
145
+ </td>
146
+ <td>
147
+ <div class="av-table__cell-strong" x-text="faq.question || '-'" ></div>
148
+ </td>
149
+ <td class="av-table__cell-muted">
150
+ <span x-text="previewAnswer(faq.answer_md)"></span>
151
+ </td>
152
+ <td>
153
+ <AvButton
154
+ size="sm"
155
+ variant="ghost"
156
+ type="button"
157
+ @click.prevent="togglePublished(faq)"
158
+ :disabled="loading || saving || !faq.id"
159
+ x-text="faq.is_published ? 'Published' : 'Draft'"
160
+ />
161
+ </td>
162
+ <td x-show="showUpdatedColumn" x-cloak>
163
+ <span class="av-table__cell-muted" x-text="formatUpdatedAt(faq.updated_at)"></span>
164
+ </td>
165
+ <td class="av-table__td-actions">
166
+ <div class="av-table__cell-actions">
167
+ <AvButton
168
+ size="sm"
169
+ variant="ghost"
170
+ type="button"
171
+ @click.prevent="openEdit(faq)"
172
+ :disabled="loading || saving"
173
+ >
174
+ Edit
175
+ </AvButton>
176
+ <AvButton
177
+ size="sm"
178
+ variant="ghost"
179
+ type="button"
180
+ @click.prevent="removeFaq(faq)"
181
+ :disabled="loading || saving || !faq.id"
182
+ >
183
+ Delete
184
+ </AvButton>
185
+ </div>
186
+ </td>
187
+ </tr>
188
+ </template>
189
+ </tbody>
190
+ </AvTable>
191
+ </AvCard>
192
+ </template>
193
+
194
+ <template x-if="drawerOpen">
195
+ <div
196
+ class="av-drawer-overlay"
197
+ @click.self="closeDrawer()"
198
+ @keydown.escape.window="closeDrawer()"
199
+ @close-drawer="closeDrawer()"
200
+ x-cloak
201
+ >
202
+ <AvDrawer title="Manage FAQ" description="Create or update a frequently asked question.">
203
+ <form class="av-auth-stack-md" @submit.prevent="submitDrawer()">
204
+ <AvInput label="Question" name="question" required placeholder="Enter question" x-model="draftQuestion" />
205
+
206
+ <AvTextarea
207
+ label="Answer (Markdown)"
208
+ name="answer_md"
209
+ rows={7}
210
+ required
211
+ placeholder="Write answer in markdown"
212
+ x-model="draftAnswerMd"
213
+ />
214
+
215
+ <div x-show="showAudienceToggle" x-cloak>
216
+ <AvSelect label="Audience" name="draft-audience" x-model="draftAudience">
217
+ <option value="user">User</option>
218
+ <option value="admin">Admin</option>
219
+ </AvSelect>
220
+ </div>
221
+
222
+ <AvInput label="Category (optional)" name="category" placeholder="General" x-model="draftCategory" />
223
+
224
+ <AvInput
225
+ label="Sort order"
226
+ type="number"
227
+ min="0"
228
+ name="sort_order"
229
+ x-model.number="draftSortOrder"
230
+ />
231
+
232
+ <div class="av-faq-manager__publish-toggle">
233
+ <label class="av-label" for="faq-draft-published">Published</label>
234
+ <input
235
+ id="faq-draft-published"
236
+ type="checkbox"
237
+ x-model="draftIsPublished"
238
+ :disabled="saving"
239
+ />
240
+ </div>
241
+
242
+ <template x-if="drawerError">
243
+ <div class="av-alert av-alert-danger" role="status" x-text="drawerError"></div>
244
+ </template>
245
+ </form>
246
+
247
+ <div slot="footer">
248
+ <AvButton variant="ghost" type="button" @click.prevent="closeDrawer()" :disabled="saving">Cancel</AvButton>
249
+ <AvButton type="button" @click.prevent="submitDrawer()" :disabled="saving">
250
+ <span x-show="!saving" x-text="drawerMode === 'edit' ? 'Update' : 'Create'"></span>
251
+ <span x-show="saving">Saving...</span>
252
+ </AvButton>
253
+ </div>
254
+ </AvDrawer>
255
+ </div>
256
+ </template>
257
+
258
+ <AvLoading x-show="loading" x-cloak label="Loading FAQs..." />
259
+ </div>
260
+
261
+ <script is:inline>
262
+ if (typeof window !== "undefined" && !window.avFaqManager) {
263
+ window.avFaqManager = (config) => ({
264
+ title: typeof config.title === "string" && config.title.trim() ? config.title.trim() : "FAQs",
265
+ basePath: typeof config.basePath === "string" ? config.basePath.replace(/\/$/, "") : "",
266
+ showAudienceToggle: Boolean(config.showAudienceToggle),
267
+ audience: config.defaultAudience === "admin" ? "admin" : "user",
268
+ faqs: [],
269
+ loading: false,
270
+ saving: false,
271
+ error: "",
272
+ notice: "",
273
+ drawerOpen: false,
274
+ drawerMode: "create",
275
+ drawerError: "",
276
+ draftId: null,
277
+ draftAudience: "user",
278
+ draftCategory: "",
279
+ draftQuestion: "",
280
+ draftAnswerMd: "",
281
+ draftSortOrder: 0,
282
+ draftIsPublished: true,
283
+
284
+ get showAudienceColumn() {
285
+ if (this.showAudienceToggle) return true;
286
+ return this.faqs.some((faq) => typeof faq.audience === "string" && faq.audience.trim());
287
+ },
288
+
289
+ get showCategoryColumn() {
290
+ return this.faqs.some((faq) => typeof faq.category === "string" && faq.category.trim());
291
+ },
292
+
293
+ get showUpdatedColumn() {
294
+ return this.faqs.some((faq) => Boolean(faq.updated_at));
295
+ },
296
+
297
+ init() {
298
+ this.draftAudience = this.audience;
299
+ this.fetchFaqs();
300
+ },
301
+
302
+ endpoint(path) {
303
+ return `${this.basePath}${path}`;
304
+ },
305
+
306
+ async parseJson(response) {
307
+ const text = await response.text();
308
+ if (!text) return null;
309
+ try {
310
+ return JSON.parse(text);
311
+ } catch {
312
+ return null;
313
+ }
314
+ },
315
+
316
+ mapError(status, payload, fallback) {
317
+ if (payload && typeof payload.error === "string" && payload.error.trim()) {
318
+ return payload.error.trim();
319
+ }
320
+ if (status === 400) return "Invalid request.";
321
+ if (status === 401) return "Please sign in again.";
322
+ if (status === 403) return "You do not have permission for this action.";
323
+ if (status === 404) return "FAQ endpoint not found.";
324
+ return fallback;
325
+ },
326
+
327
+ normalizeFaq(item, index) {
328
+ const source = item && typeof item === "object" ? item : {};
329
+ const parsedOrder = Number(source.sort_order);
330
+ const answerMd = typeof source.answer_md === "string"
331
+ ? source.answer_md
332
+ : typeof source.answer === "string"
333
+ ? source.answer
334
+ : "";
335
+ return {
336
+ id: source.id ?? null,
337
+ audience: source.audience === "admin" ? "admin" : source.audience === "user" ? "user" : this.audience,
338
+ category: typeof source.category === "string" ? source.category : "",
339
+ question: typeof source.question === "string" ? source.question : "",
340
+ answer_md: answerMd,
341
+ sort_order: Number.isFinite(parsedOrder) ? parsedOrder : index + 1,
342
+ is_published: source.is_published !== false,
343
+ updated_at: typeof source.updated_at === "string" ? source.updated_at : "",
344
+ };
345
+ },
346
+
347
+ previewAnswer(value) {
348
+ const text = typeof value === "string" ? value.trim() : "";
349
+ if (!text) return "-";
350
+ return text.length > 120 ? `${text.slice(0, 120)}...` : text;
351
+ },
352
+
353
+ formatUpdatedAt(value) {
354
+ if (!value) return "-";
355
+ const date = new Date(value);
356
+ if (Number.isNaN(date.getTime())) return "-";
357
+ return date.toLocaleDateString();
358
+ },
359
+
360
+ setAudience(value) {
361
+ this.audience = value === "admin" ? "admin" : "user";
362
+ this.fetchFaqs();
363
+ },
364
+
365
+ clearMessages() {
366
+ this.error = "";
367
+ this.notice = "";
368
+ },
369
+
370
+ async fetchFaqs() {
371
+ this.loading = true;
372
+ this.clearMessages();
373
+
374
+ try {
375
+ const response = await fetch(
376
+ this.endpoint(`/api/admin/faqs.json?audience=${encodeURIComponent(this.audience)}`),
377
+ {
378
+ method: "GET",
379
+ credentials: "include",
380
+ },
381
+ );
382
+
383
+ const payload = await this.parseJson(response);
384
+
385
+ if (!response.ok) {
386
+ throw new Error(this.mapError(response.status, payload, "Failed to load FAQs."));
387
+ }
388
+
389
+ const items = Array.isArray(payload)
390
+ ? payload
391
+ : Array.isArray(payload?.items)
392
+ ? payload.items
393
+ : [];
394
+
395
+ this.faqs = items.map((item, index) => this.normalizeFaq(item, index));
396
+ this.faqs.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
397
+ } catch (loadError) {
398
+ this.faqs = [];
399
+ this.error = loadError?.message || "Failed to load FAQs.";
400
+ } finally {
401
+ this.loading = false;
402
+ }
403
+ },
404
+
405
+ resetDraft() {
406
+ this.draftId = null;
407
+ this.draftAudience = this.audience;
408
+ this.draftCategory = "";
409
+ this.draftQuestion = "";
410
+ this.draftAnswerMd = "";
411
+ this.draftSortOrder = this.faqs.length + 1;
412
+ this.draftIsPublished = true;
413
+ this.drawerError = "";
414
+ },
415
+
416
+ openCreate() {
417
+ this.clearMessages();
418
+ this.drawerMode = "create";
419
+ this.resetDraft();
420
+ this.drawerOpen = true;
421
+ },
422
+
423
+ openEdit(faq) {
424
+ this.clearMessages();
425
+ this.drawerMode = "edit";
426
+ this.draftId = faq?.id ?? null;
427
+ this.draftAudience = faq?.audience === "admin" ? "admin" : "user";
428
+ this.draftCategory = faq?.category ?? "";
429
+ this.draftQuestion = faq?.question ?? "";
430
+ this.draftAnswerMd = faq?.answer_md ?? "";
431
+ this.draftSortOrder = Number.isFinite(Number(faq?.sort_order)) ? Number(faq.sort_order) : 0;
432
+ this.draftIsPublished = faq?.is_published !== false;
433
+ this.drawerError = "";
434
+ this.drawerOpen = true;
435
+ },
436
+
437
+ closeDrawer() {
438
+ if (this.saving) return;
439
+ this.drawerOpen = false;
440
+ this.drawerError = "";
441
+ },
442
+
443
+ buildPayload() {
444
+ return {
445
+ audience: this.draftAudience === "admin" ? "admin" : "user",
446
+ category: String(this.draftCategory || "").trim(),
447
+ question: String(this.draftQuestion || "").trim(),
448
+ answer_md: String(this.draftAnswerMd || "").trim(),
449
+ sort_order: Number(this.draftSortOrder || 0),
450
+ is_published: Boolean(this.draftIsPublished),
451
+ };
452
+ },
453
+
454
+ async submitDrawer() {
455
+ const payload = this.buildPayload();
456
+
457
+ if (!payload.question || !payload.answer_md) {
458
+ this.drawerError = "Question and answer are required.";
459
+ return;
460
+ }
461
+
462
+ this.saving = true;
463
+ this.drawerError = "";
464
+ this.error = "";
465
+
466
+ try {
467
+ const isEdit = this.drawerMode === "edit";
468
+ const endpoint = isEdit
469
+ ? this.endpoint(`/api/admin/faqs/${encodeURIComponent(String(this.draftId || ""))}.json`)
470
+ : this.endpoint("/api/admin/faqs.json");
471
+
472
+ const response = await fetch(endpoint, {
473
+ method: isEdit ? "PATCH" : "POST",
474
+ credentials: "include",
475
+ headers: {
476
+ "Content-Type": "application/json",
477
+ },
478
+ body: JSON.stringify(payload),
479
+ });
480
+
481
+ const responsePayload = await this.parseJson(response);
482
+
483
+ if (!response.ok) {
484
+ throw new Error(this.mapError(response.status, responsePayload, "Failed to save FAQ."));
485
+ }
486
+
487
+ this.drawerOpen = false;
488
+ this.notice = isEdit ? "FAQ updated." : "FAQ created.";
489
+ await this.fetchFaqs();
490
+ } catch (saveError) {
491
+ this.drawerError = saveError?.message || "Failed to save FAQ.";
492
+ } finally {
493
+ this.saving = false;
494
+ }
495
+ },
496
+
497
+ async removeFaq(faq) {
498
+ if (!faq?.id) return;
499
+ if (!window.confirm("Delete this FAQ? This action cannot be undone.")) return;
500
+
501
+ this.saving = true;
502
+ this.error = "";
503
+ this.notice = "";
504
+
505
+ try {
506
+ const response = await fetch(
507
+ this.endpoint(`/api/admin/faqs/${encodeURIComponent(String(faq.id))}.json`),
508
+ {
509
+ method: "DELETE",
510
+ credentials: "include",
511
+ },
512
+ );
513
+
514
+ const responsePayload = await this.parseJson(response);
515
+
516
+ if (!response.ok) {
517
+ throw new Error(this.mapError(response.status, responsePayload, "Failed to delete FAQ."));
518
+ }
519
+
520
+ this.notice = "FAQ deleted.";
521
+ await this.fetchFaqs();
522
+ } catch (deleteError) {
523
+ this.error = deleteError?.message || "Failed to delete FAQ.";
524
+ } finally {
525
+ this.saving = false;
526
+ }
527
+ },
528
+
529
+ async togglePublished(faq) {
530
+ if (!faq?.id) return;
531
+
532
+ this.saving = true;
533
+ this.error = "";
534
+ this.notice = "";
535
+
536
+ try {
537
+ const response = await fetch(
538
+ this.endpoint(`/api/admin/faqs/${encodeURIComponent(String(faq.id))}.json`),
539
+ {
540
+ method: "PATCH",
541
+ credentials: "include",
542
+ headers: {
543
+ "Content-Type": "application/json",
544
+ },
545
+ body: JSON.stringify({ is_published: !faq.is_published }),
546
+ },
547
+ );
548
+
549
+ const responsePayload = await this.parseJson(response);
550
+
551
+ if (!response.ok) {
552
+ throw new Error(this.mapError(response.status, responsePayload, "Failed to update publish status."));
553
+ }
554
+
555
+ this.notice = "Publish status updated.";
556
+ await this.fetchFaqs();
557
+ } catch (toggleError) {
558
+ this.error = toggleError?.message || "Failed to update publish status.";
559
+ } finally {
560
+ this.saving = false;
561
+ }
562
+ },
563
+
564
+ async saveSort(faq) {
565
+ if (!faq?.id) return;
566
+
567
+ const nextSortOrder = Number(faq.sort_order);
568
+ if (!Number.isFinite(nextSortOrder)) {
569
+ this.error = "Sort order must be a valid number.";
570
+ return;
571
+ }
572
+
573
+ this.saving = true;
574
+ this.error = "";
575
+ this.notice = "";
576
+
577
+ try {
578
+ const response = await fetch(
579
+ this.endpoint(`/api/admin/faqs/${encodeURIComponent(String(faq.id))}.json`),
580
+ {
581
+ method: "PATCH",
582
+ credentials: "include",
583
+ headers: {
584
+ "Content-Type": "application/json",
585
+ },
586
+ body: JSON.stringify({ sort_order: nextSortOrder }),
587
+ },
588
+ );
589
+
590
+ const responsePayload = await this.parseJson(response);
591
+
592
+ if (!response.ok) {
593
+ throw new Error(this.mapError(response.status, responsePayload, "Failed to update sort order."));
594
+ }
595
+
596
+ this.notice = "Sort order updated.";
597
+ await this.fetchFaqs();
598
+ } catch (sortError) {
599
+ this.error = sortError?.message || "Failed to update sort order.";
600
+ } finally {
601
+ this.saving = false;
602
+ }
603
+ },
604
+ });
605
+ }
606
+ </script>
607
+
608
+ <style>
609
+ .av-faq-manager__order-cell {
610
+ display: inline-flex;
611
+ gap: 0.35rem;
612
+ align-items: center;
613
+ }
614
+
615
+ .av-faq-manager__order-input {
616
+ width: 88px;
617
+ min-height: 2.25rem;
618
+ }
619
+
620
+ .av-faq-manager__publish-toggle {
621
+ display: flex;
622
+ align-items: center;
623
+ justify-content: space-between;
624
+ gap: 0.75rem;
625
+ }
626
+
627
+ .av-faq-manager__publish-toggle input[type="checkbox"] {
628
+ width: 1rem;
629
+ height: 1rem;
630
+ }
631
+ </style>