@ansiversa/components 0.0.142 → 0.0.144

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
@@ -35,6 +35,7 @@ export { default as AvTableToolbar } from './src/AvTableToolbar.astro';
35
35
  export { default as AvTablePagination } from './src/AvTablePagination.astro';
36
36
  export { default as QuizSummary } from './src/Summary/QuizSummary.astro';
37
37
  export { default as FlashNoteSummary } from './src/Summary/FlashNoteSummary.astro';
38
+ export { default as StudyPlannerSummary } from './src/Summary/StudyPlannerSummary.astro';
38
39
  export { default as ResumeBuilderSummary } from './src/Summary/ResumeBuilderSummary.astro';
39
40
  export { default as PortfolioCreatorSummary } from './src/Summary/PortfolioCreatorSummary.astro';
40
41
  export { default as AvImageUploader } from "./src/components/media/AvImageUploader.astro";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansiversa/components",
3
- "version": "0.0.142",
3
+ "version": "0.0.144",
4
4
  "description": "Shared UI components and layouts for the Ansiversa ecosystem",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,95 @@
1
+ ---
2
+ import { AvButton, AvCard } from "@ansiversa/components";
3
+ import type { StudyPlannerDashboardSummaryV1 } from "./types";
4
+ import { buildAppUrl } from "../utils/appUrls";
5
+
6
+ interface Props {
7
+ summary: StudyPlannerDashboardSummaryV1;
8
+ }
9
+
10
+ const { summary } = Astro.props as Props;
11
+
12
+ const formatDateTime = (value: string | null, fallback = "Not yet") => {
13
+ if (!value) return fallback;
14
+ const parsed = new Date(value);
15
+ return Number.isNaN(parsed.getTime()) ? fallback : parsed.toLocaleString();
16
+ };
17
+
18
+ const appBaseUrl = buildAppUrl(summary.appId);
19
+ const plansUrl = buildAppUrl(summary.appId, "/plans");
20
+ const trackOpenPayload = JSON.stringify({
21
+ id: summary.appId,
22
+ key: summary.appId,
23
+ name: "Study Planner",
24
+ description: "Study Planner",
25
+ url: appBaseUrl,
26
+ });
27
+
28
+ const lastPlan = summary.recent.recentPlans[0];
29
+ const lastTask = summary.recent.recentTasks[0];
30
+ const lastLog = summary.recent.recentLogs[0];
31
+ ---
32
+
33
+ <section class="av-auth-stack-lg">
34
+ <div class="av-form-row">
35
+ <div class="av-auth-stack-xxs">
36
+ <p class="av-text-soft">Last plan: {formatDateTime(lastPlan?.updatedAt ?? null)}</p>
37
+ </div>
38
+ <div class="av-row-wrap">
39
+ <AvButton
40
+ href={appBaseUrl}
41
+ size="sm"
42
+ onclick={`window.dispatchEvent(new CustomEvent('ansiversa:app-opened', { detail: ${trackOpenPayload} }))`}
43
+ >
44
+ Open App ->
45
+ </AvButton>
46
+ <AvButton href={plansUrl} size="sm" variant="ghost">View plans</AvButton>
47
+ </div>
48
+ </div>
49
+
50
+ <div class="av-grid-auto av-grid-auto--260">
51
+ <AvCard variant="soft" className="av-card--fullheight">
52
+ <div class="av-auth-stack-xxs">
53
+ <p class="av-card-heading">Plans</p>
54
+ <h3 class="av-app-card-title">{summary.totals.plansTotal}</h3>
55
+ <p class="av-text-soft">{summary.totals.plansActive} active</p>
56
+ </div>
57
+ </AvCard>
58
+ <AvCard variant="soft" className="av-card--fullheight">
59
+ <div class="av-auth-stack-xxs">
60
+ <p class="av-card-heading">Tasks</p>
61
+ <h3 class="av-app-card-title">{summary.totals.tasksTotal}</h3>
62
+ <p class="av-text-soft">{summary.totals.tasksCompleted} completed</p>
63
+ </div>
64
+ </AvCard>
65
+ <AvCard variant="soft" className="av-card--fullheight">
66
+ <div class="av-auth-stack-xxs">
67
+ <p class="av-card-heading">Due today</p>
68
+ <h3 class="av-app-card-title">{summary.totals.tasksDueToday}</h3>
69
+ <p class="av-text-soft">{summary.totals.logsThisWeek} logs this week</p>
70
+ </div>
71
+ </AvCard>
72
+ <AvCard variant="soft" className="av-card--fullheight">
73
+ <div class="av-auth-stack-xxs">
74
+ <p class="av-card-heading">Bookmarks</p>
75
+ <h3 class="av-app-card-title">{summary.totals.bookmarksTotal}</h3>
76
+ <p class="av-text-soft">Last log: {formatDateTime(lastLog?.createdAt ?? null)}</p>
77
+ </div>
78
+ </AvCard>
79
+ </div>
80
+
81
+ <div class="av-grid-auto av-grid-auto--260">
82
+ <AvCard variant="soft" className="av-card--fullheight">
83
+ <div class="av-auth-stack-xxs">
84
+ <p class="av-card-heading">Recent plan</p>
85
+ <p class="av-text-strong">{lastPlan?.title ?? "No recent plans"}</p>
86
+ </div>
87
+ </AvCard>
88
+ <AvCard variant="soft" className="av-card--fullheight">
89
+ <div class="av-auth-stack-xxs">
90
+ <p class="av-card-heading">Recent task</p>
91
+ <p class="av-text-strong">{lastTask?.title ?? "No recent tasks"}</p>
92
+ </div>
93
+ </AvCard>
94
+ </div>
95
+ </section>
@@ -64,3 +64,35 @@ export type PortfolioDashboardSummaryV1 = {
64
64
  };
