COOL
This commit is contained in:
+157
-142
@@ -1,31 +1,33 @@
|
|||||||
// blog.js – with collapsible posts
|
// blog.js
|
||||||
const BLOG_DIR = 'blog/';
|
const BLOG_DIR = 'blog/';
|
||||||
const MANIFEST = BLOG_DIR + 'manifest.json';
|
const MANIFEST = BLOG_DIR + 'manifest.json';
|
||||||
|
|
||||||
// ---------- helpers ----------
|
// ------------------------------------------------------------
|
||||||
function parseIndexHtmlList(text) {
|
// 1. Fetch the list of blog post files
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
async function getPostFiles() {
|
||||||
|
// Try manifest first
|
||||||
|
try {
|
||||||
|
const r = await fetch(MANIFEST, { cache: 'no-cache' });
|
||||||
|
if (r.ok) {
|
||||||
|
const data = await r.json();
|
||||||
|
return data.filter(f => f.endsWith('.txt'));
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
|
||||||
|
// Fallback: parse directory listing (requires autoindex)
|
||||||
|
try {
|
||||||
|
const r = await fetch(BLOG_DIR, { cache: 'no-cache' });
|
||||||
|
if (r.ok) {
|
||||||
|
const text = await r.text();
|
||||||
const hrefs = [];
|
const hrefs = [];
|
||||||
const re = /href="([^"]+)"/g;
|
const re = /href="([^"]+)"/g;
|
||||||
let m;
|
let m;
|
||||||
while ((m = re.exec(text)) !== null) {
|
while ((m = re.exec(text)) !== null) {
|
||||||
const name = m[1];
|
const name = m[1];
|
||||||
if (name.match(/\.(txt)$/i)) hrefs.push(name);
|
if (name.endsWith('.txt')) hrefs.push(name);
|
||||||
}
|
}
|
||||||
return hrefs;
|
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) {
|
} catch (e) {
|
||||||
console.warn('Could not read blog directory listing', e);
|
console.warn('Could not read blog directory listing', e);
|
||||||
@@ -33,161 +35,174 @@ async function loadPostList() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse a single post file: first line = date/time, second = title, rest = content
|
// ------------------------------------------------------------
|
||||||
|
// 2. Parse a single blog post file
|
||||||
|
// ------------------------------------------------------------
|
||||||
async function fetchPost(filename) {
|
async function fetchPost(filename) {
|
||||||
const resp = await fetch(BLOG_DIR + filename);
|
const url = BLOG_DIR + filename;
|
||||||
if (!resp.ok) throw new Error('Failed to fetch ' + filename);
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error(`Failed to fetch ${filename}`);
|
||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
const lines = text.split(/\r?\n/);
|
const lines = text.split('\n');
|
||||||
const dateTime = lines[0]?.trim() || '';
|
// First line: date/time, second: title, rest: content
|
||||||
|
const dateTime = lines[0]?.trim() || 'Unknown date';
|
||||||
const title = lines[1]?.trim() || 'Untitled';
|
const title = lines[1]?.trim() || 'Untitled';
|
||||||
const content = lines.slice(2).join('\n').trim();
|
const content = lines.slice(2).join('\n').trim();
|
||||||
return { dateTime, title, content, filename };
|
return { filename, dateTime, title, content };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- rendering with collapse ----------
|
// ------------------------------------------------------------
|
||||||
function createPostElement(post) {
|
// 3. Generate a preview for long posts
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
function createPreview(content, maxLen = 300) {
|
||||||
|
// Return { preview: string, truncated: boolean }
|
||||||
|
if (content.length <= maxLen) {
|
||||||
|
return { preview: content, truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get at least 3 sentences
|
||||||
|
const sentences = content.match(/[^.!?]*[.!?](\s+|$)/g) || [];
|
||||||
|
if (sentences.length >= 3) {
|
||||||
|
let preview = sentences.slice(0, 3).join('');
|
||||||
|
// If preview is still > maxLen, trim to nearest space
|
||||||
|
if (preview.length > maxLen) {
|
||||||
|
const trimmed = preview.slice(0, maxLen);
|
||||||
|
const lastSpace = trimmed.lastIndexOf(' ');
|
||||||
|
if (lastSpace > 0) preview = trimmed.slice(0, lastSpace) + '…';
|
||||||
|
else preview = trimmed + '…';
|
||||||
|
} else {
|
||||||
|
preview = preview.trim() + '…';
|
||||||
|
}
|
||||||
|
return { preview, truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: at least 3 lines (by newline)
|
||||||
|
const lines = content.split('\n').filter(l => l.trim() !== '');
|
||||||
|
if (lines.length >= 3) {
|
||||||
|
let preview = lines.slice(0, 3).join('\n');
|
||||||
|
if (preview.length > maxLen) {
|
||||||
|
const trimmed = preview.slice(0, maxLen);
|
||||||
|
const lastSpace = trimmed.lastIndexOf(' ');
|
||||||
|
if (lastSpace > 0) preview = trimmed.slice(0, lastSpace) + '…';
|
||||||
|
else preview = trimmed + '…';
|
||||||
|
} else {
|
||||||
|
preview = preview.trim() + '…';
|
||||||
|
}
|
||||||
|
return { preview, truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: take first maxLen chars, break at word boundary
|
||||||
|
if (content.length > maxLen) {
|
||||||
|
const trimmed = content.slice(0, maxLen);
|
||||||
|
const lastSpace = trimmed.lastIndexOf(' ');
|
||||||
|
const preview = (lastSpace > 0 ? trimmed.slice(0, lastSpace) : trimmed) + '…';
|
||||||
|
return { preview, truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { preview: content, truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 4. Render a single blog post
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
function renderPost(post) {
|
||||||
|
const { dateTime, title, content } = post;
|
||||||
|
|
||||||
const article = document.createElement('article');
|
const article = document.createElement('article');
|
||||||
article.className = 'blog-post';
|
article.className = 'blog-post';
|
||||||
|
|
||||||
// header: date & title
|
// Title
|
||||||
const header = document.createElement('header');
|
const h3 = document.createElement('h3');
|
||||||
const dateP = document.createElement('p');
|
h3.className = 'blog-title';
|
||||||
dateP.className = 'post-date';
|
h3.textContent = title;
|
||||||
dateP.textContent = post.dateTime || 'No date';
|
article.appendChild(h3);
|
||||||
const titleH = document.createElement('h3');
|
|
||||||
titleH.textContent = post.title;
|
|
||||||
header.appendChild(dateP);
|
|
||||||
header.appendChild(titleH);
|
|
||||||
article.appendChild(header);
|
|
||||||
|
|
||||||
// content container
|
// Meta (date)
|
||||||
const contentDiv = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
contentDiv.className = 'post-content';
|
meta.className = 'blog-meta';
|
||||||
|
meta.textContent = dateTime;
|
||||||
|
article.appendChild(meta);
|
||||||
|
|
||||||
// full content (preserve line breaks)
|
// Body content: split paragraphs by double newline, wrap in <p>
|
||||||
const fullContent = document.createElement('div');
|
const bodyDiv = document.createElement('div');
|
||||||
fullContent.className = 'post-full';
|
bodyDiv.className = 'blog-body';
|
||||||
fullContent.style.whiteSpace = 'pre-wrap';
|
const paragraphs = content.split(/\n\s*\n/);
|
||||||
fullContent.textContent = post.content;
|
paragraphs.forEach(para => {
|
||||||
|
const p = document.createElement('p');
|
||||||
// preview content (only if needed)
|
p.textContent = para.trim();
|
||||||
const previewContent = document.createElement('div');
|
bodyDiv.appendChild(p);
|
||||||
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);
|
// Decide if we need collapse
|
||||||
contentDiv.appendChild(fullContent);
|
const { preview, truncated } = createPreview(content);
|
||||||
contentDiv.appendChild(toggleBtn);
|
|
||||||
|
if (!truncated) {
|
||||||
|
// Short post – show full content directly
|
||||||
|
article.appendChild(bodyDiv);
|
||||||
} else {
|
} else {
|
||||||
// short post – show full content only
|
// Long post – wrap in <details>
|
||||||
fullContent.style.display = 'block';
|
const details = document.createElement('details');
|
||||||
contentDiv.appendChild(fullContent);
|
details.className = 'blog-collapse';
|
||||||
|
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
// Preview text
|
||||||
|
const previewDiv = document.createElement('div');
|
||||||
|
previewDiv.className = 'blog-preview';
|
||||||
|
// Split preview by newline and create lines, but we can just set innerHTML with <br>
|
||||||
|
previewDiv.innerHTML = preview.replace(/\n/g, '<br>');
|
||||||
|
summary.appendChild(previewDiv);
|
||||||
|
|
||||||
|
// "Read more" text
|
||||||
|
const moreSpan = document.createElement('span');
|
||||||
|
moreSpan.className = 'read-more-text';
|
||||||
|
moreSpan.textContent = 'Read more';
|
||||||
|
summary.appendChild(moreSpan);
|
||||||
|
|
||||||
|
details.appendChild(summary);
|
||||||
|
// Full content inside the details
|
||||||
|
details.appendChild(bodyDiv);
|
||||||
|
article.appendChild(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
article.appendChild(contentDiv);
|
|
||||||
return article;
|
return article;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- main init ----------
|
// ------------------------------------------------------------
|
||||||
|
// 5. Main: load posts and render
|
||||||
|
// ------------------------------------------------------------
|
||||||
async function initBlog() {
|
async function initBlog() {
|
||||||
const list = await loadPostList();
|
|
||||||
const feed = document.querySelector('.blog-feed');
|
const feed = document.querySelector('.blog-feed');
|
||||||
feed.innerHTML = '';
|
if (!feed) return;
|
||||||
|
|
||||||
if (!list.length) {
|
feed.innerHTML = 'Loading blog posts…';
|
||||||
feed.textContent = 'No blog posts found. Create blog/manifest.json or enable directory listing.';
|
|
||||||
|
try {
|
||||||
|
const files = await getPostFiles();
|
||||||
|
if (!files.length) {
|
||||||
|
feed.innerHTML = 'No blog posts found.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch and sort posts (newest first based on filename or date? we'll sort by filename descending)
|
// Fetch and parse all posts
|
||||||
const posts = await Promise.all(list.map(f => fetchPost(f).catch(err => {
|
const posts = await Promise.all(files.map(f => fetchPost(f)));
|
||||||
console.warn('Skipping', f, err);
|
|
||||||
return null;
|
// Sort by date descending (assuming ISO-like date in first line)
|
||||||
})));
|
posts.sort((a, b) => {
|
||||||
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 dA = new Date(a.dateTime);
|
||||||
const dB = new Date(b.dateTime);
|
const dB = new Date(b.dateTime);
|
||||||
if (!isNaN(dA) && !isNaN(dB)) return dB - dA;
|
return dB - dA;
|
||||||
return b.dateTime.localeCompare(a.dateTime);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
validPosts.forEach(post => {
|
// Render each post
|
||||||
const el = createPostElement(post);
|
feed.innerHTML = '';
|
||||||
|
posts.forEach(post => {
|
||||||
|
const el = renderPost(post);
|
||||||
feed.appendChild(el);
|
feed.appendChild(el);
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
feed.innerHTML = 'Error loading blog posts.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initBlog);
|
document.addEventListener('DOMContentLoaded', initBlog);
|
||||||
Reference in New Issue
Block a user