| 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 }; |