65
65
  completionHint?: number;
66
66
  };
67
+
68
+ export type StudyPlannerDashboardSummaryV1 = {
69
+ appId: "study-planner";
70
+ version: 1;
71
+ updatedAt: string;
72
+ totals: {
73
+ plansTotal: number;
74
+ plansActive: number;
75
+ tasksTotal: number;
76
+ tasksCompleted: number;
77
+ tasksDueToday: number;
78
+ logsThisWeek: number;
79
+ bookmarksTotal: number;
80
+ };
81
+ recent: {
82
+ recentPlans: Array<{
83
+ id: number;
84
+ title: string;
85
+ updatedAt: string | null;
86
+ }>;
87
+ recentTasks: Array<{
88
+ id: number;
89
+ title: string;
90
+ updatedAt: string | null;
91
+ }>;
92
+ recentLogs: Array<{
93
+ id: number;
94
+ title: string;
95
+ createdAt: string | null;
96
+ }>;
97
+ };
98
+ };
@@ -2,6 +2,8 @@
2
2
  import AvButton from "../../AvButton.astro";
3
3
  import AvCard from "../../AvCard.astro";
4
4
  import AvDrawer from "../../AvDrawer.astro";
5
+ import AvEmptyState from "../../AvEmptyState.astro";
6
+ import AvIcon from "../../AvIcon.astro";
5
7
  import AvInput from "../../AvInput.astro";
6
8
  import AvLoading from "../../AvLoading.astro";
7
9
  import AvSelect from "../../AvSelect.astro";
@@ -50,29 +52,43 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
50
52
  <p class="av-table-toolbar__subtitle av-m-0">Manage frequently asked questions.</p>
51
53
  <div class="av-admin-toolbar-actions">
52
54
  <AvButton
53
- size="sm"
55
+ size="md"
54
56
  variant="ghost"
55
57
  type="button"
58
+ title="Refresh FAQs"
59
+ aria-label="Refresh FAQs"
56
60
  @click.prevent="fetchFaqs()"
57
61
  :disabled="loading || saving"
58
62
  >
59
- Refresh
63
+ <AvIcon name="refresh" size="md" />
60
64
  </AvButton>
61
65
  <AvButton
62
- size="sm"
66
+ size="md"
63
67
  variant="primary"
64
68
  type="button"
69
+ title="Add FAQ"
70
+ aria-label="Add FAQ"
65
71
  @click.prevent="openCreate()"
66
72
  :disabled="loading || saving"
67
73
  >
68
- Add FAQ
74
+ <AvIcon name="plus" size="md" />
69
75
  </AvButton>
70
76
  </div>
71
77
  </div>
72
78
 
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)">
79
+ <div class="av-admin-filters">
80
+ <div class="av-admin-filter av-admin-filter--search">
81
+ <AvInput
82
+ name="faq-search"
83
+ type="search"
84
+ placeholder="Search by question, category, or audience..."
85
+ x-model="q"
86
+ @input="setQuery($event.target.value)"
87
+ />
88
+ </div>
89
+
90
+ <div class="av-admin-filter av-admin-filter--dropdown" x-show="showAudienceToggle" x-cloak>
91
+ <AvSelect name="faq-audience" aria-label="Filter by audience" x-model="audience" @change="setAudience($event.target.value)">
76
92
  <option value="user">User</option>
