diff --git a/src/App.css b/src/App.css index 825fd4a..316379a 100644 --- a/src/App.css +++ b/src/App.css @@ -2,6 +2,8 @@ @theme { --font-sans: "Inter", system-ui, sans-serif; + --font-serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; + --font-mono: "SFMono-Regular", "Cascadia Mono", "JetBrains Mono", Consolas, monospace; --color-canvas: #f7f7f5; --color-surface: #ffffff; @@ -196,6 +198,257 @@ body { position: relative; } +.read-view-content { + color: var(--color-content); + font-family: inherit; + font-size: var(--reader-font-size, 1.045rem); + line-height: var(--reader-line-height, 1.82); + letter-spacing: var(--reader-letter-spacing, -0.005em); + overflow-wrap: break-word; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +.reader-article, +.reader-article * { + -webkit-user-select: text; + user-select: text; +} + +.reader-article button, +.reader-article img { + -webkit-user-select: none; + user-select: none; +} + +.read-view-content > :first-child { + margin-top: 0; +} + +.read-view-content > :last-child { + margin-bottom: 0; +} + +.read-view-content h1, +.read-view-content h2, +.read-view-content h3, +.read-view-content h4, +.read-view-content h5, +.read-view-content h6 { + margin: 2.25em 0 0.82em; + color: var(--color-content); + font-weight: 600; + line-height: 1.14; + letter-spacing: -0.032em; + text-wrap: balance; +} + +.read-view-content h1 { + font-size: clamp(1.65rem, 1.1vw + 1.2rem, 2.2rem); +} + +.read-view-content h2 { + font-size: clamp(1.35rem, 0.8vw + 1.05rem, 1.75rem); +} + +.read-view-content h3 { + font-size: clamp(1.15rem, 0.45vw + 1rem, 1.35rem); +} + +.read-view-content p, +.read-view-content ul, +.read-view-content ol, +.read-view-content blockquote, +.read-view-content pre, +.read-view-content table, +.read-view-content figure { + margin: 1.1em 0; +} + +.read-view-content p { + text-wrap: pretty; + max-width: 38rem; +} + +.read-view-content p + p { + margin-top: 0.95em; +} + +.read-view-content ul, +.read-view-content ol { + padding-left: 1.45rem; +} + +.read-view-content li { + margin: 0.42em 0; +} + +.read-view-content a { + color: var(--color-content); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--color-content) 36%, transparent); + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; +} + +.read-view-content a:hover { + text-decoration-color: currentColor; +} + +.read-view-content strong, +.read-view-content b { + font-weight: 650; +} + +.read-view-content blockquote { + border-left: 2px solid var(--color-border-strong); + padding-left: 1.05rem; + color: var(--color-content-muted); + font-style: italic; +} + +.read-view-content hr { + margin: 1.75rem 0; + border: 0; + border-top: 1px solid var(--color-border-subtle); +} + +.read-view-content img { + display: block; + max-width: 100%; + height: auto; + border: 1px solid var(--color-border-subtle); + border-radius: 1rem; +} + +.read-view-content pre, +.read-view-content code { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.read-view-content pre { + overflow-x: auto; + border: 1px solid var(--color-border-subtle); + border-radius: 1rem; + background: var(--color-surface-subtle); + padding: 0.95rem 1rem; + line-height: 1.6; + font-size: 0.93em; +} + +.read-view-content :not(pre) > code { + border: 1px solid var(--color-border-subtle); + border-radius: 0.45rem; + background: var(--color-surface-subtle); + padding: 0.1rem 0.32rem; + font-size: 0.92em; +} + +.read-view-content table { + width: 100%; + border-collapse: collapse; + font-size: 0.95em; +} + +.read-view-content th, +.read-view-content td { + border-bottom: 1px solid var(--color-border-subtle); + padding: 0.7rem 0.8rem; + text-align: left; + vertical-align: top; +} + +.read-view-content thead th { + color: var(--color-content-muted); + font-size: 0.78em; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.reader-article { + --reader-font-size: 1.045rem; + --reader-line-height: 1.82; + --reader-letter-spacing: -0.005em; +} + +.reader-font-sans { + font-family: var(--font-sans); +} + +.reader-font-serif { + font-family: var(--font-serif); + --reader-line-height: 1.88; + --reader-letter-spacing: -0.003em; +} + +.reader-font-serif .read-view-content p { + max-width: 37rem; +} + +.reader-font-serif .read-view-content h1, +.reader-font-serif .read-view-content h2, +.reader-font-serif .read-view-content h3, +.reader-font-serif .read-view-content h4, +.reader-font-serif .read-view-content h5, +.reader-font-serif .read-view-content h6 { + letter-spacing: -0.025em; +} + +.reader-font-mono { + font-family: var(--font-mono); + --reader-font-size: 0.98rem; + --reader-line-height: 1.74; + --reader-letter-spacing: -0.01em; +} + +.reader-font-mono .read-view-content p { + max-width: 40rem; +} + +.reader-size-xs { + --reader-font-size: 0.95rem; +} + +.reader-size-sm { + --reader-font-size: 1rem; +} + +.reader-size-md { + --reader-font-size: 1.045rem; +} + +.reader-size-lg { + --reader-font-size: 1.11rem; +} + +.reader-size-xl { + --reader-font-size: 1.18rem; +} + +@media (max-width: 1100px) { + .read-view-content { + font-size: calc(var(--reader-font-size, 1.045rem) - 0.03rem); + line-height: calc(var(--reader-line-height, 1.82) - 0.08); + } +} + +@media (max-width: 900px) { + .read-view-content { + font-size: calc(var(--reader-font-size, 1.045rem) - 0.08rem); + line-height: calc(var(--reader-line-height, 1.82) - 0.12); + } + + .read-view-content h1, + .read-view-content h2, + .read-view-content h3, + .read-view-content h4, + .read-view-content h5, + .read-view-content h6 { + margin-top: 1.9em; + } +} + @supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { .app-panel-blur { -webkit-backdrop-filter: saturate(1.2) blur(18px); diff --git a/src/components/ArticleList.tsx b/src/components/ArticleList.tsx index a5459a5..02c7e02 100644 --- a/src/components/ArticleList.tsx +++ b/src/components/ArticleList.tsx @@ -22,6 +22,7 @@ type ArticleListProps = { showThumbnails?: boolean; selectedItemIds?: string[]; onItemClick?: (event: MouseEvent, item: ArticleListItem) => void; + onItemDoubleClick?: (item: ArticleListItem) => void; selectionActions?: ReactNode; onClearSelection?: () => void; getItemContextMenuItems?: (item: ArticleListItem) => NativeContextMenuItem[]; @@ -33,6 +34,7 @@ export function ArticleList({ showThumbnails = true, selectedItemIds = [], onItemClick, + onItemDoubleClick, selectionActions, onClearSelection, getItemContextMenuItems, @@ -68,6 +70,7 @@ export function ArticleList({ showThumbnails={showThumbnails} isSelected={selectedItemIdSet.has(item.id)} onItemClick={onItemClick} + onItemDoubleClick={onItemDoubleClick} contextMenuItems={getItemContextMenuItems?.(item)} /> ))} @@ -82,6 +85,7 @@ function ArticleListRow({ showThumbnails, isSelected, onItemClick, + onItemDoubleClick, contextMenuItems, }: { item: ArticleListItem; @@ -89,18 +93,22 @@ function ArticleListRow({ showThumbnails: boolean; isSelected: boolean; onItemClick?: (event: MouseEvent, item: ArticleListItem) => void; + onItemDoubleClick?: (item: ArticleListItem) => void; contextMenuItems?: NativeContextMenuItem[]; }) { + const isUnread = Boolean(item.unread); + return (
onItemClick(event, item) : undefined} + onDoubleClick={onItemDoubleClick ? () => onItemDoubleClick(item) : undefined} onContextMenu={ contextMenuItems?.length ? (event) => { @@ -109,7 +117,18 @@ function ArticleListRow({ : undefined } > -
+
+ {isUnread ? ( + + ) : ( +
+ +
{showThumbnails ? (
- {item.unread ? ( - - ) : ( - - )}

{item.title}

{item.starred ? ( @@ -141,8 +155,12 @@ function ArticleListRow({

-
{item.feed}
-
{item.publishedAt}
+
+ {item.feed} +
+
+ {item.publishedAt} +
); } diff --git a/src/components/ReadView.tsx b/src/components/ReadView.tsx new file mode 100644 index 0000000..be4e91e --- /dev/null +++ b/src/components/ReadView.tsx @@ -0,0 +1,457 @@ +import { useEffect, useMemo, useRef } from "react"; +import { ArrowLeftIcon, ExternalLinkIcon, Settings2Icon, StarIcon } from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { Button } from "@/components/ui/Button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/Popover"; +import { cn } from "@/lib/cn"; +import { extractFirstImageUrl, sanitizeHtml } from "@/lib/safeHtml"; +import { + useUserOptions, + type ReaderContentWidth, + type ReaderFontFamily, + type ReaderFontSize, +} from "@/stores/userOptionsStore"; + +export type ReadViewItem = { + id: string; + title: string; + feedTitle: string; + publishedAt: string | null; + publishedLabel: string; + author?: string | null; + url?: string | null; + summaryText?: string | null; + summaryHtml?: string | null; + contentHtml?: string | null; + thumbnailUrl?: string | null; + isRead?: boolean; + isStarred?: boolean; +}; + +type ReadViewProps = { + items: ReadViewItem[]; + activeItemId: string | null; + onSelectItem: (itemId: string) => void; + onClose: () => void; +}; + +export function ReadView({ + items, + activeItemId, + onSelectItem, + onClose, +}: ReadViewProps) { + const activeItem = items.find((item) => item.id === activeItemId) ?? null; + const contentRef = useRef(null); + const sidebarItemRefs = useRef>({}); + const readerSettings = useUserOptions((state) => state.readerSettings); + const updateReaderSettings = useUserOptions((state) => state.updateReaderSettings); + + const sanitizedMarkup = useMemo(() => { + if (!activeItem) { + return ""; + } + + const markup = + activeItem.contentHtml ?? + activeItem.summaryHtml ?? + buildFallbackMarkup(activeItem.summaryText, activeItem.title); + + return sanitizeHtml(markup); + }, [activeItem]); + + const coverImageUrl = useMemo(() => { + if (!activeItem) { + return null; + } + + return ( + activeItem.thumbnailUrl ?? + extractFirstImageUrl(activeItem.contentHtml) ?? + extractFirstImageUrl(activeItem.summaryHtml) + ); + }, [activeItem]); + + useEffect(() => { + if (!activeItem) { + return; + } + + const currentContentRef = contentRef.current; + if (currentContentRef) { + currentContentRef.scrollTop = 0; + } + }, [activeItem?.id]); + + useEffect(() => { + if (!activeItemId) { + return; + } + + const activeSidebarItem = sidebarItemRefs.current[activeItemId]; + activeSidebarItem?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + }, [activeItemId]); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + if (!activeItem) { + return null; + } + + return ( +
+
+ + +
+
+
+
+
+
+ {activeItem.feedTitle} +
+

+ {activeItem.title} +

+
+ {activeItem.author ? {activeItem.author} : null} + {activeItem.publishedLabel} + {activeItem.isStarred ? Saved : null} +
+
+ +
+ + updateReaderSettings({ fontFamily }) + } + onFontSizeChange={(fontSize) => + updateReaderSettings({ fontSize }) + } + onContentWidthChange={(contentWidth) => + updateReaderSettings({ contentWidth }) + } + /> + + {activeItem.url ? ( + + ) : null} +
+
+
+ +
+ {coverImageUrl ? ( +
+ +
+ ) : null} + +
{ + const target = event.target; + + if (!(target instanceof HTMLElement)) { + return; + } + + const anchor = target.closest("a"); + + if (!(anchor instanceof HTMLAnchorElement) || !anchor.href) { + return; + } + + event.preventDefault(); + void openUrl(anchor.href); + }} + dangerouslySetInnerHTML={{ __html: sanitizedMarkup }} + /> + + {activeItem.url ? ( +
+ +
+ ) : null} +
+
+
+
+
+ ); +} + +function ReaderSettingsPopover({ + fontFamily, + fontSize, + contentWidth, + onFontFamilyChange, + onFontSizeChange, + onContentWidthChange, +}: { + fontFamily: ReaderFontFamily; + fontSize: ReaderFontSize; + contentWidth: ReaderContentWidth; + onFontFamilyChange: (value: ReaderFontFamily) => void; + onFontSizeChange: (value: ReaderFontSize) => void; + onContentWidthChange: (value: ReaderContentWidth) => void; +}) { + return ( + + + + + +
+ + + + + +
+
+
+ ); +} + +function ReaderOptionGroup({ + label, + options, + value, + onChange, +}: { + label: string; + options: Array<{ label: string; value: T }>; + value: T; + onChange: (value: T) => void; +}) { + return ( +
+
+ {label} +
+
+ {options.map((option) => ( + + ))} +
+
+ ); +} + +function buildFallbackMarkup(summaryText: string | null | undefined, title: string) { + const safeSummary = summaryText?.trim(); + + if (!safeSummary) { + return `

${escapeHtml(`No article content is available for ${title}.`)}

`; + } + + return safeSummary + .split(/\n{2,}/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean) + .map((paragraph) => `

${escapeHtml(paragraph)}

`) + .join(""); +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const contentWidthClassName: Record = { + full: "max-w-[72rem]", + medium: "max-w-[56rem]", + narrow: "max-w-[46rem]", +}; + +const readerFontFamilyClassName: Record = { + sans: "reader-font-sans", + serif: "reader-font-serif", + mono: "reader-font-mono", +}; + +const readerFontSizeClassName: Record = { + xs: "reader-size-xs", + sm: "reader-size-sm", + md: "reader-size-md", + lg: "reader-size-lg", + xl: "reader-size-xl", +}; diff --git a/src/lib/safeHtml.ts b/src/lib/safeHtml.ts new file mode 100644 index 0000000..f49eb9f --- /dev/null +++ b/src/lib/safeHtml.ts @@ -0,0 +1,205 @@ +const ALLOWED_TAGS = new Set([ + "a", + "article", + "aside", + "blockquote", + "br", + "code", + "del", + "em", + "figcaption", + "figure", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "img", + "li", + "ol", + "p", + "pre", + "section", + "small", + "span", + "strong", + "sub", + "sup", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "u", + "ul", +]); + +const DROP_TAGS = new Set([ + "script", + "style", + "iframe", + "object", + "embed", + "template", + "link", + "meta", + "base", + "form", + "input", + "button", + "textarea", + "select", + "option", + "svg", + "math", +]); + +const ALLOWED_ATTRS = new Set([ + "alt", + "aria-label", + "aria-hidden", + "colspan", + "href", + "rel", + "rowspan", + "src", + "target", + "title", +]); + +export function sanitizeHtml(html: string) { + if (!html.trim()) { + return ""; + } + + const parser = new DOMParser(); + const document = parser.parseFromString(html, "text/html"); + + for (const child of Array.from(document.body.childNodes)) { + sanitizeNode(child); + } + + return document.body.innerHTML; +} + +export function extractFirstImageUrl(html: string | null | undefined) { + if (!html?.trim()) { + return null; + } + + const parser = new DOMParser(); + const document = parser.parseFromString(html, "text/html"); + const image = document.querySelector("img"); + const src = image?.getAttribute("src"); + + return isSafeUrl(src, false) ? src ?? null : null; +} + +function sanitizeNode(node: Node) { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement; + const tagName = element.tagName.toLowerCase(); + + if (DROP_TAGS.has(tagName)) { + element.remove(); + return; + } + + if (!ALLOWED_TAGS.has(tagName)) { + unwrapElement(element); + return; + } + + for (const attribute of Array.from(element.attributes)) { + const name = attribute.name.toLowerCase(); + + if (name.startsWith("on") || name === "style") { + element.removeAttribute(attribute.name); + continue; + } + + if (!ALLOWED_ATTRS.has(name)) { + element.removeAttribute(attribute.name); + } + } + + if (tagName === "a") { + const href = element.getAttribute("href"); + + if (!isSafeUrl(href, true)) { + element.removeAttribute("href"); + } else { + element.setAttribute("target", "_blank"); + element.setAttribute("rel", "noopener noreferrer nofollow"); + } + } + + if (tagName === "img") { + const src = element.getAttribute("src"); + + if (!isSafeUrl(src, false)) { + element.remove(); + return; + } + + element.setAttribute("loading", "lazy"); + if (!element.getAttribute("alt")) { + element.setAttribute("alt", ""); + } + } + } + + for (const child of Array.from(node.childNodes)) { + sanitizeNode(child); + } +} + +function unwrapElement(element: HTMLElement) { + const parent = element.parentNode; + + if (!parent) { + element.remove(); + return; + } + + while (element.firstChild) { + parent.insertBefore(element.firstChild, element); + } + + element.remove(); +} + +function isSafeUrl(value: string | null | undefined, allowMailTo: boolean) { + if (!value) { + return false; + } + + const trimmedValue = value.trim(); + + if (!trimmedValue) { + return false; + } + + if (trimmedValue.startsWith("#")) { + return true; + } + + try { + const url = new URL(trimmedValue, window.location.href); + + if (url.protocol === "http:" || url.protocol === "https:") { + return true; + } + + if (allowMailTo && url.protocol === "mailto:") { + return true; + } + } catch { + return false; + } + + return false; +} diff --git a/src/routes/DashboardRoute.tsx b/src/routes/DashboardRoute.tsx index 29f285e..d94dbde 100644 --- a/src/routes/DashboardRoute.tsx +++ b/src/routes/DashboardRoute.tsx @@ -1,4 +1,6 @@ +import { useState } from "react"; import { ArticleList } from "@/components/ArticleList"; +import { ReadView, type ReadViewItem } from "@/components/ReadView"; import { RouteFilterBar } from "@/components/RouteFilterBar"; import { ViewSelect } from "@/components/ViewSelect"; import { RouteLayout } from "@/routes/RouteLayout"; @@ -7,54 +9,65 @@ import { useUserOptions } from "@/stores/userOptionsStore"; export function DashboardRoute() { const view = useUserOptions((state) => state.articleListView); const setView = useUserOptions((state) => state.setArticleListView); + const [activeReadItemId, setActiveReadItemId] = useState(null); return ( - } - filters={ - + } + filters={ + + } + > + setActiveReadItemId(item.id)} /> - } - > - + + setActiveReadItemId(null)} /> - + ); } @@ -107,3 +120,95 @@ const dashboardItems = [ publishedAt: "4h ago", }, ]; + +const dashboardReadViewItems: ReadViewItem[] = [ + { + id: "dashboard-1", + title: "Firefox tests a quieter vertical tab experience for power users", + feedTitle: "Ars", + publishedAt: null, + publishedLabel: "9m ago", + url: "https://example.com/firefox-vertical-tabs", + thumbnailUrl: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=1600&q=80", + summaryText: + "Mozilla is refining sidebar density and selection behavior, making tab management feel more at home on desktop-sized screens.", + contentHtml: ` +

Mozilla is tuning vertical tabs around calmer density, stronger active states, and less ornamental chrome.

+

The direction is relevant for any desktop reader: lists need to scan quickly, selection needs to stay obvious, and the shell should get out of the way once the user is working.

+
Power features only feel good when the default state is quiet.
+

That same principle applies to article rows, split layouts, and keyboard-driven reading flows.

+ `, + isRead: false, + }, + { + id: "dashboard-2", + title: "The Verge reviews a week of offline-first note taking", + feedTitle: "The Verge", + publishedAt: null, + publishedLabel: "22m ago", + url: "https://example.com/offline-first-note-taking", + thumbnailUrl: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?auto=format&fit=crop&w=1600&q=80", + summaryText: + "A practical comparison of sync models, local persistence, and how much product complexity users actually notice in daily work.", + contentHtml: ` +

After a week of switching between local-first note apps, the main difference was not sync speed but confidence.

+

Products that clearly separate local state, pending work, and remote updates feel more trustworthy than products that try to hide everything behind a vague loading state.

+

For an RSS reader, that argues for pragmatic persistence and predictable refresh actions rather than premature sync complexity.

+ `, + isRead: false, + isStarred: true, + }, + { + id: "dashboard-3", + title: "Tauri 2.0 desktop patterns worth borrowing for native-feeling apps", + feedTitle: "Tauri", + publishedAt: null, + publishedLabel: "1h ago", + url: "https://example.com/tauri-desktop-patterns", + thumbnailUrl: "https://images.unsplash.com/photo-1515879218367-8466d910aaa4?auto=format&fit=crop&w=1600&q=80", + summaryText: + "A short write-up covering titlebars, drag regions, keyboard flows, and the small details that make app shells feel grounded.", + contentHtml: ` +

Native-feeling apps are usually built from a handful of disciplined decisions: compact headers, sensible focus treatment, and panels that resize cleanly.

+

Tauri makes the shell side feasible, but the product still needs a reading surface that respects long-form content.

+

That means generous line-height, narrow readable measure, calm metadata, and obvious escape hatches back to the list.

+ `, + isRead: false, + }, + { + id: "dashboard-4", + title: "How designers tune reading rhythm in dense editorial products", + feedTitle: "Figma", + publishedAt: null, + publishedLabel: "2h ago", + url: "https://example.com/reading-rhythm-editorial-products", + summaryText: + "Examples of spacing, hierarchy, and muted UI chrome that keep long article lists scannable without feeling sparse.", + contentHtml: ` +

Strong reading rhythm comes from consistency more than decoration.

+

When heading spacing, paragraph spacing, and media blocks follow a clear cadence, the article feels easier to trust and easier to stay in.

+

A desktop read view benefits from this even more than a website because it sits inside a more tool-like environment.

+ `, + isRead: true, + }, + { + id: "dashboard-5", + title: "SQLite indexing strategies for local-first content apps", + feedTitle: "PlanetScale", + publishedAt: null, + publishedLabel: "4h ago", + url: "https://example.com/sqlite-indexing-local-first-content-apps", + summaryText: + "A grounded explanation of pragmatic indexes for feed items, unread states, and date ranges without overengineering the schema.", + contentHtml: ` +

Good local data design starts with the queries the product actually runs.

+
    +
  • recent items by feed
  • +
  • unread items across feeds
  • +
  • saved items and date filters
  • +
+

That usually points to a small number of composite indexes, not a complicated storage architecture.

+ `, + isRead: true, + }, +]; diff --git a/src/routes/FeedRoute.tsx b/src/routes/FeedRoute.tsx index e624c9e..4c1cf69 100644 --- a/src/routes/FeedRoute.tsx +++ b/src/routes/FeedRoute.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { RefreshCwIcon, RssIcon, Trash2Icon } from "lucide-react"; import { listen } from "@tauri-apps/api/event"; import { ArticleList, type ArticleListItem } from "@/components/ArticleList"; +import { ReadView, type ReadViewItem } from "@/components/ReadView"; import { Button } from "@/components/ui/Button"; import { IconButton } from "@/components/ui/IconButton"; import { ViewSelect } from "@/components/ViewSelect"; @@ -30,6 +31,7 @@ export function FeedRoute() { const deleteArticlesById = useArticleStore((state) => state.deleteArticlesById); const resolvedFeedView = feedView ?? emptyFeedState; const isMissingFeed = feed?.lastFetchStatus === "not_found"; + const [activeReadItemId, setActiveReadItemId] = useState(null); useEffect(() => { if (!feedId) { @@ -95,6 +97,21 @@ export function FeedRoute() { unread: !item.isRead, starred: item.isStarred, })); + const readViewItems: ReadViewItem[] = resolvedFeedView.items.map((item) => ({ + id: item.id, + title: item.title, + feedTitle: item.feedTitle, + publishedAt: item.publishedAt, + publishedLabel: formatReadViewPublishedAt(item.publishedAt), + author: item.author, + url: item.url, + summaryText: item.summaryText, + summaryHtml: item.summaryHtml, + contentHtml: item.contentHtml, + thumbnailUrl: item.thumbnailUrl, + isRead: item.isRead, + isStarred: item.isStarred, + })); const itemIds = useMemo(() => articleItems.map((item) => item.id), [articleItems]); @@ -131,6 +148,18 @@ export function FeedRoute() { return () => window.removeEventListener("keydown", handleKeyDown); }, [feedId, refreshFeed]); + useEffect(() => { + if (!activeReadItemId) { + return; + } + + const itemStillExists = resolvedFeedView.items.some((item) => item.id === activeReadItemId); + + if (!itemStillExists) { + setActiveReadItemId(null); + } + }, [activeReadItemId, resolvedFeedView.items]); + async function handleDeleteFeed() { if (!feedId) { return; @@ -164,104 +193,124 @@ export function FeedRoute() { clearSelection(); } + async function handleOpenReadView(articleId: string) { + setActiveReadItemId(articleId); + + const article = resolvedFeedView.items.find((item) => item.id === articleId); + + if (article && !article.isRead) { + await markArticlesReadState([articleId], true); + } + } + return ( - + <> + + ) : ( + + ) + } + actions={ +
+ } + label="Refresh feed" + onClick={() => { + if (feedId) { + void refreshFeed(feedId); + } + }} + disabled={resolvedFeedView.isRefreshing} + /> + +
+ } + > + {isMissingFeed ? ( +
+
Feed does not exist.
+ +
+ ) : resolvedFeedView.error ? ( +
+ {resolvedFeedView.error} +
+ ) : articleItems.length === 0 && !resolvedFeedView.isLoading ? ( +
+ No articles yet. Background sync or a manual refresh will populate this feed. +
) : ( - - ) - } - actions={ -
- } - label="Refresh feed" - onClick={() => { - if (feedId) { - void refreshFeed(feedId); - } - }} - disabled={resolvedFeedView.isRefreshing} + handleItemClick(event, item.id)} + onItemDoubleClick={(item) => void handleOpenReadView(item.id)} + selectionActions={ + <> + + + + + } + onClearSelection={clearSelection} + getItemContextMenuItems={(item): NativeContextMenuItem[] => [ + { + id: `${item.id}:mark-read`, + text: "Mark as read", + onSelect: () => handleMarkItemReadState(item.id, true), + }, + { + id: `${item.id}:mark-unread`, + text: "Mark as unread", + onSelect: () => handleMarkItemReadState(item.id, false), + }, + { + type: "separator", + }, + { + id: `${item.id}:delete`, + text: "Delete", + onSelect: () => handleDeleteItem(item.id), + }, + ]} /> - -
- } - > - {isMissingFeed ? ( -
-
Feed does not exist.
- -
- ) : resolvedFeedView.error ? ( -
- {resolvedFeedView.error} -
- ) : articleItems.length === 0 && !resolvedFeedView.isLoading ? ( -
- No articles yet. Background sync or a manual refresh will populate this feed. -
- ) : ( - handleItemClick(event, item.id)} - selectionActions={ - <> - - - - - } - onClearSelection={clearSelection} - getItemContextMenuItems={(item): NativeContextMenuItem[] => [ - { - id: `${item.id}:mark-read`, - text: "Mark as read", - onSelect: () => handleMarkItemReadState(item.id, true), - }, - { - id: `${item.id}:mark-unread`, - text: "Mark as unread", - onSelect: () => handleMarkItemReadState(item.id, false), - }, - { - type: "separator", - }, - { - id: `${item.id}:delete`, - text: "Delete", - onSelect: () => handleDeleteItem(item.id), - }, - ]} - /> - )} -
+ )} +
+ + void handleOpenReadView(itemId)} + onClose={() => setActiveReadItemId(null)} + /> + ); } @@ -311,6 +360,26 @@ function formatPublishedAt(value: string | null) { }); } +function formatReadViewPublishedAt(value: string | null) { + if (!value) { + return "Unknown date"; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "Unknown date"; + } + + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + function isSameCalendarDay(left: Date, right: Date) { return ( left.getFullYear() === right.getFullYear() && diff --git a/src/routes/UnreadRoute.tsx b/src/routes/UnreadRoute.tsx index a17bef5..2d69cb8 100644 --- a/src/routes/UnreadRoute.tsx +++ b/src/routes/UnreadRoute.tsx @@ -1,4 +1,6 @@ +import { useState } from "react"; import { ArticleList } from "@/components/ArticleList"; +import { ReadView, type ReadViewItem } from "@/components/ReadView"; import { RouteFilterBar } from "@/components/RouteFilterBar"; import { ViewSelect } from "@/components/ViewSelect"; import { RouteLayout } from "@/routes/RouteLayout"; @@ -7,49 +9,60 @@ import { useUserOptions } from "@/stores/userOptionsStore"; export function UnreadRoute() { const view = useUserOptions((state) => state.articleListView); const setView = useUserOptions((state) => state.setArticleListView); + const [activeReadItemId, setActiveReadItemId] = useState(null); return ( - } - filters={ - + } + filters={ + + } + > + setActiveReadItemId(item.id)} /> - } - > - + + setActiveReadItemId(null)} /> - + ); } @@ -94,3 +107,71 @@ const unreadItems = [ unread: true, }, ]; + +const unreadReadViewItems: ReadViewItem[] = [ + { + id: "unread-1", + title: "Feedbin experiments with calmer keyboard-first inbox navigation", + feedTitle: "Feedbin", + publishedAt: null, + publishedLabel: "5m ago", + url: "https://example.com/feedbin-keyboard-first-inbox", + thumbnailUrl: "https://images.unsplash.com/photo-1504384308090-c894fdcc538d?auto=format&fit=crop&w=1600&q=80", + summaryText: + "The update focuses on predictable focus states, lower visual noise, and keeping frequent actions within easy reach.", + contentHtml: ` +

Feed readers live or die on navigation feel. The new direction trims decorative controls and makes state changes easier to track from the keyboard.

+

That kind of discipline matters for a desktop product where users bounce between list scanning and long-form reading all day.

+ `, + isRead: false, + }, + { + id: "unread-2", + title: "A practical guide to building sidebar trees that scale to nested folders", + feedTitle: "Smashing", + publishedAt: null, + publishedLabel: "47m ago", + url: "https://example.com/sidebar-trees-nested-folders", + thumbnailUrl: "https://images.unsplash.com/photo-1516116216624-53e697fedbea?auto=format&fit=crop&w=1600&q=80", + summaryText: + "An overview of rendering order, keyboard handling, and drag interactions when folders and feeds share the same hierarchy.", + contentHtml: ` +

Mixed trees become fragile when render order, drag targets, and persistence all derive from separate assumptions.

+

A safer model keeps transformation logic centralized and lets the UI focus on presentation.

+

That is especially important once nested folders and keyboard interaction are in play.

+ `, + isRead: false, + isStarred: true, + }, + { + id: "unread-3", + title: "Why dense list views still win for serious reading workflows", + feedTitle: "Nieman Lab", + publishedAt: null, + publishedLabel: "2h ago", + url: "https://example.com/dense-list-views-reading-workflows", + summaryText: + "A strong argument for compact rows, persistent filters, and avoiding large card layouts in information-heavy products.", + contentHtml: ` +

Dense views are not about cramming more content onto the screen. They are about preserving context while the user makes quick decisions.

+

As long as typography and selection states stay crisp, compact rows usually outperform roomy card grids for editorial work.

+ `, + isRead: false, + }, + { + id: "unread-4", + title: "Desktop app polish comes from consistency, not decoration", + feedTitle: "Craft", + publishedAt: null, + publishedLabel: "3h ago", + url: "https://example.com/desktop-polish-consistency", + summaryText: + "Small alignment, spacing, and hover decisions do more to create trust than oversized empty states or promotional headers.", + contentHtml: ` +

Users usually read polish as coherence.

+

If controls align, panels behave consistently, and actions appear where expected, the app feels mature without needing flashy visual tricks.

+

That makes the read view a good place to be restrained: a back button, clear metadata, and clean content rendering go a long way.

+ `, + isRead: false, + }, +]; diff --git a/src/stores/userOptionsStore.ts b/src/stores/userOptionsStore.ts index 838fcc8..07efcbc 100644 --- a/src/stores/userOptionsStore.ts +++ b/src/stores/userOptionsStore.ts @@ -1,10 +1,23 @@ import { create } from "zustand"; import type { ViewSettings } from "@/components/ViewSelect"; +export type ReaderFontFamily = "sans" | "serif" | "mono"; +export type ReaderFontSize = "xs" | "sm" | "md" | "lg" | "xl"; +export type ReaderContentWidth = "full" | "medium" | "narrow"; + +export type ReaderSettings = { + fontFamily: ReaderFontFamily; + fontSize: ReaderFontSize; + contentWidth: ReaderContentWidth; +}; + type UserOptionsState = { articleListView: ViewSettings; + readerSettings: ReaderSettings; setArticleListView: (nextValue: ViewSettings) => void; updateArticleListView: (updates: Partial) => void; + setReaderSettings: (nextValue: ReaderSettings) => void; + updateReaderSettings: (updates: Partial) => void; }; const defaultArticleListView: ViewSettings = { @@ -12,8 +25,15 @@ const defaultArticleListView: ViewSettings = { density: "medium", }; +const defaultReaderSettings: ReaderSettings = { + fontFamily: "sans", + fontSize: "md", + contentWidth: "medium", +}; + export const useUserOptions = create((set) => ({ articleListView: defaultArticleListView, + readerSettings: defaultReaderSettings, setArticleListView: (nextValue) => set({ articleListView: nextValue }), updateArticleListView: (updates) => set((state) => ({ @@ -22,4 +42,12 @@ export const useUserOptions = create((set) => ({ ...updates, }, })), + setReaderSettings: (nextValue) => set({ readerSettings: nextValue }), + updateReaderSettings: (updates) => + set((state) => ({ + readerSettings: { + ...state.readerSettings, + ...updates, + }, + })), }));