@ansiversa/components 0.0.138 → 0.0.140

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.138",
3
+ "version": "0.0.140",
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>
@@ -121,7 +121,7 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
121
121
  <input
122
122
  class="av-input av-faq-manager__order-input"
123
123
  type="number"
124
- min="0"
124
+ min="1"
125
125
  x-model.number="faq.sort_order"
126
126
  :disabled="loading || saving"
127
127
  aria-label="Sort order"
@@ -202,6 +202,12 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
202
202
  <AvDrawer title="Manage FAQ" description="Create or update a frequently asked question.">
203
203
  <form class="av-auth-stack-md" @submit.prevent="submitDrawer()">
204
204
  <AvInput label="Question" name="question" required placeholder="Enter question" x-model="draftQuestion" />
205
+ <div class="av-faq-manager__field-meta">
206
+ <span class="av-faq-manager__field-counter" x-text="`Question: ${draftQuestionCount}/160`"></span>
207
+ </div>
208
+ <template x-if="validationErrors.question">
209
+ <p class="av-faq-manager__field-error" role="status" x-text="validationErrors.question"></p>
210
+ </template>
205
211
 
206
212
  <AvTextarea
207
213
  label="Answer (Markdown)"
@@ -211,6 +217,12 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
211
217
  placeholder="Write answer in markdown"
212
218
  x-model="draftAnswerMd"
213
219
  />
220
+ <div class="av-faq-manager__field-meta">
221
+ <span class="av-faq-manager__field-counter" x-text="`Answer: ${draftAnswerCount}/2000`"></span>
222
+ </div>
223
+ <template x-if="validationErrors.answer_md">
224
+ <p class="av-faq-manager__field-error" role="status" x-text="validationErrors.answer_md"></p>
225
+ </template>
214
226
 
215
227
  <div x-show="showAudienceToggle" x-cloak>
216
228
  <AvSelect label="Audience" name="draft-audience" x-model="draftAudience">
@@ -218,16 +230,28 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
218
230
  <option value="admin">Admin</option>
219
231
  </AvSelect>
220
232
  </div>
233
+ <template x-if="validationErrors.audience">
234
+ <p class="av-faq-manager__field-error" role="status" x-text="validationErrors.audience"></p>
235
+ </template>
221
236
 
222
237
  <AvInput label="Category (optional)" name="category" placeholder="General" x-model="draftCategory" />
238
+ <div class="av-faq-manager__field-meta">
239
+ <span class="av-faq-manager__field-counter" x-text="`Category: ${draftCategoryCount}/40`"></span>
240
+ </div>
241
+ <template x-if="validationErrors.category">
242
+ <p class="av-faq-manager__field-error" role="status" x-text="validationErrors.category"></p>
243
+ </template>
223
244
 
224
245
  <AvInput
225
246
  label="Sort order"
226
247
  type="number"
227
- min="0"
248
+ min="1"
228
249
  name="sort_order"
229
250
  x-model.number="draftSortOrder"
230
251
  />
252
+ <template x-if="validationErrors.sort_order">
253
+ <p class="av-faq-manager__field-error" role="status" x-text="validationErrors.sort_order"></p>
254
+ </template>
231
255
 
232
256
  <div class="av-faq-manager__publish-toggle">
233
257
  <label class="av-label" for="faq-draft-published">Published</label>
@@ -246,7 +270,7 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
246
270
 
247
271
  <div slot="footer">
248
272
  <AvButton variant="ghost" type="button" @click.prevent="closeDrawer()" :disabled="saving">Cancel</AvButton>
249
- <AvButton type="button" @click.prevent="submitDrawer()" :disabled="saving">
273
+ <AvButton type="button" @click.prevent="submitDrawer()" :disabled="saving || !isDraftValid">
250
274
  <span x-show="!saving" x-text="drawerMode === 'edit' ? 'Update' : 'Create'"></span>
251
275
  <span x-show="saving">Saving...</span>
252
276
  </AvButton>
@@ -280,6 +304,13 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
280
304
  draftAnswerMd: "",
281
305
  draftSortOrder: 0,
282
306
  draftIsPublished: true,
307
+ limits: {
308
+ questionMin: 3,
309
+ questionMax: 160,
310
+ answerMin: 3,
311
+ answerMax: 2000,
312
+ categoryMax: 40,
313
+ },
283
314
 
284
315
  get showAudienceColumn() {
285
316
  if (this.showAudienceToggle) return true;
@@ -294,6 +325,66 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
294
325
  return this.faqs.some((faq) => Boolean(faq.updated_at));
295
326
  },
296
327
 
