Files
bitvid/js/blog/index.js
T

760 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createCarousel, advanceSlide, destroyCarousel } from './carousel.js';
import { mountProgressBar } from './progressBar.js';
import { initZapthreadsEmbed as defaultInitZapthreadsEmbed } from './zapthreadsEmbed.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';
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) {
console.error('[blog] zapthreads cleanup failed', error);
}
return;
}
registerCleanup(() => {
try {
dispose();
} catch (error) {
console.error('[blog] zapthreads cleanup failed', error);
}
});
})
.catch((error) => {
console.error('[blog] failed to initialise zapthreads embed', error);
});
return;
}
if (typeof maybeDispose === 'function') {
if (isDestroyed()) {
try {
maybeDispose();
} catch (error) {
console.error('[blog] zapthreads cleanup failed', error);
}
return;
}
registerCleanup(() => {
try {
maybeDispose();
} catch (error) {
console.error('[blog] zapthreads cleanup failed', error);
}
});
}
} catch (error) {
console.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) {
console.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) {
console.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);
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();
}
}