77
93
  <option value="admin">Admin</option>
78
94
  </AvSelect>
@@ -92,9 +108,13 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
92
108
  </AvCard>
93
109
 
94
110
  <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>
111
+ <AvEmptyState
112
+ headline="No FAQs match your search"
113
+ description="Try another search term or adjust filters."
114
+ ctaLabel="Add FAQ"
115
+ x-bind:ctaDisabled="loading || saving"
116
+ @cta="openCreate()"
117
+ />
98
118
  </template>
99
119
 
100
120
  <template x-if="faqs.length">
@@ -102,11 +122,10 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
102
122
  <AvTable stickyHeader>
103
123
  <thead>
104
124
  <tr>
105
- <th class="av-table__th-order">Order</th>
125
+ <th>Reorder</th>
106
126
  <th x-show="showAudienceColumn" x-cloak>Audience</th>
107
127
  <th x-show="showCategoryColumn" x-cloak>Category</th>
108
128
  <th>Question</th>
109
- <th>Answer</th>
110
129
  <th>Published</th>
111
130
  <th x-show="showUpdatedColumn" x-cloak>Updated</th>
112
131
  <th class="av-table__th-actions">Actions</th>
@@ -118,7 +137,6 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
118
137
  <tr>
119
138
  <td>
120
139
  <div class="av-faq-manager__order-cell">
121
- <span class="av-faq-manager__order-pill" x-text="faq.sort_order"></span>
122
140
  <AvButton
123
141
  size="sm"
124
142
  variant="ghost"
@@ -126,6 +144,7 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
126
144
  @click.prevent="moveFaq(index, -1)"
127
145
  :disabled="loading || saving || index === 0 || !faq.id || !faqs[index - 1]?.id"
128
146
  aria-label="Move up"
147
+ title="Move up"
129
148
  >
130
149
 
131
150
  </AvButton>
@@ -136,6 +155,7 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
136
155
  @click.prevent="moveFaq(index, 1)"
137
156
  :disabled="loading || saving || index === faqs.length - 1 || !faq.id || !faqs[index + 1]?.id"
138
157
  aria-label="Move down"
158
+ title="Move down"
139
159
  >
140
160
 
141
161
  </AvButton>
@@ -150,9 +170,6 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
150
170
  <td>
151
171
  <div class="av-table__cell-strong" x-text="faq.question || '-'" ></div>
152
172
  </td>
153
- <td class="av-table__cell-muted">
154
- <span x-text="previewAnswer(faq.answer_md)"></span>
155
- </td>
156
173
  <td>
157
174
  <AvButton
158
175
  size="sm"
@@ -246,17 +263,6 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
246
263
  <p class="av-faq-manager__field-error" role="status" x-text="validationErrors.category"></p>
247
264
  </template>
248
265
 
249
- <AvInput
250
- label="Sort order"
251
- type="number"
252
- min="1"
253
- name="sort_order"
254
- x-model.number="draftSortOrder"
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>
259
-
260
266
  <div class="av-faq-manager__publish-toggle">
261
267
  <label class="av-label" for="faq-draft-published">Published</label>
262
268
  <input
@@ -303,11 +309,12 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
303
309
  drawerError: "",
304
310
  draftId: null,
305
311
  draftAudience: "user",
312
+ q: "",
306
313
  draftCategory: "",
307
314
  draftQuestion: "",
308
315
  draftAnswerMd: "",
309
- draftSortOrder: 0,
310
316
  draftIsPublished: true,
317
+ _queryTimer: null,
311
318
  limits: {
312
319
  questionMin: 3,
313
320
  questionMax: 160,
@@ -354,7 +361,6 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
354
361
  const questionLen = this.draftQuestionCount;
355
362
  const answerLen = this.draftAnswerCount;
356
363
  const categoryLen = this.draftCategoryCount;
357
- const sortOrder = Number(this.draftSortOrder);
358
364
 
359
365
  if (questionLen < this.limits.questionMin || questionLen > this.limits.questionMax) {
360
366
  errors.question = `Question must be ${this.limits.questionMin}-${this.limits.questionMax} characters`;
@@ -368,10 +374,6 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
368
374
  errors.category = `Category must be ≤ ${this.limits.categoryMax} characters`;
369
375
  }
370
376
 
371
- if (!Number.isInteger(sortOrder) || sortOrder < 1) {
372
- errors.sort_order = "Sort order must be 1 or greater";
373
- }
374
-
375
377
  if (this.draftAudience !== "user" && this.draftAudience !== "admin") {
376
378
  errors.audience = "Audience must be user or admin";
377
379
  }
@@ -439,12 +441,6 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
439
441
  };
