From c5457549dc861d7db447ae524c514c7e5434c166 Mon Sep 17 00:00:00 2001 From: bdbch Date: Thu, 23 Apr 2026 14:47:38 +0200 Subject: [PATCH] Add persisted sidebar with drag-and-drop --- package.json | 4 + pnpm-lock.yaml | 66 ++++++ src-tauri/Cargo.lock | 103 +++++++++ src-tauri/Cargo.toml | 3 +- src-tauri/src/db.rs | 328 +++++++++++++++++++++++++++ src-tauri/src/lib.rs | 54 ++++- src/components/AppRoutes.tsx | 12 +- src/components/Sidebar.tsx | 301 +++++++++++++++++++++++- src/components/SidebarButton.tsx | 2 +- src/components/SidebarDropZone.tsx | 29 +++ src/components/SidebarFeedItem.tsx | 50 ++++ src/components/SidebarFolderItem.tsx | 93 ++++++++ src/lib/sidebarApi.ts | 24 ++ src/lib/sidebarExpansionStorage.ts | 19 ++ src/lib/sidebarMove.ts | 288 +++++++++++++++++++++++ src/lib/sidebarTree.ts | 66 ++++++ src/routes/FeedRoute.tsx | 18 ++ src/routes/FolderRoute.tsx | 18 ++ src/stores/routeStore.ts | 2 + src/stores/sidebarStore.ts | 112 +++++++++ src/types/sidebar.ts | 41 ++++ 21 files changed, 1622 insertions(+), 11 deletions(-) create mode 100644 src-tauri/src/db.rs create mode 100644 src/components/SidebarDropZone.tsx create mode 100644 src/components/SidebarFeedItem.tsx create mode 100644 src/components/SidebarFolderItem.tsx create mode 100644 src/lib/sidebarApi.ts create mode 100644 src/lib/sidebarExpansionStorage.ts create mode 100644 src/lib/sidebarMove.ts create mode 100644 src/lib/sidebarTree.ts create mode 100644 src/routes/FeedRoute.tsx create mode 100644 src/routes/FolderRoute.tsx create mode 100644 src/stores/sidebarStore.ts create mode 100644 src/types/sidebar.ts diff --git a/package.json b/package.json index 8f8ff6b..8c375f9 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "tauri": "tauri" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource/inter": "^5.2.8", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", @@ -19,6 +22,7 @@ "@tailwindcss/vite": "^4.2.4", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-store": "^2.4.2", "clsx": "^2.1.1", "lucide-react": "^1.8.0", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9f7dbe..c3fa20d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@fontsource/inter': specifier: ^5.2.8 version: 5.2.8 @@ -35,6 +44,9 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.3 + '@tauri-apps/plugin-store': + specifier: ^2.4.2 + version: 2.4.2 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -161,6 +173,28 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -989,6 +1023,9 @@ packages: '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} + '@tauri-apps/plugin-store@2.4.2': + resolution: {integrity: sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1511,6 +1548,31 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -2165,6 +2227,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-store@2.4.2': + dependencies: + '@tauri-apps/api': 2.10.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 303c2e1..ad071cd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -925,6 +937,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1433,6 +1457,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1448,6 +1481,15 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1977,6 +2019,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2965,11 +3018,13 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "releader" version = "0.1.0" dependencies = [ + "rusqlite", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-opener", + "tauri-plugin-store", ] [[package]] @@ -3006,6 +3061,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3770,6 +3839,22 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-store" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -3996,9 +4081,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4352,6 +4449,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0600edb..52d678e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,4 +22,5 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" - +rusqlite = { version = "0.32", features = ["bundled"] } +tauri-plugin-store = "2.4.2" diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs new file mode 100644 index 0000000..f3ec234 --- /dev/null +++ b/src-tauri/src/db.rs @@ -0,0 +1,328 @@ +use rusqlite::{params, Connection}; +use serde::Serialize; +use std::collections::HashMap; +use std::{fs, path::PathBuf}; +use tauri::{AppHandle, Manager}; + +pub struct AppState { + pub db_path: PathBuf, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FolderRecord { + pub id: String, + pub name: String, + pub parent_folder_id: Option, + pub sort_order: i64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedRecord { + pub id: String, + pub title: String, + pub url: String, + pub site_url: Option, + pub folder_id: Option, + pub sort_order: i64, + pub icon_url: Option, + pub last_fetched_at: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SidebarData { + pub folders: Vec, + pub feeds: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FolderMoveInput { + pub folder_id: String, + pub parent_folder_id: Option, + pub sort_order: i64, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedMoveInput { + pub feed_id: String, + pub folder_id: Option, + pub sort_order: i64, +} + +pub fn init_database(app_handle: &AppHandle) -> Result { + let app_data_dir = app_handle + .path() + .app_data_dir() + .map_err(|error| format!("failed to resolve app data directory: {error}"))?; + + fs::create_dir_all(&app_data_dir) + .map_err(|error| format!("failed to create app data directory: {error}"))?; + + let db_path = app_data_dir.join("releader.sqlite"); + let connection = Connection::open(&db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + + connection + .execute_batch( + r#" + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + parent_folder_id TEXT NULL REFERENCES folders(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS feeds ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + site_url TEXT NULL, + folder_id TEXT NULL REFERENCES folders(id) ON DELETE SET NULL, + sort_order INTEGER NOT NULL, + icon_url TEXT NULL, + last_fetched_at TEXT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + "#, + ) + .map_err(|error| format!("failed to initialize schema: {error}"))?; + + seed_sidebar_data(&connection)?; + + Ok(db_path) +} + +pub fn get_sidebar_data(db_path: &PathBuf) -> Result { + let connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + + let mut folder_statement = connection + .prepare( + r#" + SELECT id, name, parent_folder_id, sort_order, created_at, updated_at + FROM folders + ORDER BY COALESCE(parent_folder_id, ''), sort_order, name + "#, + ) + .map_err(|error| format!("failed to prepare folder query: {error}"))?; + + let folders = folder_statement + .query_map([], |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 query folders: {error}"))? + .collect::, _>>() + .map_err(|error| format!("failed to read folders: {error}"))?; + + let mut feed_statement = connection + .prepare( + r#" + SELECT id, title, url, site_url, folder_id, sort_order, icon_url, last_fetched_at, created_at, updated_at + FROM feeds + ORDER BY COALESCE(folder_id, ''), sort_order, title + "#, + ) + .map_err(|error| format!("failed to prepare feed query: {error}"))?; + + let feeds = feed_statement + .query_map([], |row| { + Ok(FeedRecord { + id: row.get(0)?, + title: row.get(1)?, + url: row.get(2)?, + site_url: row.get(3)?, + folder_id: row.get(4)?, + sort_order: row.get(5)?, + icon_url: row.get(6)?, + last_fetched_at: row.get(7)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, + }) + }) + .map_err(|error| format!("failed to query feeds: {error}"))? + .collect::, _>>() + .map_err(|error| format!("failed to read feeds: {error}"))?; + + Ok(SidebarData { folders, feeds }) +} + +pub fn load_sidebar_expansion_state( + db_path: &PathBuf, +) -> Result, String> { + let connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + + let raw_value: Option = connection + .query_row( + "SELECT value FROM app_state WHERE key = 'sidebar.expandedFolderIds'", + [], + |row| row.get(0), + ) + .ok(); + + match raw_value { + Some(value) => serde_json::from_str::>(&value) + .map_err(|error| format!("failed to parse sidebar expansion state: {error}")), + None => Ok(HashMap::new()), + } +} + +pub fn save_sidebar_expansion_state( + db_path: &PathBuf, + expanded_folder_ids: HashMap, +) -> Result<(), String> { + let connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + let serialized = serde_json::to_string(&expanded_folder_ids) + .map_err(|error| format!("failed to serialize sidebar expansion state: {error}"))?; + + connection + .execute( + "INSERT INTO app_state (key, value) VALUES ('sidebar.expandedFolderIds', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![serialized], + ) + .map_err(|error| format!("failed to save sidebar expansion state: {error}"))?; + + Ok(()) +} + +pub fn save_sidebar_structure( + db_path: &PathBuf, + folders: Vec, + feeds: Vec, +) -> Result<(), String> { + 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 transaction: {error}"))?; + + for folder in folders { + transaction + .execute( + "UPDATE folders SET parent_folder_id = ?1, sort_order = ?2, updated_at = CURRENT_TIMESTAMP WHERE id = ?3", + params![folder.parent_folder_id, folder.sort_order, folder.folder_id], + ) + .map_err(|error| format!("failed to update folder structure: {error}"))?; + } + + for feed in feeds { + transaction + .execute( + "UPDATE feeds SET folder_id = ?1, sort_order = ?2, updated_at = CURRENT_TIMESTAMP WHERE id = ?3", + params![feed.folder_id, feed.sort_order, feed.feed_id], + ) + .map_err(|error| format!("failed to update feed structure: {error}"))?; + } + + transaction + .commit() + .map_err(|error| format!("failed to commit sidebar structure: {error}"))?; + + Ok(()) +} + +fn seed_sidebar_data(connection: &Connection) -> Result<(), String> { + let folder_count: i64 = connection + .query_row("SELECT COUNT(*) FROM folders", [], |row| row.get(0)) + .map_err(|error| format!("failed to count folders: {error}"))?; + let feed_count: i64 = connection + .query_row("SELECT COUNT(*) FROM feeds", [], |row| row.get(0)) + .map_err(|error| format!("failed to count feeds: {error}"))?; + + if folder_count > 0 || feed_count > 0 { + return Ok(()); + } + + let now = "2026-04-23T00:00:00Z"; + + connection + .execute( + "INSERT INTO folders (id, name, parent_folder_id, sort_order, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params!["folder-tech", "Tech", Option::::None, 0, now, now], + ) + .map_err(|error| format!("failed to seed folder Tech: {error}"))?; + connection + .execute( + "INSERT INTO folders (id, name, parent_folder_id, sort_order, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params!["folder-design", "Design", Option::::None, 1, now, now], + ) + .map_err(|error| format!("failed to seed folder Design: {error}"))?; + connection + .execute( + "INSERT INTO folders (id, name, parent_folder_id, sort_order, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params!["folder-devtools", "Dev Tools", Some("folder-tech"), 0, now, now], + ) + .map_err(|error| format!("failed to seed folder Dev Tools: {error}"))?; + + let feeds = [ + ( + "feed-ars", + "Ars Technica", + "https://feeds.arstechnica.com/arstechnica/index", + Some("https://arstechnica.com"), + Some("folder-tech"), + 0, + ), + ( + "feed-tauri", + "Tauri Blog", + "https://tauri.app/blog/rss.xml", + Some("https://tauri.app"), + Some("folder-devtools"), + 0, + ), + ( + "feed-verge", + "The Verge", + "https://www.theverge.com/rss/index.xml", + Some("https://www.theverge.com"), + Option::<&str>::None, + 0, + ), + ( + "feed-figma", + "Figma", + "https://www.figma.com/blog/feed.xml", + Some("https://www.figma.com/blog"), + Some("folder-design"), + 0, + ), + ]; + + for (id, title, url, site_url, folder_id, sort_order) in feeds { + connection + .execute( + "INSERT INTO feeds (id, title, url, site_url, folder_id, sort_order, icon_url, last_fetched_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![id, title, url, site_url, folder_id, sort_order, Option::::None, Option::::None, now, now], + ) + .map_err(|error| format!("failed to seed feed {title}: {error}"))?; + } + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..962f8c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,14 +1,60 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +mod db; + +use db::{ + get_sidebar_data as load_sidebar_data, init_database, + load_sidebar_expansion_state as read_sidebar_expansion_state, + save_sidebar_expansion_state as persist_sidebar_expansion_state, + save_sidebar_structure as persist_sidebar_structure, AppState, FeedMoveInput, FolderMoveInput, + SidebarData, +}; +use std::collections::HashMap; +use tauri::Manager; + +#[tauri::command] +fn get_sidebar_data(state: tauri::State<'_, AppState>) -> Result { + load_sidebar_data(&state.db_path) +} + +#[tauri::command] +fn save_sidebar_structure( + state: tauri::State<'_, AppState>, + folders: Vec, + feeds: Vec, +) -> Result<(), String> { + persist_sidebar_structure(&state.db_path, folders, feeds) +} + +#[tauri::command] +fn load_sidebar_expansion_state( + state: tauri::State<'_, AppState>, +) -> Result, String> { + read_sidebar_expansion_state(&state.db_path) +} + #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn save_sidebar_expansion_state( + state: tauri::State<'_, AppState>, + expanded_folder_ids: HashMap, +) -> Result<(), String> { + persist_sidebar_expansion_state(&state.db_path, expanded_folder_ids) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .setup(|app| { + let db_path = init_database(app.handle())?; + app.manage(AppState { db_path }); + Ok(()) + }) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .plugin(tauri_plugin_store::Builder::default().build()) + .invoke_handler(tauri::generate_handler![ + get_sidebar_data, + save_sidebar_structure, + load_sidebar_expansion_state, + save_sidebar_expansion_state + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/components/AppRoutes.tsx b/src/components/AppRoutes.tsx index 59ba26c..401f9a0 100644 --- a/src/components/AppRoutes.tsx +++ b/src/components/AppRoutes.tsx @@ -1,11 +1,17 @@ -import { DashboardRoute } from "../routes/DashboardRoute"; -import { UnreadRoute } from "../routes/UnreadRoute"; -import { ROUTE, useRoutes } from "../stores/routeStore"; +import { DashboardRoute } from "@/routes/DashboardRoute"; +import { FeedRoute } from "@/routes/FeedRoute"; +import { FolderRoute } from "@/routes/FolderRoute"; +import { UnreadRoute } from "@/routes/UnreadRoute"; +import { ROUTE, useRoutes } from "@/stores/routeStore"; export function AppRoutes() { const currentRoute = useRoutes((state) => state.currentRoute); switch (currentRoute) { + case ROUTE.FOLDER: + return ; + case ROUTE.FEED: + return ; case ROUTE.UNREAD: return ; case ROUTE.DASHBOARD: diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 17f459d..9f8fc22 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,13 +1,118 @@ +import { useEffect, useMemo, useState } from "react"; +import type { DragEndEvent, DragMoveEvent } from "@dnd-kit/core"; +import { + DndContext, + MouseSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { LayoutDashboardIcon, MailIcon } 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 { SidebarDropZone } from "./SidebarDropZone"; +import { SidebarFeedItem } from "./SidebarFeedItem"; +import { SidebarFolderItem } from "./SidebarFolderItem"; import { SidebarButton } from "./SidebarButton"; -import { ROUTE, useRoutes } from "../stores/routeStore"; export function Sidebar() { const currentRoute = useRoutes((state) => state.currentRoute); + const routeParams = useRoutes((state) => state.routeParams); const setCurrentRoute = useRoutes((state) => state.setCurrentRoute); + const folders = useSidebarStore((state) => state.folders); + const feeds = useSidebarStore((state) => state.feeds); + const expandedFolderIds = useSidebarStore((state) => state.expandedFolderIds); + const isLoading = useSidebarStore((state) => state.isLoading); + const error = useSidebarStore((state) => state.error); + const loadSidebarData = useSidebarStore((state) => state.loadSidebarData); + const toggleFolder = useSidebarStore((state) => state.toggleFolder); + const toggleFolderTree = useSidebarStore((state) => state.toggleFolderTree); + const setSidebarStructure = useSidebarStore((state) => state.setSidebarStructure); + const persistSidebarStructure = useSidebarStore( + (state) => state.persistSidebarStructure, + ); + const [activeDropFolderId, setActiveDropFolderId] = useState(null); + + useEffect(() => { + void loadSidebarData(); + }, [loadSidebarData]); + + const tree = buildSidebarTree(folders, feeds); + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { distance: 6 }, + }), + ); + const sortableIds = useMemo(() => flattenNodeIds(tree), [tree]); + + function handleDragMove(event: DragMoveEvent) { + const overId = event.over ? String(event.over.id) : null; + + if (!overId) { + setActiveDropFolderId(null); + return; + } + + const target = + parseContextDropZoneId(overId) ?? + (folders.some((folder) => folder.id === overId) + ? { + contextId: overId, + index: 0, + } + : null); + + setActiveDropFolderId(target?.contextId ?? null); + } + + function handleDragEnd(event: DragEndEvent) { + const activeId = String(event.active.id); + const overId = event.over ? String(event.over.id) : null; + + if (!overId || activeId === overId) { + setActiveDropFolderId(null); + return; + } + + const target = + parseContextDropZoneId(overId) ?? + (folders.some((folder) => folder.id === overId) + ? { + contextId: overId, + index: 0, + } + : null); + + if (!target) { + setActiveDropFolderId(null); + return; + } + + const nextState = moveSidebarNode({ + folders, + feeds, + tree, + activeId, + targetContextId: target.contextId, + targetIndex: target.index, + }); + + if (nextState.folders === folders && nextState.feeds === feeds) { + setActiveDropFolderId(null); + return; + } + + setSidebarStructure(nextState.folders, nextState.feeds); + setActiveDropFolderId(null); + void persistSidebarStructure(); + } return ( -
+
+ +
+
+ Library +
+ + {isLoading ? ( +
Loading…
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + setActiveDropFolderId(null)} + > + + + setCurrentRoute(ROUTE.FOLDER, { folderId }) + } + onSelectFeed={(feedId) => setCurrentRoute(ROUTE.FEED, { feedId })} + /> + + +
); } + +function SidebarTreeContext({ + nodes, + contextId, + depth, + currentRoute, + routeParams, + expandedFolderIds, + activeDropFolderId, + toggleFolderTree, + onToggleFolder, + onSelectFolder, + onSelectFeed, +}: { + nodes: SidebarNode[]; + contextId: string | null; + depth: number; + currentRoute: ROUTE; + routeParams: Record; + expandedFolderIds: Record; + activeDropFolderId: string | null; + toggleFolderTree: (folderId: string) => void; + onToggleFolder: (folderId: string) => void; + onSelectFolder: (folderId: string) => void; + onSelectFeed: (feedId: string) => void; +}) { + return ( +
+ + + {nodes.map((node, index) => { + if (node.type === "feed") { + const isActive = + currentRoute === ROUTE.FEED && routeParams.feedId === node.id; + + return ( +
+ onSelectFeed(node.id)} + /> + +
+ ); + } + + const isExpanded = expandedFolderIds[node.id] ?? true; + const isActive = + currentRoute === ROUTE.FOLDER && routeParams.folderId === node.id; + + return ( +
+ onToggleFolder(node.id)} + onClick={(event) => { + if (event.altKey) { + toggleFolderTree(node.id); + return; + } + + onSelectFolder(node.id); + }} + /> + + {isExpanded ? ( + + ) : null} + + +
+ ); + })} +
+ ); +} + +function flattenNodeIds(nodes: SidebarNode[]): string[] { + return nodes.flatMap((node) => + node.type === "folder" + ? [node.id, ...flattenNodeIds(node.children)] + : [node.id], + ); +} + +function buildContextDropZoneId(contextId: string | null, index: number) { + return `dropzone:${contextId ?? "root"}:${index}`; +} + +function buildFolderRowDropZoneId(folderId: string) { + return `folderrow:${folderId}`; +} + +function parseContextDropZoneId(id: string) { + if (id.startsWith("folderrow:")) { + const [, folderId] = id.split(":"); + + if (!folderId) { + return null; + } + + return { + contextId: folderId, + index: 0, + }; + } + + if (!id.startsWith("dropzone:")) { + return null; + } + + const [, rawContextId, rawIndex] = id.split(":"); + + if (rawIndex === undefined) { + return null; + } + + return { + contextId: rawContextId === "root" ? null : rawContextId, + index: Number(rawIndex), + }; +} diff --git a/src/components/SidebarButton.tsx b/src/components/SidebarButton.tsx index 8b97439..eee1aaa 100644 --- a/src/components/SidebarButton.tsx +++ b/src/components/SidebarButton.tsx @@ -1,4 +1,4 @@ -import { cn } from "../lib/cn"; +import { cn } from "@/lib/cn"; export function SidebarButton({ label, diff --git a/src/components/SidebarDropZone.tsx b/src/components/SidebarDropZone.tsx new file mode 100644 index 0000000..2949ad3 --- /dev/null +++ b/src/components/SidebarDropZone.tsx @@ -0,0 +1,29 @@ +import { useDroppable } from "@dnd-kit/core"; +import { cn } from "@/lib/cn"; + +type SidebarDropZoneProps = { + id: string; + depth: number; +}; + +export function SidebarDropZone({ id, depth }: SidebarDropZoneProps) { + const { isOver, setNodeRef } = useDroppable({ id }); + + return ( +
+
+
+ ); +} diff --git a/src/components/SidebarFeedItem.tsx b/src/components/SidebarFeedItem.tsx new file mode 100644 index 0000000..76bb18f --- /dev/null +++ b/src/components/SidebarFeedItem.tsx @@ -0,0 +1,50 @@ +import { RssIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { useSortable } from "@dnd-kit/sortable"; + +type SidebarFeedItemProps = { + id: string; + label: string; + depth: number; + isActive?: boolean; + onClick?: () => void; +}; + +export function SidebarFeedItem({ + id, + label, + depth, + isActive, + onClick, +}: SidebarFeedItemProps) { + const { attributes, listeners, setNodeRef, isDragging } = useSortable({ id }); + + return ( + + ); +} + +const baseRowClassName = + "flex h-8 w-full items-center gap-1.5 rounded-[8px] pr-2 text-[13px] font-medium text-content-muted text-left transition-colors"; + +const hoverRowClassName = "hover:bg-interactive-hover hover:text-content"; + +const activeRowClassName = "bg-surface-subtle text-content"; diff --git a/src/components/SidebarFolderItem.tsx b/src/components/SidebarFolderItem.tsx new file mode 100644 index 0000000..0fd74bb --- /dev/null +++ b/src/components/SidebarFolderItem.tsx @@ -0,0 +1,93 @@ +import { + ChevronDownIcon, + ChevronRightIcon, + FolderIcon, + FolderOpenIcon, +} from "lucide-react"; +import { cn } from "@/lib/cn"; +import { useSortable } from "@dnd-kit/sortable"; +import { useDroppable } from "@dnd-kit/core"; + +type SidebarFolderItemProps = { + id: string; + label: string; + depth: number; + isExpanded: boolean; + isActive?: boolean; + isDropTarget?: boolean; + dropInsideZoneId?: string; + onClick?: (event: React.MouseEvent) => void; + onToggle?: () => void; +}; + +export function SidebarFolderItem({ + id, + label, + depth, + isExpanded, + isActive, + isDropTarget, + dropInsideZoneId, + onClick, + onToggle, +}: SidebarFolderItemProps) { + const { attributes, listeners, setNodeRef, isDragging } = useSortable({ id }); + const { isOver, setNodeRef: setDropInsideNodeRef } = useDroppable({ + id: dropInsideZoneId ?? `folder-drop:${id}`, + disabled: !dropInsideZoneId, + }); + + return ( +
+ +
+ ); +} + +const baseRowClassName = + "flex h-8 w-full items-center gap-1.5 rounded-[8px] pr-2 text-[13px] font-medium text-content-muted transition-colors"; + +const hoverRowClassName = "hover:bg-interactive-hover hover:text-content"; + +const activeRowClassName = "bg-surface-subtle text-content"; diff --git a/src/lib/sidebarApi.ts b/src/lib/sidebarApi.ts new file mode 100644 index 0000000..b2a9347 --- /dev/null +++ b/src/lib/sidebarApi.ts @@ -0,0 +1,24 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { SidebarData } from "@/types/sidebar"; + +export async function getSidebarData() { + return invoke("get_sidebar_data"); +} + +export async function saveSidebarStructure({ + folders, + feeds, +}: { + folders: Array<{ + folderId: string; + parentFolderId: string | null; + sortOrder: number; + }>; + feeds: Array<{ + feedId: string; + folderId: string | null; + sortOrder: number; + }>; +}) { + return invoke("save_sidebar_structure", { folders, feeds }); +} diff --git a/src/lib/sidebarExpansionStorage.ts b/src/lib/sidebarExpansionStorage.ts new file mode 100644 index 0000000..b0ce942 --- /dev/null +++ b/src/lib/sidebarExpansionStorage.ts @@ -0,0 +1,19 @@ +import { invoke } from "@tauri-apps/api/core"; + +export async function loadExpandedFolderIds() { + try { + return await invoke>("load_sidebar_expansion_state"); + } catch { + return null; + } +} + +export async function saveExpandedFolderIds( + expandedFolderIds: Record, +) { + try { + await invoke("save_sidebar_expansion_state", { expandedFolderIds }); + } catch { + return; + } +} diff --git a/src/lib/sidebarMove.ts b/src/lib/sidebarMove.ts new file mode 100644 index 0000000..430b10a --- /dev/null +++ b/src/lib/sidebarMove.ts @@ -0,0 +1,288 @@ +import type { FeedRecord, FolderRecord, SidebarNode } from "@/types/sidebar"; + +type MovePosition = { + parentFolderId: string | null; + index: number; +}; + +export function moveSidebarNode({ + folders, + feeds, + tree, + activeId, + targetContextId, + targetIndex, +}: { + folders: FolderRecord[]; + feeds: FeedRecord[]; + tree: SidebarNode[]; + activeId: string; + targetContextId: string | null; + targetIndex: number; +}) { + const activeFolder = folders.find((folder) => folder.id === activeId); + const activeFeed = feeds.find((feed) => feed.id === activeId); + + if (!activeFolder && !activeFeed) { + return { folders, feeds }; + } + + const contextNodes = getContextNodes(tree, targetContextId); + + if (!contextNodes) { + return { folders, feeds }; + } + + if (activeFolder) { + if (targetContextId === activeFolder.id) { + return { folders, feeds }; + } + + if ( + targetContextId && + isDescendantFolder(tree, activeFolder.id, targetContextId) + ) { + return { folders, feeds }; + } + + const folderIndex = countNodesBeforeIndex(contextNodes, targetIndex, "folder"); + + return { + folders: reorderFolders(folders, activeFolder.id, { + parentFolderId: targetContextId, + index: folderIndex, + }), + feeds, + }; + } + + const feedIndex = countNodesBeforeIndex(contextNodes, targetIndex, "feed"); + + return { + folders, + feeds: reorderFeeds(feeds, activeId, { + parentFolderId: targetContextId, + index: feedIndex, + }), + }; +} + +export function serializeSidebarStructure( + folders: FolderRecord[], + feeds: FeedRecord[], +) { + return { + folders: folders.map((folder) => ({ + folderId: folder.id, + parentFolderId: folder.parentFolderId, + sortOrder: folder.sortOrder, + })), + feeds: feeds.map((feed) => ({ + feedId: feed.id, + folderId: feed.folderId, + sortOrder: feed.sortOrder, + })), + }; +} + +function reorderFolders( + folders: FolderRecord[], + folderId: string, + position: MovePosition, +) { + const movingFolder = folders.find((folder) => folder.id === folderId); + + if (!movingFolder) { + return folders; + } + + const targetSiblings = folders + .filter( + (folder) => + folder.parentFolderId === position.parentFolderId && folder.id !== folderId, + ) + .sort((left, right) => left.sortOrder - right.sortOrder); + + targetSiblings.splice(position.index, 0, { + ...movingFolder, + parentFolderId: position.parentFolderId, + }); + + const targetOrderById = new Map( + targetSiblings.map((folder, index) => [folder.id, index]), + ); + + const sourceSiblings = folders + .filter( + (folder) => + folder.parentFolderId === movingFolder.parentFolderId && folder.id !== folderId, + ) + .sort((left, right) => left.sortOrder - right.sortOrder); + + const sourceOrderById = new Map( + sourceSiblings.map((folder, index) => [folder.id, index]), + ); + + return folders.map((folder) => { + if (folder.id === folderId) { + return { + ...folder, + parentFolderId: position.parentFolderId, + sortOrder: position.index, + }; + } + + if ( + folder.parentFolderId === position.parentFolderId && + targetOrderById.has(folder.id) + ) { + return { + ...folder, + sortOrder: targetOrderById.get(folder.id) ?? folder.sortOrder, + }; + } + + if ( + folder.parentFolderId === movingFolder.parentFolderId && + sourceOrderById.has(folder.id) && + movingFolder.parentFolderId !== position.parentFolderId + ) { + return { + ...folder, + sortOrder: sourceOrderById.get(folder.id) ?? folder.sortOrder, + }; + } + + return folder; + }); +} + +function reorderFeeds(feeds: FeedRecord[], feedId: string, position: MovePosition) { + const movingFeed = feeds.find((feed) => feed.id === feedId); + + if (!movingFeed) { + return feeds; + } + + const targetSiblings = feeds + .filter( + (feed) => feed.folderId === position.parentFolderId && feed.id !== feedId, + ) + .sort((left, right) => left.sortOrder - right.sortOrder); + + targetSiblings.splice(position.index, 0, { + ...movingFeed, + folderId: position.parentFolderId, + }); + + const targetOrderById = new Map( + targetSiblings.map((feed, index) => [feed.id, index]), + ); + + const sourceSiblings = feeds + .filter( + (feed) => feed.folderId === movingFeed.folderId && feed.id !== feedId, + ) + .sort((left, right) => left.sortOrder - right.sortOrder); + + const sourceOrderById = new Map( + sourceSiblings.map((feed, index) => [feed.id, index]), + ); + + return feeds.map((feed) => { + if (feed.id === feedId) { + return { + ...feed, + folderId: position.parentFolderId, + sortOrder: position.index, + }; + } + + if ( + feed.folderId === position.parentFolderId && + targetOrderById.has(feed.id) + ) { + return { + ...feed, + sortOrder: targetOrderById.get(feed.id) ?? feed.sortOrder, + }; + } + + if ( + feed.folderId === movingFeed.folderId && + sourceOrderById.has(feed.id) && + movingFeed.folderId !== position.parentFolderId + ) { + return { + ...feed, + sortOrder: sourceOrderById.get(feed.id) ?? feed.sortOrder, + }; + } + + return feed; + }); +} + +function countNodesBeforeIndex( + nodes: SidebarNode[], + index: number, + type: SidebarNode["type"], +) { + return nodes.slice(0, index).filter((node) => node.type === type).length; +} + +function getContextNodes(nodes: SidebarNode[], contextFolderId: string | null): SidebarNode[] | null { + if (contextFolderId === null) { + return nodes; + } + + const folderNode = findNode(nodes, contextFolderId); + if (!folderNode || folderNode.type !== "folder") { + return null; + } + + return folderNode.children; +} + +function isDescendantFolder( + nodes: SidebarNode[], + folderId: string, + candidateParentId: string, +) { + const folder = findNode(nodes, folderId); + + if (!folder || folder.type !== "folder") { + return false; + } + + return containsFolder(folder.children, candidateParentId); +} + +function containsFolder(nodes: SidebarNode[], folderId: string): boolean { + for (const node of nodes) { + if (node.type === "folder") { + if (node.id === folderId || containsFolder(node.children, folderId)) { + return true; + } + } + } + + return false; +} + +function findNode(nodes: SidebarNode[], nodeId: string): SidebarNode | null { + for (const node of nodes) { + if (node.id === nodeId) { + return node; + } + + if (node.type === "folder") { + const childMatch = findNode(node.children, nodeId); + if (childMatch) { + return childMatch; + } + } + } + + return null; +} diff --git a/src/lib/sidebarTree.ts b/src/lib/sidebarTree.ts new file mode 100644 index 0000000..7d9c14f --- /dev/null +++ b/src/lib/sidebarTree.ts @@ -0,0 +1,66 @@ +import type { + FeedRecord, + FolderRecord, + SidebarFeedNode, + SidebarFolderNode, + SidebarNode, +} from "@/types/sidebar"; + +export function buildSidebarTree(folders: FolderRecord[], feeds: FeedRecord[]) { + const foldersByParent = new Map(); + const feedsByFolder = new Map(); + + for (const folder of folders) { + const key = folder.parentFolderId; + const siblings = foldersByParent.get(key) ?? []; + siblings.push(folder); + foldersByParent.set(key, siblings); + } + + for (const feed of feeds) { + const key = feed.folderId; + const siblings = feedsByFolder.get(key) ?? []; + siblings.push(feed); + feedsByFolder.set(key, siblings); + } + + return buildNodes(null, foldersByParent, feedsByFolder); +} + +function buildNodes( + parentFolderId: string | null, + foldersByParent: Map, + feedsByFolder: Map, +): SidebarNode[] { + const folderNodes = (foldersByParent.get(parentFolderId) ?? []) + .sort(compareBySortOrderAndLabel) + .map((folder) => ({ + type: "folder", + id: folder.id, + name: folder.name, + children: buildNodes(folder.id, foldersByParent, feedsByFolder), + })); + + const feedNodes = (feedsByFolder.get(parentFolderId) ?? []) + .sort(compareBySortOrderAndLabel) + .map((feed) => ({ + type: "feed", + id: feed.id, + title: feed.title, + })); + + return [...folderNodes, ...feedNodes]; +} + +function compareBySortOrderAndLabel( + left: { sortOrder: number; name?: string; title?: string }, + right: { sortOrder: number; name?: string; title?: string }, +) { + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder; + } + + return (left.name ?? left.title ?? "").localeCompare( + right.name ?? right.title ?? "", + ); +} diff --git a/src/routes/FeedRoute.tsx b/src/routes/FeedRoute.tsx new file mode 100644 index 0000000..28aa98e --- /dev/null +++ b/src/routes/FeedRoute.tsx @@ -0,0 +1,18 @@ +import { RouteLayout } from "@/routes/RouteLayout"; +import { useRoutes } from "@/stores/routeStore"; +import { useSidebarStore } from "@/stores/sidebarStore"; + +export function FeedRoute() { + const feedId = useRoutes((state) => String(state.routeParams.feedId ?? "")); + const feed = useSidebarStore((state) => + state.feeds.find((item) => item.id === feedId), + ); + + return ( + +
+ Feed articles will appear here. +
+
+ ); +} diff --git a/src/routes/FolderRoute.tsx b/src/routes/FolderRoute.tsx new file mode 100644 index 0000000..fd2a23b --- /dev/null +++ b/src/routes/FolderRoute.tsx @@ -0,0 +1,18 @@ +import { RouteLayout } from "@/routes/RouteLayout"; +import { useRoutes } from "@/stores/routeStore"; +import { useSidebarStore } from "@/stores/sidebarStore"; + +export function FolderRoute() { + const folderId = useRoutes((state) => String(state.routeParams.folderId ?? "")); + const folder = useSidebarStore((state) => + state.folders.find((item) => item.id === folderId), + ); + + return ( + +
+ Folder articles will appear here. +
+
+ ); +} diff --git a/src/stores/routeStore.ts b/src/stores/routeStore.ts index 4a3a0a1..d185066 100644 --- a/src/stores/routeStore.ts +++ b/src/stores/routeStore.ts @@ -3,6 +3,8 @@ import { create } from "zustand"; export enum ROUTE { DASHBOARD = "DASHBOARD", UNREAD = "UNREAD", + FOLDER = "FOLDER", + FEED = "FEED", } export type RouteParams = Record; diff --git a/src/stores/sidebarStore.ts b/src/stores/sidebarStore.ts new file mode 100644 index 0000000..9010d21 --- /dev/null +++ b/src/stores/sidebarStore.ts @@ -0,0 +1,112 @@ +import { create } from "zustand"; +import { getSidebarData, saveSidebarStructure } from "@/lib/sidebarApi"; +import { + loadExpandedFolderIds, + saveExpandedFolderIds, +} from "@/lib/sidebarExpansionStorage"; +import { serializeSidebarStructure } from "@/lib/sidebarMove"; +import type { FeedRecord, FolderRecord } from "@/types/sidebar"; + +type SidebarState = { + folders: FolderRecord[]; + feeds: FeedRecord[]; + expandedFolderIds: Record; + isLoading: boolean; + hasLoaded: boolean; + error: string | null; + loadSidebarData: () => Promise; + toggleFolder: (folderId: string) => void; + toggleFolderTree: (folderId: string) => void; + setSidebarStructure: (folders: FolderRecord[], feeds: FeedRecord[]) => void; + persistSidebarStructure: () => Promise; +}; + +export const useSidebarStore = create((set, get) => ({ + folders: [], + feeds: [], + expandedFolderIds: {}, + isLoading: false, + hasLoaded: false, + error: null, + loadSidebarData: async () => { + if (get().isLoading || get().hasLoaded) { + return; + } + + set({ isLoading: true, error: null }); + + try { + const sidebarData = await getSidebarData(); + const storedExpandedFolderIds = await loadExpandedFolderIds(); + const expandedFolderIds = Object.fromEntries( + sidebarData.folders.map((folder) => [ + folder.id, + storedExpandedFolderIds?.[folder.id] ?? false, + ]), + ); + + set({ + folders: sidebarData.folders, + feeds: sidebarData.feeds, + expandedFolderIds, + isLoading: false, + hasLoaded: true, + }); + } catch (error) { + set({ + isLoading: false, + error: + error instanceof Error + ? error.message + : "Failed to load sidebar data.", + }); + } + }, + toggleFolder: (folderId) => + set((state) => { + const expandedFolderIds = { + ...state.expandedFolderIds, + [folderId]: !state.expandedFolderIds[folderId], + }; + + void saveExpandedFolderIds(expandedFolderIds); + + return { expandedFolderIds }; + }), + toggleFolderTree: (folderId) => + set((state) => { + const descendantFolderIds = getDescendantFolderIds(state.folders, folderId); + const nextExpandedState = !state.expandedFolderIds[folderId]; + const expandedFolderIds = { + ...state.expandedFolderIds, + [folderId]: nextExpandedState, + }; + + for (const descendantFolderId of descendantFolderIds) { + expandedFolderIds[descendantFolderId] = nextExpandedState; + } + + void saveExpandedFolderIds(expandedFolderIds); + + return { expandedFolderIds }; + }), + setSidebarStructure: (folders, feeds) => set({ folders, feeds }), + persistSidebarStructure: async () => { + const { folders, feeds } = get(); + await saveSidebarStructure(serializeSidebarStructure(folders, feeds)); + }, +})); + +function getDescendantFolderIds( + folders: FolderRecord[], + folderId: string, +): string[] { + const childFolderIds = folders + .filter((folder) => folder.parentFolderId === folderId) + .map((folder) => folder.id); + + return childFolderIds.flatMap((childFolderId) => [ + childFolderId, + ...getDescendantFolderIds(folders, childFolderId), + ]); +} diff --git a/src/types/sidebar.ts b/src/types/sidebar.ts new file mode 100644 index 0000000..0c51a39 --- /dev/null +++ b/src/types/sidebar.ts @@ -0,0 +1,41 @@ +export type FolderRecord = { + id: string; + name: string; + parentFolderId: string | null; + sortOrder: number; + createdAt: string; + updatedAt: string; +}; + +export type FeedRecord = { + id: string; + title: string; + url: string; + siteUrl: string | null; + folderId: string | null; + sortOrder: number; + iconUrl: string | null; + lastFetchedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type SidebarData = { + folders: FolderRecord[]; + feeds: FeedRecord[]; +}; + +export type SidebarNode = SidebarFolderNode | SidebarFeedNode; + +export type SidebarFolderNode = { + type: "folder"; + id: string; + name: string; + children: SidebarNode[]; +}; + +export type SidebarFeedNode = { + type: "feed"; + id: string; + title: string; +}; -- 2.51.2