refactor frontend
This commit is contained in:
parent
320e1ad166
commit
0ab56a7279
16 changed files with 1026 additions and 508 deletions
|
|
@ -88,6 +88,7 @@ impl FeedEntry {
|
||||||
.ok_or(Error::MissingField("published".into()))?,
|
.ok_or(Error::MissingField("published".into()))?,
|
||||||
updated: entry
|
updated: entry
|
||||||
.updated
|
.updated
|
||||||
|
.or(entry.published)
|
||||||
.map(|d| d.to_rfc3339())
|
.map(|d| d.to_rfc3339())
|
||||||
.ok_or(Error::MissingField("updated".into()))?,
|
.ok_or(Error::MissingField("updated".into()))?,
|
||||||
summary,
|
summary,
|
||||||
|
|
|
||||||
63
src/App.css
63
src/App.css
|
|
@ -210,3 +210,66 @@
|
||||||
@apply border-l-2 border-primary pl-4 text-muted-foreground;
|
@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);
|
||||||
|
}
|
||||||
|
|
|
||||||
587
src/App.tsx
587
src/App.tsx
|
|
@ -1,409 +1,99 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { commands, Feed, FeedEntry, FeedItem, Person } from "./bindings";
|
import { Menu, PanelLeftOpen } from "lucide-react";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
import { useTheme } from "next-themes";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import {
|
import { useFeeds } from "./hooks/useFeeds";
|
||||||
Tooltip,
|
import { ArticleView } from "./components/article/ArticleView";
|
||||||
TooltipContent,
|
import { FeedItemsList } from "./components/feed/FeedItemsList";
|
||||||
TooltipProvider,
|
import { SidebarContent } from "./components/sidebar/SidebarContent";
|
||||||
TooltipTrigger,
|
import { EmptyState } from "./components/EmptyState";
|
||||||
} from "@/components/ui/tooltip";
|
import { ErrorToast } from "./components/ErrorToast";
|
||||||
import {
|
import { LoadingView } from "./components/LoadingView";
|
||||||
PlusCircle,
|
import { Feed, FeedItem } from "./bindings";
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
key={`${author.name}-${index}`}
|
|
||||||
className="flex flex-wrap items-center gap-2 rounded-md border border-border/70 bg-muted/40 px-2.5 py-1.5 text-xs"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-foreground" dir="auto">{author.name}</span>
|
|
||||||
{author.email && (
|
|
||||||
<a
|
|
||||||
href={`mailto:${author.email}`}
|
|
||||||
className="text-muted-foreground underline decoration-dotted underline-offset-4 transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
{author.email}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{normalizedUri && (
|
|
||||||
<a
|
|
||||||
href={normalizedUri}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-primary underline decoration-dotted underline-offset-4 transition-opacity hover:opacity-80"
|
|
||||||
>
|
|
||||||
{getAuthorUriLabel(normalizedUri)}
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { resolvedTheme, setTheme } = useTheme();
|
const {
|
||||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
feeds,
|
||||||
const [loadingFeeds, setLoadingFeeds] = useState(true);
|
loadingFeeds,
|
||||||
const [selectedFeed, setSelectedFeed] = useState<Feed | null>(null);
|
selectedFeed,
|
||||||
const [view, setView] = useState<View>({ type: "empty" });
|
view,
|
||||||
const [loadingView, setLoadingView] = useState(false);
|
loadingView,
|
||||||
const [addUrl, setAddUrl] = useState("");
|
uiError,
|
||||||
const [adding, setAdding] = useState(false);
|
setUiError,
|
||||||
const [showAddInput, setShowAddInput] = useState(false);
|
loadFeeds,
|
||||||
const [uiError, setUiError] = useState<UiError | null>(null);
|
selectFeed,
|
||||||
|
selectItem,
|
||||||
|
addFeed,
|
||||||
|
removeFeed,
|
||||||
|
showError,
|
||||||
|
} = useFeeds();
|
||||||
|
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
const [isDesktopSidebarOpen, setIsDesktopSidebarOpen] = useState(true);
|
const [isDesktopSidebarOpen, setIsDesktopSidebarOpen] = useState(true);
|
||||||
|
|
||||||
const showError = (error: unknown, fallbackMessage: string) => {
|
const handleSelectFeed = useCallback(
|
||||||
console.error(error);
|
async (feed: Feed) => {
|
||||||
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);
|
setIsMobileSidebarOpen(false);
|
||||||
setSelectedFeed(feed);
|
await selectFeed(feed);
|
||||||
setLoadingView(true);
|
},
|
||||||
setView({ type: "empty" });
|
[selectFeed]
|
||||||
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) => {
|
const handleSelectItem = useCallback(
|
||||||
setLoadingView(true);
|
async (feed: Feed, item: FeedItem) => {
|
||||||
try {
|
await selectItem(feed, item);
|
||||||
const entry = await commands.getEntry(feed.url, item.url);
|
},
|
||||||
setView({ type: "entry", feed, entry });
|
[selectItem]
|
||||||
} catch (e) {
|
);
|
||||||
showError(e, "Unable to open this article.");
|
|
||||||
} finally {
|
|
||||||
setLoadingView(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddFeed = async () => {
|
const handleAddFeed = useCallback(
|
||||||
if (!addUrl.trim()) return;
|
async (url: string) => {
|
||||||
setAdding(true);
|
|
||||||
try {
|
try {
|
||||||
const feed = await commands.addFeed(addUrl.trim());
|
await addFeed(url);
|
||||||
setFeeds((prev) => [...prev, feed]);
|
|
||||||
setAddUrl("");
|
|
||||||
setShowAddInput(false);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(e, "Unable to add this feed.");
|
showError(e, "Unable to add this feed.");
|
||||||
} finally {
|
|
||||||
setAdding(false);
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[addFeed, showError]
|
||||||
|
);
|
||||||
|
|
||||||
const handleRemoveFeed = async (feed: Feed, e: React.MouseEvent) => {
|
const handleRemoveFeed = useCallback(
|
||||||
|
async (feed: Feed, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
await commands.removeFeed(feed.url);
|
await removeFeed(feed);
|
||||||
setFeeds((prev) => prev.filter((f) => f.id !== feed.id));
|
} catch (err) {
|
||||||
if (selectedFeed?.id === feed.id) {
|
showError(err, "Unable to remove this feed.");
|
||||||
setSelectedFeed(null);
|
|
||||||
setView({ type: "empty" });
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
showError(e, "Unable to remove this feed.");
|
[removeFeed, showError]
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sidebarContent = (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between border-b border-border/70 px-5 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Rss className="h-4 w-4 text-primary" />
|
|
||||||
<span className="text-sm font-semibold tracking-tight">RuFeed</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 md:mr-8">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
|
||||||
>
|
|
||||||
{resolvedTheme === "dark" ? (
|
|
||||||
<Sun className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Moon className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{resolvedTheme === "dark" ? "Switch to light" : "Switch to dark"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={loadFeeds}>
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Refresh feeds</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => setShowAddInput((v) => !v)}
|
|
||||||
>
|
|
||||||
<PlusCircle className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Add feed</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAddInput && (
|
|
||||||
<div className="flex gap-2 border-b border-border/70 px-3 py-3">
|
|
||||||
<input
|
|
||||||
className="h-8 flex-1 rounded-md border border-border/80 bg-muted/50 px-2.5 text-xs outline-none transition-colors focus:border-primary"
|
|
||||||
placeholder="https://..."
|
|
||||||
value={addUrl}
|
|
||||||
onChange={(e) => setAddUrl(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAddFeed()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button size="sm" className="h-8 px-3 text-xs" onClick={handleAddFeed} disabled={adding}>
|
|
||||||
{adding ? "..." : "Add"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
||||||
<div className="space-y-1.5 p-2.5">
|
|
||||||
{loadingFeeds ? (
|
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2 rounded-lg border border-border/60 px-3 py-2.5">
|
|
||||||
<Skeleton className="h-5 w-5 rounded-full" />
|
|
||||||
<Skeleton className="h-4 flex-1 rounded" />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : feeds.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-border/70 px-4 py-10 text-center text-xs text-muted-foreground">
|
|
||||||
No feeds yet. Add one above.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
feeds.map((feed) => (
|
|
||||||
<button
|
|
||||||
key={feed.id}
|
|
||||||
onClick={() => handleSelectFeed(feed)}
|
|
||||||
className={`group relative flex w-full items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left text-sm transition-all duration-200 ${
|
|
||||||
selectedFeed?.id === feed.id
|
|
||||||
? "border-border bg-card shadow-sm"
|
|
||||||
: "border-transparent hover:border-border/70 hover:bg-card/60"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{feed.icon ? (
|
|
||||||
<img src={feed.icon} alt="" className="h-4 w-4 rounded-sm object-contain shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Rss className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span dir="auto" className="min-w-0 flex-1 truncate font-medium">
|
|
||||||
{feed.title || feed.url}
|
|
||||||
</span>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<span
|
|
||||||
onClick={(e) => handleRemoveFeed(feed, e)}
|
|
||||||
className="inline-flex items-center justify-center rounded-md p-1 align-middle opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Remove feed</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border/70 px-4 py-3">
|
|
||||||
<p className="text-xs text-muted-foreground">{feeds.length} feed{feeds.length !== 1 ? "s" : ""}</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sidebarProps = {
|
||||||
|
feeds,
|
||||||
|
loading: loadingFeeds,
|
||||||
|
selectedFeedId: selectedFeed?.id,
|
||||||
|
onSelectFeed: handleSelectFeed,
|
||||||
|
onRemoveFeed: handleRemoveFeed,
|
||||||
|
onAddFeed: handleAddFeed,
|
||||||
|
onRefresh: loadFeeds,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="relative flex h-screen overflow-hidden bg-background text-foreground">
|
<div className="relative flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
|
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
<aside
|
{isDesktopSidebarOpen && (
|
||||||
className={`relative hidden w-72 shrink-0 flex-col border-r border-border/70 bg-sidebar/80 backdrop-blur-sm ${
|
<aside className="hidden w-72 shrink-0 flex-col border-r border-border/70 bg-sidebar/80 backdrop-blur-sm md:flex">
|
||||||
isDesktopSidebarOpen ? "md:flex" : "md:hidden"
|
<SidebarContent
|
||||||
}`}
|
{...sidebarProps}
|
||||||
>
|
showHideButton
|
||||||
<div className="absolute right-3 top-4 hidden md:block">
|
onHideSidebar={() => setIsDesktopSidebarOpen(false)}
|
||||||
<Button
|
/>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => setIsDesktopSidebarOpen(false)}
|
|
||||||
aria-label="Hide sidebar"
|
|
||||||
>
|
|
||||||
<PanelLeftClose className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{sidebarContent}
|
|
||||||
</aside>
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isDesktopSidebarOpen && (
|
{!isDesktopSidebarOpen && (
|
||||||
<div className="absolute left-4 top-4 z-50 hidden md:block">
|
<div className="absolute left-4 top-4 z-50 hidden md:block">
|
||||||
|
|
@ -419,19 +109,21 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile sidebar */}
|
||||||
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
|
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="left"
|
side="left"
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className="w-72 max-w-[85vw] border-r border-border/70 bg-sidebar/80 p-0 backdrop-blur-sm"
|
className="w-72 max-w-[85vw] border-r border-border/70 bg-sidebar/80 p-0 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">{sidebarContent}</div>
|
<SidebarContent {...sidebarProps} />
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 flex min-h-0 flex-col overflow-hidden">
|
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex items-center gap-2 border-b border-border/70 px-4 py-2.5 md:hidden select-none">
|
{/* Mobile top bar */}
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b border-border/70 px-4 py-2.5 select-none md:hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -441,130 +133,33 @@ export default function App() {
|
||||||
>
|
>
|
||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span dir="auto" className="truncate text-sm font-medium tracking-tight">
|
<span dir="auto" className="min-w-0 flex-1 truncate text-sm font-medium tracking-tight">
|
||||||
{selectedFeed?.title || selectedFeed?.url || "RuFeed"}
|
{selectedFeed?.title || selectedFeed?.url || "RuFeed"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingView ? (
|
{loadingView ? (
|
||||||
<div className="flex-1 p-6 space-y-4">
|
<LoadingView />
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<div key={i} className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
<Skeleton className="h-3 w-1/3" />
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : view.type === "empty" ? (
|
) : view.type === "empty" ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
<EmptyState hasFeedsConfigured={feeds.length > 0} />
|
||||||
<Rss className="h-10 w-10 opacity-20" />
|
|
||||||
<p className="text-sm">{feeds.length === 0 ? "Add a feed to get started" : "Select a feed"}</p>
|
|
||||||
</div>
|
|
||||||
) : view.type === "items" ? (
|
) : view.type === "items" ? (
|
||||||
<>
|
<FeedItemsList
|
||||||
<div className="flex items-center gap-2 border-b border-border/70 px-6 py-3.5">
|
feed={view.feed}
|
||||||
{view.feed.icon && (
|
items={view.items}
|
||||||
<img src={view.feed.icon} alt="" className="h-5 w-5 rounded-sm object-contain" />
|
loading={false}
|
||||||
)}
|
onSelectItem={handleSelectItem}
|
||||||
<h1 dir="auto" className="font-semibold text-sm truncate">
|
/>
|
||||||
{view.feed.title || view.feed.url}
|
) : view.type === "entry" ? (
|
||||||
</h1>
|
<ArticleView
|
||||||
<a href={view.feed.url} target="_blank" rel="noreferrer" className="ml-auto">
|
feed={view.feed}
|
||||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground transition-colors" />
|
entry={view.entry}
|
||||||
</a>
|
onBack={handleSelectFeed}
|
||||||
</div>
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
|
||||||
<div className="space-y-2 p-3">
|
|
||||||
{view.items.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
|
||||||
No items found.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
view.items.map((item, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => handleSelectItem(view.feed, item)}
|
|
||||||
className="w-full select-none rounded-lg border border-transparent bg-card/40 px-4 py-3.5 text-left transition-all duration-200 hover:border-border/70 hover:bg-card hover:shadow-sm"
|
|
||||||
>
|
|
||||||
<p dir="auto" className="mb-1 line-clamp-2 text-sm font-semibold leading-snug tracking-tight">
|
|
||||||
{item.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{formatPublishedDate(item.published)}</p>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</>
|
|
||||||
) : view.type === "entry" ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-3 border-b border-border/70 bg-card/40 px-6 py-3.5 select-none">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 shrink-0"
|
|
||||||
onClick={() => handleSelectFeed(view.feed)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-muted-foreground truncate">{view.feed.title}</span>
|
|
||||||
<a href={view.entry.url} target="_blank" rel="noreferrer" className="ml-auto">
|
|
||||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground transition-colors" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
|
||||||
<article className="mx-auto w-full max-w-3xl select-text px-4 py-7 sm:px-6 lg:px-8">
|
|
||||||
<h1 dir="auto" className="mb-3 text-2xl font-semibold leading-tight tracking-tight">
|
|
||||||
{view.entry.title}
|
|
||||||
</h1>
|
|
||||||
<div className="mb-6 space-y-2">
|
|
||||||
{view.entry.authors.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{view.entry.authors.map((author, index) => renderAuthor(author, index))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{view.entry.published && (
|
|
||||||
<span className="text-xs text-muted-foreground">{formatPublishedDate(view.entry.published, true)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{view.entry.summary && !isSummarySameAsContent(view.entry.summary, view.entry.content) && (
|
|
||||||
<p className="mb-7 border-l-2 border-border pl-4 text-sm italic leading-6 text-muted-foreground">
|
|
||||||
{view.entry.summary}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="reader-content prose prose-neutral prose-sm sm:prose-base break-words dark:prose-invert prose-headings:font-semibold prose-headings:tracking-tight prose-p:leading-7 prose-li:leading-7 prose-a:text-primary prose-a:no-underline prose-a:hover:underline prose-blockquote:border-primary/40 prose-blockquote:text-muted-foreground prose-img:rounded-lg prose-img:border [&_img]:max-w-full [&_pre]:max-w-full"
|
|
||||||
dangerouslySetInnerHTML={{ __html: getRenderableHtml(view.entry.content) }}
|
|
||||||
/>
|
/>
|
||||||
</article>
|
|
||||||
</ScrollArea>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{uiError && (
|
{uiError && (
|
||||||
<div className="pointer-events-none absolute right-4 top-4 z-[70] w-full max-w-sm">
|
<ErrorToast error={uiError} onDismiss={() => setUiError(null)} />
|
||||||
<div className="pointer-events-auto animate-in slide-in-from-top-2 fade-in-0 rounded-lg border border-destructive/30 bg-card p-3 shadow-lg duration-200">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="rounded-md bg-destructive/15 p-1.5 text-destructive">
|
|
||||||
<CircleAlert className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-semibold text-foreground">{uiError.title}</p>
|
|
||||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{uiError.message}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 shrink-0"
|
|
||||||
onClick={() => setUiError(null)}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
|
||||||
19
src/components/EmptyState.tsx
Normal file
19
src/components/EmptyState.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
|
<Rss className="h-10 w-10 opacity-20" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{hasFeedsConfigured ? "Select a feed to read" : "Add a feed to get started"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
41
src/components/ErrorToast.tsx
Normal file
41
src/components/ErrorToast.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="pointer-events-none absolute right-4 top-4 z-[70] w-full max-w-sm">
|
||||||
|
<div className="pointer-events-auto animate-in slide-in-from-top-2 fade-in-0 rounded-lg border border-destructive/30 bg-card p-3 shadow-lg duration-200">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="shrink-0 rounded-md bg-destructive/15 p-1.5 text-destructive">
|
||||||
|
<CircleAlert className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold text-foreground">{error.title}</p>
|
||||||
|
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
17
src/components/LoadingView.tsx
Normal file
17
src/components/LoadingView.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex-1 space-y-4 overflow-hidden p-5">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2.5">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-3 w-1/3" />
|
||||||
|
{i < 5 && <Separator className="mt-1" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
122
src/components/article/ArticleView.tsx
Normal file
122
src/components/article/ArticleView.tsx
Normal file
|
|
@ -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 */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-border/70 bg-card/40 px-4 py-3 select-none shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => onBack(feed)}
|
||||||
|
aria-label="Go back"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||||
|
{feed.title || feed.url}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={entry.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label="Open original article"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground transition-colors hover:text-foreground" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable article body — native scroll for best performance */}
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<article className="mx-auto w-full max-w-2xl select-text px-5 py-8 sm:px-8">
|
||||||
|
{/* Title */}
|
||||||
|
<h1
|
||||||
|
dir="auto"
|
||||||
|
className="mb-4 text-2xl font-bold leading-tight tracking-tight text-foreground sm:text-3xl"
|
||||||
|
>
|
||||||
|
{entry.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Meta: authors + date */}
|
||||||
|
<div className="mb-6 space-y-3">
|
||||||
|
{entry.authors.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{entry.authors.map((author, index) => (
|
||||||
|
<AuthorTag key={`${author.name}-${index}`} author={author} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entry.published && (
|
||||||
|
<time
|
||||||
|
dateTime={entry.published}
|
||||||
|
className="block text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{formattedDate}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary / lead paragraph */}
|
||||||
|
{showSummary && entry.summary && (
|
||||||
|
<p className="mb-8 border-l-2 border-primary/50 pl-4 text-sm italic leading-7 text-muted-foreground">
|
||||||
|
{entry.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Article HTML content */}
|
||||||
|
<div
|
||||||
|
className="reader-content prose prose-neutral prose-sm sm:prose-base max-w-none break-words dark:prose-invert prose-headings:font-semibold prose-headings:tracking-tight prose-p:leading-7 prose-li:leading-7 prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-blockquote:border-primary/40 prose-blockquote:text-muted-foreground prose-img:rounded-lg prose-img:border [&_img]:max-w-full [&_pre]:max-w-full"
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
43
src/components/article/AuthorTag.tsx
Normal file
43
src/components/article/AuthorTag.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
key={`${author.name}-${index}`}
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-md border border-border/70 bg-muted/40 px-2.5 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-foreground" dir="auto">
|
||||||
|
{author.name}
|
||||||
|
</span>
|
||||||
|
{author.email && (
|
||||||
|
<a
|
||||||
|
href={`mailto:${author.email}`}
|
||||||
|
className="text-muted-foreground underline decoration-dotted underline-offset-4 transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{author.email}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{normalizedUri && (
|
||||||
|
<a
|
||||||
|
href={normalizedUri}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-primary underline decoration-dotted underline-offset-4 transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{getAuthorUriLabel(normalizedUri)}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
34
src/components/feed/FeedItemCard.tsx
Normal file
34
src/components/feed/FeedItemCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<button
|
||||||
|
onClick={() => onClick(item)}
|
||||||
|
className="group w-full select-none rounded-lg border border-transparent bg-card/40 px-4 py-3.5 text-left transition-all duration-150 hover:border-border/70 hover:bg-card hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
dir="auto"
|
||||||
|
className="mb-1.5 line-clamp-2 text-sm font-semibold leading-snug tracking-tight text-foreground"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatPublishedDate(item.published)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
84
src/components/feed/FeedItemsList.tsx
Normal file
84
src/components/feed/FeedItemsList.tsx
Normal file
|
|
@ -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 */}
|
||||||
|
<div className="flex shrink-0 items-center gap-2.5 border-b border-border/70 px-5 py-3.5 select-none">
|
||||||
|
{feed.icon && (
|
||||||
|
<img
|
||||||
|
src={feed.icon}
|
||||||
|
alt=""
|
||||||
|
className="h-5 w-5 shrink-0 rounded-sm object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h1 dir="auto" className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||||
|
{feed.title || feed.url}
|
||||||
|
</h1>
|
||||||
|
<a
|
||||||
|
href={feed.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label="Open feed source"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground transition-colors hover:text-foreground" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items list */}
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2 rounded-lg bg-card/40 p-4">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-3 w-1/3" />
|
||||||
|
{i < 5 && <Separator className="mt-3" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex h-40 items-center justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground">No items found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 p-3">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<FeedItemCard
|
||||||
|
key={item.url || i}
|
||||||
|
item={item}
|
||||||
|
onClick={handleItemClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
42
src/components/sidebar/AddFeedInput.tsx
Normal file
42
src/components/sidebar/AddFeedInput.tsx
Normal file
|
|
@ -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<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 border-b border-border/70 px-3 py-3">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="h-8 flex-1 rounded-md border border-border/80 bg-muted/50 px-2.5 text-xs outline-none transition-colors focus:border-primary focus:ring-0"
|
||||||
|
placeholder="https://..."
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onAdd()}
|
||||||
|
autoFocus
|
||||||
|
disabled={adding}
|
||||||
|
aria-label="Feed URL"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3 text-xs"
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={adding || !value.trim()}
|
||||||
|
>
|
||||||
|
{adding ? "Adding…" : "Add"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
50
src/components/sidebar/FeedListItem.tsx
Normal file
50
src/components/sidebar/FeedListItem.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(feed)}
|
||||||
|
className={`group relative flex w-full items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left text-sm transition-all duration-150 ${
|
||||||
|
isSelected
|
||||||
|
? "border-border bg-card shadow-sm"
|
||||||
|
: "border-transparent hover:border-border/60 hover:bg-card/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{feed.icon ? (
|
||||||
|
<img
|
||||||
|
src={feed.icon}
|
||||||
|
alt=""
|
||||||
|
className="h-4 w-4 shrink-0 rounded-sm object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Rss className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span dir="auto" className="min-w-0 flex-1 truncate font-medium">
|
||||||
|
{feed.title || feed.url}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
onClick={(e) => onRemove(feed, e)}
|
||||||
|
role="button"
|
||||||
|
aria-label="Remove feed"
|
||||||
|
title="Remove feed"
|
||||||
|
className="inline-flex items-center justify-center rounded-md p-1 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
171
src/components/sidebar/SidebarContent.tsx
Normal file
171
src/components/sidebar/SidebarContent.tsx
Normal file
|
|
@ -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<void>;
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-border/70 px-4 py-3.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Rss className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm font-semibold tracking-tight">RuFeed</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={resolvedTheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
title={resolvedTheme === "dark" ? "Light mode" : "Dark mode"}
|
||||||
|
>
|
||||||
|
{resolvedTheme === "dark" ? (
|
||||||
|
<Sun className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={onRefresh}
|
||||||
|
aria-label="Refresh feeds"
|
||||||
|
title="Refresh feeds"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => setShowAddInput((v) => !v)}
|
||||||
|
aria-label="Add feed"
|
||||||
|
title="Add feed"
|
||||||
|
>
|
||||||
|
<PlusCircle className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showHideButton && onHideSidebar && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={onHideSidebar}
|
||||||
|
aria-label="Hide sidebar"
|
||||||
|
title="Hide sidebar"
|
||||||
|
>
|
||||||
|
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add feed input */}
|
||||||
|
{showAddInput && (
|
||||||
|
<AddFeedInput
|
||||||
|
value={addUrl}
|
||||||
|
adding={adding}
|
||||||
|
onChange={setAddUrl}
|
||||||
|
onAdd={handleAdd}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feed list */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
|
<div className="space-y-1 p-2.5">
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-2.5 rounded-lg border border-border/40 px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-4 w-4 rounded-sm" />
|
||||||
|
<Skeleton className="h-3.5 flex-1 rounded" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : feeds.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-border/60 px-4 py-10 text-center text-xs text-muted-foreground">
|
||||||
|
No feeds yet. Add one above.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
feeds.map((feed) => (
|
||||||
|
<FeedListItem
|
||||||
|
key={feed.id}
|
||||||
|
feed={feed}
|
||||||
|
isSelected={selectedFeedId === feed.id}
|
||||||
|
onSelect={onSelectFeed}
|
||||||
|
onRemove={onRemoveFeed}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="shrink-0 border-t border-border/70 px-4 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{feeds.length} feed{feeds.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
113
src/hooks/useFeeds.ts
Normal file
113
src/hooks/useFeeds.ts
Normal file
|
|
@ -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<Feed[]>([]);
|
||||||
|
const [loadingFeeds, setLoadingFeeds] = useState(true);
|
||||||
|
const [selectedFeed, setSelectedFeed] = useState<Feed | null>(null);
|
||||||
|
const [view, setView] = useState<View>({ type: "empty" });
|
||||||
|
const [loadingView, setLoadingView] = useState(false);
|
||||||
|
const [uiError, setUiError] = useState<UiError | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/types/index.ts
Normal file
14
src/types/index.ts
Normal file
|
|
@ -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 };
|
||||||
109
src/utils/index.ts
Normal file
109
src/utils/index.ts
Normal file
|
|
@ -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";
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue