|
3 | 3 | endpoint: "https://datacite.org/wp-json/wp/v2/toast/", |
4 | 4 | site: "", |
5 | 5 | storageKey: "datacite_toasts_dismissed", |
| 6 | + maxVisible: 3, |
6 | 7 | }; |
| 8 | + |
7 | 9 | const SCRIPT_OPTIONS = (() => { |
8 | 10 | const script = document.currentScript; |
9 | 11 | if (!script || !script.dataset) return {}; |
|
29 | 31 | min-width: 500px; |
30 | 32 | max-width: 500px; |
31 | 33 | padding: 20px; |
32 | | - border-color: rgba(36, 59, 84, 0.08); |
| 34 | + border-color: rgba(36, 59, 84, 0.32); |
33 | 35 | border-width: 1px; |
34 | 36 | border-style: solid; |
35 | 37 | background-color: #ffffff; |
36 | | - box-shadow: 0px 28px 36px -10px rgba(13, 96, 212, 0.08); |
| 38 | + box-shadow: 0px 28px 36px -10px rgba(13, 96, 212, 0.48); |
37 | 39 | display: flex; |
38 | 40 | flex-direction: column; |
39 | 41 | gap: 6px; |
|
179 | 181 | const arr = Array.isArray(parsed) ? parsed : []; |
180 | 182 | return new Set(arr.map((value) => String(value))); |
181 | 183 | } catch (err) { |
182 | | - console.warn("[DataCiteToasts] Failed to read dismissed set", err); |
183 | 184 | return new Set(); |
184 | 185 | } |
185 | 186 | } |
|
189 | 190 | try { |
190 | 191 | localStorage.setItem(key, JSON.stringify(Array.from(set))); |
191 | 192 | } catch (err) { |
192 | | - console.warn("[DataCiteToasts] Failed to persist dismissed set", err); |
| 193 | + return; |
193 | 194 | } |
194 | 195 | } |
195 | 196 |
|
196 | | - function markToastDismissed(id, options) { |
197 | | - if (!id || !options?.storageKey) return; |
198 | | - const set = |
199 | | - options._dismissedSet || |
200 | | - (options._dismissedSet = readDismissedSet(options.storageKey)); |
| 197 | + function markToastDismissed(id, dismissedSet, storageKey) { |
| 198 | + if (!id || !dismissedSet || !storageKey) return; |
201 | 199 | const entry = String(id); |
202 | | - if (set.has(entry)) return; |
203 | | - set.add(entry); |
204 | | - writeDismissedSet(set, options.storageKey); |
205 | | - } |
206 | | - |
207 | | - function htmlDecode(str) { |
208 | | - const div = document.createElement("div"); |
209 | | - div.innerHTML = str; |
210 | | - return div.textContent || str; |
| 200 | + if (dismissedSet.has(entry)) return; |
| 201 | + dismissedSet.add(entry); |
| 202 | + writeDismissedSet(dismissedSet, storageKey); |
211 | 203 | } |
212 | 204 |
|
213 | 205 | function isActiveToast(acf, now) { |
|
222 | 214 | return true; |
223 | 215 | } |
224 | 216 |
|
| 217 | + function isSafeHttpsUri(value) { |
| 218 | + if (typeof value !== "string") return false; |
| 219 | + const trimmed = value.trim(); |
| 220 | + if (!trimmed) return false; |
| 221 | + |
| 222 | + try { |
| 223 | + const parsed = new URL(trimmed); |
| 224 | + if (parsed.protocol !== "https:") return false; |
| 225 | + return true; |
| 226 | + } catch (err) { |
| 227 | + return false; |
| 228 | + } |
| 229 | + } |
| 230 | + |
225 | 231 | function matchesSite(acf, site) { |
226 | | - if (!site) return true; |
227 | | - return (acf.toast_site || []) |
228 | | - .map((s) => s.toLowerCase()) |
229 | | - .includes(site.toLowerCase()); |
| 232 | + const currentSite = site.toLowerCase(); |
| 233 | + const sites = acf.toast_site; |
| 234 | + |
| 235 | + return sites.some((value) => String(value).toLowerCase() === currentSite); |
230 | 236 | } |
231 | 237 |
|
232 | | - function createToast(notice, options) { |
| 238 | + function createToast(notice, dismissedSet, storageKey) { |
233 | 239 | const { |
234 | 240 | toast_type, |
235 | 241 | toast_title, |
|
241 | 247 | } = notice.acf; |
242 | 248 | const toast = document.createElement("div"); |
243 | 249 | toast.className = "dc-toast"; |
244 | | - toast.dataset.type = toast_type || "info"; |
245 | 250 | const toastId = notice.id; |
246 | 251 | toast.dataset.toastId = toastId; |
247 | 252 |
|
|
252 | 257 | dismissBtn.className = "dc-toast__dismiss"; |
253 | 258 | dismissBtn.setAttribute("aria-label", "Dismiss notification"); |
254 | 259 | dismissBtn.innerHTML = "×"; |
255 | | - dismissBtn.addEventListener("click", () => dismissToast(toast, options)); |
| 260 | + dismissBtn.addEventListener("click", () => |
| 261 | + dismissToast(toast, dismissedSet, storageKey), |
| 262 | + ); |
256 | 263 |
|
257 | 264 | const content = document.createElement("div"); |
258 | 265 | content.className = "dc-toast__content"; |
|
280 | 287 |
|
281 | 288 | const body = document.createElement("div"); |
282 | 289 | body.className = "dc-toast__body"; |
283 | | - body.textContent = toast_message ? htmlDecode(toast_message) : ""; |
| 290 | + body.textContent = toast_message || ""; |
284 | 291 |
|
285 | 292 | textWrap.appendChild(title); |
286 | 293 | textWrap.appendChild(body); |
287 | 294 |
|
288 | | - if (toast_cta_enabled && toast_cta_label && toast_cta_url) { |
| 295 | + if (toast_cta_enabled && toast_cta_url && toast_cta_label) { |
289 | 296 | const toastTitleForAnalytics = (toast_title || "") |
290 | 297 | .toString() |
291 | 298 | .trim() |
|
312 | 319 | return toast; |
313 | 320 | } |
314 | 321 |
|
315 | | - function dismissToast(node, options) { |
| 322 | + function dismissToast(node, dismissedSet, storageKey) { |
316 | 323 | if (!node || node.dataset.dismissed === "true") return; |
317 | 324 | node.dataset.dismissed = "true"; |
318 | 325 | const toastId = node.dataset.toastId; |
319 | | - markToastDismissed(toastId, options); |
320 | | - setTimeout(() => node.remove(), 200); |
| 326 | + markToastDismissed(toastId, dismissedSet, storageKey); |
| 327 | + node.remove(); |
321 | 328 | } |
322 | 329 |
|
323 | 330 | async function fetchToasts(endpoint) { |
324 | 331 | const res = await fetch(endpoint); |
325 | | - if (!res.ok) throw new Error(`Toast fetch failed (${res.status})`); |
| 332 | + if (!res.ok) return []; |
326 | 333 | return res.json(); |
327 | 334 | } |
328 | 335 |
|
329 | 336 | async function init() { |
330 | | - const options = { ...DEFAULTS, ...SCRIPT_OPTIONS }; |
| 337 | + const { endpoint, site, storageKey, maxVisible } = { |
| 338 | + ...DEFAULTS, |
| 339 | + ...SCRIPT_OPTIONS, |
| 340 | + }; |
331 | 341 |
|
332 | 342 | try { |
333 | 343 | ensureStyles(); |
334 | | - const data = await fetchToasts(options.endpoint); |
| 344 | + const data = await fetchToasts(endpoint); |
335 | 345 | const now = new Date(); |
336 | | - const dismissedSet = readDismissedSet(options.storageKey); |
337 | | - options._dismissedSet = dismissedSet; |
338 | | - const relevant = (Array.isArray(data) ? data : []).filter((item) => { |
| 346 | + const dismissedSet = readDismissedSet(storageKey); |
| 347 | + const relevantPosts = (Array.isArray(data) ? data : []).filter((item) => { |
339 | 348 | if (!item?.acf) return false; |
340 | 349 | if (!isActiveToast(item.acf, now)) return false; |
341 | | - if (!matchesSite(item.acf, options.site)) return false; |
| 350 | + if (!matchesSite(item.acf, site)) return false; |
| 351 | + if (item.acf.toast_cta_url && !isSafeHttpsUri(item.acf.toast_cta_url)) return false; |
342 | 352 | return !dismissedSet.has(String(item.id)); |
343 | 353 | }); |
344 | | - |
345 | | - if (!relevant.length) return; |
| 354 | + const postsToRender = relevantPosts.slice(0, maxVisible); |
| 355 | + if (!postsToRender.length) return; |
346 | 356 |
|
347 | 357 | const wrapper = ensureWrapper(); |
348 | 358 |
|
349 | | - relevant.forEach((notice) => |
350 | | - wrapper.appendChild(createToast(notice, options)), |
| 359 | + postsToRender.forEach((notice) => |
| 360 | + wrapper.appendChild(createToast(notice, dismissedSet, storageKey)), |
351 | 361 | ); |
352 | 362 | } catch (err) { |
353 | | - console.error("[DataCiteToasts]", err); |
| 363 | + return; |
354 | 364 | } |
355 | 365 | } |
356 | 366 |
|
|
0 commit comments