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 ( -
- {author.name} - {author.email && ( - - {author.email} - - )} - {normalizedUri && ( - - {getAuthorUriLabel(normalizedUri)} - - - )} -
- ); -}; +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 ( +
+ + {author.name} + + {author.email && ( + + {author.email} + + )} + {normalizedUri && ( + + {getAuthorUriLabel(normalizedUri)} + + + )} +
+ ); +}); 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 ? ( +
+

No items found.

+
+ ) : ( +
+ {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"; + } +};