Initial commit
This commit is contained in:
commit
cbc367b397
63
README.md
Normal file
63
README.md
Normal 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
73
components.jsx
Normal 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
59
proxy.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user