@ansiversa/components 0.0.139 → 0.0.141

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansiversa/components",
3
- "version": "0.0.139",
3
+ "version": "0.0.141",
4
4
  "description": "Shared UI components and layouts for the Ansiversa ecosystem",
5
5
  "type": "module",
6
6
  "exports": {
@@ -4,6 +4,7 @@ import AppLogoFlashNote from "./logos/AppLogoFlashNote.astro";
4
4
  import AppLogoPortfolioCreator from "./logos/AppLogoPortfolioCreator.astro";
5
5
  import AppLogoQuiz from "./logos/AppLogoQuiz.astro";
6
6
  import AppLogoResumeBuilder from "./logos/AppLogoResumeBuilder.astro";
7
+ import AppLogoStudyPlanner from "./logos/AppLogoStudyPlanner.astro";
7
8
 
8
9
  export type AppLogoProps = {
9
10
  appId: string;
@@ -25,6 +26,7 @@ const logoRegistry = {
25
26
  "resume-builder": AppLogoResumeBuilder,
26
27
  "portfolio-creator": AppLogoPortfolioCreator,
27
28
  flashnote: AppLogoFlashNote,
29
+ "study-planner": AppLogoStudyPlanner,
28
30
  } as const;
29
31
 
30
32
  const Logo = logoRegistry[appId as keyof typeof logoRegistry] ?? AppLogoDefault;
package/src/Logo/index.ts CHANGED
@@ -4,4 +4,5 @@ export { default as AppLogoFlashNote } from "./logos/AppLogoFlashNote.astro";
4
4
  export { default as AppLogoPortfolioCreator } from "./logos/AppLogoPortfolioCreator.astro";
5
5
  export { default as AppLogoQuiz } from "./logos/AppLogoQuiz.astro";
6
6
  export { default as AppLogoResumeBuilder } from "./logos/AppLogoResumeBuilder.astro";
7
+ export { default as AppLogoStudyPlanner } from "./logos/AppLogoStudyPlanner.astro";
7
8
  export type { AppLogoProps } from "./AppLogo.astro";
@@ -0,0 +1,27 @@
1
+ ---
2
+ import type { AppLogoGlyphProps } from "./AppLogoQuiz.astro";
3
+
4
+ const { size = 18, class: className, title } = Astro.props as AppLogoGlyphProps;
5
+ ---
6
+
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ width={size}
11
+ height={size}
12
+ fill="none"
13
+ stroke="currentColor"
14
+ stroke-width="1.75"
15
+ stroke-linecap="round"
16
+ stroke-linejoin="round"
17
+ class={className}
18
+ aria-hidden={title ? undefined : "true"}
19
+ role={title ? "img" : "presentation"}
20
+ >
21
+ {title ? <title>{title}</title> : null}
22
+ <rect x="3.5" y="4.5" width="17" height="16" rx="2.5" />
23
+ <path d="M8 2.8v3.4" />
24
+ <path d="M16 2.8v3.4" />
25
+ <path d="M3.5 9h17" />
26
+ <path d="m9.4 14 1.9 1.9 3.4-3.5" />
27
+ </svg>
@@ -118,22 +118,26 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
118
118
  <tr>
119
119
  <td>
120
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
- />
121
+ <span class="av-faq-manager__order-pill" x-text="faq.sort_order"></span>
129
122
  <AvButton
130
123
  size="sm"
131
124
  variant="ghost"
132
125
  type="button"
133
- @click.prevent="saveSort(faq)"
134
- :disabled="loading || saving || !faq.id"
126
+ @click.prevent="moveFaq(index, -1)"
127
+ :disabled="loading || saving || index === 0 || !faq.id || !faqs[index - 1]?.id"
128
+ aria-label="Move up"
135
129
  >
136
- Save
130
+
131
+ </AvButton>
132
+ <AvButton
133
+ size="sm"
134
+ variant="ghost"
135
+ type="button"
136
+ @click.prevent="moveFaq(index, 1)"
137
+ :disabled="loading || saving || index === faqs.length - 1 || !faq.id || !faqs[index + 1]?.id"
138
+ aria-label="Move down"
139
+ >
140
+
137
141
  </AvButton>
138
142
  </div>
139
143
  </td>
@@ -202,6 +206,12 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
202
206
  <AvDrawer title="Manage FAQ" description="Create or update a frequently asked question.">
203
207
  <form class="av-auth-stack-md" @submit.prevent="submitDrawer()">
204
208
  <AvInput label="Question" name="question" required placeholder="Enter question" x-model="draftQuestion" />
209
+ <div class="av-faq-manager__field-meta">
210
+ <span class="av-faq-manager__field-counter" x-text="`Question: ${draftQuestionCount}/160`"></span>
211
+ </div>
212
+ <template x-if="validationErrors.question">
213
+ <p class="av-faq-manager__field-error" role="status" x-text="validationErrors.question"></p>
214
+ </template>
205
215
 
206
216
  <AvTextarea
207
217
  label="Answer (Markdown)"
@@ -211,6 +221,12 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
211
221
  placeholder="Write answer in markdown"
212
222
  x-model="draftAnswerMd"
213
223
  />
224
+ <div class="av-faq-manager__field-meta">
225
+ <span class="av-faq-manager__field-counter" x-text="`Answer: ${draftAnswerCount}/2000`"></span>
226
+ </div>
227
+ <template x-if="validationErrors.answer_md">
228
+ <p class="av-faq-manager__field-error" role="status" x-text="validationErrors.answer_md"></p>
229
+ </template>
214
230
 
215
231
  <div x-show="showAudienceToggle" x-cloak>
216
232
  <AvSelect label="Audience" name="draft-audience" x-model="draftAudience">
@@ -218,16 +234,28 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
218
234
  <option value="admin">Admin</option>
219
235
  </AvSelect>
220
236
  </div>
237
+ <template x-if="validationErrors.audience">
238
+ <p class="av-faq-manager__field-error" role="status" x-text="validationErrors.audience"></p>
239
+ </template>
221
240
 
222
241
  <AvInput label="Category (optional)" name="category" placeholder="General" x-model="draftCategory" />
242
+ <div class="av-faq-manager__field-meta">
243
+ <span class="av-faq-manager__field-counter" x-text="`Category: ${draftCategoryCount}/40`"></span>
244
+ </div>
245
+ <template x-if="validationErrors.category">
246
+ <p class="av-faq-manager__field-error" role="status" x-text="validationErrors.category"></p>
247
+ </template>
223
248
 
224
249
  <AvInput
225
250
  label="Sort order"
226
251
  type="number"
227
- min="0"
252
+ min="1"
228
253
  name="sort_order"
229
254
  x-model.number="draftSortOrder"
230
255
  />
256
+ <template x-if="validationErrors.sort_order">
257
+ <p class="av-faq-manager__field-error" role="status" x-text="validationErrors.sort_order"></p>
258
+ </template>
231
259
 
232
260
  <div class="av-faq-manager__publish-toggle">
233
261
  <label class="av-label" for="faq-draft-published">Published</label>
@@ -246,7 +274,7 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
246
274
 
247
275
  <div slot="footer">
248
276
  <AvButton variant="ghost" type="button" @click.prevent="closeDrawer()" :disabled="saving">Cancel</AvButton>
249
- <AvButton type="button" @click.prevent="submitDrawer()" :disabled="saving">
277
+ <AvButton type="button" @click.prevent="submitDrawer()" :disabled="saving || !isDraftValid">
250
278
  <span x-show="!saving" x-text="drawerMode === 'edit' ? 'Update' : 'Create'"></span>
251
279
  <span x-show="saving">Saving...</span>
252
280
  </AvButton>
@@ -280,6 +308,13 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
280
308
  draftAnswerMd: "",
281
309
  draftSortOrder: 0,
282
310
  draftIsPublished: true,
311
+ limits: {
312
+ questionMin: 3,
313
+ questionMax: 160,
314
+ answerMin: 3,
315
+ answerMax: 2000,
316
+ categoryMax: 40,
317
+ },
283
318
 
284
319
  get showAudienceColumn() {
285
320
  if (this.showAudienceToggle) return true;
@@ -294,6 +329,66 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
294
329
  return this.faqs.some((faq) => Boolean(faq.updated_at));
295
330
  },
296
331
 
332
+ trimValue(value) {
333
+ return typeof value === "string" ? value.trim() : "";
334
+ },
335
+
336
+ trimLength(value) {
337
+ return this.trimValue(value).length;
338
+ },
339
+
340
+ get draftQuestionCount() {
341
+ return this.trimLength(this.draftQuestion);
342
+ },
343
+
344
+ get draftAnswerCount() {
345
+ return this.trimLength(this.draftAnswerMd);
346
+ },
347
+
348
+ get draftCategoryCount() {
349
+ return this.trimLength(this.draftCategory);
350
+ },
351
+
352
+ get validationErrors() {
353
+ const errors = {};
354
+ const questionLen = this.draftQuestionCount;
355
+ const answerLen = this.draftAnswerCount;
356
+ const categoryLen = this.draftCategoryCount;
357
+ const sortOrder = Number(this.draftSortOrder);
358
+
359
+ if (questionLen < this.limits.questionMin || questionLen > this.limits.questionMax) {
360
+ errors.question = `Question must be ${this.limits.questionMin}-${this.limits.questionMax} characters`;
361
+ }
362
+
363
+ if (answerLen < this.limits.answerMin || answerLen > this.limits.answerMax) {
364
+ errors.answer_md = `Answer must be ${this.limits.answerMin}-${this.limits.answerMax} characters`;
365
+ }
366
+
367
+ if (categoryLen > this.limits.categoryMax) {
368
+ errors.category = `Category must be ≤ ${this.limits.categoryMax} characters`;
369
+ }
370
+
371
+ if (!Number.isInteger(sortOrder) || sortOrder < 1) {
372
+ errors.sort_order = "Sort order must be 1 or greater";
373
+ }
374
+
375
+ if (this.draftAudience !== "user" && this.draftAudience !== "admin") {
376
+ errors.audience = "Audience must be user or admin";
377
+ }
378
+
379
+ return errors;
380
+ },
381
+
382
+ get isDraftValid() {
383
+ return Object.keys(this.validationErrors).length === 0;
384
+ },
385
+
386
+ firstValidationError() {
387
+ const errors = this.validationErrors;
388
+ const firstKey = Object.keys(errors)[0];
389
+ return firstKey ? errors[firstKey] : "";
390
+ },
391
+
297
392
  init() {
298
393
  this.draftAudience = this.audience;
299
394
  this.fetchFaqs();
@@ -441,17 +536,23 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
441
536
  },
442
537
 
443
538
  buildPayload() {
539
+ const parsedSortOrder = Number.parseInt(String(this.draftSortOrder ?? ""), 10);
444
540
  return {
445
541
  audience: this.draftAudience === "admin" ? "admin" : "user",
446
542
  category: String(this.draftCategory || "").trim(),
447
543
  question: String(this.draftQuestion || "").trim(),
448
544
  answer_md: String(this.draftAnswerMd || "").trim(),
449
- sort_order: Number(this.draftSortOrder || 0),
545
+ sort_order: Number.isInteger(parsedSortOrder) ? parsedSortOrder : 0,
450
546
  is_published: Boolean(this.draftIsPublished),
451
547
  };
452
548
  },
453
549
 
454
550
  async submitDrawer() {
551
+ if (!this.isDraftValid) {
552
+ this.drawerError = this.firstValidationError() || "Please correct the highlighted fields.";
553
+ return;
554
+ }
555
+
455
556
  const payload = this.buildPayload();
456
557
 
457
558
  if (!payload.question || !payload.answer_md) {
@@ -561,41 +662,70 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
561
662
  }
562
663
  },
563
664
 
564
- async saveSort(faq) {
565
- if (!faq?.id) return;
665
+ normalizeSortOrder(value, fallbackOrder) {
666
+ const parsed = Number.parseInt(String(value ?? ""), 10);
667
+ if (Number.isInteger(parsed) && parsed >= 1) return parsed;
668
+ return Math.max(1, Number.parseInt(String(fallbackOrder ?? 1), 10) || 1);
669
+ },
670
+
671
+ async patchSortOrder(id, sortOrder) {
672
+ const response = await fetch(
673
+ this.endpoint(`/api/admin/faqs/${encodeURIComponent(String(id))}.json`),
674
+ {
675
+ method: "PATCH",
676
+ credentials: "include",
677
+ headers: {
678
+ "Content-Type": "application/json",
679
+ },
680
+ body: JSON.stringify({ sort_order: sortOrder }),
681
+ },
682
+ );
683
+
684
+ const responsePayload = await this.parseJson(response);
566
685
 
567
- const nextSortOrder = Number(faq.sort_order);
568
- if (!Number.isFinite(nextSortOrder)) {
569
- this.error = "Sort order must be a valid number.";
686
+ if (!response.ok) {
687
+ throw new Error(this.mapError(response.status, responsePayload, "Failed to update sort order."));
688
+ }
689
+ },
690
+
691
+ async moveFaq(index, direction) {
692
+ const currentIndex = Number(index);
693
+ const nextIndex = currentIndex + Number(direction);
694
+ if (!Number.isInteger(currentIndex) || !Number.isInteger(nextIndex)) return;
695
+ if (nextIndex < 0 || nextIndex >= this.faqs.length) return;
696
+
697
+ const currentFaq = this.faqs[currentIndex];
698
+ const nextFaq = this.faqs[nextIndex];
699
+ if (!currentFaq?.id || !nextFaq?.id) return;
700
+
701
+ const currentOrder = this.normalizeSortOrder(currentFaq.sort_order, currentIndex + 1);
702
+ const nextOrder = this.normalizeSortOrder(nextFaq.sort_order, nextIndex + 1);
703
+ if (currentOrder < 1 || nextOrder < 1) {
704
+ this.error = "Sort order must be 1 or greater.";
570
705
  return;
571
706
  }
572
707
 
708
+ const snapshot = this.faqs.map((item) => ({ ...item }));
709
+ const reordered = this.faqs.map((item) => ({ ...item }));
710
+ [reordered[currentIndex], reordered[nextIndex]] = [reordered[nextIndex], reordered[currentIndex]];
711
+ reordered[nextIndex].sort_order = currentOrder;
712
+ reordered[currentIndex].sort_order = nextOrder;
713
+
714
+ this.faqs = reordered;
715
+
573
716
  this.saving = true;
574
717
  this.error = "";
575
718
  this.notice = "";
576
719
 
577
720
  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
- }
721
+ await Promise.all([
722
+ this.patchSortOrder(reordered[currentIndex].id, reordered[currentIndex].sort_order),
723
+ this.patchSortOrder(reordered[nextIndex].id, reordered[nextIndex].sort_order),
724
+ ]);
595
725
 
596
- this.notice = "Sort order updated.";
597
- await this.fetchFaqs();
726
+ this.notice = "Order updated.";
598
727
  } catch (sortError) {
728
+ this.faqs = snapshot;
599
729
  this.error = sortError?.message || "Failed to update sort order.";
600
730
  } finally {
601
731
  this.saving = false;
@@ -612,9 +742,18 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
612
742
  align-items: center;
613
743
  }
614
744
 
615
- .av-faq-manager__order-input {
616
- width: 88px;
617
- min-height: 2.25rem;
745
+ .av-faq-manager__order-pill {
746
+ min-width: 2rem;
747
+ height: 2rem;
748
+ display: inline-flex;
749
+ align-items: center;
750
+ justify-content: center;
751
+ border-radius: 9999px;
752
+ border: 1px solid rgba(148, 163, 184, 0.35);
753
+ color: rgba(226, 232, 240, 0.95);
754
+ font-size: 0.78rem;
755
+ line-height: 1;
756
+ padding: 0 0.5rem;
618
757
  }
619
758
 
620
759
  .av-faq-manager__publish-toggle {
@@ -628,4 +767,23 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
628
767
  width: 1rem;
629
768
  height: 1rem;
630
769
  }
770
+
771
+ .av-faq-manager__field-meta {
772
+ display: flex;
773
+ justify-content: flex-end;
774
+ margin-top: -0.35rem;
775
+ }
776
+
777
+ .av-faq-manager__field-counter {
778
+ font-size: 0.78rem;
779
+ line-height: 1.2;
780
+ color: rgba(148, 163, 184, 0.9);
781
+ }
782
+
783
+ .av-faq-manager__field-error {
784
+ margin: -0.2rem 0 0;
785
+ font-size: 0.78rem;
786
+ line-height: 1.3;
787
+ color: rgba(244, 63, 94, 0.95);
788
+ }
631
789
  </style>