diff --git a/README.md b/README.md index 40a0855b..4ae94d20 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ hairline borders or browser reset quirks) add an explicit allowlist entry in `scripts/check-design-tokens.mjs` so future contributors understand why the constant exists. -The script now crawls every markup-emitting module under `js/blog`, `js/ui`, +The script now crawls every markup-emitting module under `js/ui`, `js/channelProfile.js`, and `torrent/ui`, so UI tweaks anywhere in the repo benefit from the guard without manual configuration. diff --git a/blog.html b/blog.html deleted file mode 100644 index d569d71e..00000000 --- a/blog.html +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bitvid Blog - - - - -
- - - - diff --git a/components/sidebar.html b/components/sidebar.html index d3a75c98..46966c9b 100644 --- a/components/sidebar.html +++ b/components/sidebar.html @@ -168,7 +168,13 @@ /> Roadmap - + Blog Nostr + + Community + + βeacon IPNS ` selector or toggle them via controller logic so the defaults remain stable when the flag is off. -### Blog layout tokens - -The blog embed now consumes dedicated typography and spacing tokens alongside Tailwind utilities. `css/tokens.css` exposes the following helpers: - -- `--blog-font-size-root`, `--blog-font-size-display`, `--blog-font-size-title`, `--blog-font-size-body`, `--blog-font-size-meta`, and `--blog-font-size-h1-mobile` scale copy across the blog shell, hero headings, and metadata. -- `--blog-line-height-body` and `--blog-line-height-tight` set the rhythm for long-form text and compact headers. -- `--blog-layout-fluid-width` and `--blog-layout-max-width` define the responsive container width; pair them with Tailwind utilities (`mx-auto`, `md:w-[var(--blog-layout-fluid-width)]`, `md:max-w-[var(--blog-layout-max-width)]`) when mounting the blog root. -- `--blog-space-compact`, `--blog-space-snug`, `--blog-space-heading-stack`, `--blog-space-heading-gap`, `--blog-space-inline-lg`, `--blog-space-media-stack-start`, `--blog-space-media-stack-end`, and `--blog-space-stack-xxl` standardise the bespoke gaps used throughout article summaries, media blocks, and footer treatments. -- `--blog-avatar-size` and `--blog-avatar-size-compact` size topic avatars responsively. - -Templates should reference these tokens (directly or via Tailwind arbitrary values) instead of hard-coded measurements so the embed stays in sync with future blog refreshes. - -The blog palette now resolves from the base `:root` scope, making color and layout tokens theme-agnostic. Neither the light nor dark theme overrides these variables, so any future blog-specific deltas should be called out explicitly in this guide if they return. - ## Semantic Palette All interactive surfaces now draw from a shared semantic color map exposed through `css/tokens.css` and Tailwind utility aliases. The palette is grouped into text, surface, overlay, and status slots so controllers can swap state styles without reaching for raw Tailwind hues: @@ -112,43 +98,9 @@ All interactive surfaces now draw from a shared semantic color map exposed throu - Use `text-info`/`hover:text-info-strong` for links that previously relied on `text-blue-400` so anchors adapt in both light and dark modes. - When rendering tinted descriptions or placeholders, prefer `text-muted`, `text-muted-strong`, or `text-subtle` over semi-transparent whites. Token overrides keep the same classes usable across the default and light themes. -### Blog carousel markup - -The short-notes carousel ships as a semantic wrapper around the `blog-carousel` primitive defined in `css/tailwind.source.css`. The runtime no longer injects inline styles or Splide classes—layout is driven entirely by data attributes that map onto tokenised CSS rules. - -**Structure** - -```html - -``` - -**State contract** - -- `data-per-page` controls how many columns render inside the viewport. CSS exposes variants for 1–4 slides per page; the custom property `--blog-carousel-per-page` updates automatically. -- `data-index` represents the active *page* (zero-indexed). The track translates by `100%` for each page, so the attribute should advance in whole-number increments rather than slide indices. -- Slides expose `data-index`, `data-active`, `data-inert`, and `data-state`. The carousel controller toggles `data-state="active"`, `"idle"`, and `"leaving"` to drive scale/opacity transitions without touching inline styles. -- The root `section` advertises `data-state="active" | "idle"` plus `data-transition="auto" | "instant"` when short-circuiting animations (for example, during first render or programmatic jumps). -- Progress wrappers set `data-state="idle" | "active" | "paused" | "complete"` while the inner `` leverages the shared `progress` primitive (`data-progress-meter`, `data-variant="blog"`) so theme tokens continue to drive track/fill colours. - -Controllers should rely on these data hooks when orchestrating autoplay, pausing on pointer/visibility changes, or syncing the segmented progress indicator. Avoid reintroducing inline `style` mutations—tests enforce the contract via the inline-style guard. - ### Layout & spacing extensions -The core spacing scale gained intermediate stops for tighter micro-layouts and larger hero treatments. `css/tokens.css` now exports `--space-4xs`, `--space-3xs`, `--space-xs-snug`, `--space-md-plus`, `--space-xl-compact`, `--space-xl-plus`, and `--space-2xl-plus`; Tailwind surfaces matching utilities such as `p-4xs`, `p-3xs`, `px-xs-snug`, `py-md-plus`, `gap-xl-compact`, `px-xl-plus`, and `gap-2xl-plus`. Pair them with the new layout primitives—`--menu-min-width` (`min-w-menu`), `--modal-max-width` (`max-w-modal`), `--layout-player-max-width` (`max-w-player`), and `--layout-docs-max-width` (`max-w-docs`)—to anchor consistent breakpoints across popovers, modals, and documentation shells without reintroducing raw `rem`/`px` literals. Blog-specific chrome reuses the same additions: the theme toggle knob now references `--radius-toggle-thumb`, and focus affordances draw from `--outline-thick-width` instead of hard-coded pixel outlines. +The core spacing scale gained intermediate stops for tighter micro-layouts and larger hero treatments. `css/tokens.css` now exports `--space-4xs`, `--space-3xs`, `--space-xs-snug`, `--space-md-plus`, `--space-xl-compact`, `--space-xl-plus`, and `--space-2xl-plus`; Tailwind surfaces matching utilities such as `p-4xs`, `p-3xs`, `px-xs-snug`, `py-md-plus`, `gap-xl-compact`, `px-xl-plus`, and `gap-2xl-plus`. Pair them with the new layout primitives—`--menu-min-width` (`min-w-menu`), `--modal-max-width` (`max-w-modal`), `--layout-player-max-width` (`max-w-player`), and `--layout-docs-max-width` (`max-w-docs`)—to anchor consistent breakpoints across popovers, modals, and documentation shells without reintroducing raw `rem`/`px` literals. ### Previewing themes diff --git a/js/blog/carousel.js b/js/blog/carousel.js deleted file mode 100644 index 675f72b5..00000000 --- a/js/blog/carousel.js +++ /dev/null @@ -1,232 +0,0 @@ -import { userLogger } from "../utils/logger.js"; -function toArray(value) { - if (!value) { - return []; - } - return Array.isArray(value) ? value : Array.from(value); -} - -const SLIDE_STATE_IDLE = 'idle'; -const SLIDE_STATE_ACTIVE = 'active'; -const SLIDE_STATE_LEAVING = 'leaving'; -const SLIDE_TRANSITION_TIMEOUT = 420; - -function getRange(pageIndex, perPage) { - const start = pageIndex * perPage; - return { start, end: start + perPage }; -} - -function clearTimer(state, slide) { - const handle = state.timers.get(slide); - if (typeof handle === 'number') { - clearTimeout(handle); - } - state.timers.delete(slide); -} - -function scheduleSlideIdle(state, slide) { - clearTimer(state, slide); - const handle = setTimeout(() => { - state.timers.delete(slide); - if (slide.dataset.active !== 'true') { - slide.dataset.state = SLIDE_STATE_IDLE; - } - }, SLIDE_TRANSITION_TIMEOUT); - state.timers.set(slide, handle); -} - -function applySlideState(state, slide, { isActive, wasActive, immediate }) { - if (isActive) { - clearTimer(state, slide); - slide.dataset.active = 'true'; - slide.dataset.state = SLIDE_STATE_ACTIVE; - slide.dataset.inert = 'false'; - slide.setAttribute('aria-hidden', 'false'); - return; - } - - slide.dataset.active = 'false'; - slide.dataset.inert = 'true'; - slide.setAttribute('aria-hidden', 'true'); - - if (!immediate && wasActive) { - slide.dataset.state = SLIDE_STATE_LEAVING; - scheduleSlideIdle(state, slide); - return; - } - - clearTimer(state, slide); - slide.dataset.state = SLIDE_STATE_IDLE; -} - -function updateSlides(state, { previousIndex, immediate = false } = {}) { - const currentRange = getRange(state.index, state.perPage); - const previousRange = - typeof previousIndex === 'number' ? getRange(previousIndex, state.perPage) : currentRange; - - state.slides.forEach((slide, slideIndex) => { - const isActive = slideIndex >= currentRange.start && slideIndex < currentRange.end; - const wasActive = slideIndex >= previousRange.start && slideIndex < previousRange.end; - applySlideState(state, slide, { isActive, wasActive, immediate }); - }); -} - -function syncTransitionMode(container, track, mode) { - if (!container || !track) { - return; - } - - if (mode) { - container.dataset.transition = mode; - track.dataset.transition = mode; - } else { - delete container.dataset.transition; - delete track.dataset.transition; - } -} - -export function createCarousel(container, options = {}) { - const track = options.track || container?.querySelector('[data-carousel-track]'); - const slides = options.slides || toArray(track?.children); - if (!container || !track || slides.length === 0) { - return null; - } - - const perPage = Math.max(1, Math.min(options.perPage || 1, slides.length)); - const pageCount = Math.max(1, Math.ceil(slides.length / perPage)); - - const state = { - container, - track, - slides, - perPage, - pageCount, - loop: Boolean(options.loop), - autoPlayInterval: Math.max(0, options.autoPlayInterval || 0), - index: 0, - progress: null, - cleanup: [], - timers: new Map(), - setProgress(progressInstance) { - this.progress = progressInstance || null; - }, - }; - - container.dataset.count = String(pageCount); - container.dataset.perPage = String(perPage); - container.dataset.index = '0'; - container.dataset.state = pageCount > 1 ? 'active' : 'idle'; - - syncTransitionMode(container, track, 'auto'); - - slides.forEach((slide, slideIndex) => { - slide.dataset.index = String(slideIndex); - slide.dataset.active = 'false'; - slide.dataset.state = SLIDE_STATE_IDLE; - slide.dataset.inert = 'true'; - slide.setAttribute('aria-hidden', 'true'); - }); - - function handlePrev(event) { - event?.preventDefault?.(); - advanceSlide(state, -1); - } - - function handleNext(event) { - event?.preventDefault?.(); - advanceSlide(state, 1); - } - - if (options.controls?.prev) { - options.controls.prev.addEventListener('click', handlePrev); - state.cleanup.push(() => options.controls.prev.removeEventListener('click', handlePrev)); - } - - if (options.controls?.next) { - options.controls.next.addEventListener('click', handleNext); - state.cleanup.push(() => options.controls.next.removeEventListener('click', handleNext)); - } - - state.goTo = function goTo(targetPage, { restartProgress = true, instant = false } = {}) { - const maxIndex = state.pageCount - 1; - let nextIndex = targetPage; - - if (nextIndex < 0) { - nextIndex = state.loop ? maxIndex : 0; - } else if (nextIndex > maxIndex) { - nextIndex = state.loop ? 0 : maxIndex; - } - - const previousIndex = state.index; - state.index = nextIndex; - - container.dataset.index = String(nextIndex); - - syncTransitionMode(container, track, instant ? 'instant' : 'auto'); - - updateSlides(state, { previousIndex, immediate: instant }); - - if (instant) { - syncTransitionMode(container, track, 'auto'); - } - - if (restartProgress && state.autoPlayInterval > 0 && state.progress) { - state.progress.reset(); - state.progress.start(state.autoPlayInterval); - } - }; - - state.goTo(0, { restartProgress: false, instant: true }); - - return state; -} - -export function advanceSlide(state, step = 1, options = {}) { - if (!state || typeof state.goTo !== 'function') { - return; - } - state.goTo(state.index + step, options); -} - -export function destroyCarousel(state) { - if (!state) { - return; - } - - state.cleanup?.forEach((dispose) => { - try { - dispose(); - } catch (error) { - userLogger.warn('Failed to dispose carousel listener', error); - } - }); - state.cleanup = []; - - for (const handle of state.timers.values()) { - clearTimeout(handle); - } - state.timers.clear(); - - if (state.progress && typeof state.progress.destroy === 'function') { - state.progress.destroy(); - } - - if (state.container) { - delete state.container.dataset.index; - delete state.container.dataset.count; - delete state.container.dataset.perPage; - delete state.container.dataset.state; - delete state.container.dataset.transition; - } - - if (state.track) { - delete state.track.dataset.transition; - } - - state.slides?.forEach((slide) => { - delete slide.dataset.active; - delete slide.dataset.state; - delete slide.dataset.inert; - slide.setAttribute('aria-hidden', 'false'); - }); -} diff --git a/js/blog/index.js b/js/blog/index.js deleted file mode 100644 index 725c7641..00000000 --- a/js/blog/index.js +++ /dev/null @@ -1,1123 +0,0 @@ -import { createCarousel, advanceSlide, destroyCarousel } from './carousel.js'; -import { mountProgressBar } from './progressBar.js'; -import { initZapthreadsEmbed as defaultInitZapthreadsEmbed } from './zapthreadsEmbed.js'; -import { userLogger } from "../utils/logger.js"; - -const CAROUSEL_AUTOPLAY_INTERVAL = 8000; - -function repeatParagraph(text, times) { - return Array.from({ length: times }, () => text).join(' '); -} - -const NOTE_BLUEPRINTS = [ - { - id: 'relay-health-q3', - title: 'Relay health checks land in bitvid', - paragraph: - 'We reduced fallback churn by folding relay health signals directly into playback orchestration, keeping viewers pinned to the fastest source while magnets warm in the background.', - repeats: 9, - createdAt: Date.parse('2024-08-14T09:00:00Z') / 1000, - image: './assets/png/bitvid-banner.png', - topics: ['infrastructure', 'release'], - }, - { - id: 'nostr-tooling-upgrade', - title: 'Tooling overhaul simplifies Nostr publishing', - paragraph: - 'We rebuilt the publishing helpers so creators can author video and audio notes with cleaner defaults, automatic metadata validation, and better local previews before signing.', - repeats: 8, - createdAt: Date.parse('2024-07-02T15:30:00Z') / 1000, - image: './assets/png/bitvid-banner.png', - topics: ['creator', 'tooling'], - }, - { - id: 'zapthreads-updates', - title: 'Zapthreads embeds now hydrate instantly', - paragraph: - 'Comment embeds now stream profile cards and replies incrementally, so community conversations appear without waiting for full thread reconciliation across relays.', - repeats: 8, - createdAt: Date.parse('2024-06-18T18:45:00Z') / 1000, - topics: ['community', 'release'], - }, - { - id: 'progressive-download', - title: 'Progressive download prioritises hosted sources', - paragraph: - 'Playback prefers direct HTTP sources with smoother resume support while still preparing magnet fallbacks, unlocking faster start times for mobile viewers on congested relays.', - repeats: 7, - createdAt: Date.parse('2024-05-09T12:00:00Z') / 1000, - topics: ['playback'], - }, - { - id: 'moderation-tooling', - title: 'Moderation tooling gains topic level controls', - paragraph: - 'Admin teams can now enforce topic-specific guidelines with clearer override reporting, making it easier to surface community playlists without compromising safety.', - repeats: 7, - createdAt: Date.parse('2024-04-21T20:15:00Z') / 1000, - topics: ['moderation', 'community'], - }, - { - id: 'mobile-ux-refresh', - title: 'Mobile UX refresh ships across the blog', - paragraph: - 'We tuned typography, spacing, and gesture targets throughout the blog so updates remain readable on small screens while matching the main app aesthetic.', - repeats: 6, - createdAt: Date.parse('2024-03-05T10:00:00Z') / 1000, - topics: ['design'], - }, -]; - -function buildDefaultNotes() { - return NOTE_BLUEPRINTS.map((entry) => ({ - ...entry, - summary: repeatParagraph(entry.paragraph, entry.repeats), - })); -} - -function readMetaValue(name) { - const element = document.querySelector(`meta[name="${name}"]`); - if (!element) { - return ''; - } - const value = element.getAttribute('value'); - return value == null ? '' : value; -} - -function parseNumber(value, fallback = 0) { - if (typeof value !== 'string') { - return fallback; - } - const trimmed = value.trim(); - if (!trimmed) { - return fallback; - } - const numeric = Number(trimmed); - return Number.isFinite(numeric) ? numeric : fallback; -} - -function parseTopics(value) { - if (typeof value !== 'string') { - return []; - } - return value - .split(',') - .map((topic) => topic.trim()) - .filter((topic) => topic.length > 0); -} - -function readBlogConfig() { - return { - npub: readMetaValue('author'), - relays: readMetaValue('relays') - .split(',') - .map((relay) => relay.trim()) - .filter((relay) => relay.length > 0), - topNotes: parseNumber(readMetaValue('top-notes'), 0), - shortNotesMode: readMetaValue('short-notes'), - shortNotesMinChars: parseNumber(readMetaValue('short-notes-min-chars'), 0), - shortFeedSummaryMaxChars: parseNumber( - readMetaValue('short-notes-summary-max-chars'), - 0, - ), - topics: parseTopics(readMetaValue('topics')), - commentsEnabled: readMetaValue('comments').toLowerCase() === 'yes', - }; -} - -function resolveNotes() { - if (typeof window !== 'undefined' && Array.isArray(window.bitvidBlogNotes)) { - return window.bitvidBlogNotes; - } - return buildDefaultNotes(); -} - -function setupEmbedHeightBroadcast({ root, registerCleanup }) { - if (typeof window === 'undefined' || window.parent === window) { - return; - } - - const element = root || document.querySelector('.blog-app-root'); - if (!element) { - return; - } - - const parentOrigin = (() => { - try { - if (document.referrer) { - return new URL(document.referrer).origin; - } - } catch (error) { - userLogger.error('[blog] failed to parse parent origin for resize messaging', error); - } - return '*'; - })(); - - let destroyed = false; - registerCleanup(() => { - destroyed = true; - }); - - const parseCssNumber = (value) => { - if (typeof value !== 'string') { - return 0; - } - const numeric = Number.parseFloat(value); - return Number.isFinite(numeric) ? numeric : 0; - }; - - const computeMarginAdjustment = (node) => { - if (!node || typeof window.getComputedStyle !== 'function') { - return 0; - } - let topMargin = 0; - let bottomMargin = 0; - try { - const first = node.firstElementChild; - const last = node.lastElementChild; - if (first) { - topMargin = parseCssNumber(window.getComputedStyle(first).marginTop); - } - if (last) { - bottomMargin = parseCssNumber(window.getComputedStyle(last).marginBottom); - } - } catch (error) { - userLogger.error('[blog] failed to compute margin adjustment for resize messaging', error); - } - const adjustment = Math.max(0, topMargin) + Math.max(0, bottomMargin); - return Number.isFinite(adjustment) ? adjustment : 0; - }; - - let lastHeight = null; - let pendingFrame = null; - - const measureAndPost = (force = false) => { - if (destroyed) { - return; - } - const docElement = document.documentElement; - const body = document.body; - - const candidates = []; - const addCandidate = (value) => { - if (typeof value !== 'number') { - return; - } - const rounded = Math.max(0, Math.ceil(value)); - if (Number.isFinite(rounded)) { - candidates.push(rounded); - } - }; - - const addElementMetrics = (node) => { - if (!node) { - return; - } - const marginAdjustment = computeMarginAdjustment(node); - addCandidate(node.scrollHeight + marginAdjustment); - addCandidate(node.offsetHeight + marginAdjustment); - addCandidate(node.clientHeight + marginAdjustment); - try { - const rect = node.getBoundingClientRect(); - addCandidate(rect.height + marginAdjustment); - } catch (error) { - userLogger.error('[blog] failed to compute element rect for resize messaging', error); - } - }; - - addElementMetrics(element); - if (body && body !== element) { - addElementMetrics(body); - } - if (docElement && docElement !== element && docElement !== body) { - addElementMetrics(docElement); - } - if (document.scrollingElement && document.scrollingElement !== body && document.scrollingElement !== docElement) { - addElementMetrics(document.scrollingElement); - } - - const height = candidates.length > 0 ? Math.max(...candidates) : null; - if (!Number.isFinite(height)) { - return; - } - if (!force && height === lastHeight) { - return; - } - lastHeight = height; - try { - window.parent.postMessage({ type: 'bitvid-blog-resize', height }, parentOrigin); - } catch (error) { - userLogger.error('[blog] failed to post resize message', error); - } - }; - - const queueMeasurement = (force = false) => { - if (destroyed) { - return; - } - if (force) { - measureAndPost(true); - return; - } - if (pendingFrame != null) { - return; - } - pendingFrame = window.requestAnimationFrame(() => { - pendingFrame = null; - measureAndPost(false); - }); - }; - - pendingFrame = window.requestAnimationFrame(() => { - pendingFrame = null; - measureAndPost(true); - }); - - registerCleanup(() => { - if (pendingFrame != null) { - window.cancelAnimationFrame(pendingFrame); - pendingFrame = null; - } - }); - - const observedNodes = new Set(); - - const observeNode = (observer, node) => { - if (!node || observedNodes.has(node)) { - return; - } - observer.observe(node); - observedNodes.add(node); - }; - - if (typeof ResizeObserver === 'function') { - const observer = new ResizeObserver(() => { - queueMeasurement(); - }); - observeNode(observer, element); - if (document.body) { - observeNode(observer, document.body); - } - if (document.documentElement) { - observeNode(observer, document.documentElement); - } - if (document.scrollingElement) { - observeNode(observer, document.scrollingElement); - } - registerCleanup(() => { - observer.disconnect(); - observedNodes.clear(); - }); - } - - const pollingIntervalId = window.setInterval(() => { - queueMeasurement(); - }, 750); - registerCleanup(() => { - window.clearInterval(pollingIntervalId); - }); - - const mutationTargets = [element]; - if (document.body && document.body !== element) { - mutationTargets.push(document.body); - } - if (document.documentElement && document.documentElement !== element && document.documentElement !== document.body) { - mutationTargets.push(document.documentElement); - } - if ( - document.scrollingElement && - document.scrollingElement !== element && - document.scrollingElement !== document.body && - document.scrollingElement !== document.documentElement - ) { - mutationTargets.push(document.scrollingElement); - } - - const trackedMediaNodes = new WeakSet(); - const MEDIA_SELECTOR = 'img, picture, video, audio, iframe, object, embed'; - - const watchMediaNode = (node) => { - if (!(node instanceof Element)) { - return; - } - const candidates = []; - if (node.matches(MEDIA_SELECTOR)) { - candidates.push(node); - } - candidates.push(...node.querySelectorAll(MEDIA_SELECTOR)); - candidates.forEach((media) => { - if (trackedMediaNodes.has(media)) { - return; - } - trackedMediaNodes.add(media); - const handleMediaEvent = () => { - queueMeasurement(true); - }; - const mediaEvents = ['load', 'loadeddata', 'loadedmetadata', 'readystatechange', 'error']; - mediaEvents.forEach((eventName) => { - media.addEventListener(eventName, handleMediaEvent); - }); - registerCleanup(() => { - mediaEvents.forEach((eventName) => { - media.removeEventListener(eventName, handleMediaEvent); - }); - }); - if ( - (media instanceof HTMLImageElement && media.complete) || - (media instanceof HTMLVideoElement && media.readyState >= 1) || - (media instanceof HTMLAudioElement && media.readyState >= 1) || - (media instanceof HTMLIFrameElement && media.contentWindow) - ) { - queueMeasurement(true); - } - }); - }; - - watchMediaNode(element); - - if (typeof MutationObserver === 'function' && mutationTargets.length > 0) { - const mutationObserver = new MutationObserver((mutations) => { - let shouldMeasure = false; - mutations.forEach((mutation) => { - for (const node of mutation.addedNodes) { - watchMediaNode(node); - } - if (mutation.type === 'attributes') { - watchMediaNode(mutation.target); - } - if ( - mutation.type === 'childList' || - mutation.type === 'attributes' || - mutation.type === 'characterData' - ) { - shouldMeasure = true; - } - }); - if (shouldMeasure) { - queueMeasurement(); - } - }); - mutationTargets.forEach((target) => { - mutationObserver.observe(target, { - childList: true, - subtree: true, - attributes: true, - characterData: true, - }); - }); - registerCleanup(() => { - mutationObserver.disconnect(); - }); - } - - const handleMessage = (event) => { - if (event.source !== window.parent) { - return; - } - if (parentOrigin !== '*' && event.origin !== parentOrigin) { - return; - } - if (event?.data?.type === 'bitvid-blog-request-height') { - queueMeasurement(true); - } - }; - - window.addEventListener('message', handleMessage); - registerCleanup(() => { - window.removeEventListener('message', handleMessage); - }); - - const handleResize = () => { - queueMeasurement(); - }; - window.addEventListener('resize', handleResize); - registerCleanup(() => { - window.removeEventListener('resize', handleResize); - }); - - const handleOrientationChange = () => { - queueMeasurement(true); - }; - window.addEventListener('orientationchange', handleOrientationChange); - registerCleanup(() => { - window.removeEventListener('orientationchange', handleOrientationChange); - }); - - const handleTransitionEnd = () => { - queueMeasurement(); - }; - element.addEventListener('transitionend', handleTransitionEnd, true); - element.addEventListener('animationend', handleTransitionEnd, true); - registerCleanup(() => { - element.removeEventListener('transitionend', handleTransitionEnd, true); - element.removeEventListener('animationend', handleTransitionEnd, true); - }); - - const handleLoad = () => { - queueMeasurement(true); - }; - window.addEventListener('load', handleLoad, { once: true }); - registerCleanup(() => { - window.removeEventListener('load', handleLoad); - }); - - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { - queueMeasurement(true); - } - }; - document.addEventListener('visibilitychange', handleVisibilityChange); - registerCleanup(() => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - }); - - if (document.fonts && typeof document.fonts.addEventListener === 'function') { - const handleFontLoad = () => { - queueMeasurement(true); - }; - document.fonts.addEventListener('loadingdone', handleFontLoad); - document.fonts.addEventListener('loadingerror', handleFontLoad); - registerCleanup(() => { - document.fonts.removeEventListener('loadingdone', handleFontLoad); - document.fonts.removeEventListener('loadingerror', handleFontLoad); - }); - } - - if (document.fonts?.ready && typeof document.fonts.ready.then === 'function') { - document.fonts.ready - .then(() => { - queueMeasurement(true); - }) - .catch(() => { - queueMeasurement(true); - }); - } -} - -function formatDate(unixSeconds, { includeTime = false } = {}) { - if (!unixSeconds) { - return ''; - } - const date = new Date(unixSeconds * 1000); - const parts = new Intl.DateTimeFormat('en-US', { - day: '2-digit', - month: 'long', - year: 'numeric', - }).formatToParts(date); - - const lookup = Object.fromEntries(parts.map((part) => [part.type, part.value])); - let formatted = `${lookup.day ?? ''} ${lookup.month ?? ''} ${lookup.year ?? ''}`.trim(); - - if (includeTime) { - const time = new Intl.DateTimeFormat('en-US', { - hour: '2-digit', - minute: '2-digit', - hourCycle: 'h23', - }).format(date); - formatted = `${formatted} - ${time}`; - } - - return formatted; -} - -function truncate(text, limit) { - if (!limit || typeof text !== 'string' || text.length <= limit) { - return text; - } - return `${text.slice(0, limit).trim()}…`; -} -function createTopicsNav(topics) { - const nav = document.createElement('nav'); - nav.className = 'flex flex-wrap gap-2'; - - const label = document.createElement('span'); - label.className = 'text-sm font-semibold uppercase tracking-wide text-blog-gray-400'; - label.textContent = 'Topics'; - nav.appendChild(label); - - topics.forEach((topic) => { - const pill = document.createElement('span'); - pill.className = - 'rounded-full border border-blog-neutral-200 px-3 py-1 text-sm text-blog-gray-600 dark:border-blog-neutral-700 dark:text-blog-gray-200'; - pill.textContent = topic; - nav.appendChild(pill); - }); - - return nav; -} - -function renderTopNotes(notes) { - if (!Array.isArray(notes) || notes.length === 0) { - return null; - } - - const section = document.createElement('section'); - section.className = 'top-notes'; - - notes.forEach((note) => { - const noteContainer = document.createElement('article'); - noteContainer.className = 'note'; - - const link = document.createElement('a'); - link.href = `#${note.id}`; - link.className = 'flex flex-col gap-3'; - - if (note.image) { - const image = document.createElement('img'); - image.alt = note.title; - image.loading = 'lazy'; - image.src = note.image; - link.appendChild(image); - } - - const title = document.createElement('span'); - title.className = 'title'; - title.textContent = note.title; - link.appendChild(title); - - if (note.summary) { - const summary = document.createElement('div'); - summary.className = 'summary'; - summary.textContent = truncate(note.summary, 320); - link.appendChild(summary); - } - - const date = document.createElement('div'); - date.className = 'date'; - date.textContent = formatDate(note.createdAt); - link.appendChild(date); - - noteContainer.appendChild(link); - section.appendChild(noteContainer); - }); - - return section; -} - -function createArrowButton(direction) { - const button = document.createElement('button'); - button.type = 'button'; - const sideClass = - direction === 'next' - ? 'right-0 translate-x-1/2' - : 'left-0 -translate-x-1/2'; - button.className = `blog-carousel__control pointer-events-auto absolute top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-blog-neutral-200 bg-white/80 text-xl text-blog-gray-500 shadow-sm transition hover:text-blog-purple dark:border-blog-neutral-700 dark:bg-blog-neutral-900/80 dark:text-blog-gray-200 ${sideClass}`; - button.setAttribute('aria-label', direction === 'next' ? 'Next slide' : 'Previous slide'); - button.setAttribute('data-carousel-control', direction); - if (direction === 'next') { - button.setAttribute('data-carousel-next', 'true'); - } else { - button.setAttribute('data-carousel-prev', 'true'); - } - button.textContent = direction === 'next' ? '›' : '‹'; - return button; -} - -function renderShortNotesCarousel(notes, config) { - if (!Array.isArray(notes) || notes.length === 0) { - return null; - } - - const section = document.createElement('section'); - section.className = 'short-notes blog-carousel relative flex flex-col gap-4'; - section.setAttribute('data-carousel', 'true'); - - const heading = document.createElement('h2'); - heading.className = 'text-2xl font-semibold text-blog-gray-800 dark:text-blog-gray-100'; - heading.textContent = 'Short notes'; - section.appendChild(heading); - - const viewport = document.createElement('div'); - viewport.className = 'blog-carousel__viewport relative overflow-hidden'; - viewport.setAttribute('data-carousel-viewport', 'true'); - section.appendChild(viewport); - - const list = document.createElement('ul'); - list.className = 'blog-carousel__track'; - list.setAttribute('data-carousel-track', 'true'); - viewport.appendChild(list); - - const perPage = Math.min(notes.length, 3); - const summariesLimit = config.shortFeedSummaryMaxChars || 300; - - notes.forEach((note) => { - const item = document.createElement('li'); - item.className = - 'blog-carousel__slide flex flex-col gap-2 rounded-lg bg-blog-neutral-50 p-4 transition-colors dark:bg-blog-neutral-900'; - item.setAttribute('data-carousel-slide', 'true'); - - const link = document.createElement('a'); - link.href = `#${note.id}`; - link.className = 'flex flex-col gap-2 text-blog-gray-700 hover:text-blog-purple dark:text-blog-gray-50'; - - const date = document.createElement('div'); - date.className = 'date'; - date.textContent = formatDate(note.createdAt, { includeTime: true }); - link.appendChild(date); - - const summary = document.createElement('p'); - summary.className = 'leading-relaxed'; - summary.textContent = truncate(note.summary, summariesLimit); - link.appendChild(summary); - - item.appendChild(link); - list.appendChild(item); - }); - - const prev = createArrowButton('prev'); - const next = createArrowButton('next'); - viewport.appendChild(prev); - viewport.appendChild(next); - - const carousel = createCarousel(section, { - track: list, - slides: Array.from(list.children), - perPage, - loop: notes.length > perPage, - autoPlayInterval: CAROUSEL_AUTOPLAY_INTERVAL, - controls: { prev, next }, - }); - - if (!carousel) { - return { section, carousel: null }; - } - - const progress = mountProgressBar(section, { - initialDuration: CAROUSEL_AUTOPLAY_INTERVAL, - autoStart: false, - }); - - if (progress) { - progress.setOnComplete(() => { - advanceSlide(carousel, 1, { restartProgress: true }); - }); - - carousel.setProgress(progress); - if (carousel.autoPlayInterval > 0) { - progress.reset(); - progress.start(carousel.autoPlayInterval); - } - } - - return { section, carousel, progress: progress || null }; -} - -function renderShortNotesList(notes, config) { - if (!Array.isArray(notes) || notes.length === 0) { - return null; - } - - const section = document.createElement('section'); - section.className = 'short-notes flex flex-col gap-4'; - - const heading = document.createElement('h2'); - heading.className = 'text-2xl font-semibold text-blog-gray-800 dark:text-blog-gray-100'; - heading.textContent = 'Short notes'; - section.appendChild(heading); - - const list = document.createElement('ul'); - list.className = 'flex flex-col gap-4'; - - const summariesLimit = config.shortFeedSummaryMaxChars || 280; - - notes.forEach((note) => { - const item = document.createElement('li'); - item.className = 'rounded-lg border border-blog-neutral-200 p-4 dark:border-blog-neutral-800'; - - const link = document.createElement('a'); - link.href = `#${note.id}`; - link.className = 'flex flex-col gap-2 text-blog-gray-700 hover:text-blog-purple dark:text-blog-gray-50'; - - const date = document.createElement('div'); - date.className = 'date'; - date.textContent = formatDate(note.createdAt, { includeTime: true }); - link.appendChild(date); - - const summary = document.createElement('p'); - summary.className = 'leading-relaxed'; - summary.textContent = truncate(note.summary, summariesLimit); - link.appendChild(summary); - - item.appendChild(link); - list.appendChild(item); - }); - - section.appendChild(list); - return section; -} -function renderMainFeed(notes) { - if (!Array.isArray(notes) || notes.length === 0) { - return null; - } - - const section = document.createElement('section'); - section.className = 'flex flex-col gap-10'; - - notes.forEach((note) => { - const article = document.createElement('article'); - article.id = note.id; - article.className = - 'flex scroll-mt-24 flex-col gap-4 border-b border-blog-neutral-200 pb-8 last:border-b-0 dark:border-blog-neutral-800'; - - const header = document.createElement('header'); - header.className = 'flex flex-col gap-2'; - - const title = document.createElement('h2'); - title.className = 'text-3xl font-semibold text-blog-gray-900 dark:text-blog-gray-100'; - title.textContent = note.title; - header.appendChild(title); - - const date = document.createElement('div'); - date.className = 'text-sm text-blog-gray-400'; - date.textContent = formatDate(note.createdAt, { includeTime: true }); - header.appendChild(date); - - if (Array.isArray(note.topics) && note.topics.length > 0) { - const tags = document.createElement('ul'); - tags.className = 'flex flex-wrap gap-2'; - note.topics.forEach((topic) => { - const tag = document.createElement('li'); - tag.className = 'rounded-full bg-blog-neutral-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-blog-gray-500 dark:bg-blog-neutral-900 dark:text-blog-gray-300'; - tag.textContent = topic; - tags.appendChild(tag); - }); - header.appendChild(tags); - } - - article.appendChild(header); - - if (note.image) { - const figure = document.createElement('figure'); - figure.className = 'overflow-hidden rounded-lg'; - const image = document.createElement('img'); - image.src = note.image; - image.alt = note.title; - image.loading = 'lazy'; - figure.appendChild(image); - article.appendChild(figure); - } - - if (note.summary) { - const body = document.createElement('p'); - body.className = 'text-lg leading-relaxed text-blog-gray-700 dark:text-blog-gray-200'; - body.textContent = note.summary; - article.appendChild(body); - } - - section.appendChild(article); - }); - - return section; -} - -function extractZapthreadsOptions(element) { - const attributeMap = [ - ['anchor', 'anchor'], - ['title', 'title'], - ['disable', 'disable'], - ['urls', 'urls'], - ['reply-placeholder', 'replyPlaceholder'], - ['legacy-url', 'legacyUrl'], - ]; - - const options = {}; - attributeMap.forEach(([attr, key]) => { - const value = element.getAttribute(attr); - if (value === null) { - return; - } - if (key === 'legacyUrl') { - options[key] = value !== 'false'; - } else { - options[key] = value; - } - }); - return options; -} - -function initializeZapthreads({ registerCleanup, isDestroyed, initZapthreadsEmbed }) { - if (typeof initZapthreadsEmbed !== 'function') { - return; - } - const elements = document.querySelectorAll('zap-threads'); - if (!elements.length) { - return; - } - - elements.forEach((element) => { - const options = extractZapthreadsOptions(element); - try { - const maybeDispose = initZapthreadsEmbed({ root: element, options }); - if (!maybeDispose) { - return; - } - - if (typeof maybeDispose.then === 'function') { - maybeDispose - .then((dispose) => { - if (typeof dispose !== 'function') { - return; - } - if (isDestroyed()) { - try { - dispose(); - } catch (error) { - userLogger.error('[blog] zapthreads cleanup failed', error); - } - return; - } - registerCleanup(() => { - try { - dispose(); - } catch (error) { - userLogger.error('[blog] zapthreads cleanup failed', error); - } - }); - }) - .catch((error) => { - userLogger.error('[blog] failed to initialise zapthreads embed', error); - }); - return; - } - - if (typeof maybeDispose === 'function') { - if (isDestroyed()) { - try { - maybeDispose(); - } catch (error) { - userLogger.error('[blog] zapthreads cleanup failed', error); - } - return; - } - registerCleanup(() => { - try { - maybeDispose(); - } catch (error) { - userLogger.error('[blog] zapthreads cleanup failed', error); - } - }); - } - } catch (error) { - userLogger.error('[blog] failed to start zapthreads embed', error); - } - }); -} - -function mountBlogApp({ initZapthreadsEmbed = defaultInitZapthreadsEmbed } = {}) { - const runtime = { - destroyed: false, - cleanupCallbacks: new Set(), - sliderStates: new Map(), - }; - - const registerCleanup = (callback) => { - if (typeof callback !== 'function') { - return () => {}; - } - if (runtime.destroyed) { - try { - callback(); - } catch (error) { - userLogger.error('[blog] cleanup callback failed', error); - } - return () => {}; - } - runtime.cleanupCallbacks.add(callback); - return () => { - runtime.cleanupCallbacks.delete(callback); - }; - }; - - const destroy = () => { - if (runtime.destroyed) { - return; - } - runtime.destroyed = true; - runtime.cleanupCallbacks.forEach((callback) => { - try { - callback(); - } catch (error) { - userLogger.error('[blog] cleanup callback failed', error); - } - }); - runtime.cleanupCallbacks.clear(); - runtime.sliderStates.clear(); - }; - - const root = document.querySelector('.blog-app-root'); - if (!root) { - return destroy; - } - - const config = readBlogConfig(); - const notes = resolveNotes().slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); - - const hero = document.createElement('header'); - hero.className = 'flex flex-col gap-4'; - - const heading = document.createElement('h1'); - heading.className = 'text-4xl font-bold text-blog-gray-900 dark:text-blog-gray-100'; - heading.textContent = 'bitvid updates'; - hero.appendChild(heading); - - const subheading = document.createElement('p'); - subheading.className = 'max-w-2xl text-lg text-blog-gray-600 dark:text-blog-gray-300'; - subheading.textContent = - 'Shipping notes and release highlights from the decentralized video network. Expect incremental drops, roadmap checkpoints, and moderation changelogs.'; - hero.appendChild(subheading); - - if (config.topics.length > 0) { - hero.appendChild(createTopicsNav(config.topics)); - } - - const main = document.createElement('main'); - main.className = 'mx-auto flex w-full max-w-5xl flex-col gap-12 px-4 py-12 scroll-pt-24'; - main.appendChild(hero); - - const topNotes = config.topNotes > 0 ? notes.slice(0, config.topNotes) : []; - if (topNotes.length > 0) { - const topSection = renderTopNotes(topNotes); - if (topSection) { - main.appendChild(topSection); - } - } - - const remaining = config.topNotes > 0 ? notes.slice(config.topNotes) : notes; - const shortNotesCandidates = remaining.filter((note) => { - const length = typeof note.summary === 'string' ? note.summary.length : 0; - return length >= config.shortNotesMinChars; - }); - - const registerSlider = (section, { carousel, progress }) => { - if (!section || !carousel) { - return; - } - - const sliderState = { - container: section, - carousel, - progress, - pausedByPointer: false, - pausedByVisibility: document.visibilityState === 'hidden', - }; - - runtime.sliderStates.set(section, sliderState); - - const pause = () => { - if (sliderState.progress && typeof sliderState.progress.stop === 'function') { - sliderState.progress.stop(); - } - }; - - const resume = () => { - if (!sliderState.progress || typeof sliderState.progress.start !== 'function') { - return; - } - if (sliderState.carousel.autoPlayInterval <= 0) { - return; - } - sliderState.progress.reset?.(); - sliderState.progress.start(sliderState.carousel.autoPlayInterval); - }; - - const updatePlayback = () => { - if (sliderState.pausedByPointer || sliderState.pausedByVisibility) { - pause(); - return; - } - resume(); - }; - - const handlePointerEnter = () => { - sliderState.pausedByPointer = true; - pause(); - }; - - const handlePointerLeave = () => { - sliderState.pausedByPointer = false; - updatePlayback(); - }; - - section.addEventListener('pointerenter', handlePointerEnter); - section.addEventListener('pointerleave', handlePointerLeave); - registerCleanup(() => { - section.removeEventListener('pointerenter', handlePointerEnter); - section.removeEventListener('pointerleave', handlePointerLeave); - }); - - const handleVisibilityChange = () => { - sliderState.pausedByVisibility = document.visibilityState === 'hidden'; - updatePlayback(); - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - registerCleanup(() => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - }); - - if (sliderState.pausedByVisibility) { - pause(); - } - - registerCleanup(() => { - runtime.sliderStates.delete(section); - destroyCarousel(sliderState.carousel); - }); - }; - - if (config.shortNotesMode === 'carousel') { - const rendered = renderShortNotesCarousel(shortNotesCandidates, config); - if (rendered?.section) { - main.appendChild(rendered.section); - if (rendered.carousel) { - registerSlider(rendered.section, rendered); - } - } - } else if (config.shortNotesMode === 'main') { - const shortNotesSection = renderShortNotesList(shortNotesCandidates, config); - if (shortNotesSection) { - main.appendChild(shortNotesSection); - } - } - - const feed = renderMainFeed(remaining); - if (feed) { - main.appendChild(feed); - } - - root.replaceChildren(main); - setupEmbedHeightBroadcast({ root, registerCleanup }); - - registerCleanup(() => { - root.replaceChildren(); - }); - - initializeZapthreads({ - registerCleanup, - isDestroyed: () => runtime.destroyed, - initZapthreadsEmbed, - }); - - return destroy; -} - -let activeTeardown = null; - -export function bootstrapBlog(options = {}) { - if (typeof activeTeardown === 'function') { - activeTeardown(); - } - const destroy = mountBlogApp(options); - activeTeardown = () => { - destroy(); - activeTeardown = null; - }; - return activeTeardown; -} - -export function teardownBlog() { - if (typeof activeTeardown === 'function') { - activeTeardown(); - } -} - diff --git a/js/blog/progressBar.js b/js/blog/progressBar.js deleted file mode 100644 index fb5ff4bb..00000000 --- a/js/blog/progressBar.js +++ /dev/null @@ -1,126 +0,0 @@ -export function mountProgressBar( - container, - { initialDuration = 0, autoStart = false, onComplete } = {}, -) { - if (!container) { - return null; - } - - const wrapper = document.createElement('div'); - wrapper.className = 'blog-carousel__progress blog-progress mt-2'; - wrapper.dataset.state = 'idle'; - wrapper.setAttribute('data-carousel-progress', 'true'); - - const meter = document.createElement('progress'); - meter.className = 'progress progress--blog'; - meter.max = 100; - meter.value = 0; - meter.dataset.progress = '0'; - meter.dataset.state = 'idle'; - meter.setAttribute('data-progress-meter', 'true'); - meter.setAttribute('data-variant', 'blog'); - meter.setAttribute('aria-label', 'Carousel progress'); - meter.setAttribute('aria-valuetext', 'Carousel progress 0% complete'); - wrapper.appendChild(meter); - container.appendChild(wrapper); - - let duration = Math.max(0, initialDuration); - let rafId = null; - let startTimestamp = null; - let completeHandler = typeof onComplete === 'function' ? onComplete : null; - - function cancelAnimation() { - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } - } - - function setState(state) { - wrapper.dataset.state = state; - meter.dataset.state = state; - } - - function updateMeter(ratio) { - const clamped = Math.max(0, Math.min(1, ratio)); - const percent = Math.round(clamped * 100); - meter.value = percent; - meter.dataset.progress = String(percent); - meter.setAttribute('aria-valuetext', `Carousel progress ${percent}% complete`); - return clamped; - } - - function stop() { - cancelAnimation(); - startTimestamp = null; - setState('paused'); - } - - function reset() { - stop(); - updateMeter(0); - setState('idle'); - } - - function step(timestamp) { - if (startTimestamp === null) { - startTimestamp = timestamp; - } - const elapsed = timestamp - startTimestamp; - const ratio = duration > 0 ? Math.min(1, elapsed / duration) : 1; - const progress = updateMeter(ratio); - - if (progress >= 1) { - cancelAnimation(); - startTimestamp = null; - setState('complete'); - if (completeHandler) { - completeHandler(); - } - return; - } - - setState('active'); - rafId = requestAnimationFrame(step); - } - - function start(nextDuration = duration) { - duration = Math.max(0, nextDuration); - reset(); - if (duration === 0) { - updateMeter(1); - setState('complete'); - if (completeHandler) { - completeHandler(); - } - return; - } - setState('active'); - rafId = requestAnimationFrame(step); - } - - function destroy() { - cancelAnimation(); - wrapper.remove(); - } - - function setOnComplete(handler) { - completeHandler = typeof handler === 'function' ? handler : null; - } - - if (autoStart && duration > 0) { - start(duration); - } else { - reset(); - } - - return { - element: wrapper, - meter, - start, - stop, - reset, - destroy, - setOnComplete, - }; -} diff --git a/js/blog/zapthreadsEmbed.js b/js/blog/zapthreadsEmbed.js deleted file mode 100644 index e8ae9397..00000000 --- a/js/blog/zapthreadsEmbed.js +++ /dev/null @@ -1,1293 +0,0 @@ -import { escapeHTML } from '../utils/domUtils.js'; -import { userLogger } from "../utils/logger.js"; - -/** - * Responsibilities mirrored from the legacy DS component that ships inside - * blog.html: - * - * - Normalise anchors coming from HTTP URLs, note/nevent IDs, or naddr - * references. - * - Resolve relay inputs (with defaults) and build the correct Nostr filter for - * whichever anchor type we are showing. - * - Hydrate the thread from cache, refresh it from the network, and keep the - * subscription lifecycle tidy so repeated mounts don’t leak listeners. - * - Expose cache clearing, watermark/title rendering, and anchor version updates - * to the host document while dispatching mount/unmount events for observers. - */ - -function createSvg(pathD, { viewBox, className } = {}) { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - if (viewBox) { - svg.setAttribute('viewBox', viewBox); - } - if (className) { - svg.setAttribute('class', className); - } - - if (pathD) { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', pathD); - svg.appendChild(path); - } - - return svg; -} - -export function createZapthreadsCommentInfoPicture() { - const container = document.createElement('div'); - container.className = 'ztr-comment-info-picture'; - - const image = document.createElement('img'); - container.appendChild(image); - - return container; -} - -export function createZapthreadsReplyForm() { - const form = document.createElement('div'); - form.className = 'ztr-reply-form'; - - const textarea = document.createElement('textarea'); - form.appendChild(textarea); - - const controls = document.createElement('div'); - controls.className = 'ztr-reply-controls'; - form.appendChild(controls); - - return form; -} - -export function createZapthreadsPublishingDisabledMessage() { - const span = document.createElement('span'); - span.textContent = 'Publishing is disabled'; - return span; -} - -export function createZapthreadsReplyErrorMessage() { - const span = document.createElement('span'); - span.className = 'ztr-reply-error'; - span.textContent = 'Error: '; - return span; -} - -export function createZapthreadsSpinner() { - const svg = createSvg(null, { - viewBox: '0 0 50 50', - className: 'ztr-spinner', - }); - - const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('class', 'path'); - circle.setAttribute('cx', '25'); - circle.setAttribute('cy', '25'); - circle.setAttribute('r', '20'); - circle.setAttribute('fill', 'none'); - circle.setAttribute('stroke-width', '5'); - svg.appendChild(circle); - - return svg; -} - -export function createZapthreadsReplyAsButton() { - const button = document.createElement('button'); - button.className = 'ztr-reply-button'; - button.textContent = 'Reply as '; - return button; -} - -export function createZapthreadsReplyAnonymouslyButton() { - const button = document.createElement('button'); - button.className = 'ztr-reply-button'; - button.textContent = 'Reply anonymously'; - return button; -} - -export function createZapthreadsReplyLoginButton() { - const button = document.createElement('button'); - button.className = 'ztr-reply-login-button'; - button.textContent = 'Log in'; - return button; -} - -export function createZapthreadsLikeAction() { - const item = document.createElement('li'); - item.className = 'ztr-comment-action-like'; - - const span = document.createElement('span'); - span.textContent = ' likes'; - item.appendChild(span); - - return item; -} - -export function createZapthreadsZapAction() { - const item = document.createElement('li'); - item.className = 'ztr-comment-action-zap'; - - const span = document.createElement('span'); - span.textContent = ' sats'; - item.appendChild(span); - - return item; -} - -export function createZapthreadsNewCommentContainer() { - const wrapper = document.createElement('div'); - wrapper.className = 'ztr-comment-new'; - - const body = document.createElement('div'); - body.className = 'ztr-comment-body'; - wrapper.appendChild(body); - - const actions = document.createElement('ul'); - actions.className = 'ztr-comment-actions'; - body.appendChild(actions); - - return wrapper; -} - -export function createZapthreadsThreadContainer() { - const div = document.createElement('div'); - div.className = 'ztr-thread'; - return div; -} - -export function createZapthreadsReplyActionItem() { - const item = document.createElement('li'); - item.className = 'ztr-comment-action-reply'; - - const span = document.createElement('span'); - item.appendChild(span); - - return item; -} - -export function createZapthreadsCommentContainer() { - const root = document.createElement('div'); - root.className = 'ztr-comment'; - - const body = document.createElement('div'); - body.className = 'ztr-comment-body'; - root.appendChild(body); - - const infoWrapper = document.createElement('div'); - infoWrapper.className = 'ztr-comment-info-wrapper'; - body.appendChild(infoWrapper); - - const info = document.createElement('div'); - info.className = 'ztr-comment-info'; - infoWrapper.appendChild(info); - - info.appendChild(createZapthreadsCommentInfoPicture()); - - const infoItems = document.createElement('ul'); - infoItems.className = 'ztr-comment-info-items'; - info.appendChild(infoItems); - - const authorItem = document.createElement('li'); - authorItem.className = 'ztr-comment-info-author'; - infoItems.appendChild(authorItem); - - const authorLink = document.createElement('a'); - authorLink.target = '_blank'; - authorItem.appendChild(authorLink); - - const nowrap = document.createElement('span'); - nowrap.className = 'ztr-nowrap'; - const strong = document.createElement('strong'); - strong.textContent = ' ed'; - nowrap.appendChild(strong); - nowrap.appendChild(document.createTextNode(' ')); - authorItem.appendChild(nowrap); - - const dotsItem = document.createElement('li'); - infoItems.appendChild(dotsItem); - const dotsLink = document.createElement('a'); - dotsLink.className = 'ztr-comment-info-dots'; - dotsItem.appendChild(dotsLink); - - const metaList = document.createElement('ul'); - metaList.className = 'ztr-comment-info-items'; - infoWrapper.appendChild(metaList); - - const truncatedText = document.createElement('div'); - truncatedText.className = 'ztr-comment-text'; - body.appendChild(truncatedText); - - const fullText = document.createElement('div'); - fullText.className = 'ztr-comment-text'; - body.appendChild(fullText); - - const actions = document.createElement('ul'); - actions.className = 'ztr-comment-actions'; - body.appendChild(actions); - - return root; -} - -export function createZapthreadsBulletSeparator() { - const item = document.createElement('li'); - item.textContent = '●'; - return item; -} - -export function createZapthreadsRepliesLabel() { - const item = document.createElement('li'); - item.textContent = ' repl'; - return item; -} - -export function createZapthreadsActionCountItem() { - const item = document.createElement('li'); - const span = document.createElement('span'); - item.appendChild(span); - return item; -} - -export function createZapthreadsInfoPane() { - const container = document.createElement('div'); - container.className = 'ztr-info-pane'; - - const link = document.createElement('a'); - link.target = '_blank'; - container.appendChild(link); - - const small = document.createElement('small'); - small.textContent = 'Event data'; - link.appendChild(small); - - return container; -} - -export function createZapthreadsCrossPostWarning() { - const paragraph = document.createElement('p'); - paragraph.className = 'warning'; - - const span = document.createElement('span'); - span.textContent = 'This is a that referenced this article in '; - paragraph.appendChild(span); - - const link = document.createElement('a'); - link.textContent = 'another thread'; - span.appendChild(link); - - return paragraph; -} - -export function createZapthreadsContentMaybeChangedWarning() { - const paragraph = document.createElement('p'); - paragraph.className = 'warning'; - - const span = document.createElement('span'); - span.textContent = 'Article contents may have changed since this was made'; - paragraph.appendChild(span); - - return paragraph; -} - -export function createZapthreadsContentChangedWarning() { - const paragraph = document.createElement('p'); - paragraph.className = 'warning'; - - const span = document.createElement('span'); - span.textContent = 'Article contents changed since this was made'; - paragraph.appendChild(span); - - return paragraph; -} - -export function createZapthreadsCommentExpand() { - const container = document.createElement('div'); - container.className = 'ztr-comment-expand'; - - const link = document.createElement('a'); - container.appendChild(link); - - const span = document.createElement('span'); - span.textContent = 'Show full comment'; - container.appendChild(span); - - return container; -} - -export function createZapthreadsRepliesContainer() { - const container = document.createElement('div'); - container.className = 'ztr-comment-replies'; - return container; -} - -export function createZapthreadsMessageIcon() { - return createSvg( - 'M 12.6030 50.4905 C 13.3758 50.4905 13.9307 50.1140 14.8621 49.2421 L 20.6483 43.8720 C 19.5188 42.9803 18.6073 41.5733 18.6073 38.3433 L 18.6073 25.2052 C 18.6073 19.1217 22.3129 15.5152 28.3766 15.5152 L 42.2479 15.5152 L 42.2281 14.7622 C 41.9306 10.6999 39.2557 8.0643 34.7177 8.0643 L 7.5301 8.0643 C 2.9922 8.0643 0 10.7791 0 15.4954 L 0 34.9548 C 0 39.6710 2.9922 42.7028 7.5301 42.7028 L 10.8195 42.7028 L 10.8195 48.4693 C 10.8195 49.6979 11.4735 50.4905 12.6030 50.4905 Z M 44.6058 53.2450 C 45.7353 53.2450 46.3895 52.4325 46.3895 51.2237 L 46.3895 45.4374 L 48.4702 45.4374 C 53.0078 45.4374 56 42.4056 56 37.7092 L 56 25.6610 C 56 20.9250 53.0078 18.2300 48.4702 18.2300 L 28.8522 18.2300 C 24.1161 18.2300 21.3221 20.9250 21.3221 25.6610 L 21.3221 37.7092 C 21.3221 42.4056 24.1161 45.4374 28.8522 45.4374 L 35.1735 45.4374 L 42.3470 51.9767 C 43.2784 52.8487 43.8331 53.2450 44.6058 53.2450 Z', - { viewBox: '0 -6 60 60' } - ); -} - -export function createZapthreadsLightningIcon() { - return createSvg( - 'M349.4 44.6c5.9-13.7 1.5-29.7-10.6-38.5s-28.6-8-39.9 1.8l-256 224c-10 8.8-13.6 22.9-8.9 35.3S50.7 288 64 288H175.5L98.6 467.4c-5.9 13.7-1.5 29.7 10.6 38.5s28.6 8 39.9-1.8l256-224c10-8.8 13.6-22.9 8.9-35.3s-16.6-20.7-30-20.7H272.5L349.4 44.6z', - { viewBox: '-120 -80 528 588' } - ); -} - -export function createZapthreadsHeartIcon() { - return createSvg( - 'M60.732 29.7C41.107 29.7 22 39.7 22 67.41c0 27.29 45.274 67.29 74 94.89 28.744-27.6 74-67.6 74-94.89 0-27.71-19.092-37.71-38.695-37.71C116 29.7 104.325 41.575 96 54.066 87.638 41.516 76 29.7 60.732 29.7z', - { viewBox: '0 -16 180 180' } - ); -} - -export function createZapthreadsQuoteIcon() { - return createSvg( - 'M168 80c-13.3 0-24 10.7-24 24V408c0 8.4-1.4 16.5-4.1 24H440c13.3 0 24-10.7 24-24V104c0-13.3-10.7-24-24-24H168zM72 480c-39.8 0-72-32.2-72-72V112C0 98.7 10.7 88 24 88s24 10.7 24 24V408c0 13.3 10.7 24 24 24s24-10.7 24-24V104c0-39.8 32.2-72 72-72H440c39.8 0 72 32.2 72 72V408c0 39.8-32.2 72-72 72H72zM176 136c0-13.3 10.7-24 24-24h96c13.3 0 24 10.7 24 24v80c0 13.3-10.7 24-24 24H200c-13.3 0-24-10.7-24-24V136zm200-24h32c13.3 0 24 10.7 24 24s-10.7 24-24 24H376c-13.3 0-24-10.7-24-24s10.7-24 24-24zm0 80h32c13.3 0 24 10.7 24 24s-10.7 24-24 24H376c-13.3 0-24-10.7-24-24s10.7-24 24-24zM200 272H408c13.3 0 24 10.7 24 24s-10.7 24-24 24H200c-13.3 0-24-10.7-24-24s10.7-24 24-24zm0 80H408c13.3 0 24 10.7 24 24s-10.7 24-24 24H200c-13.3 0-24-10.7-24-24s10.7-24 24-24z', - { viewBox: '0 0 576 512' } - ); -} - -export function createZapthreadsEllipsisIcon() { - return createSvg( - 'M8 256a56 56 0 1 1 112 0A56 56 0 1 1 8 256zm160 0a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm216-56a56 56 0 1 1 0 112 56 56 0 1 1 0-112z', - { viewBox: '0 -200 560 640' } - ); -} - -export function createZapthreadsPlayIcon() { - return createSvg( - 'M246.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-128-128c-9.2-9.2-22.9-11.9-34.9-6.9s-19.8 16.6-19.8 29.6l0 256c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l128-128z', - { viewBox: '0 -50 256 512' } - ); -} - -export function createZapthreadsReplyIcon() { - return createSvg( - 'M137.4 374.6c12.5 12.5 32.8 12.5 45.3 0l128-128c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8L32 192c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l128 128z', - { viewBox: '0 -50 320 512' } - ); -} - -export function createZapthreadsAlertIcon() { - const svg = createSvg( - 'M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24V296c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z', - { viewBox: '0 0 512 512' } - ); - return svg; -} - -const ZAPTHREADS_MOUNT_CLASS = 'blog-ztr-container'; - -function removeExistingZapthreadsMount(blogRoot) { - const existingRoot = blogRoot.querySelector('#ztr-root'); - if (!existingRoot) { - return; - } - - const mountContainer = existingRoot.closest(`.${ZAPTHREADS_MOUNT_CLASS}`); - if (mountContainer && mountContainer.parentNode) { - mountContainer.parentNode.removeChild(mountContainer); - return; - } - - existingRoot.remove(); -} - -function createZapthreadsMountContainer(blogRoot) { - const container = document.createElement('div'); - container.className = ZAPTHREADS_MOUNT_CLASS; - blogRoot.appendChild(container); - return container; -} - -export function createZapthreadsRoot() { - const root = document.createElement('div'); - root.id = 'ztr-root'; - - const watermark = document.createElement('div'); - watermark.className = 'blog-ztr-watermark'; - root.appendChild(watermark); - - return root; -} - -export function createZapthreadsContent() { - const content = document.createElement('div'); - content.id = 'ztr-content'; - return content; -} - -export function createZapthreadsErrorHeading() { - const heading = document.createElement('h1'); - heading.textContent = 'Error!'; - return heading; -} - -export function createZapthreadsErrorDetails() { - const container = document.createElement('div'); - container.className = 'ztr-comment-text'; - - const pre = document.createElement('pre'); - container.appendChild(pre); - - const message = document.createElement('p'); - message.textContent = - 'Only properly formed NIP-19 naddr, note and nevent encoded entities and URLs are supported.'; - container.appendChild(message); - - return container; -} - -export function createZapthreadsTitleHeading() { - const heading = document.createElement('h2'); - heading.id = 'ztr-title'; - return heading; -} - -export const DEFAULT_THREAD_TITLE = 'Thread'; - -export function renderThreadTitle(titleEl, title = DEFAULT_THREAD_TITLE) { - if (!titleEl) return; - titleEl.innerHTML = escapeHTML(title || DEFAULT_THREAD_TITLE); -} - -export function createZapthreadsPoweredByMessage() { - const paragraph = document.createElement('p'); - paragraph.appendChild(document.createTextNode('Powered by ')); - - const link = document.createElement('a'); - link.href = 'https://github.com/fr4nzap/zapthreads'; - link.textContent = 'zapthreads'; - paragraph.appendChild(link); - - return paragraph; -} - -export function createZapthreadsClearCacheButton() { - const button = document.createElement('button'); - button.textContent = 'Clear cache'; - return button; -} - -export function createZapthreadsAnchorVersionLabel() { - const paragraph = document.createElement('p'); - paragraph.textContent = 'Anchor version: '; - return paragraph; -} - -export const DEFAULT_RELAYS = [ - 'wss://relay.damus.io', - 'wss://nos.lol', -]; - -export const DEFAULT_DISABLE_FEATURES = []; - -export function parseRelayList(relayList = '') { - if (!relayList.trim()) { - return [...DEFAULT_RELAYS]; - } - - const relays = []; - - for (const item of relayList.split(',')) { - const trimmed = item.trim(); - if (!trimmed) continue; - - try { - relays.push(new URL(trimmed).toString()); - } catch (error) { - userLogger.warn('[zapthreads] ignored invalid relay input', trimmed, error); - } - } - - return relays.length > 0 ? relays : [...DEFAULT_RELAYS]; -} - -export function parseDisableList(disableList = '') { - return disableList - .split(',') - .map((item) => item.trim()) - .filter(Boolean); -} - -export function parseUrlOptions(urls = '') { - if (!urls || typeof urls !== 'string') { - return {}; - } - - try { - const parsed = JSON.parse(urls); - if (parsed && typeof parsed === 'object') { - return parsed; - } - } catch (error) { - // Ignore JSON parse errors and fall back to manual parsing below. - } - - const map = {}; - for (const segment of urls.split(',')) { - const [rawKey, rawValue] = segment.split('='); - if (!rawKey || !rawValue) continue; - const key = rawKey.trim(); - const value = rawValue.trim(); - if (!key || !value) continue; - map[key] = value; - } - - return map; -} - -export function normalizeAnchor(anchor, { legacyUrl = false } = {}) { - const trimmed = (anchor || '').trim(); - - if (!trimmed) { - return { type: 'error', value: 'Missing anchor value' }; - } - - try { - if (trimmed.startsWith('http')) { - const normalized = legacyUrl ? trimmed : normalizeHttpAnchor(trimmed); - return { type: 'http', value: normalized }; - } - - const decoded = decodeNostrEntity(trimmed); - - if (!decoded) { - return { type: 'error', value: `Unsupported anchor: ${trimmed}` }; - } - - if (decoded.type === 'note' || decoded.type === 'nevent') { - return { type: 'note', value: decoded.id }; - } - - if (decoded.type === 'naddr') { - const { kind, pubkey, identifier } = decoded; - return { type: 'naddr', value: `${kind}:${pubkey}:${identifier}` }; - } - - return { type: 'error', value: `Unsupported anchor type: ${decoded.type}` }; - } catch (error) { - return { type: 'error', value: `Malformed anchor: ${trimmed}`, error }; - } -} - -export function createState(options = {}) { - const { - anchor = '', - legacyUrl = false, - relays = '', - disable = '', - version = '', - } = options; - - const normalizedAnchor = normalizeAnchor(anchor, { legacyUrl }); - - return { - anchor: normalizedAnchor, - version, - relays: parseRelayList(relays), - disableFeatures: parseDisableList(disable), - legacyUrl, - filter: {}, - rootEventIds: [], - anchorAuthor: undefined, - subscription: null, - eventMap: new Map(), - events: [], - }; -} - -export function updateFilterForAnchor(state) { - if (!state || !state.anchor) { - return; - } - - const { anchor, rootEventIds, version } = state; - - if (anchor.type === 'http' || anchor.type === 'note') { - if (!rootEventIds.length) { - state.filter = {}; - return; - } - - state.filter = { '#e': [...rootEventIds] }; - return; - } - - if (anchor.type === 'naddr') { - state.filter = { '#a': [anchor.value] }; - state.version = version || rootEventIds[0] || ''; - } -} - -export function updateStateFromEvents( - state, - events = [], - { parseEvent = Na } = {} -) { - if (!state || !Array.isArray(events) || events.length === 0) { - return []; - } - - const parsedEvents = []; - const rootIds = new Set(state.rootEventIds); - const parser = typeof parseEvent === 'function' ? parseEvent : null; - - if (!state.eventMap || typeof state.eventMap.set !== 'function') { - state.eventMap = new Map(); - } - - for (const event of events) { - if (!event) continue; - const parsed = parser ? parser(event) : event; - if (!parsed) continue; - - parsedEvents.push(parsed); - - const rootId = parsed.ro || parsed.id || event.id; - if (rootId) { - rootIds.add(rootId); - } - - if ( - !state.anchorAuthor && - (!parsed.ro || parsed.ro === parsed.id) && - parsed.pk - ) { - state.anchorAuthor = parsed.pk; - } - - const eventId = parsed.id || event.id; - if (!eventId) { - continue; - } - - const previous = state.eventMap.get(eventId) || {}; - state.eventMap.set(eventId, { - ...previous, - ...parsed, - }); - } - - if (rootIds.size > 0) { - state.rootEventIds = [...rootIds]; - } - - if (!state.version && state.rootEventIds.length > 0) { - state.version = state.rootEventIds[0]; - } - - updateFilterForAnchor(state); - - state.events = Array.from(state.eventMap.values()).sort((a, b) => { - const aTs = typeof a.ts === 'number' ? a.ts : 0; - const bTs = typeof b.ts === 'number' ? b.ts : 0; - return bTs - aTs; - }); - - return parsedEvents; -} - -function normalizeBaseUrl(baseUrl) { - if (!baseUrl) { - return 'https://njump.me/'; - } - return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; -} - -function createProfileUrl(pubkey, baseUrl) { - if (!pubkey) { - return '#'; - } - const normalized = normalizeBaseUrl(baseUrl); - return `${normalized}${pubkey}`; -} - -function createEventUrl(eventId, baseUrl) { - if (!eventId) { - return '#'; - } - const normalized = normalizeBaseUrl(baseUrl); - return `${normalized}${eventId}`; -} - -export function formatEventTimestamp(timestamp) { - if (!timestamp && timestamp !== 0) { - return ''; - } - - const date = new Date(timestamp * 1000); - if (Number.isNaN(date.getTime())) { - return ''; - } - - try { - return new Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(date); - } catch (error) { - return date.toISOString(); - } -} - -export function formatAuthorName(pubkey) { - if (!pubkey) { - return 'Unknown'; - } - - const trimmed = pubkey.trim(); - if (trimmed.length <= 16) { - return trimmed; - } - - return `${trimmed.slice(0, 8)}…${trimmed.slice(-8)}`; -} - -function truncateContent(content = '', limit = 280) { - const text = content.trim(); - if (text.length <= limit) { - return text; - } - return `${text.slice(0, Math.max(0, limit - 1))}…`; -} - -function convertContentToHtml(content = '') { - return escapeHTML(content).replace(/\n/g, '
'); -} - -function isFeatureDisabled(feature, disabled = []) { - return Array.isArray(disabled) && disabled.includes(feature); -} - -export function renderThread( - threadEl, - events = [], - { - anchor, - disableFeatures = [], - urlOptions = {}, - } = {}, -) { - if (!threadEl) { - return; - } - - clearElement(threadEl); - - const list = Array.isArray(events) ? [...events] : []; - if (list.length === 0) { - const empty = document.createElement('p'); - empty.className = 'ztr-thread-empty'; - empty.textContent = 'No comments yet.'; - threadEl.appendChild(empty); - return; - } - - const profileBaseUrl = normalizeBaseUrl( - urlOptions.profile || urlOptions.profileBase || 'https://njump.me/', - ); - const eventBaseUrl = normalizeBaseUrl( - urlOptions.event || urlOptions.eventBase || urlOptions.profile || 'https://njump.me/', - ); - - list.sort((a, b) => { - const aTs = typeof a.ts === 'number' ? a.ts : 0; - const bTs = typeof b.ts === 'number' ? b.ts : 0; - return bTs - aTs; - }); - - for (const event of list) { - const comment = createZapthreadsCommentContainer(); - const pictureWrapper = comment.querySelector('.ztr-comment-info-picture'); - const pictureImage = pictureWrapper ? pictureWrapper.querySelector('img') : null; - if (pictureImage) { - pictureImage.alt = 'User avatar'; - pictureImage.decoding = 'async'; - pictureImage.loading = 'lazy'; - if (event.picture) { - pictureImage.src = event.picture; - } else { - pictureImage.removeAttribute('src'); - } - } - - const authorLink = comment.querySelector('.ztr-comment-info-author a'); - if (authorLink) { - authorLink.href = createProfileUrl(event.pk, profileBaseUrl); - authorLink.textContent = formatAuthorName(event.pk); - authorLink.rel = 'noopener'; - authorLink.target = '_blank'; - } - - const authorBadge = comment.querySelector('.ztr-comment-info-author strong'); - if (authorBadge) { - authorBadge.textContent = ' posted'; - } - - const dotsLink = comment.querySelector('.ztr-comment-info-dots'); - if (dotsLink) { - dotsLink.href = createEventUrl(event.id, eventBaseUrl); - dotsLink.textContent = 'View'; - dotsLink.target = '_blank'; - dotsLink.rel = 'noopener'; - } - - const infoLists = comment.querySelectorAll('.ztr-comment-info-items'); - const metaList = infoLists[1]; - if (metaList) { - clearElement(metaList); - const timestampItem = document.createElement('li'); - timestampItem.textContent = formatEventTimestamp(event.ts); - metaList.appendChild(timestampItem); - - if (event.ro && anchor && anchor.value && event.ro !== anchor.value) { - const bullet = createZapthreadsBulletSeparator(); - metaList.appendChild(bullet); - const crossPost = createZapthreadsRepliesLabel(); - crossPost.textContent = 'Cross-post'; - metaList.appendChild(crossPost); - } - } - - const [truncatedText, fullText] = comment.querySelectorAll('.ztr-comment-text'); - if (truncatedText) { - truncatedText.innerHTML = convertContentToHtml(truncateContent(event.c)); - } - if (fullText) { - fullText.innerHTML = convertContentToHtml(event.c); - } - - const actions = comment.querySelector('.ztr-comment-actions'); - if (actions) { - clearElement(actions); - - if (!isFeatureDisabled('reply', disableFeatures)) { - const replyAction = createZapthreadsReplyActionItem(); - const replySpan = replyAction.querySelector('span'); - if (replySpan) { - replySpan.textContent = 'Reply'; - } - actions.appendChild(replyAction); - } - - if (!isFeatureDisabled('likes', disableFeatures)) { - const likeAction = createZapthreadsLikeAction(); - const likeSpan = likeAction.querySelector('span'); - if (likeSpan) { - const count = typeof event.likeCount === 'number' ? event.likeCount : 0; - likeSpan.textContent = `${count} likes`; - } - actions.appendChild(likeAction); - } - - if (!isFeatureDisabled('zaps', disableFeatures)) { - const zapAction = createZapthreadsZapAction(); - const zapSpan = zapAction.querySelector('span'); - if (zapSpan) { - const total = typeof event.zapTotal === 'number' ? event.zapTotal : 0; - zapSpan.textContent = `${total} sats`; - } - actions.appendChild(zapAction); - } - } - - const infoPane = createZapthreadsInfoPane(); - const infoLink = infoPane.querySelector('a'); - if (infoLink) { - infoLink.href = createEventUrl(event.id, eventBaseUrl); - infoLink.rel = 'noopener'; - infoLink.target = '_blank'; - } - comment.appendChild(infoPane); - - threadEl.appendChild(comment); - } -} - -export async function loadRootEvents({ - state, - data, - onCache, - onNetwork, -}) { - if (!state || !data) return { cache: [], network: [] }; - - const { anchor, relays } = state; - - if (!anchor || anchor.type === 'error') { - return { cache: [], network: [] }; - } - - const cacheResults = []; - const networkResults = []; - - if (anchor.type === 'http') { - const cached = await data.Ti('events', anchor.value, { index: 'r' }); - cacheResults.push(...cached); - } else if (anchor.type === 'note') { - const cached = await data.Ti('events', anchor.value, { index: 'id' }); - cacheResults.push(...cached); - } else if (anchor.type === 'naddr') { - const cached = await data.Ti('events', anchor.value, { index: 'a' }); - cacheResults.push(...cached); - } - - if (onCache) { - onCache(cacheResults); - } - - if (data.di && typeof data.di.querySync === 'function') { - const filter = createFilterForAnchor(state); - const queryResults = await data.di.querySync(relays, filter); - networkResults.push(...queryResults); - if (onNetwork) { - onNetwork(queryResults); - } - } - - return { cache: cacheResults, network: networkResults }; -} - -export function createFilterForAnchor(state) { - const { anchor, rootEventIds, legacyUrl } = state; - - if (!anchor) return {}; - - if (anchor.type === 'http') { - const values = [anchor.value]; - if (!legacyUrl) { - values.push(`${anchor.value}/`); - } - return { '#r': values, kinds: [1, 8812] }; - } - - if (anchor.type === 'note') { - return { ids: [anchor.value] }; - } - - if (anchor.type === 'naddr') { - return { '#a': [anchor.value] }; - } - - return {}; -} - -export function decodeNostrEntity(entity) { - try { - const { decode } = window.NostrTools?.nip19 ?? {}; - if (!decode) return null; - const result = decode(entity); - if (!result) return null; - - switch (result.type) { - case 'nevent': - return { type: 'note', id: result.data.id }; - case 'note': - return { type: 'note', id: result.data }; - case 'naddr': { - const { kind, pubkey, identifier } = result.data; - return { type: 'naddr', kind, pubkey, identifier }; - } - default: - return { type: result.type }; - } - } catch (error) { - return { type: 'error', error }; - } - - return null; -} - -export function normalizeHttpAnchor(url) { - try { - const normalized = new URL(url); - normalized.hash = ''; - return normalized.toString().replace(/\/$/, ''); - } catch (error) { - throw error; - } -} - -export function renderWatermark(watermarkEl) { - if (!watermarkEl) return; - watermarkEl.textContent = ''; - const icon = createZapthreadsEllipsisIcon(); - icon.classList.add('ztr-watermark-icon'); - watermarkEl.appendChild(icon); - const label = document.createElement('span'); - label.textContent = ' Zapthreads'; - watermarkEl.appendChild(label); -} - -export function renderAnchorVersion(labelEl, version) { - if (!labelEl) return; - labelEl.innerHTML = `Anchor version: ${escapeHTML(version || 'unknown')}`; -} - -export const ZAPTHREADS_EVENT_MOUNTED = 'zapthreads:mounted'; -export const ZAPTHREADS_EVENT_UNMOUNTED = 'zapthreads:unmounted'; -export const ZAPTHREADS_EVENT_CLEAR_CACHE = 'zapthreads:clear-cache'; - -export function renderClearCache(buttonEl, clearCache) { - if (!buttonEl) return () => {}; - const handler = () => { - if (typeof clearCache === 'function') { - clearCache(); - } - buttonEl.dispatchEvent( - new CustomEvent(ZAPTHREADS_EVENT_CLEAR_CACHE, { - bubbles: true, - detail: { source: 'button' }, - }) - ); - }; - buttonEl.addEventListener('click', handler); - return () => { - buttonEl.removeEventListener('click', handler); - }; -} - -export function clearElement(element) { - if (!element) return; - while (element.firstChild) { - element.removeChild(element.firstChild); - } -} - -function getBlogAppRoot() { - return document.querySelector('.blog-app-root'); -} - -function dispatchZapthreadsEvent(targets, type, detail) { - for (const target of targets) { - if (!target || typeof target.dispatchEvent !== 'function') continue; - target.dispatchEvent( - new CustomEvent(type, { - bubbles: true, - detail: detail ? { ...detail } : {}, - }) - ); - } -} - -export async function initZapthreadsEmbed({ - root, - options, - dataUtils, -} = {}) { - if (!root) { - throw new Error('initZapthreadsEmbed requires a root element'); - } - - const data = dataUtils || {}; - const state = createState(options); - const urlOptions = parseUrlOptions( - options && typeof options.urls === 'string' ? options.urls : '', - ); - - const blogRoot = getBlogAppRoot(); - if (!blogRoot) { - throw new Error('initZapthreadsEmbed requires .blog-app-root container'); - } - - const rootEl = createZapthreadsRoot(); - - const watermarkEl = rootEl.querySelector('.blog-ztr-watermark'); - renderWatermark(watermarkEl); - - const contentEl = createZapthreadsContent(); - rootEl.appendChild(contentEl); - - const titleEl = createZapthreadsTitleHeading(); - contentEl.appendChild(titleEl); - let currentTitle = DEFAULT_THREAD_TITLE; - let hasCustomTitle = false; - renderThreadTitle(titleEl, currentTitle); - - const poweredByEl = createZapthreadsPoweredByMessage(); - contentEl.appendChild(poweredByEl); - - const anchorVersionEl = createZapthreadsAnchorVersionLabel(); - contentEl.appendChild(anchorVersionEl); - renderAnchorVersion(anchorVersionEl, state.version); - - const clearCacheButton = createZapthreadsClearCacheButton(); - contentEl.appendChild(clearCacheButton); - - const threadEl = createZapthreadsThreadContainer(); - contentEl.appendChild(threadEl); - renderThread(threadEl, state.events, { - anchor: state.anchor, - disableFeatures: state.disableFeatures, - urlOptions, - }); - - const disposeCallbacks = new Set(); - const registerDispose = (callback) => { - if (typeof callback === 'function') { - disposeCallbacks.add(callback); - } - }; - - const clearCacheCleanup = renderClearCache(clearCacheButton, data.clearCache); - registerDispose(clearCacheCleanup); - - removeExistingZapthreadsMount(blogRoot); - const mountContainer = createZapthreadsMountContainer(blogRoot); - mountContainer.appendChild(rootEl); - - const lifecycleTargets = [rootEl]; - if (root && root !== rootEl && typeof root.dispatchEvent === 'function') { - lifecycleTargets.push(root); - } - - const lifecycleDetail = () => ({ anchor: state.anchor, root: rootEl }); - - const renderError = (message) => { - clearElement(contentEl); - const heading = createZapthreadsErrorHeading(); - heading.textContent = 'Error!'; - const details = createZapthreadsErrorDetails(); - const pre = details.querySelector('pre'); - if (pre) { - pre.innerHTML = escapeHTML(message || ''); - } - contentEl.appendChild(heading); - contentEl.appendChild(details); - }; - - if (state.anchor.type === 'error') { - renderError(state.anchor.value); - dispatchZapthreadsEvent(lifecycleTargets, ZAPTHREADS_EVENT_MOUNTED, lifecycleDetail()); - return () => { - disposeCallbacks.forEach((callback) => { - try { - callback(); - } catch (error) { - userLogger.error('[zapthreads] cleanup failed', error); - } - }); - disposeCallbacks.clear(); - dispatchZapthreadsEvent( - lifecycleTargets, - ZAPTHREADS_EVENT_UNMOUNTED, - lifecycleDetail() - ); - if (mountContainer.parentNode) { - mountContainer.parentNode.removeChild(mountContainer); - } - }; - } - - dispatchZapthreadsEvent( - lifecycleTargets, - ZAPTHREADS_EVENT_MOUNTED, - lifecycleDetail() - ); - - updateFilterForAnchor(state); - renderAnchorVersion(anchorVersionEl, state.version); - - const parseEvent = typeof data.Na === 'function' ? data.Na : null; - - let currentSubscription = null; - let currentFilterKey = null; - const closeSubscription = () => { - if (currentSubscription && typeof currentSubscription.close === 'function') { - try { - currentSubscription.close(); - } catch (error) { - userLogger.error('[zapthreads] failed to close subscription', error); - } - } - currentSubscription = null; - currentFilterKey = null; - state.subscription = null; - }; - registerDispose(closeSubscription); - - const processEvents = (events = []) => { - const parsedEvents = updateStateFromEvents(state, events, { parseEvent }); - if (parsedEvents.length > 0) { - const eventWithTitle = parsedEvents.find((item) => item && item.tl); - if (eventWithTitle) { - hasCustomTitle = true; - currentTitle = eventWithTitle.tl || DEFAULT_THREAD_TITLE; - renderThreadTitle(titleEl, currentTitle); - } - } else if (!hasCustomTitle) { - renderThreadTitle(titleEl, currentTitle); - } - renderAnchorVersion(anchorVersionEl, state.version); - renderThread(threadEl, state.events, { - anchor: state.anchor, - disableFeatures: state.disableFeatures, - urlOptions, - }); - return parsedEvents; - }; - - const ensureSubscription = () => { - if (!data.di || typeof data.di.subscribeMany !== 'function') return; - if (!state.filter || Object.keys(state.filter).length === 0) return; - - const nextKey = JSON.stringify(state.filter); - if (currentSubscription && currentFilterKey === nextKey) { - return; - } - - closeSubscription(); - currentFilterKey = nextKey; - currentSubscription = data.di.subscribeMany(state.relays, [state.filter], { - onevent(event) { - if (!event) return; - processEvents([event]); - if (typeof data.un === 'function') { - data.un('events', event, { immediate: true }); - } - }, - }); - state.subscription = currentSubscription; - }; - - await loadRootEvents({ - state, - data, - onCache(events = []) { - processEvents(events); - ensureSubscription(); - }, - onNetwork(events = []) { - if (!Array.isArray(events) || events.length === 0) { - ensureSubscription(); - return; - } - processEvents(events); - for (const event of events) { - if (event && typeof data.un === 'function') { - data.un('events', event, { immediate: true }); - } - } - ensureSubscription(); - }, - }); - - ensureSubscription(); - - return () => { - disposeCallbacks.forEach((callback) => { - try { - callback(); - } catch (error) { - userLogger.error('[zapthreads] cleanup failed', error); - } - }); - disposeCallbacks.clear(); - dispatchZapthreadsEvent( - lifecycleTargets, - ZAPTHREADS_EVENT_UNMOUNTED, - lifecycleDetail() - ); - if (mountContainer.parentNode) { - mountContainer.parentNode.removeChild(mountContainer); - } - }; -} diff --git a/js/config.js b/js/config.js index 77088cbb..d0a7e501 100644 --- a/js/config.js +++ b/js/config.js @@ -6,6 +6,12 @@ import { ADMIN_SUPER_NPUB, ADMIN_DM_IMAGE_URL, BITVID_WEBSITE_URL, + BLOG_URL, + COMMUNITY_URL, + NOSTR_URL, + GITHUB_URL, + BETA_URL, + DNS_URL, MAX_WALLET_DEFAULT_ZAP, PLATFORM_FEE_PERCENT, PLATFORM_LUD16_OVERRIDE, @@ -43,6 +49,12 @@ export const ADMIN_LIST_MODE = "nostr"; export { ADMIN_SUPER_NPUB }; export { ADMIN_DM_IMAGE_URL }; export { BITVID_WEBSITE_URL }; +export { BLOG_URL }; +export { COMMUNITY_URL }; +export { NOSTR_URL }; +export { GITHUB_URL }; +export { BETA_URL }; +export { DNS_URL }; export { MAX_WALLET_DEFAULT_ZAP }; export { PLATFORM_FEE_PERCENT }; export { PLATFORM_LUD16_OVERRIDE }; diff --git a/js/index.js b/js/index.js index 17c7f8e2..58f6f6a2 100644 --- a/js/index.js +++ b/js/index.js @@ -19,6 +19,14 @@ import { openStaticModal, closeStaticModal, } from "./ui/components/staticModalAccessibility.js"; +import { + BLOG_URL, + COMMUNITY_URL, + NOSTR_URL, + GITHUB_URL, + BETA_URL, + DNS_URL, +} from "./config.js"; validateInstanceConfig(); @@ -28,6 +36,29 @@ initThemeController(); let application = null; let applicationReadyPromise = Promise.resolve(); +const bindOptionalExternalLink = ({ selector, url, label }) => { + const element = document.querySelector(selector); + const sanitizedUrl = typeof url === "string" ? url.trim() : ""; + + if (!(element instanceof HTMLAnchorElement)) { + if (sanitizedUrl) { + userLogger.warn( + `${label} not found; skipping external link binding.`, + ); + } + return; + } + + if (!sanitizedUrl) { + element.remove(); + return; + } + + element.href = sanitizedUrl; + element.target = "_blank"; + element.rel = "noopener noreferrer"; +}; + function startApplication() { if (application) { return applicationReadyPromise; @@ -209,6 +240,42 @@ async function bootstrapInterface() { await loadSidebar("components/sidebar.html", "sidebarContainer"); devLogger.log("Sidebar loaded."); + bindOptionalExternalLink({ + selector: "[data-blog-link]", + url: BLOG_URL, + label: "Sidebar blog link", + }); + + bindOptionalExternalLink({ + selector: "[data-nostr-link]", + url: NOSTR_URL, + label: "Sidebar Nostr link", + }); + + bindOptionalExternalLink({ + selector: "[data-community-link]", + url: COMMUNITY_URL, + label: "Sidebar community link", + }); + + bindOptionalExternalLink({ + selector: "[data-github-link]", + url: GITHUB_URL, + label: "Sidebar GitHub link", + }); + + bindOptionalExternalLink({ + selector: "[data-beta-link]", + url: BETA_URL, + label: "Sidebar Beta link", + }); + + bindOptionalExternalLink({ + selector: "[data-dns-link]", + url: DNS_URL, + label: "Sidebar DNS link", + }); + const sidebar = document.getElementById("sidebar"); const collapseToggle = document.getElementById("sidebarCollapseToggle"); if (sidebar && !sidebar.hasAttribute("data-footer-state")) { diff --git a/scripts/check-design-tokens.mjs b/scripts/check-design-tokens.mjs index aff78855..44773994 100755 --- a/scripts/check-design-tokens.mjs +++ b/scripts/check-design-tokens.mjs @@ -9,7 +9,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); -const TARGET_DIRECTORIES = ["js/blog", "js/ui", "torrent/ui"]; +const TARGET_DIRECTORIES = ["js/ui", "torrent/ui"]; const TARGET_FILES = ["js/channelProfile.js", "css/tailwind.source.css"]; const IGNORED_DIRECTORIES = new Set(["dist", "vendor"]); const IGNORED_EXTENSIONS = new Set([".min.js", ".map"]); diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 9413bd2c..b0ba9a99 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -3,7 +3,6 @@ const typography = require("@tailwindcss/typography"); module.exports = { content: [ "./index.html", - "./blog.html", "./components/**/*.html", "./views/**/*.html", "./js/**/*.js", @@ -158,53 +157,7 @@ module.exports = { 700: "var(--color-neutral-700)" }, white: "var(--color-white)", - black: "var(--color-black)", - blog: { - transparent: "var(--color-blog-transparent)", - "cyan-500": "var(--color-blog-cyan-500)", - "sky-500": "var(--color-blog-sky-500)", - "teal-600": "var(--color-blog-teal-600)", - "green-800": "var(--color-blog-green-800)", - "green-700": "var(--color-blog-green-700)", - "teal-700": "var(--color-blog-teal-700)", - "gray-900": "var(--color-blog-gray-900)", - "coffee-900": "var(--color-blog-coffee-900)", - "green-500": "var(--color-blog-green-500)", - "gray-800": "var(--color-blog-gray-800)", - "gray-700": "var(--color-blog-gray-700)", - "teal-accent": "var(--color-blog-teal-accent)", - "gray-650": "var(--color-blog-gray-650)", - "brown-700": "var(--color-blog-brown-700)", - "purple-900": "var(--color-blog-purple-900)", - "gray-500": "var(--color-blog-gray-500)", - "gray-450": "var(--color-blog-gray-450)", - "gray-440": "var(--color-blog-gray-440)", - "brown-500": "var(--color-blog-brown-500)", - "rust-700": "var(--color-blog-rust-700)", - "purple-700": "var(--color-blog-purple-700)", - "purple-accent": "var(--color-blog-purple-accent)", - "purple-muted": "var(--color-blog-purple-muted)", - "gray-300": "var(--color-blog-gray-300)", - "purple-500": "var(--color-blog-purple-500)", - "gray-275": "var(--color-blog-gray-275)", - "red-600": "var(--color-blog-red-600)", - "rust-600": "var(--color-blog-rust-600)", - error: "var(--color-blog-error)", - "gray-220": "var(--color-blog-gray-220)", - "gray-210": "var(--color-blog-gray-210)", - "gray-200": "var(--color-blog-gray-200)", - "amber-500": "var(--color-blog-amber-500)", - "gray-190": "var(--color-blog-gray-190)", - "orange-600": "var(--color-blog-orange-600)", - "gray-180": "var(--color-blog-gray-180)", - "gray-170": "var(--color-blog-gray-170)", - "red-500": "var(--color-blog-red-500)", - "amber-400": "var(--color-blog-amber-400)", - "neutral-100": "var(--color-blog-neutral-100)", - "neutral-120": "var(--color-blog-neutral-120)", - "orange-500": "var(--color-blog-orange-500)", - "neutral-50": "var(--color-blog-neutral-50)" - } + black: "var(--color-black)" }, textColor: { primary: "var(--text-primary)", @@ -351,15 +304,11 @@ module.exports = { menu: "var(--menu-min-width)", "grid-card": "var(--grid-card-min-width)" }, - width: { - "blog-fluid": "var(--blog-layout-fluid-width)" - }, maxWidth: { modal: "var(--modal-max-width)", player: "var(--layout-player-max-width)", docs: "var(--layout-docs-max-width)", - "popover-safe": "var(--popover-inline-safe-max)", - "blog-shell": "var(--blog-layout-max-width)" + "popover-safe": "var(--popover-inline-safe-max)" }, translate: { toast: "var(--toast-translate)" diff --git a/tests/blog-carousel.test.mjs b/tests/blog-carousel.test.mjs deleted file mode 100644 index d4719b5e..00000000 --- a/tests/blog-carousel.test.mjs +++ /dev/null @@ -1,297 +0,0 @@ -import assert from 'node:assert/strict'; -import { beforeEach, afterEach, describe, test } from 'node:test'; - -import { advanceSlide, createCarousel, destroyCarousel } from '../js/blog/carousel.js'; -import { mountProgressBar } from '../js/blog/progressBar.js'; -import { createBlogDomEnvironment, createSpy } from './test-helpers/blog-dom.mjs'; - -function assertHasClasses(element, classes, messagePrefix = 'element') { - classes.forEach((className) => { - assert.ok( - element.classList.contains(className), - `${messagePrefix} should include the “${className}” class`, - ); - }); -} - -function assertNoInlineStyleAttributes(elements, messagePrefix = 'element') { - elements.forEach((element, index) => { - assert.ok(element, `${messagePrefix} at index ${index} should exist`); - assert.strictEqual( - element.hasAttribute('style'), - false, - `${messagePrefix} at index ${index} should not set inline style attributes`, - ); - }); -} - -const CAROUSEL_HTML = ` - -`; - -const PROGRESS_HTML = '
'; - -describe('blog carousel', () => { - let env; - let container; - let track; - let slides; - let prevButton; - let nextButton; - - beforeEach(() => { - env = createBlogDomEnvironment(CAROUSEL_HTML); - const doc = env.document; - container = doc.querySelector('[data-carousel]'); - track = container.querySelector('[data-carousel-track]'); - slides = Array.from(track.children); - prevButton = container.querySelector('[data-carousel-prev]'); - nextButton = container.querySelector('[data-carousel-next]'); - - env.inlineStyleGuard.guard(container); - env.inlineStyleGuard.guard(track); - env.inlineStyleGuard.guard(prevButton); - env.inlineStyleGuard.guard(nextButton); - slides.forEach((slide) => env.inlineStyleGuard.guard(slide)); - }); - - afterEach(() => { - env.cleanup(); - }); - - test('initializes slides with data attributes and accessibility state', () => { - const state = createCarousel(container, { - perPage: 1, - loop: true, - controls: { prev: prevButton, next: nextButton }, - }); - - assert.ok(state, 'state should be created when container markup is valid'); - assert.strictEqual(state.perPage, 1); - assert.strictEqual(state.slides.length, 3); - assert.strictEqual(state.pageCount, 3); - - assert.strictEqual(container.dataset.index, '0'); - assert.strictEqual(container.dataset.perPage, '1'); - assert.strictEqual(container.dataset.count, '3'); - assert.strictEqual(container.dataset.state, 'active'); - assert.strictEqual(track.dataset.transition, 'auto'); - - assertNoInlineStyleAttributes([container, track, ...slides], 'carousel element'); - - slides.forEach((slide, index) => { - assert.strictEqual(slide.dataset.index, String(index)); - assert.strictEqual(slide.dataset.active, index === 0 ? 'true' : 'false'); - assert.strictEqual(slide.dataset.inert, index === 0 ? 'false' : 'true'); - assert.strictEqual(slide.getAttribute('aria-hidden'), index === 0 ? 'false' : 'true'); - }); - - state.goTo(2); - assert.strictEqual(state.index, 2); - assert.strictEqual(container.dataset.index, '2'); - assert.strictEqual(slides[2].dataset.active, 'true'); - assert.strictEqual(slides[0].dataset.active, 'false'); - assert.strictEqual(slides[2].getAttribute('aria-hidden'), 'false'); - assert.strictEqual(slides[0].getAttribute('aria-hidden'), 'true'); - - destroyCarousel(state); - }); - - test('control buttons advance slides and respect looping', () => { - const state = createCarousel(container, { - perPage: 1, - loop: true, - controls: { prev: prevButton, next: nextButton }, - }); - - nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true })); - assert.strictEqual(state.index, 1); - nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true })); - assert.strictEqual(state.index, 2); - nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true })); - assert.strictEqual(state.index, 0, 'looping should wrap forward navigation to the first slide'); - - prevButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true })); - assert.strictEqual(state.index, 2, 'looping should wrap backward navigation to the last slide'); - - destroyCarousel(state); - }); - - test('auto play progress resets when navigation occurs', () => { - const state = createCarousel(container, { - perPage: 1, - controls: { prev: prevButton, next: nextButton }, - }); - const events = []; - const progress = { - reset: () => events.push('reset'), - start: (duration) => events.push(['start', duration]), - destroy: () => events.push('destroy'), - }; - state.setProgress(progress); - state.autoPlayInterval = 4200; - - state.goTo(1); - assert.deepStrictEqual(events, ['reset', ['start', 4200]]); - - events.length = 0; - state.goTo(2, { restartProgress: false }); - assert.deepStrictEqual(events, [], 'progress should not restart when restartProgress is false'); - - destroyCarousel(state); - }); - - test('destroyCarousel tears down listeners and progress state', () => { - const state = createCarousel(container, { - perPage: 1, - controls: { prev: prevButton, next: nextButton }, - }); - const progressEvents = []; - const progress = { - reset: () => progressEvents.push('reset'), - start: () => progressEvents.push('start'), - destroy: () => progressEvents.push('destroy'), - }; - state.setProgress(progress); - state.autoPlayInterval = 2000; - state.goTo(1); - - destroyCarousel(state); - assert.ok(progressEvents.includes('destroy'), 'progress.destroy should be called during teardown'); - assert.ok(!container.classList.contains('is-initialized')); - assert.ok(!container.classList.contains('is-active')); - assert.strictEqual(state.cleanup.length, 0); - - assertNoInlineStyleAttributes([container, track, ...slides], 'carousel element'); - - const previousIndex = state.index; - nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true })); - assert.strictEqual(state.index, previousIndex, 'clicks after destroy should not change the index'); - assert.strictEqual(container.dataset.index, undefined); - }); - - test('advanceSlide delegates to goTo with relative offsets', () => { - const state = createCarousel(container, { perPage: 1 }); - const goToSpy = createSpy(); - state.goTo = goToSpy; - state.index = 2; - - advanceSlide(state, -1, { restartProgress: false }); - assert.strictEqual(goToSpy.callCount, 1); - assert.deepStrictEqual(goToSpy.calls[0], [1, { restartProgress: false }]); - }); -}); - - -describe('progress bar', () => { - let env; - let container; - - beforeEach(() => { - env = createBlogDomEnvironment(PROGRESS_HTML); - container = env.document.getElementById('progress-root'); - env.inlineStyleGuard.guard(container); - }); - - afterEach(() => { - env.cleanup(); - }); - - test('mountProgressBar renders semantic progress meter without style injection', () => { - const progress = mountProgressBar(container, { initialDuration: 500, autoStart: false }); - assert.ok(progress, 'progress instance should be returned'); - - const wrapper = container.querySelector('.blog-carousel__progress'); - assert.ok(wrapper, 'wrapper element should be appended'); - assertHasClasses(wrapper, ['blog-carousel__progress', 'blog-progress', 'mt-2'], 'progress wrapper'); - assert.strictEqual(wrapper.dataset.carouselProgress, 'true'); - assert.strictEqual(wrapper.getAttribute('data-carousel-progress'), 'true'); - const meter = wrapper.querySelector('progress.progress--blog'); - assert.ok(meter, 'progress meter should be created'); - assertHasClasses(meter, ['progress', 'progress--blog'], 'progress meter'); - assert.strictEqual(meter.max, 100, 'progress meter should expose a 0-100 range'); - assert.strictEqual(meter.value, 0, 'progress meter should start at zero'); - assert.strictEqual(meter.dataset.state, 'idle'); - assert.strictEqual(wrapper.dataset.state, 'idle'); - assert.strictEqual(meter.dataset.progress, '0'); - assert.strictEqual( - meter.getAttribute('aria-valuetext'), - 'Carousel progress 0% complete', - 'progress meter should emit accessible status text', - ); - assert.strictEqual(meter.getAttribute('data-progress-meter'), 'true'); - assert.strictEqual(meter.dataset.progressMeter, 'true'); - assert.strictEqual(meter.getAttribute('data-variant'), 'blog'); - - progress.reset(); - - assertNoInlineStyleAttributes([container, wrapper, meter], 'progress element'); - }); - - test('autoStart runs animation and triggers completion callback', () => { - const onComplete = createSpy(); - const progress = mountProgressBar(container, { initialDuration: 600, autoStart: true, onComplete }); - const wrapper = progress.element; - const meter = wrapper.querySelector('progress.progress--blog'); - assert.ok(meter, 'progress meter should exist when autoStart is enabled'); - - assertNoInlineStyleAttributes([container, wrapper, meter], 'progress element'); - - env.raf.step(0); - env.raf.step(300); - assert.strictEqual(onComplete.callCount, 0); - assert.ok(meter.value > 0 && meter.value < 100, 'meter should report intermediate progress'); - assert.strictEqual(meter.dataset.state, 'active'); - - env.raf.step(300); - assert.strictEqual(onComplete.callCount, 1); - assert.strictEqual(meter.value, 100); - assert.strictEqual(meter.dataset.state, 'complete'); - assert.strictEqual(meter.dataset.progress, '100'); - }); - - test('manual controls support stop, restart, and cleanup', () => { - const onComplete = createSpy(); - const progress = mountProgressBar(container, { initialDuration: 800, onComplete }); - const meter = progress.element.querySelector('progress.progress--blog'); - - progress.start(800); - env.raf.step(0); - env.raf.step(400); - assert.strictEqual(meter.value, 50, 'halfway through should yield a 50% reading'); - assert.strictEqual(meter.dataset.state, 'active'); - - progress.stop(); - assert.strictEqual(meter.dataset.state, 'paused'); - const pausedValue = meter.value; - - env.raf.step(600); - assert.strictEqual(meter.value, pausedValue, 'progress should not advance after stop'); - assert.strictEqual(onComplete.callCount, 0, 'stop should cancel pending completion'); - - progress.setOnComplete(onComplete); - progress.start(0); - assert.strictEqual(onComplete.callCount, 1, 'zero duration should fire completion immediately'); - assert.strictEqual(meter.value, 100); - assert.strictEqual(meter.dataset.state, 'complete'); - - progress.destroy(); - assert.strictEqual(container.children.length, 0, 'destroy should remove wrapper from DOM'); - assert.strictEqual( - container.querySelector('[data-carousel-progress]'), - null, - 'destroy should remove declarative progress markup', - ); - assertNoInlineStyleAttributes([container], 'progress container'); - }); -}); diff --git a/tests/test-helpers/blog-dom.mjs b/tests/test-helpers/blog-dom.mjs deleted file mode 100644 index d23893e3..00000000 --- a/tests/test-helpers/blog-dom.mjs +++ /dev/null @@ -1,205 +0,0 @@ -import { JSDOM } from 'jsdom'; - -const DOM_GLOBALS = [ - 'window', - 'document', - 'HTMLElement', - 'HTMLStyleElement', - 'Element', - 'Node', - 'Event', - 'MouseEvent', - 'CustomEvent', - 'KeyboardEvent', - 'MutationObserver', - 'getComputedStyle', -]; - -export function createSpy(implementation = () => {}) { - function spy(...args) { - spy.calls.push(args); - spy.callCount += 1; - return implementation(...args); - } - spy.calls = []; - spy.callCount = 0; - return spy; -} - -export function createAnimationFrameStub() { - let now = 0; - let handle = 0; - const queue = new Map(); - - function requestAnimationFrame(callback) { - handle += 1; - const id = handle; - queue.set(id, callback); - return id; - } - - function cancelAnimationFrame(id) { - queue.delete(id); - } - - function step(elapsed = 0) { - now += elapsed; - const callbacks = Array.from(queue.entries()); - queue.clear(); - for (const [, callback] of callbacks) { - callback(now); - } - } - - return { - requestAnimationFrame, - cancelAnimationFrame, - step, - get now() { - return now; - }, - get pending() { - return queue.size; - }, - }; -} - -export function installInlineStyleGuard(document) { - const originalCreateElement = document.createElement.bind(document); - const ElementConstructor = document.defaultView?.Element; - const guardedElements = new WeakSet(); - const attributePatches = []; - - if (ElementConstructor?.prototype) { - const prototype = ElementConstructor.prototype; - - if (typeof prototype.setAttribute === 'function') { - const original = prototype.setAttribute; - prototype.setAttribute = function inlineStyleSafeSetAttribute(name, value) { - if (typeof name === 'string' && name.toLowerCase() === 'style') { - throw new Error( - 'Inline style attributes are disabled in blog widget tests. Use CSS classes or data attributes instead.', - ); - } - return original.call(this, name, value); - }; - attributePatches.push(() => { - prototype.setAttribute = original; - }); - } - - if (typeof prototype.setAttributeNS === 'function') { - const original = prototype.setAttributeNS; - prototype.setAttributeNS = function inlineStyleSafeSetAttributeNS(namespace, name, value) { - if (typeof name === 'string' && name.toLowerCase() === 'style') { - throw new Error( - 'Inline style attributes are disabled in blog widget tests. Use CSS classes or data attributes instead.', - ); - } - return original.call(this, namespace, name, value); - }; - attributePatches.push(() => { - prototype.setAttributeNS = original; - }); - } - } - - function guard(element) { - if (!element || typeof element !== 'object') { - return element; - } - - if (guardedElements.has(element)) { - return element; - } - - Object.defineProperty(element, 'style', { - get() { - throw new Error( - 'Inline style access is disabled in blog widget tests. Use CSS classes or data attributes instead.', - ); - }, - set() { - throw new Error( - 'Inline style assignment is disabled in blog widget tests. Use CSS classes or data attributes instead.', - ); - }, - configurable: true, - }); - - guardedElements.add(element); - - if (element.children?.length) { - Array.from(element.children).forEach((child) => guard(child)); - } - - return element; - } - - document.createElement = function createElementGuarded(...args) { - const element = originalCreateElement(...args); - guard(element); - return element; - }; - - return { - guard, - restore() { - document.createElement = originalCreateElement; - attributePatches.forEach((restorePatch) => { - try { - restorePatch(); - } catch (error) { - // ignore failures to restore in tests - } - }); - }, - }; -} - -export function createBlogDomEnvironment(html) { - const dom = new JSDOM(html, { url: 'https://example.com' }); - const { window } = dom; - const previousGlobals = new Map(); - - for (const key of DOM_GLOBALS) { - previousGlobals.set(key, globalThis[key]); - const nextValue = window[key]; - if (typeof nextValue !== 'undefined') { - globalThis[key] = nextValue; - } else { - delete globalThis[key]; - } - } - - const inlineStyleGuard = installInlineStyleGuard(window.document); - const raf = createAnimationFrameStub(); - - previousGlobals.set('requestAnimationFrame', globalThis.requestAnimationFrame); - previousGlobals.set('cancelAnimationFrame', globalThis.cancelAnimationFrame); - - globalThis.requestAnimationFrame = raf.requestAnimationFrame; - globalThis.cancelAnimationFrame = raf.cancelAnimationFrame; - window.requestAnimationFrame = raf.requestAnimationFrame; - window.cancelAnimationFrame = raf.cancelAnimationFrame; - - return { - window, - document: window.document, - dom, - raf, - inlineStyleGuard, - cleanup() { - inlineStyleGuard.restore(); - dom.window.close(); - - for (const [key, value] of previousGlobals) { - if (typeof value === 'undefined') { - delete globalThis[key]; - } else { - globalThis[key] = value; - } - } - }, - }; -} diff --git a/views/blog.html b/views/blog.html deleted file mode 100644 index 50af79a6..00000000 --- a/views/blog.html +++ /dev/null @@ -1,129 +0,0 @@ - -
-
-
-

- Latest Blog Posts -

-
- - -
-
-
-