From d9ae962c5f56e31ea7dc4340147dcfde199d4a58 Mon Sep 17 00:00:00 2001 From: bdbch Date: Thu, 23 Apr 2026 17:04:22 +0200 Subject: [PATCH] Add Folder Context Menu and Folder APIs Add Radix context-menu dependency and a ContextMenu UI primitive. Expose create_folder and rename_folder Tauri commands and wire frontend APIs and store actions for creating, renaming and deleting folders. Disable the native context menu, add sidebar UI hooks, a New Feed route placeholder, and update tauri app metadata (productName/identifier) and window transparency. --- package.json | 1 + pnpm-lock.yaml | 30 +++++ src-tauri/src/db.rs | 160 +++++++++++++++++++++++++ src-tauri/src/lib.rs | 31 ++++- src-tauri/tauri.conf.json | 7 +- src/App.tsx | 13 ++ src/components/AppRoutes.tsx | 3 + src/components/Sidebar.tsx | 170 ++++++++++++++++++++++++++- src/components/SidebarFolderItem.tsx | 170 ++++++++++++++++++++------- src/components/ui/ContextMenu.tsx | 38 ++++++ src/components/ui/DropdownMenu.tsx | 7 +- src/lib/sidebarApi.ts | 14 ++- src/routes/NewFeedRoute.tsx | 11 ++ src/stores/routeStore.ts | 1 + src/stores/sidebarStore.ts | 35 ++++++ 15 files changed, 639 insertions(+), 52 deletions(-) create mode 100644 src/components/ui/ContextMenu.tsx create mode 100644 src/routes/NewFeedRoute.tsx diff --git a/package.json b/package.json index 7c614ac..510c6f9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@fontsource/inter": "^5.2.8", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66a4277..39d272c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@fontsource/inter': specifier: ^5.2.8 version: 5.2.8 + '@radix-ui/react-context-menu': + specifier: ^2.2.16 + version: 2.2.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -429,6 +432,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.1.2': resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} peerDependencies: @@ -1736,6 +1752,20 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@18.3.1)': dependencies: react: 18.3.1 diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index d006006..9cd8a02 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -134,6 +134,12 @@ pub struct DeleteFeedResult { pub feed_id: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteFolderResult { + pub folder_id: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ResetSeededDataResult { @@ -141,6 +147,12 @@ pub struct ResetSeededDataResult { pub feeds_count: usize, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateFolderResult { + pub folder: FolderRecord, +} + #[derive(Debug, Clone)] struct FeedFetchTarget { id: String, @@ -478,6 +490,154 @@ pub fn delete_feed(db_path: &PathBuf, feed_id: &str) -> Result Result { + 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 delete folder transaction: {error}"))?; + let folder_ids = load_folder_subtree_ids(&transaction, folder_id)?; + + for subtree_folder_id in &folder_ids { + transaction + .execute("DELETE FROM feeds WHERE folder_id = ?1", params![subtree_folder_id]) + .map_err(|error| format!("failed to delete folder feeds: {error}"))?; + } + + let deleted_rows = transaction + .execute("DELETE FROM folders WHERE id = ?1", params![folder_id]) + .map_err(|error| format!("failed to delete folder: {error}"))?; + + if deleted_rows == 0 { + return Err(format!("folder not found: {folder_id}")); + } + + transaction + .commit() + .map_err(|error| format!("failed to commit folder deletion: {error}"))?; + + Ok(DeleteFolderResult { + folder_id: folder_id.to_string(), + }) +} + +fn load_folder_subtree_ids(connection: &Connection, folder_id: &str) -> Result, String> { + let mut statement = connection + .prepare( + r#" + WITH RECURSIVE folder_tree(id) AS ( + SELECT id + FROM folders + WHERE id = ?1 + + UNION ALL + + SELECT folders.id + FROM folders + INNER JOIN folder_tree ON folders.parent_folder_id = folder_tree.id + ) + SELECT id + FROM folder_tree + "#, + ) + .map_err(|error| format!("failed to prepare folder subtree query: {error}"))?; + + let folder_ids = statement + .query_map(params![folder_id], |row| row.get::<_, String>(0)) + .map_err(|error| format!("failed to query folder subtree: {error}"))? + .collect::, _>>() + .map_err(|error| format!("failed to read folder subtree: {error}"))?; + + if folder_ids.is_empty() { + return Err(format!("folder not found: {folder_id}")); + } + + Ok(folder_ids) +} + +pub fn create_folder(db_path: &PathBuf) -> Result { + let connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + let now = Utc::now().to_rfc3339(); + let folder_id = format!("folder-{}", Uuid::new_v4()); + let sort_order = connection + .query_row( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM folders WHERE parent_folder_id IS NULL", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|error| format!("failed to determine folder sort order: {error}"))?; + + connection + .execute( + r#" + INSERT INTO folders (id, name, parent_folder_id, sort_order, created_at, updated_at) + VALUES (?1, ?2, NULL, ?3, ?4, ?5) + "#, + params![folder_id, "New folder", sort_order, now, now], + ) + .map_err(|error| format!("failed to create folder: {error}"))?; + + Ok(CreateFolderResult { + folder: FolderRecord { + id: folder_id, + name: "New folder".to_string(), + parent_folder_id: None, + sort_order, + created_at: now.clone(), + updated_at: now, + }, + }) +} + +pub fn rename_folder(db_path: &PathBuf, folder_id: &str, name: &str) -> Result { + let connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + let trimmed_name = name.trim(); + + if trimmed_name.is_empty() { + return Err("folder name cannot be empty".to_string()); + } + + let now = Utc::now().to_rfc3339(); + let updated_rows = connection + .execute( + r#" + UPDATE folders + SET name = ?1, + updated_at = ?2 + WHERE id = ?3 + "#, + params![trimmed_name, now, folder_id], + ) + .map_err(|error| format!("failed to rename folder: {error}"))?; + + if updated_rows == 0 { + return Err(format!("folder not found: {folder_id}")); + } + + connection + .query_row( + r#" + SELECT id, name, parent_folder_id, sort_order, created_at, updated_at + FROM folders + WHERE id = ?1 + "#, + params![folder_id], + |row| { + Ok(FolderRecord { + id: row.get(0)?, + name: row.get(1)?, + parent_folder_id: row.get(2)?, + sort_order: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + }, + ) + .map_err(|error| format!("failed to load renamed folder: {error}")) +} + pub fn reset_seeded_data(db_path: &PathBuf) -> Result { let mut connection = Connection::open(db_path) .map_err(|error| format!("failed to open database: {error}"))?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 49eb51f..3793649 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,16 +1,20 @@ mod db; use db::{ + create_folder as insert_folder, + delete_folder as remove_folder, reset_seeded_data as reset_app_seeded_data, get_sidebar_data as load_sidebar_data, init_database, list_articles as load_articles, load_sidebar_expansion_state as read_sidebar_expansion_state, delete_feed as remove_feed, + rename_folder as update_folder_name, refetch_feed as refresh_feed, save_sidebar_expansion_state as persist_sidebar_expansion_state, save_sidebar_structure as persist_sidebar_structure, start_background_feed_sync, AppState, FeedMoveInput, FolderMoveInput, ListArticlesInput, RefetchFeedResult, SidebarData, - ArticlePage, DeleteFeedResult, FeedSyncState, ResetSeededDataResult, + ArticlePage, CreateFolderResult, DeleteFeedResult, DeleteFolderResult, FeedSyncState, + FolderRecord, ResetSeededDataResult, }; use std::collections::HashMap; use std::sync::Arc; @@ -30,6 +34,20 @@ fn save_sidebar_structure( persist_sidebar_structure(&state.db_path, folders, feeds) } +#[tauri::command] +fn create_folder(state: tauri::State<'_, AppState>) -> Result { + insert_folder(&state.db_path) +} + +#[tauri::command] +fn rename_folder( + state: tauri::State<'_, AppState>, + folder_id: String, + name: String, +) -> Result { + update_folder_name(&state.db_path, &folder_id, &name) +} + #[tauri::command] fn load_sidebar_expansion_state( state: tauri::State<'_, AppState>, @@ -69,6 +87,14 @@ fn delete_feed( remove_feed(&state.db_path, &feed_id) } +#[tauri::command] +fn delete_folder( + state: tauri::State<'_, AppState>, + folder_id: String, +) -> Result { + remove_folder(&state.db_path, &folder_id) +} + #[tauri::command] fn reset_seeded_data( state: tauri::State<'_, AppState>, @@ -98,11 +124,14 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_sidebar_data, save_sidebar_structure, + create_folder, + rename_folder, load_sidebar_expansion_state, save_sidebar_expansion_state, list_articles, refetch_feed, delete_feed, + delete_folder, reset_seeded_data ]) .run(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 24f2519..40273ed 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "releader", + "productName": "Readlay", "version": "0.1.0", - "identifier": "com.releader.app", + "identifier": "com.readlay.app", "build": { "beforeDevCommand": "pnpm dev", "devUrl": "http://localhost:1420", @@ -17,7 +17,8 @@ "maximized": true, "minHeight": 700, "minWidth": 1000, - "titleBarStyle": "Overlay" + "titleBarStyle": "Overlay", + "transparent": true } ], "security": { diff --git a/src/App.tsx b/src/App.tsx index 8ac5190..1f8d50f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,22 @@ import "@fontsource/inter/latin.css"; +import { useEffect } from "react"; import "./App.css"; import { AppRoutes } from "./components/AppRoutes"; import { Sidebar } from "./components/Sidebar"; function App() { + useEffect(() => { + function handleContextMenu(event: MouseEvent) { + event.preventDefault(); + } + + window.addEventListener("contextmenu", handleContextMenu); + + return () => { + window.removeEventListener("contextmenu", handleContextMenu); + }; + }, []); + return (
diff --git a/src/components/AppRoutes.tsx b/src/components/AppRoutes.tsx index 401f9a0..11419c7 100644 --- a/src/components/AppRoutes.tsx +++ b/src/components/AppRoutes.tsx @@ -1,6 +1,7 @@ import { DashboardRoute } from "@/routes/DashboardRoute"; import { FeedRoute } from "@/routes/FeedRoute"; import { FolderRoute } from "@/routes/FolderRoute"; +import { NewFeedRoute } from "@/routes/NewFeedRoute"; import { UnreadRoute } from "@/routes/UnreadRoute"; import { ROUTE, useRoutes } from "@/stores/routeStore"; @@ -12,6 +13,8 @@ export function AppRoutes() { return ; case ROUTE.FEED: return ; + case ROUTE.NEW_FEED: + return ; case ROUTE.UNREAD: return ; case ROUTE.DASHBOARD: diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a8eec24..a4634a4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,11 +12,19 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { LayoutDashboardIcon, MailIcon } from "lucide-react"; +import { PlusIcon } from "lucide-react"; import { moveSidebarNode } from "@/lib/sidebarMove"; import { buildSidebarTree } from "@/lib/sidebarTree"; import { ROUTE, useRoutes } from "@/stores/routeStore"; import { useSidebarStore } from "@/stores/sidebarStore"; import type { SidebarNode } from "@/types/sidebar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/DropdownMenu"; +import { Button } from "@/components/ui/Button"; import { SidebarDropZone } from "./SidebarDropZone"; import { SidebarFeedItem } from "./SidebarFeedItem"; import { SidebarFolderItem } from "./SidebarFolderItem"; @@ -37,12 +45,19 @@ export function Sidebar() { const setSidebarStructure = useSidebarStore( (state) => state.setSidebarStructure, ); + const createRootFolder = useSidebarStore((state) => state.createRootFolder); + const renameFolder = useSidebarStore((state) => state.renameFolder); + const removeFolder = useSidebarStore((state) => state.removeFolder); const persistSidebarStructure = useSidebarStore( (state) => state.persistSidebarStructure, ); const [activeDropFolderId, setActiveDropFolderId] = useState( null, ); + const [editingFolderId, setEditingFolderId] = useState(null); + const [pendingDeleteFolderId, setPendingDeleteFolderId] = useState( + null, + ); useEffect(() => { void loadSidebarData(); @@ -118,6 +133,59 @@ export function Sidebar() { void persistSidebarStructure(); } + async function handleCreateFolder() { + const folder = await createRootFolder(); + setEditingFolderId(folder.id); + } + + async function handleCommitFolderRename(folderId: string, name: string) { + try { + await renameFolder(folderId, name); + setEditingFolderId(null); + } catch { + setEditingFolderId(null); + } + } + + async function handleDeleteFolder(folderId: string) { + const deletedFolderIds = [folderId, ...getDescendantFolderIds(folders, folderId)]; + const deletedFeedIds = feeds + .filter((feed) => feed.folderId && deletedFolderIds.includes(feed.folderId)) + .map((feed) => feed.id); + + await removeFolder(folderId); + setPendingDeleteFolderId(null); + setEditingFolderId(null); + + if ( + currentRoute === ROUTE.FOLDER && + typeof routeParams.folderId === "string" && + deletedFolderIds.includes(routeParams.folderId) + ) { + setCurrentRoute(ROUTE.DASHBOARD); + return; + } + + if ( + currentRoute === ROUTE.FEED && + typeof routeParams.feedId === "string" && + deletedFeedIds.includes(routeParams.feedId) + ) { + setCurrentRoute(ROUTE.DASHBOARD); + } + } + + const pendingDeleteFolder = pendingDeleteFolderId + ? folders.find((folder) => folder.id === pendingDeleteFolderId) ?? null + : null; + const pendingDeleteFolderIds = pendingDeleteFolderId + ? [pendingDeleteFolderId, ...getDescendantFolderIds(folders, pendingDeleteFolderId)] + : []; + const pendingDeleteFeedCount = feeds.filter( + (feed) => feed.folderId && pendingDeleteFolderIds.includes(feed.folderId), + ).length; + const pendingDeleteSubfolderCount = Math.max(0, pendingDeleteFolderIds.length - 1); + return (
@@ -136,8 +204,29 @@ export function Sidebar() {
-
- Library +
+
+ Library +
+ + + + + + + + setCurrentRoute(ROUTE.NEW_FEED)} + > + Feed + + void handleCreateFolder()}> + Folder + + +
{isLoading ? ( @@ -173,10 +262,46 @@ export function Sidebar() { setCurrentRoute(ROUTE.FOLDER, { folderId }) } onSelectFeed={(feedId) => setCurrentRoute(ROUTE.FEED, { feedId })} + editingFolderId={editingFolderId} + onCommitFolderRename={handleCommitFolderRename} + onCancelFolderRename={() => setEditingFolderId(null)} + onStartFolderRename={(folderId) => setEditingFolderId(folderId)} + onRequestDeleteFolder={(folderId) => setPendingDeleteFolderId(folderId)} />
+ + {pendingDeleteFolder ? ( +
+
+ Delete "{pendingDeleteFolder.name}"? +
+
+ {pendingDeleteSubfolderCount > 0 || pendingDeleteFeedCount > 0 + ? `This will permanently delete ${pendingDeleteSubfolderCount} subfolder${pendingDeleteSubfolderCount === 1 ? "" : "s"}, ${pendingDeleteFeedCount} feed${pendingDeleteFeedCount === 1 ? "" : "s"}, and all stored articles inside them.` + : "This folder is empty."} +
+
+ + +
+
+ ) : null}
); } @@ -193,6 +318,11 @@ function SidebarTreeContext({ onToggleFolder, onSelectFolder, onSelectFeed, + editingFolderId, + onCommitFolderRename, + onCancelFolderRename, + onStartFolderRename, + onRequestDeleteFolder, }: { nodes: SidebarNode[]; contextId: string | null; @@ -205,6 +335,14 @@ function SidebarTreeContext({ onToggleFolder: (folderId: string) => void; onSelectFolder: (folderId: string) => void; onSelectFeed: (feedId: string) => void; + editingFolderId: string | null; + onCommitFolderRename: ( + folderId: string, + name: string, + ) => void | Promise; + onCancelFolderRename: () => void; + onStartFolderRename: (folderId: string) => void; + onRequestDeleteFolder: (folderId: string) => void; }) { return (
@@ -249,8 +387,13 @@ function SidebarTreeContext({ isActive={isActive} isDropTarget={activeDropFolderId === node.id} dropInsideZoneId={buildFolderRowDropZoneId(node.id)} + isEditing={editingFolderId === node.id} onToggle={() => onToggleFolder(node.id)} onClick={(event) => { + if (editingFolderId === node.id) { + return; + } + if (event.altKey) { toggleFolderTree(node.id); return; @@ -258,6 +401,10 @@ function SidebarTreeContext({ onSelectFolder(node.id); }} + onContextMenuRename={() => onStartFolderRename(node.id)} + onContextMenuDelete={() => onRequestDeleteFolder(node.id)} + onCommitRename={(name) => onCommitFolderRename(node.id, name)} + onCancelRename={onCancelFolderRename} /> {isExpanded ? ( @@ -273,6 +420,11 @@ function SidebarTreeContext({ onToggleFolder={onToggleFolder} onSelectFolder={onSelectFolder} onSelectFeed={onSelectFeed} + editingFolderId={editingFolderId} + onCommitFolderRename={onCommitFolderRename} + onCancelFolderRename={onCancelFolderRename} + onStartFolderRename={onStartFolderRename} + onRequestDeleteFolder={onRequestDeleteFolder} /> ) : null} @@ -295,6 +447,20 @@ function flattenNodeIds(nodes: SidebarNode[]): string[] { ); } +function getDescendantFolderIds( + folders: Array<{ id: string; parentFolderId: string | null }>, + folderId: string, +): string[] { + const childFolderIds = folders + .filter((folder) => folder.parentFolderId === folderId) + .map((folder) => folder.id); + + return childFolderIds.flatMap((childFolderId) => [ + childFolderId, + ...getDescendantFolderIds(folders, childFolderId), + ]); +} + function buildContextDropZoneId(contextId: string | null, index: number) { return `dropzone:${contextId ?? "root"}:${index}`; } diff --git a/src/components/SidebarFolderItem.tsx b/src/components/SidebarFolderItem.tsx index 8430933..1226743 100644 --- a/src/components/SidebarFolderItem.tsx +++ b/src/components/SidebarFolderItem.tsx @@ -3,7 +3,16 @@ import { ChevronRightIcon, FolderIcon, FolderOpenIcon, + TextCursorInputIcon, + TrashIcon, } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/ContextMenu"; import { cn } from "@/lib/cn"; import { useSortable } from "@dnd-kit/sortable"; import { useDroppable } from "@dnd-kit/core"; @@ -16,8 +25,13 @@ type SidebarFolderItemProps = { isActive?: boolean; isDropTarget?: boolean; dropInsideZoneId?: string; + isEditing?: boolean; onClick?: (event: React.MouseEvent) => void; + onContextMenuRename?: () => void; + onContextMenuDelete?: () => void; onToggle?: () => void; + onCommitRename?: (name: string) => void | Promise; + onCancelRename?: () => void; }; export function SidebarFolderItem({ @@ -28,61 +42,131 @@ export function SidebarFolderItem({ isActive, isDropTarget, dropInsideZoneId, + isEditing, onClick, + onContextMenuRename, + onContextMenuDelete, onToggle, + onCommitRename, + onCancelRename, }: SidebarFolderItemProps) { const { attributes, listeners, setNodeRef, isDragging } = useSortable({ id }); const { isOver, setNodeRef: setDropInsideNodeRef } = useDroppable({ id: dropInsideZoneId ?? `folder-drop:${id}`, disabled: !dropInsideZoneId, }); + const inputRef = useRef(null); + const [draftName, setDraftName] = useState(label); + + useEffect(() => { + setDraftName(label); + }, [label]); + + useEffect(() => { + if (!isEditing) { + return; + } + + inputRef.current?.focus(); + inputRef.current?.select(); + }, [isEditing]); + + function handleCommit() { + void onCommitRename?.(draftName); + } return (
- + style={{ paddingLeft: 10 + depth * 13 }} + {...(isEditing ? {} : attributes)} + {...(isEditing ? {} : listeners)} + onClick={onClick} + onDoubleClick={onToggle} + > + { + event.stopPropagation(); + onToggle?.(); + }} + > + {isExpanded ? ( + + ) : ( + + )} + + + + + {isExpanded ? ( + + ) : ( + + )} + + {isEditing ? ( + setDraftName(event.target.value)} + onClick={(event) => event.stopPropagation()} + onBlur={handleCommit} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleCommit(); + } + + if (event.key === "Escape") { + event.preventDefault(); + setDraftName(label); + onCancelRename?.(); + } + }} + className="h-5 min-w-0 flex-1 rounded-sm border border-border-strong bg-background px-1.5 text-xs text-content outline-none" + /> + ) : ( + {label} + )} + + + + + { + window.setTimeout(() => onContextMenuRename?.(), 0); + }} + > + + Rename folder + + { + window.setTimeout(() => onContextMenuDelete?.(), 0); + }} + > + + Delete folder + + +
); } diff --git a/src/components/ui/ContextMenu.tsx b/src/components/ui/ContextMenu.tsx new file mode 100644 index 0000000..9fb42ce --- /dev/null +++ b/src/components/ui/ContextMenu.tsx @@ -0,0 +1,38 @@ +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { cn } from "@/lib/cn"; + +export const ContextMenu = ContextMenuPrimitive.Root; +export const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +export function ContextMenuContent({ + className, + ...props +}: ContextMenuPrimitive.ContextMenuContentProps) { + return ( + + + + ); +} + +export function ContextMenuItem({ + className, + ...props +}: ContextMenuPrimitive.ContextMenuItemProps) { + return ( + + ); +} diff --git a/src/components/ui/DropdownMenu.tsx b/src/components/ui/DropdownMenu.tsx index 4c6030d..63c7dfb 100644 --- a/src/components/ui/DropdownMenu.tsx +++ b/src/components/ui/DropdownMenu.tsx @@ -36,7 +36,7 @@ export function DropdownMenuItem({ return ( ) { return ( ); diff --git a/src/lib/sidebarApi.ts b/src/lib/sidebarApi.ts index a092599..0da2f88 100644 --- a/src/lib/sidebarApi.ts +++ b/src/lib/sidebarApi.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { SidebarData } from "@/types/sidebar"; +import type { FolderRecord, SidebarData } from "@/types/sidebar"; export async function getSidebarData() { return invoke("get_sidebar_data"); @@ -23,6 +23,18 @@ export async function saveSidebarStructure({ return invoke("save_sidebar_structure", { folders, feeds }); } +export async function createFolder() { + return invoke<{ folder: FolderRecord }>("create_folder"); +} + +export async function renameFolder(folderId: string, name: string) { + return invoke("rename_folder", { folderId, name }); +} + +export async function deleteFolder(folderId: string) { + return invoke<{ folderId: string }>("delete_folder", { folderId }); +} + export async function deleteFeed(feedId: string) { return invoke<{ feedId: string }>("delete_feed", { feedId }); } diff --git a/src/routes/NewFeedRoute.tsx b/src/routes/NewFeedRoute.tsx new file mode 100644 index 0000000..8075fa1 --- /dev/null +++ b/src/routes/NewFeedRoute.tsx @@ -0,0 +1,11 @@ +import { RouteLayout } from "@/routes/RouteLayout"; + +export function NewFeedRoute() { + return ( + +
+ Feed creation form will appear here. +
+
+ ); +} diff --git a/src/stores/routeStore.ts b/src/stores/routeStore.ts index d185066..cab52a2 100644 --- a/src/stores/routeStore.ts +++ b/src/stores/routeStore.ts @@ -5,6 +5,7 @@ export enum ROUTE { UNREAD = "UNREAD", FOLDER = "FOLDER", FEED = "FEED", + NEW_FEED = "NEW_FEED", } export type RouteParams = Record; diff --git a/src/stores/sidebarStore.ts b/src/stores/sidebarStore.ts index 66f2b32..8238aa5 100644 --- a/src/stores/sidebarStore.ts +++ b/src/stores/sidebarStore.ts @@ -1,7 +1,10 @@ import { create } from "zustand"; import { + createFolder, + deleteFolder, deleteFeed, getSidebarData, + renameFolder, resetSeededData, saveSidebarStructure, } from "@/lib/sidebarApi"; @@ -24,6 +27,9 @@ type SidebarState = { toggleFolderTree: (folderId: string) => void; setSidebarStructure: (folders: FolderRecord[], feeds: FeedRecord[]) => void; persistSidebarStructure: () => Promise; + createRootFolder: () => Promise; + renameFolder: (folderId: string, name: string) => Promise; + removeFolder: (folderId: string) => Promise; removeFeed: (feedId: string) => Promise; resetToSeededData: () => Promise<{ foldersCount: number; feedsCount: number }>; }; @@ -102,6 +108,35 @@ export const useSidebarStore = create((set, get) => ({ const { folders, feeds } = get(); await saveSidebarStructure(serializeSidebarStructure(folders, feeds)); }, + createRootFolder: async () => { + const result = await createFolder(); + + set((state) => ({ + folders: [...state.folders, result.folder].sort((a, b) => a.sortOrder - b.sortOrder), + expandedFolderIds: { + ...state.expandedFolderIds, + [result.folder.id]: false, + }, + })); + + return result.folder; + }, + renameFolder: async (folderId, name) => { + const folder = await renameFolder(folderId, name); + + set((state) => ({ + folders: state.folders.map((item) => + item.id === folder.id ? folder : item, + ), + })); + + return folder; + }, + removeFolder: async (folderId) => { + await deleteFolder(folderId); + set({ hasLoaded: false }); + await get().loadSidebarData(); + }, removeFeed: async (feedId) => { await deleteFeed(feedId); set({ hasLoaded: false }); -- 2.51.2