main
js 190 lines 5.31 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { toastFrontendError } from "/components/notifications/notification-store.js";
3 import { store as pluginListStore } from "/components/plugins/list/pluginListStore.js";
4 import * as API from "/js/api.js";
5
6 const STORAGE_KEY = "dismissed_discovery_cards";
7
8 const model = {
9 // --- State ---
10 /** @type {any[]} */
11 cards: [],
12 hasDismissedCards: false,
13 _initialized: false,
14 _isLoading: false,
15
16 // --- Lifecycle ---
17 init() {
18 if (this._initialized) return;
19 this._initialized = true;
20 },
21
22 // --- Actions ---
23
24 async refreshCards() {
25 if (this._isLoading) return;
26 this._isLoading = true;
27
28 try {
29 const response = await API.callJsonApi("/banners", {
30 banners: [],
31 context: {
32 is_onboarding: document.body.dataset.mode === "onboarding"
33 },
34 });
35
36 const banners = response?.banners || [];
37 const dismissed = this._getDismissedIds();
38
39 // Filter out standard banners, keep only hero and feature
40 // Also respect the onboarding filtering
41 const is_onboarding = document.body.dataset.mode === "onboarding";
42
43 this.cards = banners
44 .filter((card) => card.type === "hero" || card.type === "feature")
45 .filter((card) => !is_onboarding || card.show_in_onboarding === true)
46 .filter((card) => !dismissed.has(card.id))
47 .sort((left, right) => (right.priority || 0) - (left.priority || 0));
48
49 this.hasDismissedCards = dismissed.size > 0;
50 } catch (error) {
51 console.error("Failed to fetch discovery cards:", error);
52 } finally {
53 this._isLoading = false;
54 }
55 },
56
57 dismissCard(cardId) {
58 const dismissed = this._getDismissedIds();
59 dismissed.add(cardId);
60 this._persistDismissedIds(dismissed);
61
62 // Optimistically update UI
63 this.cards = this.cards.filter(c => c.id !== cardId);
64 this.hasDismissedCards = true;
65 },
66
67 dismissFeatureCards() {
68 const featureIds = this.featureCards
69 .filter((card) => card.dismissible !== false)
70 .map((card) => card.id)
71 .filter(Boolean);
72
73 if (!featureIds.length) return;
74
75 const dismissed = this._getDismissedIds();
76 const dismissSet = new Set(featureIds);
77 for (const id of dismissSet) {
78 dismissed.add(id);
79 }
80 this._persistDismissedIds(dismissed);
81
82 this.cards = this.cards.filter((card) => !dismissSet.has(card.id));
83 this.hasDismissedCards = true;
84 },
85
86 undismissCards() {
87 localStorage.removeItem(STORAGE_KEY);
88 this.refreshCards();
89 },
90
91 async executeCta(action) {
92 if (!action) return;
93
94 try {
95 if (action === "open-plugin-hub") {
96 await pluginListStore.open("pluginHub");
97 return;
98 }
99
100 if (action.startsWith("open-plugin-config:")) {
101 const pluginName = action.split(":")[1];
102 await pluginListStore.openPluginConfig(pluginName);
103 return;
104 }
105
106 if (action.startsWith("open-url:")) {
107 const url = action.slice("open-url:".length);
108 if (url) {
109 window.open(url, "_blank", "noopener,noreferrer");
110 }
111 return;
112 }
113 } catch (error) {
114 console.error("Discovery action failed:", error);
115 const message = error instanceof Error ? error.message : String(error);
116 await toastFrontendError(message, "Discovery");
117 }
118 },
119
120 usageWidth(window) {
121 const value = Math.max(0, Math.min(100, this.remainingPercent(window)));
122 return `${value}%`;
123 },
124
125 remainingPercent(window) {
126 const remaining = Number(window?.remaining_percent);
127 if (Number.isFinite(remaining)) return remaining;
128 const used = Number(window?.used_percent);
129 if (Number.isFinite(used)) return 100 - used;
130 return Number.NaN;
131 },
132
133 formatRemainingPercent(window) {
134 const number = this.remainingPercent(window);
135 if (!Number.isFinite(number)) return "0%";
136 return `${Math.round(number * 10) / 10}% left`;
137 },
138
139 formatReset(window) {
140 const seconds = Number(window?.reset_at || 0);
141 if (!Number.isFinite(seconds) || seconds <= 0) return "";
142 const remainingMs = Math.max(0, seconds * 1000 - Date.now());
143 const minutes = Math.round(remainingMs / 60000);
144 if (minutes < 60) return `${minutes}m`;
145 const hours = Math.round(minutes / 60);
146 if (hours < 48) return `${hours}h`;
147 return `${Math.round(hours / 24)}d`;
148 },
149
150 // --- Helpers (Private-ish) ---
151
152 _getDismissedIds() {
153 try {
154 const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
155 if (!Array.isArray(raw)) return new Set();
156 return new Set(raw);
157 } catch {
158 return new Set();
159 }
160 },
161
162 _persistDismissedIds(ids) {
163 localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(ids)));
164 },
165
166 // --- Computed ---
167
168 get topHeroCards() {
169 return this.cards.filter((card) => card.type === "hero" && card.placement !== "after-features");
170 },
171
172 get bottomHeroCards() {
173 return this.cards.filter((card) => card.type === "hero" && card.placement === "after-features");
174 },
175
176 get oauthAccountCards() {
177 return this.bottomHeroCards.filter((card) => card.id === "discovery-oauth-accounts");
178 },
179
180 get heroCards() {
181 return [...this.topHeroCards, ...this.bottomHeroCards];
182 },
183
184 get featureCards() {
185 return this.cards.filter((card) => card.type === "feature");
186 },
187 };
188
189 const store = createStore("discoveryStore", model);
190 export { store };