fix: FeedLand widget click-to-expand and dark mode for expanded items

Add row selection and expand/collapse behavior matching Dave Winer's
blogroll.js: first click selects, second click (or caret click) expands
to show up to 5 recent items fetched from blogroll API. Items cached
after first fetch. Added dark mode styles for expanded items.
This commit is contained in:
Ricardo
2026-02-17 15:54:31 +01:00
parent 690a10ecf8
commit fec999793d
+124 -22
View File
@@ -86,6 +86,7 @@
padding-right: 0;
vertical-align: top;
width: 12px;
cursor: pointer;
}
.trBlogrollFeed .tdBlogrollFeedTitle {
font-size: 15px;
@@ -117,12 +118,46 @@
.trBlogrollFeed:hover {
background-color: whitesmoke;
}
.trBlogrollFeed.selectedFeed {
background-color: #e8f0fe;
}
.darkCaretColor {
opacity: .9;
}
.lightCaretColor {
opacity: .2;
}
/* Expanded items (newspod) */
.divNewsPod {
padding: 2px 0 4px 16px;
}
.divNewsPod ul {
list-style: none;
margin: 0;
padding: 0;
}
.divNewsPod li {
padding: 2px 0;
font-size: 13px;
line-height: 1.3;
}
.divNewsPod li a {
color: #1a73e8;
text-decoration: none;
}
.divNewsPod li a:hover {
text-decoration: underline;
}
.divNewsPod li .spItemWhen {
font-size: 11px;
opacity: 0.6;
margin-left: 4px;
}
.divNewsPod .feedLoading {
font-size: 12px;
opacity: 0.5;
padding: 4px 0;
}
.divBlogrollFooter {
text-align: center;
font-size: 13px;
@@ -163,21 +198,6 @@
}
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.dark .divBlogrollContainer {
border-color: #444;
color: #e0e0e0;
}
.dark .divBlogrollContainer:focus {
background-color: #1a1a1a;
}
.dark .trBlogrollFeed:hover {
background-color: #2a2a2a;
}
.dark .divBlogrollFooter {
border-top-color: #444;
}
}
.dark .divBlogrollContainer {
border-color: #444;
color: #e0e0e0;
@@ -188,6 +208,12 @@
.dark .trBlogrollFeed:hover {
background-color: #2a2a2a;
}
.dark .trBlogrollFeed.selectedFeed {
background-color: #1e3a5f;
}
.dark .divNewsPod li a {
color: #8ab4f8;
}
.dark .divBlogrollFooter {
border-top-color: #444;
}
@@ -219,15 +245,46 @@
<table class="divBlogrollTable">
<tbody>
<template x-for="blog in sortedBlogs" :key="blog.id">
<tr class="trBlogrollFeed">
<td class="tdBlogrollWedge">
<span class="darkCaretColor">&#9654;</span>
</td>
<td class="tdBlogrollFeedTitle">
<tr>
{# Feed row #}
<td colspan="2" style="padding:0; border:0;">
<div class="trBlogrollFeed"
:class="selectedId === blog.id ? 'selectedFeed' : ''"
style="display:flex; align-items:flex-start;">
<div class="tdBlogrollWedge"
@click="toggleExpand(blog)">
<span :class="expandedId === blog.id ? 'darkCaretColor' : (selectedId === blog.id ? 'darkCaretColor' : 'lightCaretColor')"
x-text="expandedId === blog.id ? '\u25BC' : '\u25B6'"></span>
</div>
<div class="tdBlogrollFeedTitle" style="flex:1; min-width:0;"
@click="handleRowClick(blog)">
<span class="spWhenUpdated" x-text="relativeTime(blog.lastFetchAt)"></span>
<span class="spTitleString">
<a :href="blog.siteUrl || blog.feedUrl" target="_blank" rel="noopener" x-text="blog.title"></a>
<a :href="blog.siteUrl || blog.feedUrl" target="_blank" rel="noopener"
x-text="blog.title" @click.stop></a>
</span>
</div>
</div>
{# Expanded items #}
<div class="divNewsPod" x-show="expandedId === blog.id" x-collapse>
<template x-if="blog._loadingItems">
<div class="feedLoading">Loading…</div>
</template>
<template x-if="!blog._loadingItems && blog._items && blog._items.length > 0">
<ul>
<template x-for="item in blog._items" :key="item.id">
<li>
<a :href="item.url" target="_blank" rel="noopener"
x-text="truncate(item.title || item.summary || item.url, 100)"></a>
<span class="spItemWhen" x-text="relativeTime(item.published)"></span>
</li>
</template>
</ul>
</template>
<template x-if="!blog._loadingItems && (!blog._items || blog._items.length === 0)">
<div class="feedLoading">No recent items</div>
</template>
</div>
</td>
</tr>
</template>
@@ -250,6 +307,8 @@ function feedlandWidget() {
title: 'FeedLand',
riverUrl: 'https://feedland.com',
loading: true,
selectedId: null,
expandedId: null,
get sortedBlogs() {
const sorted = [...this.blogs];
@@ -265,6 +324,45 @@ function feedlandWidget() {
return sorted;
},
handleRowClick(blog) {
if (this.selectedId !== blog.id) {
// First click: select
this.selectedId = blog.id;
} else {
// Second click: toggle expand
this.toggleExpand(blog);
}
},
async toggleExpand(blog) {
this.selectedId = blog.id;
if (this.expandedId === blog.id) {
// Collapse
this.expandedId = null;
return;
}
// Expand — fetch items if not cached
this.expandedId = blog.id;
if (!blog._items) {
blog._loadingItems = true;
try {
const res = await fetch('/blogrollapi/api/blogs/' + blog.id);
const data = await res.json();
blog._items = (data.items || []).slice(0, 5);
} catch (err) {
console.error('FeedLand: failed to load items for', blog.title, err);
blog._items = [];
} finally {
blog._loadingItems = false;
}
}
},
truncate(str, max) {
if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str;
},
relativeTime(iso) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
@@ -280,7 +378,11 @@ function feedlandWidget() {
try {
const res = await fetch('/blogrollapi/api/blogs?source=feedland&sort=recent&limit=100');
const data = await res.json();
this.blogs = data.items || [];
this.blogs = (data.items || []).map(b => ({
...b,
_items: null,
_loadingItems: false,
}));
} catch (err) {
console.error('FeedLand widget error:', err);
} finally {