feat: build full RSS reader UI and wire Tauri feed commands with caching

This commit is contained in:
mindfreq 2026-05-03 20:39:03 +02:00
parent 799bf18af7
commit d4831c6e41
33 changed files with 4918 additions and 274 deletions

25
components.json Normal file
View file

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/App.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": true,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View file

@ -10,17 +10,30 @@
"tauri": "tauri"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.2.4",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2"
"react-resizable-panels": "^4.11.0",
"shadcn": "^4.6.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"@tauri-apps/cli": "^2"
"vite": "^7.0.4"
}
}

2949
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

212
src-tauri/Cargo.lock generated
View file

@ -2,18 +2,24 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]]
name = "adler2"
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"
@ -38,6 +44,12 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
@ -362,6 +374,42 @@ dependencies = [
"serde",
]
[[package]]
name = "cached"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae"
dependencies = [
"ahash",
"async-trait",
"cached_proc_macro",
"cached_proc_macro_types",
"futures",
"hashbrown 0.14.5",
"once_cell",
"thiserror 1.0.69",
"tokio",
"web-time",
]
[[package]]
name = "cached_proc_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa"
dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "cached_proc_macro_types"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
[[package]]
name = "cairo-rs"
version = "0.18.5"
@ -677,14 +725,38 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.23.0",
"darling_macro 0.23.0",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.117",
]
[[package]]
@ -700,13 +772,24 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"darling_core 0.23.0",
"quote",
"syn 2.0.117",
]
@ -1145,6 +1228,20 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@ -1152,6 +1249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@ -1569,6 +1667,16 @@ 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",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@ -2671,12 +2779,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
@ -3246,19 +3348,18 @@ dependencies = [
[[package]]
name = "rufeed"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"cached",
"feed-rs",
"once_cell",
"reqwest",
"scraper",
"serde",
"serde_json",
"specta",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tauri-specta",
"thiserror 2.0.18",
"tokio",
"uuid",
@ -3654,7 +3755,7 @@ version = "3.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
dependencies = [
"darling",
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@ -3816,50 +3917,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "specta"
version = "2.0.0-rc.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
dependencies = [
"paste",
"specta-macros",
"thiserror 1.0.69",
]
[[package]]
name = "specta-macros"
version = "2.0.0-rc.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0074b9e30ed84c6924eb63ad8d2fe71cdc82628525d84b1fcb1f2fd40676517"
dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "specta-serde"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77216504061374659e7245eac53d30c7b3e5fe64b88da97c753e7184b0781e63"
dependencies = [
"specta",
"thiserror 1.0.69",
]
[[package]]
name = "specta-typescript"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3220a0c365e51e248ac98eab5a6a32f544ff6f961906f09d3ee10903a4f52b2d"
dependencies = [
"specta",
"specta-serde",
"thiserror 1.0.69",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@ -4105,7 +4162,6 @@ dependencies = [
"serde_json",
"serde_repr",
"serialize-to-javascript",
"specta",
"swift-rs",
"tauri-build",
"tauri-macros",
@ -4273,34 +4329,6 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-specta"
version = "2.0.0-rc.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b23c0132dd3cf6064e5cd919b82b3f47780e9280e7b5910babfe139829b76655"
dependencies = [
"heck 0.5.0",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tauri",
"tauri-specta-macros",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-specta-macros"
version = "2.0.0-rc.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tauri-utils"
version = "2.9.0"

View file

@ -1,6 +1,6 @@
[package]
name = "rufeed"
version = "0.1.0"
version = "0.3.0"
description = "A Tauri App"
authors = ["Ahmed Nagi"]
edition = "2021"
@ -26,8 +26,7 @@ reqwest = "0.13.3"
feed-rs = "2.3.1"
once_cell = "1"
tokio = { version = "1", features = ["full"] }
specta = { version = "=2.0.0-rc.22" }
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
thiserror = "2.0.18"
uuid = { version = "1", features = ["v4"] }
scraper = "0.22"
cached = { version = "0.54", features = ["async"] }

View file

@ -1,2 +1,23 @@
[
{
"id": "17a52b7a-cb76-4d84-bc37-110537c2f7ca",
"title": "Rust Blog",
"url": "https://blog.rust-lang.org/",
"feed_url": "https://blog.rust-lang.org/feed.xml",
"icon": "https://blog.rust-lang.org/images/apple-touch-icon.png"
},
{
"id": "c414b24b-c429-4d56-9be5-aa2e0c042fc0",
"title": "RSS feed for os.phil-opp.com",
"url": "https://os.phil-opp.com/",
"feed_url": "https://os.phil-opp.com/rss.xml",
"icon": "https://os.phil-opp.com/favicon.ico"
},
{
"id": "e4a39047-3c64-4beb-9622-9fd02062cff5",
"title": "عالم الكمبيوتر- RSS",
"url": "https://www.computer-wd.com/",
"feed_url": "https://www.computer-wd.com/rss.xml",
"icon": "https://www.computer-wd.com/favicon.ico"
}
]

View file

@ -1,3 +1,6 @@
use cached::proc_macro::cached;
use crate::client::CLIENT;
use crate::config::feed_config::Feed;
use crate::parser::{extract_feed_info, FeedEntry, FeedItem};
@ -36,7 +39,9 @@ pub async fn remove_feed(website_url: &str) -> Result<Feed, Error> {
}
// =======================================================
pub async fn get_feed_item(website_url: &str) -> Result<Vec<FeedItem>, Error> {
#[tauri::command]
#[cached(size=100, time=300, result=true)]
pub async fn get_feed_item(website_url: String) -> Result<Vec<FeedItem>, Error> {
if let Ok(feed) = Feed::get(&website_url) {
let xml = CLIENT.get(&feed.feed_url).send().await?.text().await?;
@ -46,7 +51,9 @@ pub async fn get_feed_item(website_url: &str) -> Result<Vec<FeedItem>, Error> {
}
}
pub async fn get_entry(website_url: &str, target_url: &str) -> Result<FeedEntry, Error> {
#[tauri::command]
#[cached(size=100, time=300, result=true)]
pub async fn get_entry(website_url: String, target_url: String) -> Result<FeedEntry, Error> {
if let Ok(feed) = Feed::get(&website_url) {
let xml = CLIENT.get(&feed.feed_url).send().await?.text().await?;
FeedEntry::from_feed(&xml, &target_url)

View file

@ -3,13 +3,13 @@ pub mod commands;
pub mod config;
pub mod parser;
use commands::feed::{add_feed, get_entry, get_feeds, get_feed_item};
use commands::feed::{add_feed, get_entry, get_feeds, get_feed_item, remove_feed};
pub async fn test_thing() -> Result<(), Error> {
match get_entry("https://blog.rust-lang.org/", "https://blog.rust-lang.org/2026/03/20/rust-challenges/").await {
Ok(feed) => println!("{:?}", feed),
Err(e) => eprintln!("add_feed failed: {}", e),
}
// match get_entry("https://blog.rust-lang.org/", "https://blog.rust-lang.org/2026/03/20/rust-challenges/").await {
// Ok(feed) => println!("{:?}", feed),
// Err(e) => eprintln!("add_feed failed: {}", e),
// }
Ok(())
}
@ -18,7 +18,13 @@ pub async fn test_thing() -> Result<(), Error> {
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![add_feed])
.invoke_handler(tauri::generate_handler![
add_feed,
get_feeds,
remove_feed,
get_feed_item,
get_entry
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -3,6 +3,6 @@
#[tokio::main]
async fn main() {
rufeed_lib::test_thing().await;
// rufeed_lib::run()
// rufeed_lib::test_thing().await;
rufeed_lib::run()
}

View file

@ -1,16 +1,20 @@
use feed_rs::model::Person;
use feed_rs::parser;
use scraper::{Html, Selector};
use crate::Error;
#[derive(Debug)]
#[derive(Debug, Clone, serde::Serialize)]
pub struct FeedItem {
pub title: String,
pub published: String,
pub url: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Person {
pub name: String,
}
impl FeedItem {
pub fn from_feed(xml: &str) -> Result<Vec<Self>, Error> {
let feed = parser::parse(xml.as_bytes())?;
@ -25,7 +29,7 @@ impl FeedItem {
published: entry
.published
.or(entry.updated)
.map(|d| d.to_string())
.map(|d| d.to_rfc3339())
.ok_or(Error::MissingField("published".into()))?,
url: entry
.links
@ -41,7 +45,7 @@ impl FeedItem {
}
}
#[derive(Debug)]
#[derive(Debug, Clone, serde::Serialize)]
pub struct FeedEntry {
pub id: String,
pub title: String,
@ -77,18 +81,22 @@ impl FeedEntry {
published: entry
.published
.or(entry.updated)
.map(|d| d.to_string())
.map(|d| d.to_rfc3339())
.ok_or(Error::MissingField("published".into()))?,
updated: entry
.updated
.map(|d| d.to_string())
.map(|d| d.to_rfc3339())
.ok_or(Error::MissingField("updated".into()))?,
summary: entry.summary.map(|t| t.content),
content: entry
.content
.and_then(|c| c.body)
.ok_or(Error::MissingField("content".into()))?,
authors: entry.authors,
authors: entry
.authors
.into_iter()
.map(|author| Person { name: author.name })
.collect(),
});
}
}

View file

@ -1,116 +1,194 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
--background: oklch(0.98 0.008 90);
--foreground: oklch(0.2 0.01 260);
--card: oklch(0.995 0.004 90);
--card-foreground: oklch(0.2 0.01 260);
--popover: oklch(0.995 0.004 90);
--popover-foreground: oklch(0.2 0.01 260);
--primary: oklch(0.585 0.19 278);
--primary-foreground: oklch(0.98 0.005 280);
--secondary: oklch(0.96 0.007 90);
--secondary-foreground: oklch(0.25 0.01 260);
--muted: oklch(0.95 0.008 90);
--muted-foreground: oklch(0.49 0.012 260);
--accent: oklch(0.92 0.02 275);
--accent-foreground: oklch(0.2 0.01 260);
--destructive: oklch(0.62 0.2 28);
--border: oklch(0.89 0.008 88);
--input: oklch(0.89 0.008 88);
--ring: var(--primary);
--radius: 0.75rem;
--chart-1: oklch(0.75 0.14 278);
--chart-2: oklch(0.66 0.12 230);
--chart-3: oklch(0.58 0.1 180);
--chart-4: oklch(0.51 0.09 110);
--chart-5: oklch(0.45 0.08 45);
--sidebar: oklch(0.965 0.008 90);
--sidebar-foreground: oklch(0.2 0.01 260);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: var(--accent);
--sidebar-accent-foreground: var(--accent-foreground);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
.dark {
--background: oklch(0.14 0.004 260);
--foreground: oklch(0.94 0.01 90);
--card: oklch(0.19 0.006 260);
--card-foreground: oklch(0.94 0.01 90);
--popover: oklch(0.18 0.006 260);
--popover-foreground: oklch(0.94 0.01 90);
--primary: oklch(0.68 0.17 278);
--primary-foreground: oklch(0.16 0.005 260);
--secondary: oklch(0.23 0.007 260);
--secondary-foreground: oklch(0.93 0.01 90);
--muted: oklch(0.22 0.007 260);
--muted-foreground: oklch(0.72 0.008 260);
--accent: oklch(0.27 0.017 278);
--accent-foreground: oklch(0.94 0.01 90);
--destructive: oklch(0.68 0.18 28);
--border: oklch(0.32 0.007 260);
--input: oklch(0.32 0.007 260);
--ring: var(--primary);
--sidebar: oklch(0.16 0.005 260);
--sidebar-foreground: oklch(0.94 0.01 90);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: var(--accent);
--sidebar-accent-foreground: var(--accent-foreground);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
@theme inline {
--font-heading:
Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-sans:
Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
@layer base {
* {
@apply border-border outline-ring/50;
}
a:hover {
color: #24c8db;
html,
body,
#root {
height: 100%;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
body {
@apply bg-background text-foreground;
font-feature-settings: "cv11", "ss01";
}
}
@layer components {
:root:not(.dark) .prose pre {
@apply border-zinc-200 bg-zinc-900 text-zinc-100 shadow-sm;
}
:root:not(.dark) .prose pre code {
@apply text-zinc-100;
}
.dark .prose pre,
.dark .prose pre.z-code,
.dark .prose pre[class*="language-"] {
@apply border-zinc-700 text-zinc-100 shadow-none;
background-color: rgb(39 39 42) !important;
}
.dark .prose pre code,
.dark .prose pre code[class*="language-"] {
@apply text-zinc-100;
background: transparent !important;
}
[data-slot="resizable-panel-group"] {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow: hidden;
}
[data-slot="resizable-panel"] {
min-width: 0;
min-height: 0;
overflow: hidden;
}
[data-slot="resizable-panel-group"][aria-orientation="vertical"] {
flex-direction: column;
}
.reader-content > * + * {
margin-top: 1.2rem;
}
.reader-content p,
.reader-content li {
@apply text-base leading-[1.8] text-foreground;
}
.reader-content a {
@apply text-primary underline underline-offset-4;
}
.reader-content img {
@apply my-4 w-full rounded-lg border;
}
.reader-content pre {
@apply overflow-x-auto rounded-lg border bg-muted p-3;
}
.reader-content blockquote {
@apply border-l-2 border-primary pl-4 text-muted-foreground;
}
}

View file

@ -1,51 +1,490 @@
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke } from "@tauri-apps/api/core";
import "./App.css";
import { useEffect, useState } from "react";
import { commands, Feed, FeedEntry, FeedItem } from "./bindings";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useTheme } from "next-themes";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
PlusCircle,
Trash2,
Rss,
ExternalLink,
ChevronLeft,
RefreshCw,
CircleAlert,
X,
Sun,
Moon,
Menu,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
function App() {
const [greetMsg, setGreetMsg] = useState("");
const [name, setName] = useState("");
type View =
| { type: "empty" }
| { type: "items"; feed: Feed; items: FeedItem[] }
| { type: "entry"; feed: Feed; entry: FeedEntry };
async function greet() {
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
setGreetMsg(await invoke("greet", { name }));
type UiError = {
id: number;
title: string;
message: string;
};
const extractErrorMessage = (error: unknown): string => {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
if (typeof error === "object" && error !== null && "message" in error) {
const message = (error as { message?: unknown }).message;
if (typeof message === "string") return message;
}
return (
<main className="container">
<h1>Welcome to Tauri + React</h1>
return "Something went wrong. Please try again.";
};
<div className="row">
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
const normalizeErrorMessage = (message: string): string => {
const cleanedMessage = message.replace(/^Error:\s*/i, "").trim();
if (cleanedMessage.toLowerCase().startsWith("missing required field:")) {
return cleanedMessage.replace(/^missing required field:\s*/i, "");
}
return cleanedMessage;
};
const formatPublishedDate = (value?: string, long = false): string => {
if (!value) return "Date unavailable";
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return "Date unavailable";
return new Date(timestamp).toLocaleDateString(undefined, long
? { year: "numeric", month: "long", day: "numeric" }
: { year: "numeric", month: "short", day: "numeric" });
};
export default function App() {
const { resolvedTheme, setTheme } = useTheme();
const [feeds, setFeeds] = useState<Feed[]>([]);
const [loadingFeeds, setLoadingFeeds] = useState(true);
const [selectedFeed, setSelectedFeed] = useState<Feed | null>(null);
const [view, setView] = useState<View>({ type: "empty" });
const [loadingView, setLoadingView] = useState(false);
const [addUrl, setAddUrl] = useState("");
const [adding, setAdding] = useState(false);
const [showAddInput, setShowAddInput] = useState(false);
const [uiError, setUiError] = useState<UiError | null>(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [isDesktopSidebarOpen, setIsDesktopSidebarOpen] = useState(true);
const showError = (error: unknown, fallbackMessage: string) => {
console.error(error);
const message = normalizeErrorMessage(extractErrorMessage(error)) || fallbackMessage;
setUiError({
id: Date.now(),
title: "Action failed",
message,
});
};
useEffect(() => {
if (!uiError) return;
const timer = window.setTimeout(() => {
setUiError((current) => (current?.id === uiError.id ? null : current));
}, 5500);
return () => window.clearTimeout(timer);
}, [uiError]);
useEffect(() => {
loadFeeds();
}, []);
const loadFeeds = async () => {
setLoadingFeeds(true);
try {
const data = await commands.getFeeds();
setFeeds(data);
} catch (e) {
showError(e, "Unable to load feeds right now.");
} finally {
setLoadingFeeds(false);
}
};
const handleSelectFeed = async (feed: Feed) => {
setIsMobileSidebarOpen(false);
setSelectedFeed(feed);
setLoadingView(true);
setView({ type: "empty" });
try {
const items = await commands.getFeedItem(feed.url);
setView({ type: "items", feed, items });
} catch (e) {
showError(e, "Unable to load feed items.");
} finally {
setLoadingView(false);
}
};
const handleSelectItem = async (feed: Feed, item: FeedItem) => {
setLoadingView(true);
try {
const entry = await commands.getEntry(feed.url, item.url);
setView({ type: "entry", feed, entry });
} catch (e) {
showError(e, "Unable to open this article.");
} finally {
setLoadingView(false);
}
};
const handleAddFeed = async () => {
if (!addUrl.trim()) return;
setAdding(true);
try {
const feed = await commands.addFeed(addUrl.trim());
setFeeds((prev) => [...prev, feed]);
setAddUrl("");
setShowAddInput(false);
} catch (e) {
showError(e, "Unable to add this feed.");
} finally {
setAdding(false);
}
};
const handleRemoveFeed = async (feed: Feed, e: React.MouseEvent) => {
e.stopPropagation();
try {
await commands.removeFeed(feed.url);
setFeeds((prev) => prev.filter((f) => f.id !== feed.id));
if (selectedFeed?.id === feed.id) {
setSelectedFeed(null);
setView({ type: "empty" });
}
} catch (e) {
showError(e, "Unable to remove this feed.");
}
};
const sidebarContent = (
<>
<div className="flex items-center justify-between border-b border-border/70 px-5 py-4">
<div className="flex items-center gap-2">
<Rss className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold tracking-tight">RuFeed</span>
</div>
<div className="flex items-center gap-1.5 md:mr-8">
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
>
{resolvedTheme === "dark" ? (
<Sun className="h-3.5 w-3.5" />
) : (
<Moon className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{resolvedTheme === "dark" ? "Switch to light" : "Switch to dark"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={loadFeeds}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh feeds</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setShowAddInput((v) => !v)}
>
<PlusCircle className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Add feed</TooltipContent>
</Tooltip>
</div>
</div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
greet();
}}
>
<input
id="greet-input"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="Enter a name..."
/>
<button type="submit">Greet</button>
</form>
<p>{greetMsg}</p>
</main>
{showAddInput && (
<div className="flex gap-2 border-b border-border/70 px-3 py-3">
<input
className="h-8 flex-1 rounded-md border border-border/80 bg-muted/50 px-2.5 text-xs outline-none transition-colors focus:border-primary"
placeholder="https://..."
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddFeed()}
autoFocus
/>
<Button size="sm" className="h-8 px-3 text-xs" onClick={handleAddFeed} disabled={adding}>
{adding ? "..." : "Add"}
</Button>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div className="space-y-1.5 p-2.5">
{loadingFeeds ? (
Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 rounded-lg border border-border/60 px-3 py-2.5">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-4 flex-1 rounded" />
</div>
))
) : feeds.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 px-4 py-10 text-center text-xs text-muted-foreground">
No feeds yet. Add one above.
</div>
) : (
feeds.map((feed) => (
<button
key={feed.id}
onClick={() => handleSelectFeed(feed)}
className={`group relative flex w-full items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left text-sm transition-all duration-200 ${
selectedFeed?.id === feed.id
? "border-border bg-card shadow-sm"
: "border-transparent hover:border-border/70 hover:bg-card/60"
}`}
>
{feed.icon ? (
<img src={feed.icon} alt="" className="h-4 w-4 rounded-sm object-contain shrink-0" />
) : (
<Rss className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span dir="auto" className="min-w-0 flex-1 truncate font-medium">
{feed.title || feed.url}
</span>
<Tooltip>
<TooltipTrigger>
<span
onClick={(e) => handleRemoveFeed(feed, e)}
className="inline-flex items-center justify-center rounded-md p-1 align-middle opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
>
<Trash2 className="h-3 w-3" />
</span>
</TooltipTrigger>
<TooltipContent>Remove feed</TooltipContent>
</Tooltip>
</button>
))
)}
</div>
</div>
<div className="border-t border-border/70 px-4 py-3">
<p className="text-xs text-muted-foreground">{feeds.length} feed{feeds.length !== 1 ? "s" : ""}</p>
</div>
</>
);
return (
<TooltipProvider>
<div className="relative flex h-screen overflow-hidden bg-background text-foreground">
{/* Desktop sidebar */}
<aside
className={`relative hidden w-72 shrink-0 flex-col border-r border-border/70 bg-sidebar/80 backdrop-blur-sm ${
isDesktopSidebarOpen ? "md:flex" : "md:hidden"
}`}
>
<div className="absolute right-3 top-4 hidden md:block">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setIsDesktopSidebarOpen(false)}
aria-label="Hide sidebar"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
</div>
{sidebarContent}
</aside>
{!isDesktopSidebarOpen && (
<div className="absolute left-4 top-4 z-50 hidden md:block">
<Button
variant="outline"
size="icon"
className="h-8 w-8 bg-background/90 backdrop-blur-sm"
onClick={() => setIsDesktopSidebarOpen(true)}
aria-label="Show sidebar"
>
<PanelLeftOpen className="h-4 w-4" />
</Button>
</div>
)}
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
<SheetContent
side="left"
showCloseButton={false}
className="w-72 max-w-[85vw] border-r border-border/70 bg-sidebar/80 p-0 backdrop-blur-sm"
>
<div className="flex h-full flex-col">{sidebarContent}</div>
</SheetContent>
</Sheet>
{/* Main content */}
<main className="flex-1 flex min-h-0 flex-col overflow-hidden">
<div className="flex items-center gap-2 border-b border-border/70 px-4 py-2.5 md:hidden">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setIsMobileSidebarOpen((v) => !v)}
aria-label="Toggle sidebar"
>
<Menu className="h-4 w-4" />
</Button>
<span dir="auto" className="truncate text-sm font-medium tracking-tight">
{selectedFeed?.title || selectedFeed?.url || "RuFeed"}
</span>
</div>
{loadingView ? (
<div className="flex-1 p-6 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/3" />
<Separator />
</div>
))}
</div>
) : view.type === "empty" ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<Rss className="h-10 w-10 opacity-20" />
<p className="text-sm">{feeds.length === 0 ? "Add a feed to get started" : "Select a feed"}</p>
</div>
) : view.type === "items" ? (
<>
<div className="flex items-center gap-2 border-b border-border/70 px-6 py-3.5">
{view.feed.icon && (
<img src={view.feed.icon} alt="" className="h-5 w-5 rounded-sm object-contain" />
)}
<h1 dir="auto" className="font-semibold text-sm truncate">
{view.feed.title || view.feed.url}
</h1>
<a href={view.feed.url} target="_blank" rel="noreferrer" className="ml-auto">
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground transition-colors" />
</a>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-2 p-3">
{view.items.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
No items found.
</div>
) : (
view.items.map((item, i) => (
<button
key={i}
onClick={() => handleSelectItem(view.feed, item)}
className="w-full rounded-lg border border-transparent bg-card/40 px-4 py-3.5 text-left transition-all duration-200 hover:border-border/70 hover:bg-card hover:shadow-sm"
>
<p dir="auto" className="mb-1 line-clamp-2 text-sm font-semibold leading-snug tracking-tight">
{item.title}
</p>
<p className="text-xs text-muted-foreground">{formatPublishedDate(item.published)}</p>
</button>
))
)}
</div>
</ScrollArea>
</>
) : view.type === "entry" ? (
<>
<div className="flex items-center gap-3 border-b border-border/70 bg-card/40 px-6 py-3.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => handleSelectFeed(view.feed)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground truncate">{view.feed.title}</span>
<a href={view.entry.url} target="_blank" rel="noreferrer" className="ml-auto">
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground transition-colors" />
</a>
</div>
<ScrollArea className="min-h-0 flex-1">
<article className="mx-auto w-full max-w-3xl px-4 py-7 sm:px-6 lg:px-8">
<h1 dir="auto" className="mb-3 text-2xl font-semibold leading-tight tracking-tight">
{view.entry.title}
</h1>
<div className="mb-6 flex flex-wrap items-center gap-3">
{view.entry.authors.length > 0 && (
<Badge variant="secondary" className="text-xs font-normal">
{view.entry.authors.map((a) => a.name).join(", ")}
</Badge>
)}
{view.entry.published && (
<span className="text-xs text-muted-foreground">{formatPublishedDate(view.entry.published, true)}</span>
)}
</div>
{view.entry.summary && (
<p className="mb-7 border-l-2 border-border pl-4 text-sm italic leading-6 text-muted-foreground">
{view.entry.summary}
</p>
)}
<div
className="prose prose-neutral prose-sm sm:prose-base max-w-none break-words dark:prose-invert prose-headings:font-semibold prose-headings:tracking-tight prose-p:leading-7 prose-li:leading-7 prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-blockquote:border-primary/40 prose-blockquote:text-muted-foreground prose-img:rounded-lg prose-img:border [&_img]:max-w-full [&_pre]:max-w-full"
dangerouslySetInnerHTML={{ __html: view.entry.content }}
/>
</article>
</ScrollArea>
</>
) : null}
</main>
{uiError && (
<div className="pointer-events-none absolute right-4 top-4 z-[70] w-full max-w-sm">
<div className="pointer-events-auto animate-in slide-in-from-top-2 fade-in-0 rounded-lg border border-destructive/30 bg-card p-3 shadow-lg duration-200">
<div className="flex items-start gap-3">
<div className="rounded-md bg-destructive/15 p-1.5 text-destructive">
<CircleAlert className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">{uiError.title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{uiError.message}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => setUiError(null)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
)}
</div>
</TooltipProvider>
);
}
export default App;

56
src/bindings.ts Normal file
View file

@ -0,0 +1,56 @@
import { invoke } from "@tauri-apps/api/core";
export type Feed = {
id: string;
title: string;
url: string;
feed_url: string;
icon: string;
};
export type FeedItem = {
title: string;
published: string;
url: string;
};
export type Person = {
name: string;
};
export type FeedEntry = {
id: string;
title: string;
url: string;
published: string;
updated: string;
summary: string | null;
content: string;
authors: Person[];
};
export type Error = string;
type TauriInvoke = <T>(
command: string,
args?: Record<string, unknown>,
) => Promise<T>;
const tauriInvoke: TauriInvoke = invoke;
export const commands = {
addFeed: (url: string): Promise<Feed> =>
tauriInvoke<Feed>("add_feed", { url }),
getFeeds: (): Promise<Feed[]> =>
tauriInvoke<Feed[]>("get_feeds"),
removeFeed: (websiteUrl: string): Promise<Feed> =>
tauriInvoke<Feed>("remove_feed", { websiteUrl: websiteUrl }),
getFeedItem: (websiteUrl: string): Promise<FeedItem[]> =>
tauriInvoke<FeedItem[]>("get_feed_item", { websiteUrl: websiteUrl }),
getEntry: (websiteUrl: string, targetUrl: string): Promise<FeedEntry> =>
tauriInvoke<FeedEntry>("get_entry", { websiteUrl: websiteUrl, targetUrl: targetUrl }),
};

View file

@ -0,0 +1,5 @@
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View file

@ -0,0 +1,107 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View file

@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,48 @@
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

View file

@ -0,0 +1,54 @@
"use client"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View file

@ -0,0 +1,23 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

138
src/components/ui/sheet.tsx Normal file
View file

@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -0,0 +1,87 @@
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }

View file

@ -0,0 +1,45 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View file

@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -1,9 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./App.css"
import { ThemeProvider } from "@/components/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<App />
</TooltipProvider>
</ThemeProvider>
</React.StrictMode>,
);

View file

@ -5,20 +5,20 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View file

@ -1,12 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from '@tailwindcss/vite'
import path from "node:path";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
plugins: [
react(),
tailwindcss(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//