Compare commits

..

6 Commits

Author SHA1 Message Date
spinline
09a4c69282 fix(auth): fix MsgPack serialization by adding missing feature in backend and reverting to individual arguments
All checks were successful
Build MIPS Binary / build (push) Successful in 5m15s
2026-02-11 22:03:46 +03:00
spinline
a877e0c393 chore: remove db files from git and update gitignore
All checks were successful
Build MIPS Binary / build (push) Successful in 5m13s
2026-02-11 21:54:15 +03:00
spinline
fd65df2962 fix(setup): add logging and error details for setup debugging
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-11 21:53:53 +03:00
spinline
9d160a7ef5 fix(ui): use thread_local for toast signal to fix context issue
All checks were successful
Build MIPS Binary / build (push) Successful in 5m12s
2026-02-11 21:43:06 +03:00
spinline
a24e4101e8 feat(ui): toast entegrasyonu (sonner)
All checks were successful
Build MIPS Binary / build (push) Successful in 5m13s
2026-02-11 21:32:39 +03:00
spinline
7539307e18 feat: shadcn -> rust-ui.com migration + TorrentDetail silme
All checks were successful
Build MIPS Binary / build (push) Successful in 5m15s
- Tüm leptos-shadcn-* paketleri kaldırıldı (19 dependency)
- leptos_ui, tw_merge, strum eklendi
- components/ui/ modülü oluşturuldu (Button, Card, Input)
- TorrentDetail bileşeni tamamen silindi
- sidebar.rs: saf HTML+Tailwind ile SidebarButton yardımcı bileşeni
- toolbar.rs, login.rs, setup.rs, add_torrent.rs: saf HTML+Tailwind
- table.rs: shadcn Card -> rust-ui Card
- app.rs: Skeleton -> animate-pulse div
2026-02-11 21:01:51 +03:00
26 changed files with 732 additions and 801 deletions

6
.gitignore vendored
View File

@@ -8,3 +8,9 @@ backend.log
.runner .runner
.env .env
backend/.env backend/.env
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal

434
Cargo.lock generated
View File

