@ansiversa/components 0.0.140 → 0.0.142

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
@@ -40,6 +40,7 @@ export { default as PortfolioCreatorSummary } from './src/Summary/PortfolioCreat
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
42
  export { default as FaqManager } from "./src/components/Admin/FaqManager.astro";
43
+ export { AvBookmarkButton, AvBookmarksEmpty, AvBookmarksList } from "./src/components/Bookmarks";
43
44
  export { AppLogo } from "./src/Logo";
44
45
  export type { AppLogoProps } from "./src/Logo";
45
46
  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.140",
3
+ "version": "0.0.142",
4
4
  "description": "Shared UI components and layouts for the Ansiversa ecosystem",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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="1"
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>
@@ -658,41 +662,70 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
658
662
  }
659
663
  },
660
664
 
661
- async saveSort(faq) {
662
- 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);
685
+
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;
663
700
 
664
- const nextSortOrder = Number(faq.sort_order);
665
- if (!Number.isInteger(nextSortOrder) || nextSortOrder < 1) {
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) {
666
704
  this.error = "Sort order must be 1 or greater.";
667
705
  return;
668
706
  }
669
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
+
670
716
  this.saving = true;
671
717
  this.error = "";
672
718
  this.notice = "";
673
719
 
674
720
  try {
675
- const response = await fetch(
676
- this.endpoint(`/api/admin/faqs/${encodeURIComponent(String(faq.id))}.json`),
677
- {
678
- method: "PATCH",
679
- credentials: "include",
680
- headers: {
681
- "Content-Type": "application/json",
682
- },
683
- body: JSON.stringify({ sort_order: nextSortOrder }),
684
- },
685
- );
686
-
687
- const responsePayload = await this.parseJson(response);
688
-
689
- if (!response.ok) {
690
- throw new Error(this.mapError(response.status, responsePayload, "Failed to update sort order."));
691
- }
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
+ ]);
692
725
 
693
- this.notice = "Sort order updated.";
694
- await this.fetchFaqs();
726
+ this.notice = "Order updated.";
695
727
  } catch (sortError) {
728
+ this.faqs = snapshot;
696
729
  this.error = sortError?.message || "Failed to update sort order.";
697
730
  } finally {
698
731
  this.saving = false;
@@ -709,9 +742,18 @@ const initialAudience = defaultAudience === "admin" ? "admin" : "user";
709
742
  align-items: center;
710
743
  }
711
744
 
712
- .av-faq-manager__order-input {
713
- width: 88px;
714
- 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;
715
757
  }
716
758
 