440
442
  },
441
443
 
442
- previewAnswer(value) {
443
- const text = typeof value === "string" ? value.trim() : "";
444
- if (!text) return "-";
445
- return text.length > 120 ? `${text.slice(0, 120)}...` : text;
446
- },
447
-
448
444
  formatUpdatedAt(value) {
449
445
  if (!value) return "-";
450
446
  const date = new Date(value);
@@ -457,6 +453,19 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
457
453
  this.fetchFaqs();
458
454
  },
459
455
 
456
+ setQuery(value) {
457
+ this.q = typeof value === "string" ? value : "";
458
+ if (this._queryTimer) {
459
+ window.clearTimeout(this._queryTimer);
460
+ this._queryTimer = null;
461
+ }
462
+
463
+ this._queryTimer = window.setTimeout(() => {
464
+ this.fetchFaqs();
465
+ this._queryTimer = null;
466
+ }, 250);
467
+ },
468
+
460
469
  clearMessages() {
461
470
  this.error = "";
462
471
  this.notice = "";
@@ -467,8 +476,13 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
467
476
  this.clearMessages();
468
477
 
469
478
  try {
479
+ const searchParams = new URLSearchParams();
480
+ searchParams.set("audience", this.audience);
481
+ const trimmedQuery = this.trimValue(this.q);
482
+ if (trimmedQuery) searchParams.set("q", trimmedQuery);
483
+
470
484
  const response = await fetch(
471
- this.endpoint(`/api/admin/faqs.json?audience=${encodeURIComponent(this.audience)}`),
485
+ this.endpoint(`/api/admin/faqs.json?${searchParams.toString()}`),
472
486
  {
473
487
  method: "GET",
474
488
  credentials: "include",
@@ -503,7 +517,6 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
503
517
  this.draftCategory = "";
504
518
  this.draftQuestion = "";
505
519
  this.draftAnswerMd = "";
506
- this.draftSortOrder = this.faqs.length + 1;
507
520
  this.draftIsPublished = true;
508
521
  this.drawerError = "";
509
522
  },
@@ -523,7 +536,6 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
523
536
  this.draftCategory = faq?.category ?? "";
524
537
  this.draftQuestion = faq?.question ?? "";
525
538
  this.draftAnswerMd = faq?.answer_md ?? "";
526
- this.draftSortOrder = Number.isFinite(Number(faq?.sort_order)) ? Number(faq.sort_order) : 0;
527
539
  this.draftIsPublished = faq?.is_published !== false;
528
540
  this.drawerError = "";
529
541
  this.drawerOpen = true;
@@ -536,13 +548,11 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
536
548
  },
537
549
 
538
550
  buildPayload() {
539
- const parsedSortOrder = Number.parseInt(String(this.draftSortOrder ?? ""), 10);
540
551
  return {
541
552
  audience: this.draftAudience === "admin" ? "admin" : "user",
542
553
  category: String(this.draftCategory || "").trim(),
543
554
  question: String(this.draftQuestion || "").trim(),
544
555
  answer_md: String(this.draftAnswerMd || "").trim(),
545
- sort_order: Number.isInteger(parsedSortOrder) ? parsedSortOrder : 0,
546
556
  is_published: Boolean(this.draftIsPublished),
547
557
  };
548
558
  },
@@ -742,20 +752,6 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
742
752
  align-items: center;
743
753
  }
744
754
 
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;
757
- }
758
-
759
755
  .av-faq-manager__publish-toggle {
760
756
  display: flex;
761
757
  align-items: center;
@@ -3,6 +3,7 @@ const ROOT_DOMAIN = "ansiversa.com";
3
3
  const MINI_APP_SLUGS = new Set([
4
4
  "quiz",
5
5
  "flashnote",
6
+ "study-planner",
6
7
  "resume-builder",
7
8
  "portfolio-creator",
8
9
  ]);