diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index bb96fb8..401f404 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -141,6 +141,19 @@ pub struct DeleteFolderResult { pub folder_id: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateArticlesReadStateResult { + pub article_ids: Vec, + pub is_read: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteArticlesResult { + pub article_ids: Vec, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ResetSeededDataResult { @@ -513,6 +526,77 @@ pub fn delete_folder(db_path: &PathBuf, folder_id: &str) -> Result Result { + if article_ids.is_empty() { + return Ok(UpdateArticlesReadStateResult { + article_ids: Vec::new(), + is_read, + }); + } + + let mut connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + let transaction = connection + .transaction() + .map_err(|error| format!("failed to start article update transaction: {error}"))?; + let now = Utc::now().to_rfc3339(); + + for article_id in article_ids { + transaction + .execute( + r#" + UPDATE articles + SET is_read = ?1, + updated_at = ?2 + WHERE id = ?3 + "#, + params![is_read as i64, now, article_id], + ) + .map_err(|error| format!("failed to update article read state: {error}"))?; + } + + transaction + .commit() + .map_err(|error| format!("failed to commit article read state update: {error}"))?; + + Ok(UpdateArticlesReadStateResult { + article_ids: article_ids.to_vec(), + is_read, + }) +} + +pub fn delete_articles(db_path: &PathBuf, article_ids: &[String]) -> Result { + if article_ids.is_empty() { + return Ok(DeleteArticlesResult { + article_ids: Vec::new(), + }); + } + + let mut connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + let transaction = connection + .transaction() + .map_err(|error| format!("failed to start article delete transaction: {error}"))?; + + for article_id in article_ids { + transaction + .execute("DELETE FROM articles WHERE id = ?1", params![article_id]) + .map_err(|error| format!("failed to delete article: {error}"))?; + } + + transaction + .commit() + .map_err(|error| format!("failed to commit article deletion: {error}"))?; + + Ok(DeleteArticlesResult { + article_ids: article_ids.to_vec(), + }) +} + fn load_folder_subtree_ids(connection: &Connection, folder_id: &str) -> Result, String> { let mut statement = connection .prepare( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0561815..63b7fae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod db; use db::{ create_folder as insert_folder, create_feed_draft as insert_feed_draft, + delete_articles as remove_articles, delete_folder as remove_folder, reset_seeded_data as reset_app_seeded_data, get_sidebar_data as load_sidebar_data, init_database, @@ -17,7 +18,8 @@ use db::{ save_sidebar_structure as persist_sidebar_structure, start_background_feed_sync, AppState, FeedMoveInput, FolderMoveInput, ListArticlesInput, RefetchFeedResult, SidebarData, ArticlePage, CreateFeedDraftResult, CreateFolderResult, DeleteFeedResult, - DeleteFolderResult, FeedRecord, FeedSyncState, FolderRecord, ResetSeededDataResult, + DeleteFolderResult, DeleteArticlesResult, FeedRecord, FeedSyncState, FolderRecord, ResetSeededDataResult, + UpdateArticlesReadStateResult, update_articles_read_state as set_articles_read_state, }; use std::collections::HashMap; use std::sync::Arc; @@ -127,6 +129,23 @@ fn delete_folder( remove_folder(&state.db_path, &folder_id) } +#[tauri::command] +fn update_articles_read_state( + state: tauri::State<'_, AppState>, + article_ids: Vec, + is_read: bool, +) -> Result { + set_articles_read_state(&state.db_path, &article_ids, is_read) +} + +#[tauri::command] +fn delete_articles( + state: tauri::State<'_, AppState>, + article_ids: Vec, +) -> Result { + remove_articles(&state.db_path, &article_ids) +} + #[tauri::command] fn reset_seeded_data( state: tauri::State<'_, AppState>, @@ -167,6 +186,8 @@ pub fn run() { initialize_feed_from_url, delete_feed, delete_folder, + update_articles_read_state, + delete_articles, reset_seeded_data ]) .run(tauri::generate_context!()) diff --git a/src/components/ArticleList.tsx b/src/components/ArticleList.tsx index 2b04275..27c3cf6 100644 --- a/src/components/ArticleList.tsx +++ b/src/components/ArticleList.tsx @@ -2,6 +2,7 @@ import type { MouseEvent, ReactNode } from "react"; import { cn } from "@/lib/cn"; import type { ArticleListDensity } from "@/components/ViewSelect"; import { Button } from "@/components/ui/Button"; +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@/components/ui/ContextMenu"; export type ArticleListItem = { id: string; @@ -22,6 +23,7 @@ type ArticleListProps = { onItemClick?: (event: MouseEvent, item: ArticleListItem) => void; selectionActions?: ReactNode; onClearSelection?: () => void; + renderItemContextMenu?: (item: ArticleListItem) => ReactNode; }; export function ArticleList({ @@ -32,13 +34,14 @@ export function ArticleList({ onItemClick, selectionActions, onClearSelection, + renderItemContextMenu, }: ArticleListProps) { const selectedItemIdSet = new Set(selectedItemIds); - const hasSelection = selectedItemIds.length > 0; + const hasMultiSelection = selectedItemIds.length > 1; return (
- {hasSelection && selectedItemIds.length > 1 ? ( + {hasMultiSelection ? (
@@ -57,65 +60,93 @@ export function ArticleList({ ) : null}
{items.map((item) => ( -
+ ))} +
+
+ ); +} + +function ArticleListRow({ + item, + density, + showThumbnails, + isSelected, + onItemClick, + contextMenuContent, +}: { + item: ArticleListItem; + density: ArticleListDensity; + showThumbnails: boolean; + isSelected: boolean; + onItemClick?: (event: MouseEvent, item: ArticleListItem) => void; + contextMenuContent?: ReactNode; +}) { + const row = ( +
onItemClick(event, item) : undefined} + > +
+ {showThumbnails ? ( + + ) : null} +
+
+ {item.unread ? ( + + ) : ( + + )} +

{item.title}

+ {item.starred ? ( + + Saved + + ) : null} +
+

onItemClick(event, item) : undefined - } > -

- {showThumbnails ? ( - - ) : null} -
-
- {item.unread ? ( - - ) : ( - - )} -

- {item.title} -

- {item.starred ? ( - - Saved - - ) : null} -
-

- {item.summary} -

-
-
-
- {item.feed} -
-
- {item.publishedAt} -
-
- ))} + {item.summary} +

+
- +
{item.feed}
+
{item.publishedAt}
+ + ); + + if (!contextMenuContent) { + return row; + } + + return ( + + {row} + {contextMenuContent} + ); } diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 7e74309..9bc9046 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -29,9 +29,9 @@ export const Button = forwardRef(function Button ref={ref} type={type} className={cn( - "inline-flex shrink-0 items-center justify-center gap-1.5 rounded-lg border text-[13px] font-medium outline-none transition-colors", - "disabled:pointer-events-none disabled:opacity-50", - "focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-focus-ring", + "inline-flex shrink-0 items-center justify-center gap-1.5 rounded-[10px] border text-[13px] font-medium outline-none transition-colors", + "disabled:pointer-events-none disabled:opacity-45", + "focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-focus-ring active:translate-y-px", buttonVariantClassName[variant], buttonSizeClassName[size], className ?? "", @@ -47,16 +47,17 @@ export const Button = forwardRef(function Button const buttonVariantClassName: Record = { default: - "border-transparent bg-accent text-accent-foreground shadow-sm hover:opacity-95", + "border-border-strong bg-content text-accent-foreground hover:bg-black/88 active:border-black/55 active:bg-black/82", secondary: - "border-border-subtle bg-surface-raised text-content hover:bg-interactive-hover", - ghost: "border-transparent bg-transparent text-content-muted hover:bg-interactive-hover hover:text-content", + "border-border-subtle bg-surface text-content hover:border-border-strong hover:bg-surface-subtle active:bg-interactive-active", + ghost: + "border-transparent bg-transparent text-content-muted hover:bg-interactive-hover hover:text-content active:bg-interactive-active", destructive: - "border-transparent bg-danger text-danger-foreground shadow-sm hover:opacity-95", + "border-[#d8b7b3] bg-[#f8ecea] text-[#8f312b] hover:border-[#cda49f] hover:bg-[#f4e1de] active:bg-[#edd2cd]", }; const buttonSizeClassName: Record = { - sm: "h-7.5 px-2.5", - md: "h-8.5 px-3", - icon: "size-8", + sm: "h-7 px-2.5", + md: "h-8 px-3", + icon: "size-7.5", }; diff --git a/src/lib/articleApi.ts b/src/lib/articleApi.ts index 292339f..bb93f29 100644 --- a/src/lib/articleApi.ts +++ b/src/lib/articleApi.ts @@ -1,5 +1,11 @@ import { invoke } from "@tauri-apps/api/core"; -import type { ArticleCursor, ArticlePage, RefetchFeedResult } from "@/types/article"; +import type { + ArticleCursor, + ArticlePage, + DeleteArticlesResult, + RefetchFeedResult, + UpdateArticlesReadStateResult, +} from "@/types/article"; export async function listFeedArticles({ feedId, @@ -23,3 +29,14 @@ export async function listFeedArticles({ export async function refetchFeed(feedId: string) { return invoke("refetch_feed", { feedId }); } + +export async function updateArticlesReadState(articleIds: string[], isRead: boolean) { + return invoke("update_articles_read_state", { + articleIds, + isRead, + }); +} + +export async function deleteArticles(articleIds: string[]) { + return invoke("delete_articles", { articleIds }); +} diff --git a/src/routes/FeedRoute.tsx b/src/routes/FeedRoute.tsx index 4c88fe5..7059656 100644 --- a/src/routes/FeedRoute.tsx +++ b/src/routes/FeedRoute.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useRef } from "react"; -import { RefreshCwIcon, RssIcon } from "lucide-react"; +import { RefreshCwIcon, RssIcon, Trash2Icon } from "lucide-react"; import { listen } from "@tauri-apps/api/event"; import { ArticleList, type ArticleListItem } from "@/components/ArticleList"; +import { ContextMenuItem } from "@/components/ui/ContextMenu"; import { Button } from "@/components/ui/Button"; import { IconButton } from "@/components/ui/IconButton"; import { ViewSelect } from "@/components/ViewSelect"; @@ -25,6 +26,8 @@ export function FeedRoute() { const feedView = useArticleStore((state) => state.feedViews[feedId]); const loadFeedArticles = useArticleStore((state) => state.loadFeedArticles); const refreshFeed = useArticleStore((state) => state.refreshFeed); + const markArticlesReadState = useArticleStore((state) => state.markArticlesReadState); + const deleteArticlesById = useArticleStore((state) => state.deleteArticlesById); const resolvedFeedView = feedView ?? emptyFeedState; const isMissingFeed = feed?.lastFetchStatus === "not_found"; @@ -137,6 +140,30 @@ export function FeedRoute() { setCurrentRoute(ROUTE.DASHBOARD); } + async function handleMarkSelectionAsReadState(isRead: boolean) { + await markArticlesReadState(selectedItemIds, isRead); + clearSelection(); + } + + async function handleDeleteSelection() { + await deleteArticlesById(selectedItemIds); + clearSelection(); + } + + function getContextActionArticleIds(articleId: string) { + return Array.from(new Set([...selectedItemIds, articleId])); + } + + async function handleMarkItemReadState(articleId: string, isRead: boolean) { + await markArticlesReadState(getContextActionArticleIds(articleId), isRead); + clearSelection(); + } + + async function handleDeleteItem(articleId: string) { + await deleteArticlesById(getContextActionArticleIds(articleId)); + clearSelection(); + } + return ( handleItemClick(event, item.id)} + selectionActions={ + <> + + + + + } onClearSelection={clearSelection} + renderItemContextMenu={(item) => ( + <> + void handleMarkItemReadState(item.id, true)}> + Mark as read + + void handleMarkItemReadState(item.id, false)}> + Mark as unread + + void handleDeleteItem(item.id)}> + Delete + + + )} /> )} diff --git a/src/stores/articleStore.ts b/src/stores/articleStore.ts index ed2275a..11eb5b9 100644 --- a/src/stores/articleStore.ts +++ b/src/stores/articleStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { listFeedArticles, refetchFeed } from "@/lib/articleApi"; +import { deleteArticles, listFeedArticles, refetchFeed, updateArticlesReadState } from "@/lib/articleApi"; import type { ArticleCursor, ArticleRecord } from "@/types/article"; type FeedArticleState = { @@ -14,6 +14,8 @@ type ArticleStoreState = { feedViews: Record; loadFeedArticles: (feedId: string, reset?: boolean) => Promise; refreshFeed: (feedId: string) => Promise; + markArticlesReadState: (articleIds: string[], isRead: boolean) => Promise; + deleteArticlesById: (articleIds: string[]) => Promise; }; export const emptyFeedState: FeedArticleState = { @@ -120,4 +122,44 @@ export const useArticleStore = create((set, get) => ({ })); } }, + markArticlesReadState: async (articleIds, isRead) => { + if (articleIds.length === 0) { + return; + } + + await updateArticlesReadState(articleIds, isRead); + + set((state) => ({ + feedViews: Object.fromEntries( + Object.entries(state.feedViews).map(([feedId, view]) => [ + feedId, + { + ...view, + items: view.items.map((item) => + articleIds.includes(item.id) ? { ...item, isRead } : item, + ), + }, + ]), + ), + })); + }, + deleteArticlesById: async (articleIds) => { + if (articleIds.length === 0) { + return; + } + + await deleteArticles(articleIds); + + set((state) => ({ + feedViews: Object.fromEntries( + Object.entries(state.feedViews).map(([feedId, view]) => [ + feedId, + { + ...view, + items: view.items.filter((item) => !articleIds.includes(item.id)), + }, + ]), + ), + })); + }, })); diff --git a/src/types/article.ts b/src/types/article.ts index 3427c11..54d5957 100644 --- a/src/types/article.ts +++ b/src/types/article.ts @@ -30,3 +30,12 @@ export type RefetchFeedResult = { insertedCount: number; updatedCount: number; }; + +export type UpdateArticlesReadStateResult = { + articleIds: string[]; + isRead: boolean; +}; + +export type DeleteArticlesResult = { + articleIds: string[]; +};