@@ -1256,30 +1256,14 @@ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"console_log", "console_log",
"futures", "futures",
"gloo-console",
"gloo-net", "gloo-net",
"gloo-timers", "gloo-timers",
"js-sys", "js-sys",
"leptos", "leptos",
"leptos-shadcn-alert",
"leptos-shadcn-avatar",
"leptos-shadcn-badge",
"leptos-shadcn-button",
"leptos-shadcn-card",
"leptos-shadcn-context-menu",
"leptos-shadcn-dialog",
"leptos-shadcn-dropdown-menu",
"leptos-shadcn-input",
"leptos-shadcn-label",
"leptos-shadcn-progress",
"leptos-shadcn-scroll-area",
"leptos-shadcn-separator",
"leptos-shadcn-sheet",
"leptos-shadcn-skeleton",
"leptos-shadcn-tabs",
"leptos-shadcn-toast",
"leptos-shadcn-tooltip",
"leptos-use", "leptos-use",
"leptos_router", "leptos_router",
"leptos_ui",
"log", "log",
"rmp-serde", "rmp-serde",
"serde", "serde",
@@ -1287,8 +1271,10 @@ dependencies = [
"serde_json", "serde_json",
"shared", "shared",
"struct-patch", "struct-patch",
"strum",
"tailwind_fuse", "tailwind_fuse",
"thiserror 2.0.18", "thiserror 2.0.18",
"tw_merge",
"uuid", "uuid",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
@@ -1440,6 +1426,19 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "gloo-console"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261"
dependencies = [
"gloo-utils",
"js-sys",
"serde",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-net" name = "gloo-net"
version = "0.6.0" version = "0.6.0"
@@ -2156,334 +2155,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "leptos-node-ref"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f57b1ebc451fe9e7b6c7eba680fa8bc7313b410cc6c0f18481cb55a60ff3ac6"
dependencies = [
"leptos",
"send_wrapper",
]
[[package]]
name = "leptos-shadcn-alert"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "884551df61ade405bdb61b0d5a92a3a88b3a7af7a2d283b8d3e942ae0d71309c"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-avatar"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cb3c5b1f5ba02f7282b55fde1513cdfecef3b25bf5fa44e1eb29fcaf8b927c5"
dependencies = [
"leptos",
"leptos-shadcn-signal-management",
"leptos-style",
"tailwind_fuse",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "leptos-shadcn-badge"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24578fb0bc21eb21be4e686e6719c7e183acb8fd071a4f81fb27fe452751c88a"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-button"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6d1a7b813b726be7920f7238c127a14129ba4a45fa879312cad3ed2f8a1745"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-card"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b5cda16742d1e20284e5f6805eab88b6e54c1378d1548a8e15a5eedda1ea3eb"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-context-menu"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0f440e9a7517dfe6ba758080ddba1dfe42e4697008f60adfc112c5da02dca8d"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "leptos-shadcn-dialog"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3fdbb636393b150c2db1e37d44a6832e9dde177ce2e81281932fefad8bde98e"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-dropdown-menu"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f50189623a176386a30443d281483c5aa6cc34dc45fa11c3e53bd187ffccde21"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-input"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0939cdad5a878d920decda39a4b42ecf4eba15736a92bbd73b1b408807899b8"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"regex",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-label"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e7cad4b5fae11df9bf3b1d4265a56509a9bb7d3a8580e7487f398b733eadf0c"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-progress"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a34ca41b8ebfd7f29126e4f8656987834f3613717016f11f3983da85a90669f6"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-scroll-area"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef3d7bdcae4919ad495529ec2a5974036fb0b959580df310f36b2fd33f90860c"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-separator"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5dfda49f059fd4d1549d663e6743e37a5c6c84d1ac2d6daec32caa3156bc268"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-sheet"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba85819a0c94a7705ed92989442c64cc75d9ed3a4540e711e87c56b206431611"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-signal-management"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5097c5171eb0be12bbf8fd736f4e669012657112865506a825480f2b013f6de"
dependencies = [
"chrono",
"js-sys",
"leptos",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "leptos-shadcn-skeleton"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c14b6bd0f2fe191e3e114a34cee889fc983546ad488e76e76511e3d75ea3f86"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-tabs"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39f817c834e70a8359933b7b274564313be64105370611af96f05508541b661b"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-shadcn-toast"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3315f7ed844e3286704cc7b57db7209cad592c11eee770f5dc48ebdc92d66cfb"
dependencies = [
"gloo-timers",
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"uuid",
"web-sys",
]
[[package]]
name = "leptos-shadcn-tooltip"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e41d37932d700444e1d3a21f10f198c3c9e76dde3fd78d58da4b5a099939fd7"
dependencies = [
"leptos",
"leptos-node-ref",
"leptos-shadcn-signal-management",
"leptos-struct-component",
"leptos-style",
"tailwind_fuse",
"web-sys",
]
[[package]]
name = "leptos-struct-component"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c32085b37b67e61e69e0949d94e36c40e4fde83867681cbb884f9cd40a43881e"
dependencies = [
"leptos",
"leptos-struct-component-macro",
]
[[package]]
name = "leptos-struct-component-macro"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a40efd792acc28a115605b84ecb39e89397a278950bc8f2aad1bdcc7af2033af"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "leptos-style"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c65408961a0bd8e70f317de8973d532a0cb9ffbac910c488d97f9c5a2e4411e2"
dependencies = [
"indexmap",
"leptos",
]
[[package]] [[package]]
name = "leptos-use" name = "leptos-use"
version = "0.16.3" version = "0.16.3"
@@ -2689,6 +2360,17 @@ dependencies = [
"tachys", "tachys",
] ]
[[package]]
name = "leptos_ui"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c30ca85b1aac5637bc59a9201a6aeb648452679bf0ef0e451a8f30acf153f7"
dependencies = [
"leptos",
"paste",
"tw_merge",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.180" version = "0.2.180"
@@ -3981,6 +3663,7 @@ dependencies = [
"inventory", "inventory",
"js-sys", "js-sys",
"pin-project-lite", "pin-project-lite",
"rmp-serde",
"rustc_version", "rustc_version",
"rustversion", "rustversion",
"send_wrapper", "send_wrapper",
@@ -4066,6 +3749,7 @@ dependencies = [
"bcrypt", "bcrypt",
"bytes", "bytes",
"cookie", "cookie",
"http 1.4.0",
"jsonwebtoken", "jsonwebtoken",
"leptos", "leptos",
"leptos_axum", "leptos_axum",
@@ -4077,6 +3761,7 @@ dependencies = [
"struct-patch", "struct-patch",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing",
"utoipa", "utoipa",
] ]
@@ -4447,6 +4132,28 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.114",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -4557,19 +4264,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca71fb01735fbc6fa13e9390d7a3037dde97053c0b65c0c72c0159cd009d26b" checksum = "7ca71fb01735fbc6fa13e9390d7a3037dde97053c0b65c0c72c0159cd009d26b"
dependencies = [ dependencies = [
"nom", "nom",
"tailwind_fuse_macro",
]
[[package]]
name = "tailwind_fuse_macro"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efa51b9ff80b5533001f8452d254a688bc7bb39c6bb77f9e0a19c1664d035888"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
] ]
[[package]] [[package]]
@@ -4994,6 +4688,28 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "tw_merge"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e4ae38c226104e3c821c60b311bca321f45dcf46e48b683a0db2fac9e2c6e2"
dependencies = [
"nom",
"tw_merge_variants",
]
[[package]]
name = "tw_merge_variants"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03de956478d5562138828bb736cc066949bda33dbb99c55ef77b2bb5438868e4"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "typed-builder" name = "typed-builder"
version = "0.21.2" version = "0.21.2"

View File

@@ -44,6 +44,6 @@ tower_governor = "0.8.0"
governor = "0.10.4" governor = "0.10.4"
# Leptos # Leptos
leptos = { version = "0.8.15", features = ["nightly"] } leptos = { version = "0.8.15", features = ["nightly", "msgpack"] }
leptos_axum = { version = "0.8.7" } leptos_axum = { version = "0.8.7" }
jsonwebtoken = "9" jsonwebtoken = "9"

View File

@@ -35,22 +35,10 @@ thiserror = "2.0"
rmp-serde = "1.3" rmp-serde = "1.3"
struct-patch = "0.5" struct-patch = "0.5"
# ShadCN UI Components (Individual) # Rust/UI Components
leptos-shadcn-button = "0.8" leptos_ui = "0.3"
leptos-shadcn-input = "0.8" tw_merge = "0.1"
leptos-shadcn-card = "0.8" strum = { version = "0.26", features = ["derive"] }
leptos-shadcn-badge = "0.8"
leptos-shadcn-context-menu = "0.8"
leptos-shadcn-separator = "0.8"
leptos-shadcn-progress = "0.8"
leptos-shadcn-avatar = "0.8"
leptos-shadcn-sheet = "0.8"
leptos-shadcn-tabs = "0.8"
leptos-shadcn-scroll-area = "0.8"
leptos-shadcn-dialog = "0.8"
leptos-shadcn-label = "0.8"
leptos-shadcn-alert = "0.8"
leptos-shadcn-dropdown-menu = "0.8" [package.metadata.leptos]
leptos-shadcn-tooltip = "0.8" tailwind-input-file = "input.css"
leptos-shadcn-skeleton = "0.8"

View File

@@ -1,14 +1,13 @@
{ {
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "tailwind.config.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "", "author": "",
"license": "ISC", "dependencies": {
"@tailwindcss/cli": "^4.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
"description": "",
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
@@ -17,11 +16,13 @@
"postcss-preset-env": "^10.1.3", "postcss-preset-env": "^10.1.3",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
"dependencies": { "keywords": [],
"@tailwindcss/cli": "^4.1.18", "license": "ISC",
"class-variance-authority": "^0.7.1", "main": "tailwind.config.js",
"clsx": "^2.1.1", "name": "frontend",
"tailwind-merge": "^3.4.0", "scripts": {
"tailwindcss-animate": "^1.0.7" "test": "echo \"Error: no test specified\" && exit 1"
} },
} "type": "module",
"version": "1.0.0"
}

View File

@@ -1,27 +1,25 @@
use crate::components::layout::protected::Protected; use crate::components::layout::protected::Protected;
use crate::components::torrent::table::TorrentTable; use crate::components::torrent::table::TorrentTable;
use crate::components::torrent::detail::TorrentDetail;
use crate::components::auth::login::Login; use crate::components::auth::login::Login;
use crate::components::auth::setup::Setup; use crate::components::auth::setup::Setup;
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_router::components::{Router, Routes, Route}; use leptos_router::components::{Router, Routes, Route};
use leptos_router::hooks::use_navigate; use leptos_router::hooks::use_navigate;
use leptos_shadcn_skeleton::Skeleton; use crate::components::ui::toast::Toaster;
use crate::components::toast::Toaster;
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
crate::components::ui::toast::provide_toaster();
view! { view! {
<InnerApp />
<Toaster /> <Toaster />
<InnerApp />
} }
} }
#[component] #[component]
fn InnerApp() -> impl IntoView { fn InnerApp() -> impl IntoView {
crate::store::provide_torrent_store(); crate::store::provide_torrent_store();
crate::components::toast::provide_toast_context();
let store = use_context::<crate::store::TorrentStore>(); let store = use_context::<crate::store::TorrentStore>();
let is_loading = signal(true); let is_loading = signal(true);
@@ -131,36 +129,33 @@ fn InnerApp() -> impl IntoView {
<div class="flex h-screen bg-background"> <div class="flex h-screen bg-background">
// Sidebar skeleton // Sidebar skeleton
<div class="w-56 border-r border-border p-4 space-y-4"> <div class="w-56 border-r border-border p-4 space-y-4">
<Skeleton class="h-8 w-3/4" /> <div class="h-8 w-3/4 animate-pulse rounded-md bg-muted" />
<div class="space-y-2"> <div class="space-y-2">
<Skeleton class="h-6 w-full" /> <div class="h-6 w-full animate-pulse rounded-md bg-muted" />
<Skeleton class="h-6 w-full" /> <div class="h-6 w-full animate-pulse rounded-md bg-muted" />
<Skeleton class="h-6 w-4/5" /> <div class="h-6 w-4/5 animate-pulse rounded-md bg-muted" />
<Skeleton class="h-6 w-full" /> <div class="h-6 w-full animate-pulse rounded-md bg-muted" />
<Skeleton class="h-6 w-3/5" /> <div class="h-6 w-3/5 animate-pulse rounded-md bg-muted" />
<Skeleton class="h-6 w-full" /> <div class="h-6 w-full animate-pulse rounded-md bg-muted" />
</div> </div>
</div> </div>
// Main content skeleton // Main content skeleton
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col">
// Header skeleton
<div class="border-b border-border p-4 flex items-center gap-4"> <div class="border-b border-border p-4 flex items-center gap-4">
<Skeleton class="h-8 w-48" /> <div class="h-8 w-48 animate-pulse rounded-md bg-muted" />
<Skeleton class="h-8 w-64" /> <div class="h-8 w-64 animate-pulse rounded-md bg-muted" />
<div class="ml-auto"><Skeleton class="h-8 w-24" /></div> <div class="ml-auto"><div class="h-8 w-24 animate-pulse rounded-md bg-muted" /></div>
</div> </div>
// Table skeleton rows
<div class="flex-1 p-4 space-y-3"> <div class="flex-1 p-4 space-y-3">
<Skeleton class="h-10 w-full" /> <div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<Skeleton class="h-10 w-full" /> <div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<Skeleton class="h-10 w-full" /> <div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<Skeleton class="h-10 w-full" /> <div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<Skeleton class="h-10 w-full" /> <div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<Skeleton class="h-10 w-3/4" /> <div class="h-10 w-3/4 animate-pulse rounded-md bg-muted" />
</div> </div>
// Status bar skeleton
<div class="border-t border-border p-3"> <div class="border-t border-border p-3">
<Skeleton class="h-5 w-96" /> <div class="h-5 w-96 animate-pulse rounded-md bg-muted" />
</div> </div>
</div> </div>
</div> </div>
@@ -171,7 +166,6 @@ fn InnerApp() -> impl IntoView {
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden">
<TorrentTable /> <TorrentTable />
</div> </div>
<TorrentDetail />
</div> </div>
</Protected> </Protected>
</Show> </Show>

View File

@@ -1,15 +1,12 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_shadcn_card::{Card, CardHeader, CardContent}; use crate::components::ui::card::{Card, CardHeader, CardContent};
use leptos_shadcn_input::Input; use crate::components::ui::input::{Input, InputType};
use leptos_shadcn_button::Button;
use leptos_shadcn_label::Label;
use leptos_shadcn_alert::{Alert, AlertDescription, AlertVariant};
#[component] #[component]
pub fn Login() -> impl IntoView { pub fn Login() -> impl IntoView {
let username = signal(String::new()); let username = RwSignal::new(String::new());
let password = signal(String::new()); let password = RwSignal::new(String::new());
let error = signal(Option::<String>::None); let error = signal(Option::<String>::None);
let loading = signal(false); let loading = signal(false);
@@ -18,8 +15,8 @@ pub fn Login() -> impl IntoView {
loading.1.set(true); loading.1.set(true);
error.1.set(None); error.1.set(None);
let user = username.0.get(); let user = username.get();
let pass = password.0.get(); let pass = password.get();
spawn_local(async move { spawn_local(async move {
match shared::server_fns::auth::login(user, pass).await { match shared::server_fns::auth::login(user, pass).await {
@@ -52,44 +49,40 @@ pub fn Login() -> impl IntoView {
<CardContent class="pt-4"> <CardContent class="pt-4">
<form on:submit=handle_login class="space-y-4"> <form on:submit=handle_login class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>"Kullanıcı Adı"</Label> <label class="text-sm font-medium leading-none">"Kullanıcı Adı"</label>
<Input <Input
input_type="text" r#type=InputType::Text
placeholder="Kullanıcı adınız" placeholder="Kullanıcı adınız"
value=MaybeProp::derive(move || Some(username.0.get())) bind_value=username
on_change=Callback::new(move |val: String| username.1.set(val)) disabled=loading.0.get()
disabled=Signal::derive(move || loading.0.get())
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>"Şifre"</Label> <label class="text-sm font-medium leading-none">"Şifre"</label>
<Input <Input
input_type="password" r#type=InputType::Password
placeholder="******" placeholder="******"
value=MaybeProp::derive(move || Some(password.0.get())) bind_value=password
on_change=Callback::new(move |val: String| password.1.set(val)) disabled=loading.0.get()
disabled=Signal::derive(move || loading.0.get())
/> />
</div> </div>
<Show when=move || error.0.get().is_some()> <Show when=move || error.0.get().is_some()>
<Alert variant=AlertVariant::Destructive> <div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertDescription> {move || error.0.get().unwrap_or_default()}
{move || error.0.get().unwrap_or_default()} </div>
</AlertDescription>
</Alert>
</Show> </Show>
<div class="pt-2"> <div class="pt-2">
<Button <button
class="w-full" class="inline-flex items-center justify-center w-full h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
disabled=Signal::derive(move || loading.0.get()) disabled=move || loading.0.get()
> >
<Show when=move || loading.0.get() fallback=|| "Giriş Yap"> <Show when=move || loading.0.get() fallback=|| "Giriş Yap">
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span> <span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Giriş Yapılıyor..." "Giriş Yapılıyor..."
</Show> </Show>
</Button> </button>
</div> </div>
</form> </form>
</CardContent> </CardContent>

View File

@@ -1,24 +1,21 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_shadcn_card::{Card, CardHeader, CardContent}; use crate::components::ui::card::{Card, CardHeader, CardContent};
use leptos_shadcn_input::Input; use crate::components::ui::input::{Input, InputType};
use leptos_shadcn_button::Button;
use leptos_shadcn_label::Label;
use leptos_shadcn_alert::{Alert, AlertDescription, AlertVariant};
#[component] #[component]
pub fn Setup() -> impl IntoView { pub fn Setup() -> impl IntoView {
let username = signal(String::new()); let username = RwSignal::new(String::new());
let password = signal(String::new()); let password = RwSignal::new(String::new());
let confirm_password = signal(String::new()); let confirm_password = RwSignal::new(String::new());
let error = signal(Option::<String>::None); let error = signal(Option::<String>::None);
let loading = signal(false); let loading = signal(false);
let handle_setup = move |ev: web_sys::SubmitEvent| { let handle_setup = move |ev: web_sys::SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
let pass = password.0.get(); let pass = password.get();
let confirm = confirm_password.0.get(); let confirm = confirm_password.get();
if pass != confirm { if pass != confirm {
error.1.set(Some("Şifreler eşleşmiyor".to_string())); error.1.set(Some("Şifreler eşleşmiyor".to_string()));
@@ -33,7 +30,7 @@ pub fn Setup() -> impl IntoView {
loading.1.set(true); loading.1.set(true);
error.1.set(None); error.1.set(None);
let user = username.0.get(); let user = username.get();
spawn_local(async move { spawn_local(async move {
match shared::server_fns::auth::setup(user, pass).await { match shared::server_fns::auth::setup(user, pass).await {
@@ -44,7 +41,8 @@ pub fn Setup() -> impl IntoView {
} }
Err(e) => { Err(e) => {
log::error!("Setup failed: {:?}", e); log::error!("Setup failed: {:?}", e);
error.1.set(Some("Kurulum sırasında bir hata oluştu".to_string())); // Hatanın sadece mesaj kısmını almaya çalışalım, yoksa full struct basılabilir
error.1.set(Some(format!("Hata: {}", e)));
loading.1.set(false); loading.1.set(false);
} }
} }
@@ -67,54 +65,49 @@ pub fn Setup() -> impl IntoView {
<CardContent class="pt-4"> <CardContent class="pt-4">
<form on:submit=handle_setup class="space-y-4"> <form on:submit=handle_setup class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>"Yönetici Kullanıcı Adı"</Label> <label class="text-sm font-medium leading-none">"Yönetici Kullanıcı Adı"</label>
<Input <Input
input_type="text" r#type=InputType::Text
placeholder="admin" placeholder="admin"
value=MaybeProp::derive(move || Some(username.0.get())) bind_value=username
on_change=Callback::new(move |val: String| username.1.set(val)) disabled=loading.0.get()
disabled=Signal::derive(move || loading.0.get())
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>"Şifre"</Label> <label class="text-sm font-medium leading-none">"Şifre"</label>
<Input <Input
input_type="password" r#type=InputType::Password
placeholder="******" placeholder="******"
value=MaybeProp::derive(move || Some(password.0.get())) bind_value=password
on_change=Callback::new(move |val: String| password.1.set(val)) disabled=loading.0.get()
disabled=Signal::derive(move || loading.0.get())
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>"Şifre Onay"</Label> <label class="text-sm font-medium leading-none">"Şifre Onay"</label>
<Input <Input
input_type="password" r#type=InputType::Password
placeholder="******" placeholder="******"
value=MaybeProp::derive(move || Some(confirm_password.0.get())) bind_value=confirm_password
on_change=Callback::new(move |val: String| confirm_password.1.set(val)) disabled=loading.0.get()
disabled=Signal::derive(move || loading.0.get())
/> />
</div> </div>
<Show when=move || error.0.get().is_some() fallback=|| ()> <Show when=move || error.0.get().is_some() fallback=|| ()>
<Alert variant=AlertVariant::Destructive> <div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertDescription> <span>{move || error.0.get().unwrap_or_default()}</span>
<span>{move || error.0.get().unwrap_or_default()}</span> </div>
</AlertDescription>
</Alert>
</Show> </Show>
<div class="pt-2"> <div class="pt-2">
<Button <button
class="w-full" class="inline-flex items-center justify-center w-full h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
disabled=Signal::derive(move || loading.0.get()) disabled=move || loading.0.get()
> >
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla"> <Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span> <span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Kuruluyor..." "Kuruluyor..."
</Show> </Show>
</Button> </button>
</div> </div>
</form> </form>
</CardContent> </CardContent>

View File

@@ -1,8 +1,5 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
use leptos_shadcn_avatar::{Avatar, AvatarFallback};
use leptos_shadcn_separator::Separator;
use leptos_use::storage::use_local_storage; use leptos_use::storage::use_local_storage;
use ::codee::string::FromToStringCodec; use ::codee::string::FromToStringCodec;
@@ -84,7 +81,6 @@ pub fn Sidebar() -> impl IntoView {
let theme = current_theme.get().to_lowercase(); let theme = current_theme.get().to_lowercase();
if let Some(doc) = document().document_element() { if let Some(doc) = document().document_element() {
let _ = doc.set_attribute("data-theme", &theme); let _ = doc.set_attribute("data-theme", &theme);
// Also set class for Shadcn dark mode support
if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" || theme == "sunset" || theme == "cyberpunk" || theme == "nord" || theme == "business" || theme == "night" || theme == "black" || theme == "luxury" || theme == "coffee" || theme == "forest" || theme == "halloween" || theme == "synthwave" { if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" || theme == "sunset" || theme == "cyberpunk" || theme == "nord" || theme == "business" || theme == "night" || theme == "black" || theme == "luxury" || theme == "coffee" || theme == "forest" || theme == "halloween" || theme == "synthwave" {
let _ = doc.class_list().add_1("dark"); let _ = doc.class_list().add_1("dark");
} else { } else {
@@ -93,8 +89,6 @@ pub fn Sidebar() -> impl IntoView {
} }
}); });
let toggle_theme = move |_| { let toggle_theme = move |_| {
let new_theme = if current_theme.get() == "dark" { "light" } else { "dark" }; let new_theme = if current_theme.get() == "dark" { "light" } else { "dark" };
set_current_theme.set(new_theme.to_string()); set_current_theme.set(new_theme.to_string());
@@ -110,110 +104,70 @@ pub fn Sidebar() -> impl IntoView {
<div class="space-y-1"> <div class="space-y-1">
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4> <h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4>
<Button <SidebarButton
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::All) { ButtonVariant::Secondary } else { ButtonVariant::Ghost })) active=Signal::derive(move || is_active(crate::store::FilterStatus::All))
size=ButtonSize::Sm on_click=move |_| set_filter(crate::store::FilterStatus::All)
class="w-full justify-start gap-2" icon="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::All)) label="All"
> count=Signal::derive(total_count)
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> />
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> <SidebarButton
</svg> active=Signal::derive(move || is_active(crate::store::FilterStatus::Downloading))
"All" on_click=move |_| set_filter(crate::store::FilterStatus::Downloading)
<span class="ml-auto text-xs font-mono opacity-70">{total_count}</span> icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
</Button> label="Downloading"
count=Signal::derive(downloading_count)
<Button />
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Downloading) { ButtonVariant::Secondary } else { ButtonVariant::Ghost })) <SidebarButton
size=ButtonSize::Sm active=Signal::derive(move || is_active(crate::store::FilterStatus::Seeding))
class="w-full justify-start gap-2" on_click=move |_| set_filter(crate::store::FilterStatus::Seeding)
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Downloading)) icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
> label="Seeding"
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> count=Signal::derive(seeding_count)
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /> />
</svg> <SidebarButton
"Downloading" active=Signal::derive(move || is_active(crate::store::FilterStatus::Completed))
<span class="ml-auto text-xs font-mono opacity-70">{downloading_count}</span> on_click=move |_| set_filter(crate::store::FilterStatus::Completed)
</Button> icon="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
label="Completed"
<Button count=Signal::derive(completed_count)
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Seeding) { ButtonVariant::Secondary } else { ButtonVariant::Ghost })) />
size=ButtonSize::Sm <SidebarButton
class="w-full justify-start gap-2" active=Signal::derive(move || is_active(crate::store::FilterStatus::Paused))
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Seeding)) on_click=move |_| set_filter(crate::store::FilterStatus::Paused)
> icon="M15.75 5.25v13.5m-7.5-13.5v13.5"
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> label="Paused"
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> count=Signal::derive(paused_count)
</svg> />
"Seeding" <SidebarButton
<span class="ml-auto text-xs font-mono opacity-70">{seeding_count}</span> active=Signal::derive(move || is_active(crate::store::FilterStatus::Inactive))
</Button> on_click=move |_| set_filter(crate::store::FilterStatus::Inactive)
icon="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
<Button label="Inactive"
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Completed) { ButtonVariant::Secondary } else { ButtonVariant::Ghost })) count=Signal::derive(inactive_count)
size=ButtonSize::Sm />
class="w-full justify-start gap-2"
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Completed))
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
"Completed"
<span class="ml-auto text-xs font-mono opacity-70">{completed_count}</span>
</Button>
<Button
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Paused) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
size=ButtonSize::Sm
class="w-full justify-start gap-2"
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Paused))
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
"Paused"
<span class="ml-auto text-xs font-mono opacity-70">{paused_count}</span>
</Button>
<Button
variant=MaybeProp::derive(move || Some(if is_active(crate::store::FilterStatus::Inactive) { ButtonVariant::Secondary } else { ButtonVariant::Ghost }))
size=ButtonSize::Sm
class="w-full justify-start gap-2"
on_click=Callback::new(move |()| set_filter(crate::store::FilterStatus::Inactive))
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
"Inactive"
<span class="ml-auto text-xs font-mono opacity-70">{inactive_count}</span>
</Button>
</div> </div>
</div> </div>
<Separator /> // Separator
<div class="border-t border-border" />
<div class="p-4 bg-card" style="padding-bottom: calc(1rem + env(safe-area-inset-bottom));"> <div class="p-4 bg-card" style="padding-bottom: calc(1rem + env(safe-area-inset-bottom));">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Avatar class="h-8 w-8"> // Avatar
<AvatarFallback class="bg-primary text-primary-foreground text-xs font-medium"> <div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0">
{first_letter} {first_letter}
</AvatarFallback> </div>
</Avatar>
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden">
<div class="font-medium text-sm truncate text-foreground">{username}</div> <div class="font-medium text-sm truncate text-foreground">{username}</div>
<div class="text-[10px] text-muted-foreground truncate">"Online"</div> <div class="text-[10px] text-muted-foreground truncate">"Online"</div>
</div> </div>
// --- THEME BUTTON --- // Theme toggle button
<Button <button
variant=ButtonVariant::Ghost class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:text-foreground transition-colors"
size=ButtonSize::Icon on:click=toggle_theme
class="h-8 w-8 text-muted-foreground hover:text-foreground"
on_click=Callback::new(toggle_theme)
> >
// Sun icon for dark mode (to switch to light), Moon for light (to switch to dark)
// Actually show current state or action? Usually action.
// If dark, show Sun. If light, show Moon.
<Show when=move || current_theme.get() == "dark" fallback=|| view! { <Show when=move || current_theme.get() == "dark" fallback=|| view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
@@ -223,26 +177,51 @@ pub fn Sidebar() -> impl IntoView {
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg> </svg>
</Show> </Show>
</Button> </button>
<Button // Logout button
variant=ButtonVariant::Ghost <button
size=ButtonSize::Icon class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent text-destructive transition-colors"
class="text-destructive h-8 w-8" on:click=move |_| {
on_click=Callback::new(move |()| {
spawn_local(async move { spawn_local(async move {
if shared::server_fns::auth::logout().await.is_ok() { if shared::server_fns::auth::logout().await.is_ok() {
let window = web_sys::window().expect("window should exist"); let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/login"); let _ = window.location().set_href("/login");
} }
}); });
}) }
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg> </svg>
</Button> </button>
</div> </div>
</div> </div>
</div> </div>
} }
} }
#[component]
fn SidebarButton(
active: Signal<bool>,
on_click: impl Fn(web_sys::MouseEvent) + 'static,
#[prop(into)] icon: String,
#[prop(into)] label: &'static str,
count: Signal<usize>,
) -> impl IntoView {
view! {
<button
class=move || if active.get() {
"inline-flex items-center justify-start gap-2 w-full h-8 rounded-md px-3 text-sm font-medium bg-secondary text-secondary-foreground transition-colors"
} else {
"inline-flex items-center justify-start gap-2 w-full h-8 rounded-md px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
}
on:click=on_click
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d=icon.clone() />
</svg>
{label}
<span class="ml-auto text-xs font-mono opacity-70">{count}</span>
</button>
}
}

View File

@@ -1,6 +1,4 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_shadcn_input::Input;
use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
use crate::components::torrent::add_torrent::AddTorrentDialog; use crate::components::torrent::add_torrent::AddTorrentDialog;
#[component] #[component]
@@ -9,30 +7,36 @@ pub fn Toolbar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided"); let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
let search_value = RwSignal::new(String::new());
// Sync search_value to store
Effect::new(move |_| {
let val = search_value.get();
store.search_query.set(val);
});
view! { view! {
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);"> <div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
// Sol kısım: Menü butonu + Add Torrent // Sol kısım: Menü butonu + Add Torrent
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
// Mobile Menu Trigger // Mobile Menu Trigger
<Button <button
variant=ButtonVariant::Ghost class="inline-flex items-center justify-center size-9 rounded-md hover:bg-accent hover:text-accent-foreground lg:hidden"
size=ButtonSize::Icon on:click=move |_| is_mobile_menu_open.update(|v| *v = !*v)
class="lg:hidden"
on_click=Callback::new(move |()| is_mobile_menu_open.update(|v| *v = !*v))
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</Button> </button>
<Button <button
class="gap-2 shadow" class="inline-flex items-center justify-center gap-2 h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all active:scale-[0.98]"
on_click=Callback::new(move |()| show_add_modal.1.set(true)) on:click=move |_| show_add_modal.1.set(true)
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 md:w-5 md:h-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 md:w-5 md:h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
<span class="hidden sm:inline">"Add Torrent"</span> <span class="hidden sm:inline">"Add Torrent"</span>
<span class="sm:hidden">"Add"</span> <span class="sm:hidden">"Add"</span>
</Button> </button>
</div> </div>
// Sağ kısım: Search kutusu // Sağ kısım: Search kutusu
@@ -42,12 +46,11 @@ pub fn Toolbar() -> impl IntoView {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg> </svg>
<Input <input
input_type="search" type="search"
placeholder="Search..." placeholder="Search..."
value=MaybeProp::derive(move || Some(store.search_query.get())) class="file:text-foreground placeholder:text-muted-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 md:text-sm pl-8"
on_change=Callback::new(move |val: String| store.search_query.set(val)) bind:value=search_value
class="pl-8 h-9"
/> />
</div> </div>
</div> </div>

View File

@@ -2,4 +2,5 @@ pub mod context_menu;
pub mod layout; pub mod layout;
pub mod torrent; pub mod torrent;
pub mod auth; pub mod auth;
pub mod toast; // pub mod toast; (Removed)
pub mod ui;

View File

@@ -1,8 +1,6 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_shadcn_input::Input; use crate::components::ui::input::{Input, InputType};
use leptos_shadcn_button::{Button, ButtonVariant};
use leptos_shadcn_alert::{Alert, AlertDescription, AlertVariant};
use crate::store::TorrentStore; use crate::store::TorrentStore;
use crate::api; use crate::api;
@@ -12,13 +10,13 @@ pub fn AddTorrentDialog(
) -> impl IntoView { ) -> impl IntoView {
let _store = use_context::<TorrentStore>().expect("TorrentStore not provided"); let _store = use_context::<TorrentStore>().expect("TorrentStore not provided");
let uri = signal(String::new()); let uri = RwSignal::new(String::new());
let is_loading = signal(false); let is_loading = signal(false);
let error_msg = signal(Option::<String>::None); let error_msg = signal(Option::<String>::None);
let handle_submit = move |ev: web_sys::SubmitEvent| { let handle_submit = move |ev: web_sys::SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
let uri_val = uri.0.get(); let uri_val = uri.get();
if uri_val.is_empty() { if uri_val.is_empty() {
error_msg.1.set(Some("Please enter a Magnet URI or URL".to_string())); error_msg.1.set(Some("Please enter a Magnet URI or URL".to_string()));
@@ -69,29 +67,31 @@ pub fn AddTorrentDialog(
<form on:submit=handle_submit class="space-y-4"> <form on:submit=handle_submit class="space-y-4">
<Input <Input
input_type="text" r#type=InputType::Text
placeholder="magnet:?xt=urn:btih:..." placeholder="magnet:?xt=urn:btih:..."
value=MaybeProp::derive(move || Some(uri.0.get())) bind_value=uri
on_change=Callback::new(move |val: String| uri.1.set(val)) disabled=is_loading.0.get()
disabled=Signal::derive(move || is_loading.0.get())
/> />
{move || error_msg.0.get().map(|msg| view! { {move || error_msg.0.get().map(|msg| view! {
<Alert variant=AlertVariant::Destructive> <div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertDescription>{msg}</AlertDescription> {msg}
</Alert> </div>
})} })}
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2"> <div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
<Button <button
variant=ButtonVariant::Ghost type="button"
on_click=Callback::new(move |()| { class="inline-flex items-center justify-center h-9 px-4 py-2 rounded-md text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
on_close.run(()); on:click=move |_| on_close.run(())
})
> >
"Cancel" "Cancel"
</Button> </button>
<Button disabled=Signal::derive(move || is_loading.0.get())> <button
type="submit"
class="inline-flex items-center justify-center h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
disabled=move || is_loading.0.get()
>
{move || if is_loading.0.get() { {move || if is_loading.0.get() {
leptos::either::Either::Left(view! { leptos::either::Either::Left(view! {
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span> <span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
@@ -100,7 +100,7 @@ pub fn AddTorrentDialog(
} else { } else {
leptos::either::Either::Right(view! { "Add" }) leptos::either::Either::Right(view! { "Add" })
}} }}
</Button> </button>
</div> </div>
</form> </form>

View File

@@ -1,156 +0,0 @@
use leptos::prelude::*;
use leptos_shadcn_tabs::{Tabs, TabsList, TabsTrigger, TabsContent};
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes < 1024 { return format!("{} B", bytes); }
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
format!("{:.1} {}", (bytes as f64) / 1024_f64.powi(i as i32), UNITS[i])
}
fn format_speed(bytes_per_sec: i64) -> String {
if bytes_per_sec == 0 { return "0 B/s".to_string(); }
format!("{}/s", format_bytes(bytes_per_sec))
}
fn format_date(timestamp: i64) -> String {
if timestamp <= 0 { return "N/A".to_string(); }
let dt = chrono::DateTime::from_timestamp(timestamp, 0);
match dt { Some(dt) => dt.format("%d/%m/%Y %H:%M").to_string(), None => "N/A".to_string() }
}
fn format_duration(seconds: i64) -> String {
if seconds <= 0 { return "".to_string(); }
let days = seconds / 86400;
let hours = (seconds % 86400) / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if days > 0 { format!("{}d {}h", days, hours) }
else if hours > 0 { format!("{}h {}m", hours, minutes) }
else if minutes > 0 { format!("{}m {}s", minutes, secs) }
else { format!("{}s", secs) }
}
#[component]
pub fn TorrentDetail() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let torrent = Memo::new(move |_| {
let hash = store.selected_torrent.get()?;
store.torrents.with(|map| map.get(&hash).cloned())
});
let close = move |_| {
store.selected_torrent.set(None);
};
view! {
<Show when=move || torrent.get().is_some()>
{move || {
let t = torrent.get().unwrap();
let name = t.name.clone();
let status_color = match t.status {
shared::TorrentStatus::Seeding => "text-green-500",
shared::TorrentStatus::Downloading => "text-blue-500",
shared::TorrentStatus::Paused => "text-yellow-500",
shared::TorrentStatus::Error => "text-red-500",
_ => "text-muted-foreground",
};
let status_text = format!("{:?}", t.status);
view! {
<div class="border-t border-border bg-card flex flex-col" style="height: 280px; min-height: 200px;">
// Header
<div class="flex items-center justify-between px-4 py-2 border-b border-border bg-muted/30">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h3 class="text-sm font-semibold truncate">{name}</h3>
<span class={format!("text-xs font-medium {}", status_color)}>{status_text}</span>
</div>
<button
class="inline-flex items-center justify-center rounded-md text-sm font-medium hover:bg-accent hover:text-accent-foreground h-7 w-7 text-muted-foreground shrink-0"
on:click=close
title="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
// Tabs
<Tabs default_value="general" class="flex-1 flex flex-col overflow-hidden">
<div class="px-4 pt-2">
<TabsList class="w-full">
<TabsTrigger value="general">"General"</TabsTrigger>
<TabsTrigger value="transfer">"Transfer"</TabsTrigger>
<TabsTrigger value="files">"Files"</TabsTrigger>
<TabsTrigger value="peers">"Peers"</TabsTrigger>
</TabsList>
</div>
<TabsContent value="general" class="flex-1 overflow-y-auto px-4 pb-3">
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-sm">
<DetailItem label="Size" value=format_bytes(t.size) />
<DetailItem label="Downloaded" value=format_bytes(t.completed) />
<DetailItem label="Progress" value=format!("{:.1}%", t.percent_complete) />
<DetailItem label="Added" value=format_date(t.added_date) />
<DetailItem label="Hash" value={
let hash = store.selected_torrent.get().unwrap_or_default();
format!("{}", &hash[..std::cmp::min(16, hash.len())])
} />
<DetailItem label="Label" value=t.label.clone().unwrap_or_else(|| "".to_string()) />
<DetailItem label="Error" value={
if t.error_message.is_empty() { "None".to_string() } else { t.error_message.clone() }
} />
</div>
</TabsContent>
<TabsContent value="transfer" class="flex-1 overflow-y-auto px-4 pb-3">
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-sm">
<DetailItem label="Download Speed" value=format_speed(t.down_rate) />
<DetailItem label="Upload Speed" value=format_speed(t.up_rate) />
<DetailItem label="ETA" value=format_duration(t.eta) />
<DetailItem label="Downloaded" value=format_bytes(t.completed) />
<DetailItem label="Total Size" value=format_bytes(t.size) />
<DetailItem label="Remaining" value=format_bytes(t.size - t.completed) />
</div>
</TabsContent>
<TabsContent value="files" class="flex-1 overflow-y-auto px-4 pb-3">
<div class="text-sm text-muted-foreground flex items-center gap-2 py-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
"File list will be available when file API is connected."
</div>
</TabsContent>
<TabsContent value="peers" class="flex-1 overflow-y-auto px-4 pb-3">
<div class="text-sm text-muted-foreground flex items-center gap-2 py-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
"Peer list will be available when peer API is connected."
</div>
</TabsContent>
</Tabs>
</div>
}
}}
</Show>
}
}
#[component]
fn DetailItem(
#[prop(into)] label: String,
#[prop(into)] value: String,
) -> impl IntoView {
let title = value.clone();
view! {
<div class="flex flex-col gap-0.5 py-1">
<span class="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</span>
<span class="text-foreground font-mono text-xs truncate" title=title>{value}</span>
</div>
}
}

View File

@@ -1,3 +1,2 @@
pub mod table; pub mod table;
pub mod add_torrent; pub mod add_torrent;
pub mod detail;

View File

@@ -4,7 +4,7 @@ use crate::store::{get_action_messages, show_toast};
use crate::api; use crate::api;
use shared::NotificationLevel; use shared::NotificationLevel;
use crate::components::context_menu::TorrentContextMenu; use crate::components::context_menu::TorrentContextMenu;
use leptos_shadcn_card::{Card, CardHeader, CardTitle, CardContent}; use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent};
fn format_bytes(bytes: i64) -> String { fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];

View File

@@ -0,0 +1,29 @@
use leptos::prelude::*;
use leptos_ui::variants;
variants! {
Button {
base: "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit hover:cursor-pointer active:scale-[0.98] active:opacity-100 touch-manipulation [-webkit-tap-highlight-color:transparent] select-none [-webkit-touch-callout:none]",
variants: {
variant: {
Default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
Destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
Outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/5",
Secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
Ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
Link: "text-primary underline-offset-4 hover:underline",
},
size: {
Default: "h-9 px-4 py-2 has-[>svg]:px-3",
Sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
Lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
Icon: "size-9",
}
},
component: {
element: button,
support_href: true,
support_aria_current: true
}
}
}

View File

@@ -0,0 +1,15 @@
use leptos::prelude::*;
use leptos_ui::clx;
mod components {
use super::*;
clx! {Card, div, "bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-6 shadow-sm"}
clx! {CardHeader, div, "@container/card-header flex flex-col items-start gap-1.5 px-6 [.border-b]:pb-6"}
clx! {CardTitle, h2, "leading-none font-semibold"}
clx! {CardContent, div, "px-6"}
clx! {CardDescription, p, "text-muted-foreground text-sm"}
clx! {CardFooter, footer, "flex items-center px-6 [.border-t]:pt-6", "gap-2"}
}
pub use components::*;

View File

@@ -0,0 +1,98 @@
use leptos::html;
use leptos::prelude::*;
use strum::AsRefStr;
use tw_merge::tw_merge;
#[derive(Default, Clone, Copy, PartialEq, Eq, AsRefStr)]
#[strum(serialize_all = "lowercase")]
pub enum InputType {
#[default]
Text,
Email,
Password,
Number,
Tel,
Url,
Search,
Date,
Time,
#[strum(serialize = "datetime-local")]
DatetimeLocal,
Month,
Week,
Color,
File,
Hidden,
}
#[component]
pub fn Input(
#[prop(into, optional)] class: String,
#[prop(default = InputType::default())] r#type: InputType,
#[prop(into, optional)] placeholder: Option<String>,
#[prop(into, optional)] name: Option<String>,
#[prop(into, optional)] id: Option<String>,
#[prop(into, optional)] title: Option<String>,
#[prop(optional)] disabled: bool,
#[prop(optional)] readonly: bool,
#[prop(optional)] required: bool,
#[prop(optional)] autofocus: bool,
#[prop(into, optional)] min: Option<String>,
#[prop(into, optional)] max: Option<String>,
#[prop(into, optional)] step: Option<String>,
#[prop(into, optional)] bind_value: Option<RwSignal<String>>,
#[prop(optional)] node_ref: NodeRef<html::Input>,
) -> impl IntoView {
let merged_class = tw_merge!(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50",
"focus-visible:ring-2",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"read-only:bg-muted",
class
);
let type_str = r#type.as_ref();
match bind_value {
Some(signal) => view! {
<input
data-name="Input"
type=type_str
class=merged_class
placeholder=placeholder
name=name
id=id
title=title
disabled=disabled
readonly=readonly
required=required
autofocus=autofocus
min=min
max=max
step=step
bind:value=signal
node_ref=node_ref
/>
}.into_any(),
None => view! {
<input
data-name="Input"
type=type_str
class=merged_class
placeholder=placeholder
name=name
id=id
title=title
disabled=disabled
readonly=readonly
required=required
autofocus=autofocus
min=min
max=max
step=step
node_ref=node_ref
/>
}.into_any(),
}
}

View File

@@ -0,0 +1,4 @@
pub mod button;
pub mod card;
pub mod input;
pub mod toast;

View File

@@ -0,0 +1,232 @@
use leptos::prelude::*;
use tw_merge::*;
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
pub enum ToastType {
#[default]
Default,
Success,
Error,
Warning,
Info,
Loading,
}
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
pub enum SonnerPosition {
TopLeft,
TopCenter,
TopRight,
#[default]
BottomRight,
BottomCenter,
BottomLeft,
}
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
pub enum SonnerDirection {
TopDown,
#[default]
BottomUp,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ToastData {
pub id: u64,
pub title: String,
pub description: Option<String>,
pub variant: ToastType,
pub duration: u64, // ms
}
#[derive(Clone, Copy)]
pub struct ToasterStore {
pub toasts: RwSignal<Vec<ToastData>>,
}
#[component]
pub fn SonnerTrigger(
#[prop(into, optional)] class: String,
#[prop(optional, default = ToastType::default())] variant: ToastType,
#[prop(into)] title: String,
description: Option<String>,
#[prop(into, optional)] position: String,
on_dismiss: Option<Callback<()>>,
) -> impl IntoView {
let variant_classes = match variant {
ToastType::Default => "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
ToastType::Success => "bg-green-500 text-white hover:bg-green-600",
ToastType::Error => "bg-red-500 text-white shadow-xs hover:bg-red-600",
ToastType::Warning => "bg-yellow-500 text-white hover:bg-yellow-600",
ToastType::Info => "bg-blue-500 text-white shadow-xs hover:bg-blue-600",
ToastType::Loading => "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
};
let merged_class = tw_merge!(
"inline-flex flex-col items-start justify-center gap-1 min-w-[300px] rounded-md text-sm font-medium transition-all shadow-lg p-4 cursor-pointer pointer-events-auto border border-border/50",
variant_classes,
class
);
// Only set position attribute if not empty
let position_attr = if position.is_empty() { None } else { Some(position) };
// Clone title for data attribute usage, original moved into view
let title_clone = title.clone();
view! {
<div
class=merged_class
data-name="SonnerTrigger"
data-variant=variant.to_string()
data-toast-title=title_clone
data-toast-position=position_attr
on:click=move |_| {
if let Some(cb) = on_dismiss {
cb.run(());
}
}
>
<div class="font-semibold">{title}</div>
{move || description.as_ref().map(|d| view! { <div class="text-xs opacity-90">{d.clone()}</div> })}
</div>
}
}
#[component]
pub fn SonnerContainer(
children: Children,
#[prop(into, optional)] class: String,
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
) -> impl IntoView {
let merged_class = tw_merge!("toast__container fixed z-[9999] flex flex-col gap-2 p-4 outline-none pointer-events-none", class);
view! {
<div class=merged_class data-position=position.to_string()>
{children()}
</div>
}
}
#[component]
pub fn SonnerList(
children: Children,
#[prop(into, optional)] class: String,
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
#[prop(optional, default = SonnerDirection::default())] direction: SonnerDirection,
#[prop(into, default = "false".to_string())] expanded: String,
#[prop(into, optional)] style: String,
) -> impl IntoView {
let merged_class = tw_merge!(
"contents",
class
);
view! {
<div
class=merged_class
data-name="SonnerList"
data-sonner-toaster="true"
data-sonner-theme="light"
data-position=position.to_string()
data-expanded=expanded
data-direction=direction.to_string()
style=style
>
{children()}
</div>
}
}
// Thread local storage for global access without Context
thread_local! {
static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None);
}
pub fn provide_toaster() {
let toasts = RwSignal::new(Vec::<ToastData>::new());
// Set global thread_local
TOASTS.with(|t| *t.borrow_mut() = Some(toasts));
// Also provide context for components
provide_context(ToasterStore { toasts });
}
#[component]
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
// Global store'u al
let store = use_context::<ToasterStore>().expect("Toaster context not found. Call provide_toaster() in App root.");
let toasts = store.toasts;
// Auto-derive direction from position
let direction = match position {
SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown,
_ => SonnerDirection::BottomUp,
};
let container_class = match position {
SonnerPosition::TopLeft => "left-0 top-0 items-start",
SonnerPosition::TopRight => "right-0 top-0 items-end",
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-0 items-center",
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-0 items-center",
SonnerPosition::BottomLeft => "left-0 bottom-0 items-start",
SonnerPosition::BottomRight => "right-0 bottom-0 items-end",
};
view! {
<SonnerContainer class=container_class position=position>
<SonnerList position=position direction=direction>
<For
each=move || toasts.get()
key=|toast| toast.id
children=move |toast| {
let id = toast.id;
view! {
<SonnerTrigger
variant=toast.variant
title=toast.title
description=toast.description
on_dismiss=Some(Callback::new(move |_| {
toasts.update(|vec| vec.retain(|t| t.id != id));
}))
/>
}
}
/>
</SonnerList>
</SonnerContainer>
}
}
// Global Helper Functions
pub fn toast(title: impl Into<String>, variant: ToastType) {
let signal_opt = TOASTS.with(|t| *t.borrow());
if let Some(toasts) = signal_opt {
let id = js_sys::Math::random().to_bits();
let new_toast = ToastData {
id,
title: title.into(),
description: None,
variant,
duration: 4000,
};
toasts.update(|t| t.push(new_toast.clone()));
// Auto remove after duration
let duration = new_toast.duration;
leptos::task::spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(duration as u32).await;
toasts.update(|vec| vec.retain(|t| t.id != id));
});
} else {
gloo_console::warn!("ToasterStore not found (global static). Make sure provide_toaster() is called.");
}
}
pub fn toast_success(title: impl Into<String>) { toast(title, ToastType::Success); }
pub fn toast_error(title: impl Into<String>) { toast(title, ToastType::Error); }
pub fn toast_warning(title: impl Into<String>) { toast(title, ToastType::Warning); }
pub fn toast_info(title: impl Into<String>) { toast(title, ToastType::Info); }

View File

@@ -7,19 +7,21 @@ use std::collections::HashMap;
use struct_patch::traits::Patch; use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use crate::components::toast::ToastContext; use crate::components::ui::toast::{ToastType, toast};
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) { pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
let msg = message.into(); let msg = message.into();
gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level)); gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level));
log::info!("Displaying toast: [{:?}] {}", level, msg); log::info!("Displaying toast: [{:?}] {}", level, msg);
if let Some(context) = use_context::<ToastContext>() { let variant = match level {
context.add(msg, level); NotificationLevel::Success => ToastType::Success,
} else { NotificationLevel::Error => ToastType::Error,
log::error!("ToastContext not found!"); NotificationLevel::Warning => ToastType::Warning,
gloo_console::error!("ToastContext not found!"); NotificationLevel::Info => ToastType::Info,
} };
toast(msg, variant);
} }

2
frontend/ui_config.toml Normal file
View File

@@ -0,0 +1,2 @@
base_color = "neutral"
base_path_components = "src/components"

View File

@@ -10,6 +10,7 @@ struct-patch = "0.5"
rmp-serde = "1.3" rmp-serde = "1.3"
bytes = "1" bytes = "1"
http = "1" http = "1"
tracing = "0.1"
# Leptos 0.8.7 # Leptos 0.8.7
leptos = { version = "0.8.15", features = ["nightly", "msgpack"] } leptos = { version = "0.8.15", features = ["nightly", "msgpack"] }

View File

@@ -28,8 +28,17 @@ impl Db {
} }
async fn run_migrations(&self) -> Result<()> { async fn run_migrations(&self) -> Result<()> {
sqlx::migrate!("./migrations").run(&self.pool).await?; tracing::info!("Starting database migrations...");
Ok(()) match sqlx::migrate!("./migrations").run(&self.pool).await {
Ok(_) => {
tracing::info!("Database migrations completed successfully.");
Ok(())
}
Err(e) => {
tracing::error!("Database migration failed: {}", e);
Err(e.into())
}
}
} }
// --- User Operations --- // --- User Operations ---

View File

@@ -24,9 +24,19 @@ pub struct SetupStatus {
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> { pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
use crate::DbContext; use crate::DbContext;
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?; tracing::info!("Checking setup status...");
let db_context = use_context::<DbContext>().ok_or_else(|| {
tracing::error!("DB Context missing in GetSetupStatus");
ServerFnError::new("DB Context missing")
})?;
let has_users = db_context.db.has_users().await let has_users = db_context.db.has_users().await
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?; .map_err(|e| {
tracing::error!("DB error in GetSetupStatus: {}", e);
ServerFnError::new(format!("DB error: {}", e))
})?;
tracing::info!("Setup status: completed={}", has_users);
Ok(SetupStatus { Ok(SetupStatus {
completed: has_users, completed: has_users,
@@ -37,21 +47,33 @@ pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> { pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
use crate::DbContext; use crate::DbContext;
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?; tracing::info!("Attempting setup for user: {}", username);
let db_context = use_context::<DbContext>().ok_or_else(|| {
tracing::error!("DB Context missing in Setup");
ServerFnError::new("DB Context missing")
})?;
// Check if setup is already done // Check if setup is already done
let has_users = db_context.db.has_users().await.unwrap_or(false); let has_users = db_context.db.has_users().await.unwrap_or(false);
if has_users { if has_users {
tracing::warn!("Setup attempt blocked: Setup already completed");
return Err(ServerFnError::new("Setup already completed")); return Err(ServerFnError::new("Setup already completed"));
} }
// Hash password (low cost for MIPS) // Hash password (low cost for MIPS)
let password_hash = bcrypt::hash(&password, 6) let password_hash = bcrypt::hash(&password, 6)
.map_err(|_| ServerFnError::new("Hashing error"))?; .map_err(|e| {
tracing::error!("Hashing error: {}", e);
ServerFnError::new("Hashing error")
})?;
db_context.db.create_user(&username, &password_hash).await db_context.db.create_user(&username, &password_hash).await
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?; .map_err(|e| {
tracing::error!("Failed to create user: {}", e);
ServerFnError::new(format!("DB error: {}", e))
})?;
tracing::info!("Setup completed successfully for user: {}", username);
Ok(()) Ok(())
} }

Binary file not shown.