diff --git a/blog.html b/blog.html index 87894025..cff78c9d 100644 --- a/blog.html +++ b/blog.html @@ -43,28701 +43,9 @@
diff --git a/js/blog/carousel.js b/js/blog/carousel.js new file mode 100644 index 00000000..ce608ac4 --- /dev/null +++ b/js/blog/carousel.js @@ -0,0 +1,124 @@ +import { applyDynamicStyles } from '../ui/styleSystem.js'; + +function toArray(value) { + if (!value) { + return []; + } + return Array.isArray(value) ? value : Array.from(value); +} + +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 state = { + container, + track, + slides, + perPage, + loop: Boolean(options.loop), + autoPlayInterval: Math.max(0, options.autoPlayInterval || 0), + index: 0, + progress: null, + cleanup: [], + setProgress(progressInstance) { + this.progress = progressInstance || null; + }, + }; + + container.classList.add('splide', 'is-initialized'); + container.classList.add('is-active'); + applyDynamicStyles(track, { transition: 'transform 420ms ease' }, { slot: 'transition' }); + + const basis = 100 / perPage; + slides.forEach((slide) => { + slide.classList.add('splide__slide'); + applyDynamicStyles(slide, { flex: `0 0 ${basis}%`, maxWidth: `${basis}%` }, { slot: 'size' }); + }); + + function updateActiveSlides() { + const offset = (state.index * 100) / state.perPage; + applyDynamicStyles(track, { transform: `translateX(-${offset}%)` }, { slot: 'position' }); + + state.slides.forEach((slide, idx) => { + const inRange = idx >= state.index && idx < state.index + state.perPage; + slide.classList.toggle('is-active', inRange); + slide.setAttribute('aria-hidden', inRange ? 'false' : 'true'); + }); + } + + state.goTo = function goTo(targetIndex, { restartProgress = true } = {}) { + const maxIndex = Math.max(0, state.slides.length - state.perPage); + let nextIndex = targetIndex; + + if (nextIndex < 0) { + nextIndex = state.loop ? maxIndex : 0; + } else if (nextIndex > maxIndex) { + nextIndex = state.loop ? 0 : maxIndex; + } + + state.index = nextIndex; + updateActiveSlides(); + + if (restartProgress && state.autoPlayInterval > 0 && state.progress) { + state.progress.reset(); + state.progress.start(state.autoPlayInterval); + } + }; + + const prevHandler = (event) => { + event?.preventDefault(); + advanceSlide(state, -1); + }; + const nextHandler = (event) => { + event?.preventDefault(); + advanceSlide(state, 1); + }; + + if (options.controls?.prev) { + options.controls.prev.addEventListener('click', prevHandler); + state.cleanup.push(() => options.controls.prev.removeEventListener('click', prevHandler)); + } + + if (options.controls?.next) { + options.controls.next.addEventListener('click', nextHandler); + state.cleanup.push(() => options.controls.next.removeEventListener('click', nextHandler)); + } + + state.goTo(state.index, { restartProgress: false }); + 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) { + console.warn('Failed to dispose carousel listener', error); + } + }); + state.cleanup = []; + + if (state.progress && typeof state.progress.destroy === 'function') { + state.progress.destroy(); + } + + if (state.container) { + state.container.classList.remove('is-initialized'); + state.container.classList.remove('is-active'); + } +} diff --git a/js/blog/index.js b/js/blog/index.js new file mode 100644 index 00000000..2c47cfa3 --- /dev/null +++ b/js/blog/index.js @@ -0,0 +1,556 @@ +import { createCarousel, advanceSlide, destroyCarousel } from './carousel.js'; +import { mountProgressBar } from './progressBar.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 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'; + button.className = `splide__arrow splide__arrow--${direction} flex h-10 w-10 items-center justify-center rounded-full border border-blog-neutral-200 text-xl text-blog-gray-500 transition hover:text-blog-purple dark:border-blog-neutral-700 dark:text-blog-gray-200`; + button.setAttribute('aria-label', direction === 'next' ? 'Next slide' : 'Previous slide'); + 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 splide 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 trackWrapper = document.createElement('div'); + trackWrapper.className = 'splide__track overflow-hidden'; + section.appendChild(trackWrapper); + + const list = document.createElement('ul'); + list.className = 'splide__list flex gap-4'; + list.setAttribute('data-carousel-track', 'true'); + trackWrapper.appendChild(list); + + const perPage = Math.min(notes.length, 3); + const summariesLimit = config.shortFeedSummaryMaxChars || 300; + + notes.forEach((note) => { + const item = document.createElement('li'); + item.className = 'splide__slide flex flex-col gap-2 rounded-lg bg-blog-neutral-50 p-4 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'); + section.appendChild(prev); + section.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, + }); + + progress.setOnComplete(() => { + advanceSlide(carousel, 1, { restartProgress: true }); + }); + + carousel.setProgress(progress); + if (carousel.autoPlayInterval > 0) { + progress.reset(); + progress.start(carousel.autoPlayInterval); + } + + return { section, carousel }; +} + +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 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(initZapthreadsEmbed) { + if (typeof initZapthreadsEmbed !== 'function') { + return; + } + const elements = document.querySelectorAll('zap-threads'); + if (!elements.length) { + return; + } + + elements.forEach((element) => { + const options = extractZapthreadsOptions(element); + initZapthreadsEmbed({ root: element, options }); + }); +} + +export function bootstrapBlog({ initZapthreadsEmbed } = {}) { + const carousels = []; + + function mount() { + const root = document.querySelector('.blog-app-root'); + if (!root) { + return; + } + + 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'; + 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; + }); + + if (config.shortNotesMode === 'carousel') { + const rendered = renderShortNotesCarousel(shortNotesCandidates, config); + if (rendered?.section) { + main.appendChild(rendered.section); + if (rendered.carousel) { + carousels.push(rendered.carousel); + } + } + } 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); + initializeZapthreads(initZapthreadsEmbed); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mount, { once: true }); + } else { + mount(); + } + + return () => { + carousels.forEach((instance) => { + destroyCarousel(instance); + }); + }; +} diff --git a/js/blog/progressBar.js b/js/blog/progressBar.js new file mode 100644 index 00000000..5080af54 --- /dev/null +++ b/js/blog/progressBar.js @@ -0,0 +1,98 @@ +import { applyDynamicStyles } from '../ui/styleSystem.js'; + +export function mountProgressBar( + container, + { initialDuration = 0, autoStart = false, onComplete } = {}, +) { + if (!container) { + return null; + } + + const wrapper = document.createElement('div'); + wrapper.className = + 'splide__progress mt-2 h-1 w-full overflow-hidden rounded-full bg-blog-neutral-100 dark:bg-blog-neutral-800'; + + const fill = document.createElement('div'); + fill.className = 'splide__progress__bar h-full bg-blog-purple transition-none'; + wrapper.appendChild(fill); + container.appendChild(wrapper); + + let duration = Math.max(0, initialDuration); + let rafId = null; + let startTimestamp = null; + let completeHandler = typeof onComplete === 'function' ? onComplete : null; + + function updateWidth(ratio) { + const width = `${Math.max(0, Math.min(1, ratio)) * 100}%`; + applyDynamicStyles(fill, { width }, { slot: 'width' }); + } + + function stop() { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + startTimestamp = null; + } + + function reset() { + stop(); + updateWidth(0); + } + + function step(timestamp) { + if (startTimestamp === null) { + startTimestamp = timestamp; + } + const elapsed = timestamp - startTimestamp; + const ratio = duration > 0 ? Math.min(1, elapsed / duration) : 1; + updateWidth(ratio); + + if (ratio >= 1) { + stop(); + if (completeHandler) { + completeHandler(); + } + return; + } + + rafId = requestAnimationFrame(step); + } + + function start(nextDuration = duration) { + duration = Math.max(0, nextDuration); + reset(); + if (duration === 0) { + updateWidth(1); + if (completeHandler) { + completeHandler(); + } + return; + } + rafId = requestAnimationFrame(step); + } + + function destroy() { + stop(); + wrapper.remove(); + } + + function setOnComplete(handler) { + completeHandler = typeof handler === 'function' ? handler : null; + } + + if (autoStart && duration > 0) { + start(duration); + } else { + reset(); + } + + return { + element: wrapper, + start, + stop, + reset, + destroy, + setOnComplete, + }; +}