Merge pull request #490 from PR0M3TH3AN/codex/refactor-blog.html-and-add-carousel-modules

Refactor blog bootstrap to modular loader
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-13 14:51:36 -04:00
commit ea8dfcf346
4 files changed
+780 -28694

No files matched your search

+2 -28694
View File
File diff suppressed because it is too large. Load diff
+124
View File
@@ -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');
}
}
+556
View File
@@ -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);
});
};
}
+98
View File
@@ -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,
};
}