Initial commit

This commit is contained in:
Dan 2026-06-14 22:48:52 +08:00
commit cbc367b397
4 changed files with 204 additions and 0 deletions

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# FreshRSS List Widget for Homepage
A custom widget for [gethomepage.dev](https://gethomepage.dev) that displays the latest RSS feed items from your [FreshRSS](https://freshrss.org/) subscriptions.
## Why?
The built-in FreshRSS widget for Homepage only shows the **unread count**. This widget goes a step further by listing the actual articles from your reading list (or a specific category) directly on your dashboard.
## Features
- Lists recent RSS items with title, date, author, and source feed.
- Supports filtering by FreshRSS category/label.
- Configurable number of items to display.
- Links directly to the article.
## Prerequisites
- A running FreshRSS instance with the **Google Reader API** enabled.
- In FreshRSS: **Settings → Profile → API management** → Enable API and set your API password.
- Ensure the Google Reader API endpoint (`api/greader.php`) is accessible.
## Installation
1. Copy the `freshrsslist` folder into your Homepage widgets directory (e.g., `src/widgets/` or your custom widgets path).
2. If Homepage requires widget registration, import and add it to your widget registry.
## Configuration
Add the widget to your `services.yaml` (or `widgets.yaml`):
```yaml
- FreshRSS:
href: https://your-freshrss-instance.com
widget:
type: freshrsslist
url: https://your-freshrss-instance.com
username: your_username
password: your_api_password # Not your regular login password
category: Tech # Optional: filter by FreshRSS label
limit: 5 # Optional: number of items (default: 5)
```
## Options
| Field | Required | Description |
|------------|----------|------------------------------------------------------------------------|
| `url` | Yes | Base URL of your FreshRSS instance. |
| `username` | Yes | Your FreshRSS username. |
| `password` | Yes | Your FreshRSS **API password** (set in Profile → API management). |
| `category` | No | A FreshRSS category/label to filter items. Omit to show reading list. |
| `limit` | No | Number of items to display. Default: `5`. |
## How It Works
The widget authenticates with FreshRSS via the Google Reader API (`ClientLogin`) and fetches the stream contents. It then maps the API response to a clean list of articles rendered inside a Homepage service container.
## File Structure
- `widget.js` — Defines the widget endpoint and proxy handler.
- `proxy.js` — Handles FreshRSS authentication and API requests.
- `components.jsx` — The React component that renders the RSS list.
## License
MIT

73
components.jsx Normal file
View File

@ -0,0 +1,73 @@
import { useTranslation } from "next-i18next";
import Container from "components/services/widget/container";
import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { widget } = service;
const { data, error } = useWidgetAPI(widget, "items");
if (error) {
return <Container service={service} error={error} />;
}
if (!data) {
return (
<Container service={service}>
{" "}
<div className="w-full flex flex-col pl-4 pr-3">
{" "}
<div className="mb-3 h-3 w-24 bg-theme-700/50 rounded animate-pulse" />{" "}
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex flex-col gap-1 mb-3 last:mb-0">
{" "}
<div className="h-4 w-full bg-theme-700/50 rounded animate-pulse" />{" "}
<div className="h-3 w-2/3 bg-theme-800/50 rounded animate-pulse" />{" "}
</div>
))}{" "}
</div>{" "}
</Container>
);
}
const limit = parseInt(widget.limit, 10) || 5;
const items = data?.items?.slice(0, limit) || [];
const fmtDate = (unix) => {
if (!unix) return "";
return new Date(unix * 1000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
};
return (
<Container service={service}>
{" "}
<div className="w-full flex flex-col pl-4 pr-3">
{" "}
<div className="mb-3 text-[10px] font-bold tracking-[0.15em] text-theme-500 uppercase">
{" "}
{widget.category || "FreshRSS"}{" "}
</div>{" "}
<ul className="flex flex-col gap-3">
{" "}
{items.map((item) => (
<li key={item.id} className="flex flex-col gap-1">
{" "}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-amber-200/90 hover:text-amber-100 truncate leading-snug transition-colors"
title={item.title}
>
{" "}
{item.title}{" "}
</a>{" "}
<div className="text-xs text-theme-500 truncate font-normal">
{" "}
{fmtDate(item.created_on_time)}{" "}
{item.author && <span> · {item.author}</span>}{" "}
{item.feed_name && <span> · {item.feed_name}</span>}{" "}
</div>{" "}
</li>
))}{" "}
</ul>{" "}
</div>{" "}
</Container>
);
}

59
proxy.js Normal file
View File

@ -0,0 +1,59 @@
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
const logger = createLogger("freshrsslist");
export default async function freshrsslistProxyHandler(req, res) {
try {
const { group, service } = req.query;
const serviceWidget = await getServiceWidget(group, service);
if (!serviceWidget) {
return res.status(400).json({ error: "Widget not found" });
}
const { url, username, password, category, limit } = serviceWidget;
const itemLimit = parseInt(limit, 10) || 5;
if (!url || !username || !password) {
return res.status(400).json({ error: "Missing required fields" });
}
const baseUrl = url.replace(/\/+$/, "");
// 1) ClientLogin to get Auth token
const loginUrl = `${baseUrl}/api/greader.php/accounts/ClientLogin`;
const loginRes = await fetch(loginUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `Email=${encodeURIComponent(username)}&Passwd=${encodeURIComponent(password)}`,
});
const loginText = await loginRes.text();
const authMatch = loginText.match(/Auth=([^\n]+)/);
if (!authMatch) {
logger.error(`FreshRSS ClientLogin failed: ${loginText}`);
return res.status(401).json({ error: "FreshRSS auth failed" });
}
const authToken = authMatch[1];
// 2) Fetch reading list with GoogleLogin auth header
const streamPath = category
? `user/-/label/${encodeURIComponent(category)}`
: "user/-/state/com.google/reading-list";
const targetUrl = `${baseUrl}/api/greader.php/reader/api/0/stream/contents/${streamPath}?n=${itemLimit}&output=json&r=n`;
const response = await fetch(targetUrl, {
headers: { Authorization: `GoogleLogin auth=${authToken}` },
});
if (!response.ok) {
logger.error(`FreshRSS returned ${response.status}`);
return res
.status(response.status)
.json({ error: `HTTP ${response.status}` });
}
const json = await response.json();
const items = (json.items || []).map((item) => ({
id: item.id || item.timestampUsec,
title: item.title || "Untitled",
url: item.alternate?.[0]?.href || item.canonical?.[0]?.href || "",
author: item.author || "",
created_on_time: item.published || Math.floor(Date.now() / 1000),
feed_name: item.origin?.title || "",
}));
return res.status(200).json({ items });
} catch (err) {
logger.error(err);
return res.status(500).json({ error: err.message });
}
}

9
widget.js Normal file
View File

@ -0,0 +1,9 @@
import freshrsslistProxyHandler from "./proxy";
const widget = {
api: "{url}/{endpoint}",
proxyHandler: freshrsslistProxyHandler,
mappings: {
items: { endpoint: "api/greader.php/reader/api/0/stream/contents" },
},
};
export default widget;