193 lines
6.4 KiB
JavaScript
193 lines
6.4 KiB
JavaScript
// blog.js – with collapsible posts
|
||
const BLOG_DIR = 'blog/';
|
||
const MANIFEST = BLOG_DIR + 'manifest.json';
|
||
|
||
// ---------- helpers ----------
|
||
function parseIndexHtmlList(text) {
|
||
const hrefs = [];
|
||
const re = /href="([^"]+)"/g;
|
||
let m;
|
||
while ((m = re.exec(text)) !== null) {
|
||
const name = m[1];
|
||
if (name.match(/\.(txt)$/i)) hrefs.push(name);
|
||
}
|
||
return hrefs;
|
||
}
|
||
|
||
// fetch a list of post filenames
|
||
async function loadPostList() {
|
||
try {
|
||
const r = await fetch(MANIFEST, { cache: 'no-cache' });
|
||
if (r.ok) return await r.json();
|
||
} catch (_) { /* fall through */ }
|
||
|
||
try {
|
||
const r = await fetch(BLOG_DIR, { cache: 'no-cache' });
|
||
if (r.ok) {
|
||
const text = await r.text();
|
||
return parseIndexHtmlList(text);
|
||
}
|
||
} catch (e) {
|
||
console.warn('Could not read blog directory listing', e);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// parse a single post file: first line = date/time, second = title, rest = content
|
||
async function fetchPost(filename) {
|
||
const resp = await fetch(BLOG_DIR + filename);
|
||
if (!resp.ok) throw new Error('Failed to fetch ' + filename);
|
||
const text = await resp.text();
|
||
const lines = text.split(/\r?\n/);
|
||
const dateTime = lines[0]?.trim() || '';
|
||
const title = lines[1]?.trim() || 'Untitled';
|
||
const content = lines.slice(2).join('\n').trim();
|
||
return { dateTime, title, content, filename };
|
||
}
|
||
|
||
// ---------- rendering with collapse ----------
|
||
function createPostElement(post) {
|
||
const article = document.createElement('article');
|
||
article.className = 'blog-post';
|
||
|
||
// header: date & title
|
||
const header = document.createElement('header');
|
||
const dateP = document.createElement('p');
|
||
dateP.className = 'post-date';
|
||
dateP.textContent = post.dateTime || 'No date';
|
||
const titleH = document.createElement('h3');
|
||
titleH.textContent = post.title;
|
||
header.appendChild(dateP);
|
||
header.appendChild(titleH);
|
||
article.appendChild(header);
|
||
|
||
// content container
|
||
const contentDiv = document.createElement('div');
|
||
contentDiv.className = 'post-content';
|
||
|
||
// full content (preserve line breaks)
|
||
const fullContent = document.createElement('div');
|
||
fullContent.className = 'post-full';
|
||
fullContent.style.whiteSpace = 'pre-wrap';
|
||
fullContent.textContent = post.content;
|
||
|
||
// preview content (only if needed)
|
||
const previewContent = document.createElement('div');
|
||
previewContent.className = 'post-preview';
|
||
previewContent.style.whiteSpace = 'pre-wrap';
|
||
|
||
const fullText = post.content;
|
||
const needsCollapse = fullText.length > 300;
|
||
|
||
if (needsCollapse) {
|
||
// Build preview: take up to 3 lines, but keep total length < 300
|
||
const lines = fullText.split(/\r?\n/);
|
||
let previewLines = [];
|
||
let previewLength = 0;
|
||
// take at least 3 lines if available, but stop if adding next line exceeds 300
|
||
for (let i = 0; i < Math.min(lines.length, 3); i++) {
|
||
const line = lines[i];
|
||
if (previewLength + line.length + (i > 0 ? 1 : 0) < 300) {
|
||
previewLines.push(line);
|
||
previewLength += line.length + (i > 0 ? 1 : 0);
|
||
} else {
|
||
// if we haven't taken any line yet, take part of this line
|
||
if (i === 0) {
|
||
previewLines.push(line.slice(0, 300));
|
||
previewLength = 300;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
// if we still have less than 3 lines but we have more lines, force include them up to 300 chars
|
||
while (previewLines.length < 3 && previewLines.length < lines.length) {
|
||
const nextLine = lines[previewLines.length];
|
||
if (previewLength + nextLine.length + (previewLines.length > 0 ? 1 : 0) < 300) {
|
||
previewLines.push(nextLine);
|
||
previewLength += nextLine.length + (previewLines.length > 0 ? 1 : 0);
|
||
} else {
|
||
// truncate the next line to fit within 300
|
||
const remaining = 300 - previewLength - (previewLines.length > 0 ? 1 : 0);
|
||
if (remaining > 0) {
|
||
previewLines.push(nextLine.slice(0, remaining));
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
previewContent.textContent = previewLines.join('\n');
|
||
// if preview is empty, fallback to first 300 chars
|
||
if (!previewContent.textContent) {
|
||
previewContent.textContent = fullText.slice(0, 300);
|
||
}
|
||
|
||
// toggle button
|
||
const toggleBtn = document.createElement('button');
|
||
toggleBtn.className = 'read-more-btn';
|
||
toggleBtn.textContent = 'Read more';
|
||
toggleBtn.setAttribute('aria-expanded', 'false');
|
||
|
||
// Initially, full content is hidden, preview is visible
|
||
fullContent.style.display = 'none';
|
||
previewContent.style.display = 'block';
|
||
|
||
toggleBtn.addEventListener('click', () => {
|
||
const expanded = toggleBtn.getAttribute('aria-expanded') === 'true';
|
||
if (expanded) {
|
||
fullContent.style.display = 'none';
|
||
previewContent.style.display = 'block';
|
||
toggleBtn.textContent = 'Read more';
|
||
toggleBtn.setAttribute('aria-expanded', 'false');
|
||
} else {
|
||
fullContent.style.display = 'block';
|
||
previewContent.style.display = 'none';
|
||
toggleBtn.textContent = 'Show less';
|
||
toggleBtn.setAttribute('aria-expanded', 'true');
|
||
}
|
||
});
|
||
|
||
contentDiv.appendChild(previewContent);
|
||
contentDiv.appendChild(fullContent);
|
||
contentDiv.appendChild(toggleBtn);
|
||
} else {
|
||
// short post – show full content only
|
||
fullContent.style.display = 'block';
|
||
contentDiv.appendChild(fullContent);
|
||
}
|
||
|
||
article.appendChild(contentDiv);
|
||
return article;
|
||
}
|
||
|
||
// ---------- main init ----------
|
||
async function initBlog() {
|
||
const list = await loadPostList();
|
||
const feed = document.querySelector('.blog-feed');
|
||
feed.innerHTML = '';
|
||
|
||
if (!list.length) {
|
||
feed.textContent = 'No blog posts found. Create blog/manifest.json or enable directory listing.';
|
||
return;
|
||
}
|
||
|
||
// fetch and sort posts (newest first based on filename or date? we'll sort by filename descending)
|
||
const posts = await Promise.all(list.map(f => fetchPost(f).catch(err => {
|
||
console.warn('Skipping', f, err);
|
||
return null;
|
||
})));
|
||
const validPosts = posts.filter(p => p !== null);
|
||
// sort by dateTime (if possible) or filename – we'll sort by dateTime descending
|
||
validPosts.sort((a, b) => {
|
||
// try to parse as ISO date, else compare as strings
|
||
const dA = new Date(a.dateTime);
|
||
const dB = new Date(b.dateTime);
|
||
if (!isNaN(dA) && !isNaN(dB)) return dB - dA;
|
||
return b.dateTime.localeCompare(a.dateTime);
|
||
});
|
||
|
||
validPosts.forEach(post => {
|
||
const el = createPostElement(post);
|
||
feed.appendChild(el);
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', initBlog); |