328
+ trimValue(value) {
329
+ return typeof value === "string" ? value.trim() : "";
330
+ },
331
+
332
+ trimLength(value) {
333
+ return this.trimValue(value).length;
334
+ },
335
+
336
+ get draftQuestionCount() {
337
+ return this.trimLength(this.draftQuestion);
338
+ },
339
+
340
+ get draftAnswerCount() {
341
+ return this.trimLength(this.draftAnswerMd);
342
+ },
343
+
344
+ get draftCategoryCount() {
345
+ return this.trimLength(this.draftCategory);
346
+ },
347
+
348
+ get validationErrors() {
349
+ const errors = {};
350
+ const questionLen = this.draftQuestionCount;
351
+ const answerLen = this.draftAnswerCount;
352
+ const categoryLen = this.draftCategoryCount;
353
+ const sortOrder = Number(this.draftSortOrder);
354
+
355
+ if (questionLen < this.limits.questionMin || questionLen > this.limits.questionMax) {
356
+ errors.question = `Question must be ${this.limits.questionMin}-${this.limits.questionMax} characters`;
357
+ }
358
+
359
+ if (answerLen < this.limits.answerMin || answerLen > this.limits.answerMax) {
360
+ errors.answer_md = `Answer must be ${this.limits.answerMin}-${this.limits.answerMax} characters`;
361
+ }
362
+
363
+ if (categoryLen > this.limits.categoryMax) {
364
+ errors.category = `Category must be ≤ ${this.limits.categoryMax} characters`;
365
+ }
366
+
367
+ if (!Number.isInteger(sortOrder) || sortOrder < 1) {
368
+ errors.sort_order = "Sort order must be 1 or greater";
369
+ }
370
+
371
+ if (this.draftAudience !== "user" && this.draftAudience !== "admin") {
372
+ errors.audience = "Audience must be user or admin";
373
+ }
374
+
375
+ return errors;
376
+ },
377
+
378
+ get isDraftValid() {
379
+ return Object.keys(this.validationErrors).length === 0;
380
+ },
381
+
382
+ firstValidationError() {
383
+ const errors = this.validationErrors;
384
+ const firstKey = Object.keys(errors)[0];
385
+ return firstKey ? errors[firstKey] : "";
386
+ },
387
+
297
388
  init() {
298
389
  this.draftAudience = this.audience;
299
390
  this.fetchFaqs();
@@ -441,17 +532,23 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
441
532
  },
442
533
 
443
534
  buildPayload() {
535
+ const parsedSortOrder = Number.parseInt(String(this.draftSortOrder ?? ""), 10);
444
536
  return {
445
537
  audience: this.draftAudience === "admin" ? "admin" : "user",
446
538
  category: String(this.draftCategory || "").trim(),
447
539
  question: String(this.draftQuestion || "").trim(),
448
540
  answer_md: String(this.draftAnswerMd || "").trim(),
449
- sort_order: Number(this.draftSortOrder || 0),
541
+ sort_order: Number.isInteger(parsedSortOrder) ? parsedSortOrder : 0,
450
542
  is_published: Boolean(this.draftIsPublished),
451
543
  };
452
544
  },
453
545
 
454
546
  async submitDrawer() {
547
+ if (!this.isDraftValid) {
548
+ this.drawerError = this.firstValidationError() || "Please correct the highlighted fields.";
549
+ return;
550
+ }
551
+
455
552
  const payload = this.buildPayload();
456
553
 
457
554
  if (!payload.question || !payload.answer_md) {
@@ -565,8 +662,8 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
565
662
  if (!faq?.id) return;
566
663
 
567
664
  const nextSortOrder = Number(faq.sort_order);
568
- if (!Number.isFinite(nextSortOrder)) {
569
- this.error = "Sort order must be a valid number.";
665
+ if (!Number.isInteger(nextSortOrder) || nextSortOrder < 1) {
666
+ this.error = "Sort order must be 1 or greater.";
570
667
  return;
571
668
  }
572
669
 
@@ -628,4 +725,23 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
628
725
  width: 1rem;
629
726
  height: 1rem;
630
727
  }
728
+
729
+ .av-faq-manager__field-meta {
730
+ display: flex;
731
+ justify-content: flex-end;
732
+ margin-top: -0.35rem;
733
+ }
734
+
735
+ .av-faq-manager__field-counter {
736
+ font-size: 0.78rem;
737
+ line-height: 1.2;
738
+ color: rgba(148, 163, 184, 0.9);
739
+ }
740
+
741
+ .av-faq-manager__field-error {
742
+ margin: -0.2rem 0 0;
743
+ font-size: 0.78rem;
744
+ line-height: 1.3;
745
+ color: rgba(244, 63, 94, 0.95);
746
+ }
631
747
  </style>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { AppLogo } from "@ansiversa/components";
2
3
  import type { MiniAppLink } from "./miniAppRegistry";
3
4
  import { MINI_APP_REGISTRY } from "./miniAppRegistry";
4
5
 
@@ -9,8 +10,10 @@ interface Props {
9
10
 
10
11
  const { appKey, links } = Astro.props as Props;
11
12
 
12
- const meta = MINI_APP_REGISTRY[appKey];
13
- const appName = meta?.name ?? appKey;
13
+ const normalizedAppKey = typeof appKey === "string" ? appKey.trim() : "";
14
+ const meta = normalizedAppKey ? MINI_APP_REGISTRY[normalizedAppKey] : undefined;
15
+ const appName = meta?.name ?? (normalizedAppKey || "App");
16
+ const showLogo = normalizedAppKey.length > 0;
14
17
 
15
18
  let menuLinks = links ?? meta?.links ?? [];
16
19
  if (!menuLinks.length) {
@@ -21,7 +24,10 @@ if (!menuLinks.length) {
21
24
  <div class="av-mini-app-bar">
22
25
  <div class="av-mini-app-bar__inner">
23
26
  <div class="av-mini-app-bar__title" title={appName}>
24
- <span class="av-mini-app-bar__name">{appName}</span>
27
+ <span class="av-mini-app-bar__name flex items-center gap-2">
28
+ {showLogo && <AppLogo appId={normalizedAppKey} size="sm" />}
29
+ <span class="truncate">{appName}</span>
30
+ </span>
25
31
  </div>
26
32
 
27
33
  <div class="av-mini-app-bar__menu" x-data="{ open: false }">