From 0ab56a72799dbb3efe3a9bd2f0831dabc46a73dc Mon Sep 17 00:00:00 2001
From: mindfreq <144544047+mindfreq@users.noreply.github.com>
Date: Tue, 5 May 2026 10:49:43 +0200
Subject: [PATCH] refactor frontend
---
src-tauri/src/parser.rs | 1 +
src/App.css | 63 +++
src/App.tsx | 611 ++++------------------
src/components/EmptyState.tsx | 19 +
src/components/ErrorToast.tsx | 41 ++
src/components/LoadingView.tsx | 17 +
src/components/article/ArticleView.tsx | 122 +++++
src/components/article/AuthorTag.tsx | 43 ++
src/components/feed/FeedItemCard.tsx | 34 ++
src/components/feed/FeedItemsList.tsx | 84 +++
src/components/sidebar/AddFeedInput.tsx | 42 ++
src/components/sidebar/FeedListItem.tsx | 50 ++
src/components/sidebar/SidebarContent.tsx | 171 ++++++
src/hooks/useFeeds.ts | 113 ++++
src/types/index.ts | 14 +
src/utils/index.ts | 109 ++++
16 files changed, 1026 insertions(+), 508 deletions(-)
create mode 100644 src/components/EmptyState.tsx
create mode 100644 src/components/ErrorToast.tsx
create mode 100644 src/components/LoadingView.tsx
create mode 100644 src/components/article/ArticleView.tsx
create mode 100644 src/components/article/AuthorTag.tsx
create mode 100644 src/components/feed/FeedItemCard.tsx
create mode 100644 src/components/feed/FeedItemsList.tsx
create mode 100644 src/components/sidebar/AddFeedInput.tsx
create mode 100644 src/components/sidebar/FeedListItem.tsx
create mode 100644 src/components/sidebar/SidebarContent.tsx
create mode 100644 src/hooks/useFeeds.ts
create mode 100644 src/types/index.ts
create mode 100644 src/utils/index.ts
diff --git a/src-tauri/src/parser.rs b/src-tauri/src/parser.rs
index 35f39f6..4faf5d0 100644
--- a/src-tauri/src/parser.rs
+++ b/src-tauri/src/parser.rs
@@ -88,6 +88,7 @@ impl FeedEntry {
.ok_or(Error::MissingField("published".into()))?,
updated: entry
.updated
+ .or(entry.published)
.map(|d| d.to_rfc3339())
.ok_or(Error::MissingField("updated".into()))?,
summary,
diff --git a/src/App.css b/src/App.css
index 57e295b..952e9f5 100644
--- a/src/App.css
+++ b/src/App.css
@@ -210,3 +210,66 @@
@apply border-l-2 border-primary pl-4 text-muted-foreground;
}
}
+
+/* ── Performance: GPU-accelerated scroll container ── */
+.reader-scroll-area {
+ will-change: scroll-position;
+ -webkit-overflow-scrolling: touch;
+}
+
+/* ── Improved reader typography ── */
+.reader-content {
+ font-size: 1rem;
+ line-height: 1.85;
+ color: hsl(var(--foreground));
+ word-break: break-word;
+ overflow-wrap: break-word;
+ hyphens: auto;
+}
+
+.reader-content h1,
+.reader-content h2,
+.reader-content h3,
+.reader-content h4 {
+ @apply font-semibold tracking-tight text-foreground;
+ margin-top: 1.75em;
+ margin-bottom: 0.6em;
+ line-height: 1.35;
+}
+
+.reader-content h1 { font-size: 1.5rem; }
+.reader-content h2 { font-size: 1.25rem; }
+.reader-content h3 { font-size: 1.1rem; }
+
+.reader-content p {
+ margin-bottom: 1.1em;
+}
+
+.reader-content ul,
+.reader-content ol {
+ padding-left: 1.5rem;
+ margin-bottom: 1.1em;
+}
+
+.reader-content table {
+ @apply w-full border-collapse text-sm;
+ margin-bottom: 1.25em;
+}
+
+.reader-content th,
+.reader-content td {
+ @apply border border-border px-3 py-2 text-left;
+}
+
+.reader-content th {
+ @apply bg-muted font-semibold;
+}
+
+.reader-content code:not(pre > code) {
+ @apply rounded bg-muted px-1.5 py-0.5 font-mono text-sm text-foreground;
+}
+
+/* Prevent layout shifts from images */
+.reader-content img {
+ aspect-ratio: attr(width) / attr(height);
+}
diff --git a/src/App.tsx b/src/App.tsx
index b68040e..9c308d3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,409 +1,99 @@
-import { useEffect, useState } from "react";
-import { commands, Feed, FeedEntry, FeedItem, Person } from "./bindings";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Separator } from "@/components/ui/separator";
-import { Skeleton } from "@/components/ui/skeleton";
+import { useCallback, useState } from "react";
+import { Menu, PanelLeftOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent } from "@/components/ui/sheet";
-import { useTheme } from "next-themes";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import {
- PlusCircle,
- Trash2,
- Rss,
- ExternalLink,
- ChevronLeft,
- RefreshCw,
- CircleAlert,
- X,
- Sun,
- Moon,
- Menu,
- PanelLeftClose,
- PanelLeftOpen,
-} from "lucide-react";
-
-type View =
- | { type: "empty" }
- | { type: "items"; feed: Feed; items: FeedItem[] }
- | { type: "entry"; feed: Feed; entry: FeedEntry };
-
-type UiError = {
- id: number;
- title: string;
- message: string;
-};
-
-const extractErrorMessage = (error: unknown): string => {
- if (error instanceof Error) return error.message;
- if (typeof error === "string") return error;
- if (typeof error === "object" && error !== null && "message" in error) {
- const message = (error as { message?: unknown }).message;
- if (typeof message === "string") return message;
- }
-
- return "Something went wrong. Please try again.";
-};
-
-const normalizeErrorMessage = (message: string): string => {
- const cleanedMessage = message.replace(/^Error:\s*/i, "").trim();
- if (cleanedMessage.toLowerCase().startsWith("missing required field:")) {
- return cleanedMessage.replace(/^missing required field:\s*/i, "");
- }
-
- return cleanedMessage;
-};
-
-const formatPublishedDate = (value?: string, long = false): string => {
- if (!value) return "Date unavailable";
-
- const timestamp = Date.parse(value);
- if (Number.isNaN(timestamp)) return "Date unavailable";
-
- return new Date(timestamp).toLocaleDateString(undefined, long
- ? { year: "numeric", month: "long", day: "numeric" }
- : { year: "numeric", month: "short", day: "numeric" });
-};
-
-const getRenderableHtml = (content: string): string => {
- const value = content.trim();
- if (!value) return "";
-
- const hasHtmlTags = /<[a-z][\s\S]*>/i.test(value);
- const hasEscapedTags = /<\/?[a-z][\s\S]*>/i.test(value);
- if (hasHtmlTags || !hasEscapedTags) return value;
-
- const textarea = document.createElement("textarea");
- textarea.innerHTML = value;
- const decoded = textarea.value.trim();
-
- return /<[a-z][\s\S]*>/i.test(decoded) ? decoded : value;
-};
-
-const isSummarySameAsContent = (summary: string | null, content: string): boolean => {
- if (!summary) return false;
-
- const normalizedSummary = summary.replace(/\s+/g, " ").trim();
- if (!normalizedSummary) return false;
-
- const htmlContent = getRenderableHtml(content);
- const normalizedHtml = htmlContent.replace(/\s+/g, " ").trim();
- if (normalizedSummary === normalizedHtml) return true;
-
- const textContent = htmlContent
- .replace(/<[^>]+>/g, " ")
- .replace(/\s+/g, " ")
- .trim();
-
- return normalizedSummary === textContent;
-};
-
-const normalizeAuthorUri = (value: string): string => {
- const cleanedValue = value.trim();
- if (!cleanedValue) return "";
- return /^https?:\/\//i.test(cleanedValue) ? cleanedValue : `https://${cleanedValue}`;
-};
-
-const getAuthorUriLabel = (value: string): string => {
- const normalized = normalizeAuthorUri(value);
- if (!normalized) return "Website";
-
- try {
- const url = new URL(normalized);
- return url.hostname.replace(/^www\./i, "") || "Website";
- } catch {
- return "Website";
- }
-};
-
-const renderAuthor = (author: Person, index: number) => {
- const normalizedUri = author.uri ? normalizeAuthorUri(author.uri) : "";
-
- return (
-
- );
-};
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { useFeeds } from "./hooks/useFeeds";
+import { ArticleView } from "./components/article/ArticleView";
+import { FeedItemsList } from "./components/feed/FeedItemsList";
+import { SidebarContent } from "./components/sidebar/SidebarContent";
+import { EmptyState } from "./components/EmptyState";
+import { ErrorToast } from "./components/ErrorToast";
+import { LoadingView } from "./components/LoadingView";
+import { Feed, FeedItem } from "./bindings";
export default function App() {
- const { resolvedTheme, setTheme } = useTheme();
- const [feeds, setFeeds] = useState([]);
- const [loadingFeeds, setLoadingFeeds] = useState(true);
- const [selectedFeed, setSelectedFeed] = useState(null);
- const [view, setView] = useState({ type: "empty" });
- const [loadingView, setLoadingView] = useState(false);
- const [addUrl, setAddUrl] = useState("");
- const [adding, setAdding] = useState(false);
- const [showAddInput, setShowAddInput] = useState(false);
- const [uiError, setUiError] = useState(null);
+ const {
+ feeds,
+ loadingFeeds,
+ selectedFeed,
+ view,
+ loadingView,
+ uiError,
+ setUiError,
+ loadFeeds,
+ selectFeed,
+ selectItem,
+ addFeed,
+ removeFeed,
+ showError,
+ } = useFeeds();
+
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [isDesktopSidebarOpen, setIsDesktopSidebarOpen] = useState(true);
- const showError = (error: unknown, fallbackMessage: string) => {
- console.error(error);
- const message = normalizeErrorMessage(extractErrorMessage(error)) || fallbackMessage;
-
- setUiError({
- id: Date.now(),
- title: "Action failed",
- message,
- });
- };
-
- useEffect(() => {
- if (!uiError) return;
-
- const timer = window.setTimeout(() => {
- setUiError((current) => (current?.id === uiError.id ? null : current));
- }, 5500);
-
- return () => window.clearTimeout(timer);
- }, [uiError]);
-
- useEffect(() => {
- loadFeeds();
- }, []);
-
- const loadFeeds = async () => {
- setLoadingFeeds(true);
- try {
- const data = await commands.getFeeds();
- setFeeds(data);
- } catch (e) {
- showError(e, "Unable to load feeds right now.");
- } finally {
- setLoadingFeeds(false);
- }
- };
-
- const handleSelectFeed = async (feed: Feed) => {
- setIsMobileSidebarOpen(false);
- setSelectedFeed(feed);
- setLoadingView(true);
- setView({ type: "empty" });
- try {
- const items = await commands.getFeedItem(feed.url);
- setView({ type: "items", feed, items });
- } catch (e) {
- showError(e, "Unable to load feed items.");
- } finally {
- setLoadingView(false);
- }
- };
-
- const handleSelectItem = async (feed: Feed, item: FeedItem) => {
- setLoadingView(true);
- try {
- const entry = await commands.getEntry(feed.url, item.url);
- setView({ type: "entry", feed, entry });
- } catch (e) {
- showError(e, "Unable to open this article.");
- } finally {
- setLoadingView(false);
- }
- };
-
- const handleAddFeed = async () => {
- if (!addUrl.trim()) return;
- setAdding(true);
- try {
- const feed = await commands.addFeed(addUrl.trim());
- setFeeds((prev) => [...prev, feed]);
- setAddUrl("");
- setShowAddInput(false);
- } catch (e) {
- showError(e, "Unable to add this feed.");
- } finally {
- setAdding(false);
- }
- };
-
- const handleRemoveFeed = async (feed: Feed, e: React.MouseEvent) => {
- e.stopPropagation();
- try {
- await commands.removeFeed(feed.url);
- setFeeds((prev) => prev.filter((f) => f.id !== feed.id));
- if (selectedFeed?.id === feed.id) {
- setSelectedFeed(null);
- setView({ type: "empty" });
- }
- } catch (e) {
- showError(e, "Unable to remove this feed.");
- }
- };
-
- const sidebarContent = (
- <>
-
-
-
- RuFeed
-
-
-
-
-
-
-
- {resolvedTheme === "dark" ? "Switch to light" : "Switch to dark"}
-
-
-
-
-
-
- Refresh feeds
-
-
-
-
-
- Add feed
-
-
-
-
- {showAddInput && (
-
- setAddUrl(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleAddFeed()}
- autoFocus
- />
-
-
- )}
-
-
-
- {loadingFeeds ? (
- Array.from({ length: 5 }).map((_, i) => (
-
-
-
-
- ))
- ) : feeds.length === 0 ? (
-
- No feeds yet. Add one above.
-
- ) : (
- feeds.map((feed) => (
-
- ))
- )}
-
-
-
-
-
{feeds.length} feed{feeds.length !== 1 ? "s" : ""}
-
- >
+ const handleSelectFeed = useCallback(
+ async (feed: Feed) => {
+ setIsMobileSidebarOpen(false);
+ await selectFeed(feed);
+ },
+ [selectFeed]
);
+ const handleSelectItem = useCallback(
+ async (feed: Feed, item: FeedItem) => {
+ await selectItem(feed, item);
+ },
+ [selectItem]
+ );
+
+ const handleAddFeed = useCallback(
+ async (url: string) => {
+ try {
+ await addFeed(url);
+ } catch (e) {
+ showError(e, "Unable to add this feed.");
+ }
+ },
+ [addFeed, showError]
+ );
+
+ const handleRemoveFeed = useCallback(
+ async (feed: Feed, e: React.MouseEvent) => {
+ e.stopPropagation();
+ try {
+ await removeFeed(feed);
+ } catch (err) {
+ showError(err, "Unable to remove this feed.");
+ }
+ },
+ [removeFeed, showError]
+ );
+
+ const sidebarProps = {
+ feeds,
+ loading: loadingFeeds,
+ selectedFeedId: selectedFeed?.id,
+ onSelectFeed: handleSelectFeed,
+ onRemoveFeed: handleRemoveFeed,
+ onAddFeed: handleAddFeed,
+ onRefresh: loadFeeds,
+ };
+
return (
+
{/* Desktop sidebar */}
-
+ {isDesktopSidebarOpen && (
+
+ )}
{!isDesktopSidebarOpen && (
@@ -419,19 +109,21 @@ export default function App() {
)}
+ {/* Mobile sidebar */}
- {sidebarContent}
+
{/* Main content */}
-
-
+
+ {/* Mobile top bar */}
+
-
+
{selectedFeed?.title || selectedFeed?.url || "RuFeed"}
{loadingView ? (
-
- {Array.from({ length: 6 }).map((_, i) => (
-
-
-
-
-
- ))}
-
+
) : view.type === "empty" ? (
-
-
-
{feeds.length === 0 ? "Add a feed to get started" : "Select a feed"}
-
+ 0} />
) : view.type === "items" ? (
- <>
-
- {view.feed.icon && (
-

- )}
-
- {view.feed.title || view.feed.url}
-
-
-
-
-
-
-
- {view.items.length === 0 ? (
-
- No items found.
-
- ) : (
- view.items.map((item, i) => (
-
- ))
- )}
-
-
- >
+
) : view.type === "entry" ? (
- <>
-
-
-
{view.feed.title}
-
-
-
-
-
-
-
- {view.entry.title}
-
-
- {view.entry.authors.length > 0 && (
-
- {view.entry.authors.map((author, index) => renderAuthor(author, index))}
-
- )}
- {view.entry.published && (
-
{formatPublishedDate(view.entry.published, true)}
- )}
-
- {view.entry.summary && !isSummarySameAsContent(view.entry.summary, view.entry.content) && (
-
- {view.entry.summary}
-
- )}
-
-
-
- >
+
) : null}
{uiError && (
-
-
-
-
-
-
-
-
{uiError.title}
-
{uiError.message}
-
-
-
-
-
+
setUiError(null)} />
)}
diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx
new file mode 100644
index 0000000..d7af599
--- /dev/null
+++ b/src/components/EmptyState.tsx
@@ -0,0 +1,19 @@
+import { memo } from "react";
+import { Rss } from "lucide-react";
+
+interface EmptyStateProps {
+ hasFeedsConfigured: boolean;
+}
+
+export const EmptyState = memo(function EmptyState({
+ hasFeedsConfigured,
+}: EmptyStateProps) {
+ return (
+
+
+
+ {hasFeedsConfigured ? "Select a feed to read" : "Add a feed to get started"}
+
+
+ );
+});
diff --git a/src/components/ErrorToast.tsx b/src/components/ErrorToast.tsx
new file mode 100644
index 0000000..74e4bea
--- /dev/null
+++ b/src/components/ErrorToast.tsx
@@ -0,0 +1,41 @@
+import { memo } from "react";
+import { CircleAlert, X } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { UiError } from "../types";
+
+interface ErrorToastProps {
+ error: UiError;
+ onDismiss: () => void;
+}
+
+export const ErrorToast = memo(function ErrorToast({
+ error,
+ onDismiss,
+}: ErrorToastProps) {
+ return (
+
+
+
+
+
+
+
+
{error.title}
+
+ {error.message}
+
+
+
+
+
+
+ );
+});
diff --git a/src/components/LoadingView.tsx b/src/components/LoadingView.tsx
new file mode 100644
index 0000000..9bef592
--- /dev/null
+++ b/src/components/LoadingView.tsx
@@ -0,0 +1,17 @@
+import { memo } from "react";
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export const LoadingView = memo(function LoadingView() {
+ return (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+
+
+ {i < 5 && }
+
+ ))}
+
+ );
+});
diff --git a/src/components/article/ArticleView.tsx b/src/components/article/ArticleView.tsx
new file mode 100644
index 0000000..7ebc817
--- /dev/null
+++ b/src/components/article/ArticleView.tsx
@@ -0,0 +1,122 @@
+import { memo, useMemo } from "react";
+import { ChevronLeft, ExternalLink } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Feed, FeedEntry } from "../../bindings";
+import { formatPublishedDate, getRenderableHtml, isSummarySameAsContent } from "../../utils";
+import { AuthorTag } from "./AuthorTag";
+
+interface ArticleViewProps {
+ feed: Feed;
+ entry: FeedEntry;
+ onBack: (feed: Feed) => void;
+}
+
+/**
+ * ArticleView renders the full article content.
+ *
+ * Performance optimizations:
+ * 1. `memo` prevents re-render when parent re-renders with same props.
+ * 2. `useMemo` for HTML processing — getRenderableHtml runs only when entry.content changes,
+ * not on every scroll/hover event.
+ * 3. `useMemo` for summary check — isSummarySameAsContent is a string-comparison operation
+ * that only needs to re-run when summary or content changes.
+ * 4. The heavy `dangerouslySetInnerHTML` div only re-renders when `renderedHtml` changes.
+ */
+export const ArticleView = memo(function ArticleView({
+ feed,
+ entry,
+ onBack,
+}: ArticleViewProps) {
+ // Memoize HTML processing — avoids re-running on every scroll event
+ const renderedHtml = useMemo(
+ () => getRenderableHtml(entry.content),
+ [entry.content]
+ );
+
+ const showSummary = useMemo(
+ () =>
+ Boolean(entry.summary) &&
+ !isSummarySameAsContent(entry.summary, entry.content),
+ [entry.summary, entry.content]
+ );
+
+ const formattedDate = useMemo(
+ () => formatPublishedDate(entry.published, true),
+ [entry.published]
+ );
+
+ return (
+ <>
+ {/* Article toolbar */}
+
+
+
+ {feed.title || feed.url}
+
+
+
+
+
+
+ {/* Scrollable article body — native scroll for best performance */}
+
+
+ {/* Title */}
+
+ {entry.title}
+
+
+ {/* Meta: authors + date */}
+
+ {entry.authors.length > 0 && (
+
+ {entry.authors.map((author, index) => (
+
+ ))}
+
+ )}
+ {entry.published && (
+
+ )}
+
+
+ {/* Summary / lead paragraph */}
+ {showSummary && entry.summary && (
+
+ {entry.summary}
+
+ )}
+
+ {/* Article HTML content */}
+
+
+
+ >
+ );
+});
diff --git a/src/components/article/AuthorTag.tsx b/src/components/article/AuthorTag.tsx
new file mode 100644
index 0000000..2736867
--- /dev/null
+++ b/src/components/article/AuthorTag.tsx
@@ -0,0 +1,43 @@
+import { memo } from "react";
+import { ExternalLink } from "lucide-react";
+import { Person } from "../../bindings";
+import { getAuthorUriLabel, normalizeAuthorUri } from "../../utils";
+
+interface AuthorTagProps {
+ author: Person;
+ index: number;
+}
+
+export const AuthorTag = memo(function AuthorTag({ author, index }: AuthorTagProps) {
+ const normalizedUri = author.uri ? normalizeAuthorUri(author.uri) : "";
+
+ return (
+
+ );
+});
diff --git a/src/components/feed/FeedItemCard.tsx b/src/components/feed/FeedItemCard.tsx
new file mode 100644
index 0000000..4977403
--- /dev/null
+++ b/src/components/feed/FeedItemCard.tsx
@@ -0,0 +1,34 @@
+import { memo } from "react";
+import { FeedItem } from "../../bindings";
+import { formatPublishedDate } from "../../utils";
+
+interface FeedItemCardProps {
+ item: FeedItem;
+ onClick: (item: FeedItem) => void;
+}
+
+/**
+ * Memoized card for a single feed item in the list.
+ * Prevents re-rendering all cards when one item changes.
+ */
+export const FeedItemCard = memo(function FeedItemCard({
+ item,
+ onClick,
+}: FeedItemCardProps) {
+ return (
+
+ );
+});
diff --git a/src/components/feed/FeedItemsList.tsx b/src/components/feed/FeedItemsList.tsx
new file mode 100644
index 0000000..f3443d6
--- /dev/null
+++ b/src/components/feed/FeedItemsList.tsx
@@ -0,0 +1,84 @@
+import { memo, useCallback } from "react";
+import { ExternalLink } from "lucide-react";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Feed, FeedItem } from "../../bindings";
+import { FeedItemCard } from "./FeedItemCard";
+
+interface FeedItemsListProps {
+ feed: Feed;
+ items: FeedItem[];
+ loading: boolean;
+ onSelectItem: (feed: Feed, item: FeedItem) => void;
+}
+
+export const FeedItemsList = memo(function FeedItemsList({
+ feed,
+ items,
+ loading,
+ onSelectItem,
+}: FeedItemsListProps) {
+ const handleItemClick = useCallback(
+ (item: FeedItem) => {
+ onSelectItem(feed, item);
+ },
+ [feed, onSelectItem]
+ );
+
+ return (
+ <>
+ {/* Feed header */}
+
+ {feed.icon && (
+

+ )}
+
+ {feed.title || feed.url}
+
+
+
+
+
+
+ {/* Items list */}
+
+ {loading ? (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+
+
+ {i < 5 && }
+
+ ))}
+
+ ) : items.length === 0 ? (
+
+ ) : (
+
+ {items.map((item, i) => (
+
+ ))}
+
+ )}
+
+ >
+ );
+});
diff --git a/src/components/sidebar/AddFeedInput.tsx b/src/components/sidebar/AddFeedInput.tsx
new file mode 100644
index 0000000..9ce9191
--- /dev/null
+++ b/src/components/sidebar/AddFeedInput.tsx
@@ -0,0 +1,42 @@
+import { memo, useRef } from "react";
+import { Button } from "@/components/ui/button";
+
+interface AddFeedInputProps {
+ value: string;
+ adding: boolean;
+ onChange: (value: string) => void;
+ onAdd: () => void;
+}
+
+export const AddFeedInput = memo(function AddFeedInput({
+ value,
+ adding,
+ onChange,
+ onAdd,
+}: AddFeedInputProps) {
+ const inputRef = useRef(null);
+
+ return (
+
+ onChange(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && onAdd()}
+ autoFocus
+ disabled={adding}
+ aria-label="Feed URL"
+ />
+
+
+ );
+});
diff --git a/src/components/sidebar/FeedListItem.tsx b/src/components/sidebar/FeedListItem.tsx
new file mode 100644
index 0000000..eaf8708
--- /dev/null
+++ b/src/components/sidebar/FeedListItem.tsx
@@ -0,0 +1,50 @@
+import { memo } from "react";
+import { Rss, Trash2 } from "lucide-react";
+import { Feed } from "../../bindings";
+
+interface FeedListItemProps {
+ feed: Feed;
+ isSelected: boolean;
+ onSelect: (feed: Feed) => void;
+ onRemove: (feed: Feed, e: React.MouseEvent) => void;
+}
+
+export const FeedListItem = memo(function FeedListItem({
+ feed,
+ isSelected,
+ onSelect,
+ onRemove,
+}: FeedListItemProps) {
+ return (
+
+ );
+});
diff --git a/src/components/sidebar/SidebarContent.tsx b/src/components/sidebar/SidebarContent.tsx
new file mode 100644
index 0000000..3f521fc
--- /dev/null
+++ b/src/components/sidebar/SidebarContent.tsx
@@ -0,0 +1,171 @@
+import { memo, useCallback, useState } from "react";
+import {
+ Moon,
+ PanelLeftClose,
+ PlusCircle,
+ RefreshCw,
+ Rss,
+ Sun,
+} from "lucide-react";
+import { useTheme } from "next-themes";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Feed } from "../../bindings";
+import { AddFeedInput } from "./AddFeedInput";
+import { FeedListItem } from "./FeedListItem";
+
+interface SidebarContentProps {
+ feeds: Feed[];
+ loading: boolean;
+ selectedFeedId?: string;
+ onSelectFeed: (feed: Feed) => void;
+ onRemoveFeed: (feed: Feed, e: React.MouseEvent) => void;
+ onAddFeed: (url: string) => Promise;
+ onRefresh: () => void;
+ onHideSidebar?: () => void;
+ showHideButton?: boolean;
+}
+
+export const SidebarContent = memo(function SidebarContent({
+ feeds,
+ loading,
+ selectedFeedId,
+ onSelectFeed,
+ onRemoveFeed,
+ onAddFeed,
+ onRefresh,
+ onHideSidebar,
+ showHideButton = false,
+}: SidebarContentProps) {
+ const { resolvedTheme, setTheme } = useTheme();
+ const [showAddInput, setShowAddInput] = useState(false);
+ const [addUrl, setAddUrl] = useState("");
+ const [adding, setAdding] = useState(false);
+
+ const toggleTheme = useCallback(() => {
+ setTheme(resolvedTheme === "dark" ? "light" : "dark");
+ }, [resolvedTheme, setTheme]);
+
+ const handleAdd = useCallback(async () => {
+ if (!addUrl.trim()) return;
+ setAdding(true);
+ try {
+ await onAddFeed(addUrl.trim());
+ setAddUrl("");
+ setShowAddInput(false);
+ } finally {
+ setAdding(false);
+ }
+ }, [addUrl, onAddFeed]);
+
+ return (
+
+ {/* Header */}
+
+
+
+ RuFeed
+
+
+
+
+
+
+
+
+ {showHideButton && onHideSidebar && (
+
+ )}
+
+
+
+ {/* Add feed input */}
+ {showAddInput && (
+
+ )}
+
+ {/* Feed list */}
+
+
+ {loading ? (
+ Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+ ))
+ ) : feeds.length === 0 ? (
+
+ No feeds yet. Add one above.
+
+ ) : (
+ feeds.map((feed) => (
+
+ ))
+ )}
+
+
+
+ {/* Footer */}
+
+
+ {feeds.length} feed{feeds.length !== 1 ? "s" : ""}
+
+
+
+ );
+});
diff --git a/src/hooks/useFeeds.ts b/src/hooks/useFeeds.ts
new file mode 100644
index 0000000..c653c8a
--- /dev/null
+++ b/src/hooks/useFeeds.ts
@@ -0,0 +1,113 @@
+import { useCallback, useEffect, useState } from "react";
+import { commands, Feed, FeedItem } from "../bindings";
+import { UiError, View } from "../types";
+import { extractErrorMessage, normalizeErrorMessage } from "../utils";
+
+export function useFeeds() {
+ const [feeds, setFeeds] = useState([]);
+ const [loadingFeeds, setLoadingFeeds] = useState(true);
+ const [selectedFeed, setSelectedFeed] = useState(null);
+ const [view, setView] = useState({ type: "empty" });
+ const [loadingView, setLoadingView] = useState(false);
+ const [uiError, setUiError] = useState(null);
+
+ const showError = useCallback((error: unknown, fallbackMessage: string) => {
+ console.error(error);
+ const message = normalizeErrorMessage(extractErrorMessage(error)) || fallbackMessage;
+ setUiError({ id: Date.now(), title: "Action failed", message });
+ }, []);
+
+ // Auto-dismiss error after 5.5 seconds
+ useEffect(() => {
+ if (!uiError) return;
+ const timer = window.setTimeout(() => {
+ setUiError((current) => (current?.id === uiError.id ? null : current));
+ }, 5500);
+ return () => window.clearTimeout(timer);
+ }, [uiError]);
+
+ const loadFeeds = useCallback(async () => {
+ setLoadingFeeds(true);
+ try {
+ const data = await commands.getFeeds();
+ setFeeds(data);
+ } catch (e) {
+ showError(e, "Unable to load feeds right now.");
+ } finally {
+ setLoadingFeeds(false);
+ }
+ }, [showError]);
+
+ useEffect(() => {
+ loadFeeds();
+ }, [loadFeeds]);
+
+ const selectFeed = useCallback(
+ async (feed: Feed) => {
+ setSelectedFeed(feed);
+ setLoadingView(true);
+ setView({ type: "empty" });
+ try {
+ const items = await commands.getFeedItem(feed.url);
+ setView({ type: "items", feed, items });
+ } catch (e) {
+ showError(e, "Unable to load feed items.");
+ } finally {
+ setLoadingView(false);
+ }
+ },
+ [showError]
+ );
+
+ const selectItem = useCallback(
+ async (feed: Feed, item: FeedItem) => {
+ setLoadingView(true);
+ try {
+ const entry = await commands.getEntry(feed.url, item.url);
+ setView({ type: "entry", feed, entry });
+ } catch (e) {
+ showError(e, "Unable to open this article.");
+ } finally {
+ setLoadingView(false);
+ }
+ },
+ [showError]
+ );
+
+ const addFeed = useCallback(
+ async (url: string) => {
+ const feed = await commands.addFeed(url.trim());
+ setFeeds((prev) => [...prev, feed]);
+ return feed;
+ },
+ []
+ );
+
+ const removeFeed = useCallback(
+ async (feed: Feed) => {
+ await commands.removeFeed(feed.url);
+ setFeeds((prev) => prev.filter((f) => f.id !== feed.id));
+ if (selectedFeed?.id === feed.id) {
+ setSelectedFeed(null);
+ setView({ type: "empty" });
+ }
+ },
+ [selectedFeed]
+ );
+
+ return {
+ feeds,
+ loadingFeeds,
+ selectedFeed,
+ view,
+ loadingView,
+ uiError,
+ setUiError,
+ loadFeeds,
+ selectFeed,
+ selectItem,
+ addFeed,
+ removeFeed,
+ showError,
+ };
+}
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..a5c6f96
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,14 @@
+import type { Feed, FeedEntry, FeedItem, Person } from "../bindings";
+
+export type View =
+ | { type: "empty" }
+ | { type: "items"; feed: Feed; items: FeedItem[] }
+ | { type: "entry"; feed: Feed; entry: FeedEntry };
+
+export type UiError = {
+ id: number;
+ title: string;
+ message: string;
+};
+
+export type { Feed, FeedItem, FeedEntry, Person };
diff --git a/src/utils/index.ts b/src/utils/index.ts
new file mode 100644
index 0000000..6e1109d
--- /dev/null
+++ b/src/utils/index.ts
@@ -0,0 +1,109 @@
+/**
+ * Extracts a clean error message from unknown error types.
+ */
+export const extractErrorMessage = (error: unknown): string => {
+ if (error instanceof Error) return error.message;
+ if (typeof error === "string") return error;
+ if (typeof error === "object" && error !== null && "message" in error) {
+ const message = (error as { message?: unknown }).message;
+ if (typeof message === "string") return message;
+ }
+ return "Something went wrong. Please try again.";
+};
+
+/**
+ * Cleans up common error message prefixes.
+ */
+export const normalizeErrorMessage = (message: string): string => {
+ const cleanedMessage = message.replace(/^Error:\s*/i, "").trim();
+ if (cleanedMessage.toLowerCase().startsWith("missing required field:")) {
+ return cleanedMessage.replace(/^missing required field:\s*/i, "");
+ }
+ return cleanedMessage;
+};
+
+/**
+ * Formats a date string for display.
+ */
+export const formatPublishedDate = (value?: string, long = false): string => {
+ if (!value) return "Date unavailable";
+ const timestamp = Date.parse(value);
+ if (Number.isNaN(timestamp)) return "Date unavailable";
+ return new Date(timestamp).toLocaleDateString(undefined, long
+ ? { year: "numeric", month: "long", day: "numeric" }
+ : { year: "numeric", month: "short", day: "numeric" });
+};
+
+/**
+ * Ensures HTML content is properly decoded and renderable.
+ * Uses a cached textarea element to avoid repeated DOM creation.
+ */
+let _decodeTextarea: HTMLTextAreaElement | null = null;
+const getDecodeTextarea = (): HTMLTextAreaElement => {
+ if (!_decodeTextarea) {
+ _decodeTextarea = document.createElement("textarea");
+ }
+ return _decodeTextarea;
+};
+
+export const getRenderableHtml = (content: string): string => {
+ const value = content.trim();
+ if (!value) return "";
+
+ const hasHtmlTags = /<[a-z][\s\S]*>/i.test(value);
+ const hasEscapedTags = /<\/?[a-z][\s\S]*>/i.test(value);
+ if (hasHtmlTags || !hasEscapedTags) return value;
+
+ const textarea = getDecodeTextarea();
+ textarea.innerHTML = value;
+ const decoded = textarea.value.trim();
+
+ return /<[a-z][\s\S]*>/i.test(decoded) ? decoded : value;
+};
+
+/**
+ * Checks if a summary is effectively the same as the full content,
+ * to avoid showing duplicate text.
+ */
+export const isSummarySameAsContent = (
+ summary: string | null,
+ content: string
+): boolean => {
+ if (!summary) return false;
+ const normalizedSummary = summary.replace(/\s+/g, " ").trim();
+ if (!normalizedSummary) return false;
+
+ const htmlContent = getRenderableHtml(content);
+ const normalizedHtml = htmlContent.replace(/\s+/g, " ").trim();
+ if (normalizedSummary === normalizedHtml) return true;
+
+ const textContent = htmlContent
+ .replace(/<[^>]+>/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ return normalizedSummary === textContent;
+};
+
+/**
+ * Normalizes author URI to a valid URL string.
+ */
+export const normalizeAuthorUri = (value: string): string => {
+ const cleanedValue = value.trim();
+ if (!cleanedValue) return "";
+ return /^https?:\/\//i.test(cleanedValue) ? cleanedValue : `https://${cleanedValue}`;
+};
+
+/**
+ * Returns a human-readable label for an author URI.
+ */
+export const getAuthorUriLabel = (value: string): string => {
+ const normalized = normalizeAuthorUri(value);
+ if (!normalized) return "Website";
+ try {
+ const url = new URL(normalized);
+ return url.hostname.replace(/^www\./i, "") || "Website";
+ } catch {
+ return "Website";
+ }
+};