717
759
  .av-faq-manager__publish-toggle {
@@ -0,0 +1,93 @@
1
+ ---
2
+ type Size = "sm" | "md";
3
+
4
+ interface Props {
5
+ active: boolean;
6
+ activeExpr?: string;
7
+ title?: string;
8
+ activeTitle?: string;
9
+ size?: Size;
10
+ onClick?: string;
11
+ }
12
+
13
+ const {
14
+ active,
15
+ activeExpr,
16
+ title = "Save bookmark",
17
+ activeTitle = "Remove bookmark",
18
+ size = "md",
19
+ onClick,
20
+ } = Astro.props as Props;
21
+
22
+ const iconPx = size === "sm" ? 16 : 20;
23
+ const checkPx = size === "sm" ? 10 : 12;
24
+ const resolvedActiveExpr = activeExpr ?? (active ? "true" : "false");
25
+ const escapeSingleQuotes = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
26
+ const tooltipExpr = `(${resolvedActiveExpr}) ? '${escapeSingleQuotes(activeTitle)}' : '${escapeSingleQuotes(title)}'`;
27
+ ---
28
+
29
+ <button
30
+ type="button"
31
+ class="av-bookmark-btn"
32
+ :class={`(${resolvedActiveExpr}) ? 'is-active' : ''`}
33
+ x-on:click.stop.prevent={onClick}
34
+ title={active ? activeTitle : title}
35
+ :title={tooltipExpr}
36
+ aria-label={title}
37
+ :aria-label={`(${resolvedActiveExpr}) ? ${JSON.stringify(activeTitle)} : ${JSON.stringify(title)}`}
38
+ :aria-pressed={resolvedActiveExpr}
39
+ >
40
+ <span class="av-bookmark-btn__icon" :class={`(${resolvedActiveExpr}) ? 'is-hidden' : ''`} aria-hidden="true">
41
+ <svg width={iconPx} height={iconPx} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
42
+ <path d="M19 21 12 16 5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
43
+ </svg>
44
+ </span>
45
+ <span class="av-bookmark-btn__icon" :class={`(${resolvedActiveExpr}) ? '' : 'is-hidden'`} aria-hidden="true">
46
+ <svg width={iconPx} height={iconPx} viewBox="0 0 24 24" fill="currentColor" stroke="none">
47
+ <path d="M19 21 12 16 5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
48
+ </svg>
49
+ <svg class="av-bookmark-btn__check" width={checkPx} height={checkPx} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
50
+ <path d="m5 13 4 4L19 7" />
51
+ </svg>
52
+ </span>
53
+ </button>
54
+
55
+ <style>
56
+ .av-bookmark-btn {
57
+ position: relative;
58
+ display: inline-flex;
59
+ align-items: center;
60
+ justify-content: center;
61
+ width: 2rem;
62
+ height: 2rem;
63
+ border: 1px solid rgba(148, 163, 184, 0.35);
64
+ border-radius: 999px;
65
+ background: rgba(15, 23, 42, 0.65);
66
+ color: #94a3b8;
67
+ transition: all 0.2s ease;
68
+ }
69
+ .av-bookmark-btn:hover {
70
+ border-color: rgba(14, 165, 233, 0.6);
71
+ color: #7dd3fc;
72
+ }
73
+ .av-bookmark-btn.is-active {
74
+ border-color: rgba(14, 165, 233, 0.8);
75
+ color: #38bdf8;
76
+ background: rgba(14, 116, 144, 0.25);
77
+ }
78
+ .av-bookmark-btn__icon {
79
+ position: absolute;
80
+ inset: 0;
81
+ display: inline-flex;
82
+ align-items: center;
83
+ justify-content: center;
84
+ transition: opacity 0.15s ease;
85
+ }
86
+ .av-bookmark-btn__icon.is-hidden {
87
+ opacity: 0;
88
+ }
89
+ .av-bookmark-btn__check {
90
+ position: absolute;
91
+ color: #0f172a;
92
+ }
93
+ </style>
@@ -0,0 +1,5 @@
1
+ ---
2
+ import AvEmptyState from "../../AvEmptyState.astro";
3
+ ---
4
+
5
+ <AvEmptyState headline="No bookmarks yet" description="Save items to find them quickly here." />
@@ -0,0 +1,83 @@
1
+ ---
2
+ import AvCard from "../../AvCard.astro";
3
+ import AvBookmarkButton from "./AvBookmarkButton.astro";
4
+
5
+ type RightSlot = {
6
+ active: boolean;
7
+ activeExpr?: string;
8
+ title?: string;
9
+ activeTitle?: string;
10
+ size?: "sm" | "md";
11
+ onClick?: string;
12
+ };
13
+
14
+ type Item = {
15
+ title: string;
16
+ subtitle?: string;
17
+ description?: string;
18
+ href?: string;
19
+ rightSlot?: RightSlot;
20
+ };
21
+
22
+ interface Props {
23
+ items: Item[];
24
+ }
25
+
26
+ const { items } = Astro.props as Props;
27
+ ---
28
+
29
+ <div class="av-bookmarks-list">
30
+ {items.map((item) => (
31
+ <AvCard className="av-bookmarks-list__card">
32
+ <div class="av-bookmarks-list__row">
33
+ <div>
34
+ {item.href ? (
35
+ <a class="av-bookmarks-list__title" href={item.href}>{item.title}</a>
36
+ ) : (
37
+ <p class="av-bookmarks-list__title">{item.title}</p>
38
+ )}
39
+ {item.subtitle ? <p class="av-bookmarks-list__meta">{item.subtitle}</p> : null}
40
+ {item.description ? <p class="av-bookmarks-list__desc">{item.description}</p> : null}
41
+ </div>
42
+ {item.rightSlot ? (
43
+ <AvBookmarkButton
44
+ active={item.rightSlot.active}
45
+ activeExpr={item.rightSlot.activeExpr}
46
+ title={item.rightSlot.title}
47
+ activeTitle={item.rightSlot.activeTitle}
48
+ size={item.rightSlot.size ?? "sm"}
49
+ onClick={item.rightSlot.onClick}
50
+ />
51
+ ) : null}
52
+ </div>
53
+ </AvCard>
54
+ ))}
55
+ </div>
56
+
57
+ <style>
58
+ .av-bookmarks-list {
59
+ display: grid;
60
+ gap: 0.85rem;
61
+ }
62
+ .av-bookmarks-list__row {
63
+ display: flex;
64
+ justify-content: space-between;
65
+ align-items: flex-start;
66
+ gap: 0.75rem;
67
+ }
68
+ .av-bookmarks-list__title {
69
+ color: #e2e8f0;
70
+ font-weight: 600;
71
+ text-decoration: none;
72
+ }
73
+ .av-bookmarks-list__meta {
74
+ font-size: 0.75rem;
75
+ color: #7dd3fc;
76
+ margin-top: 0.35rem;
77
+ }
78
+ .av-bookmarks-list__desc {
79
+ margin-top: 0.4rem;
80
+ color: #94a3b8;
81
+ font-size: 0.82rem;
82
+ }
83
+ </style>
@@ -0,0 +1,3 @@
1
+ export { default as AvBookmarkButton } from "./AvBookmarkButton.astro";
2
+ export { default as AvBookmarksEmpty } from "./AvBookmarksEmpty.astro";
3
+ export { default as AvBookmarksList } from "./AvBookmarksList.astro";
@@ -6,9 +6,10 @@ import { MINI_APP_REGISTRY } from "./miniAppRegistry";
6
6
  interface Props {
7
7
  appKey: string;
8
8
  links?: MiniAppLink[];
9
+ bookmarksHref?: string;
9
10
  }
10
11
 
11
- const { appKey, links } = Astro.props as Props;
12
+ const { appKey, links, bookmarksHref } = Astro.props as Props;
12
13
 
13
14
  const normalizedAppKey = typeof appKey === "string" ? appKey.trim() : "";
14
15
  const meta = normalizedAppKey ? MINI_APP_REGISTRY[normalizedAppKey] : undefined;
@@ -19,6 +20,16 @@ let menuLinks = links ?? meta?.links ?? [];
19
20
  if (!menuLinks.length) {
20
21
  menuLinks = [{ label: "Home", href: "/" }];
21
22
  }
23
+
24
+ const normalizedBookmarksHref = typeof bookmarksHref === "string" ? bookmarksHref.trim() : "";
25
+ if (normalizedBookmarksHref.length > 0) {
26
+ const hasBookmarksItem = menuLinks.some(
27
+ (item) => item.href === normalizedBookmarksHref || item.label.toLowerCase() === "bookmarks",
28
+ );
29
+ if (!hasBookmarksItem) {
30
+ menuLinks = [...menuLinks, { label: "Bookmarks", href: normalizedBookmarksHref }];
31
+ }
32
+ }
22
33
  ---
23
34
 
24
35
  <div class="av-mini-app-bar">
@@ -11,6 +11,7 @@ interface Props {
11
11
  notificationUnreadCount?: number;
12
12
  miniAppKey?: string;
13
13
  links?: { label: string; href: string }[];
14
+ bookmarksHref?: string;
14
15
  }
15
16
 
16
17
  const {
@@ -19,6 +20,7 @@ const {
19
20
  notificationUnreadCount = 0,
20
21
  miniAppKey,
21
22
  links,
23
+ bookmarksHref,
22
24
  } = Astro.props;
23
25
 
24
26
  const user = (Astro.locals as { user?: {
@@ -87,7 +89,7 @@ const ROOT_URL = rawDomain.match(/^https?:\/\//i)
87
89
  <AvNavbarActions user={user} notificationUnreadCount={notificationUnreadCount} />
88
90
  </AvNavbar>
89
91
 
90
- {miniAppKey && <AvMiniAppBar appKey={miniAppKey} links={links} />}
92
+ {miniAppKey && <AvMiniAppBar appKey={miniAppKey} links={links} bookmarksHref={bookmarksHref} />}
91
93
 
92
94
  <!-- Main content -->
93
95
  <main class="av-main">