Compare commits
4 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a877e0c393 | ||
|
|
fd65df2962 | ||
|
|
9d160a7ef5 | ||
|
|
a24e4101e8 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3761,6 +3761,7 @@ dependencies = [
|
||||
"struct-patch",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
]
|
||||
|
||||
|
||||
@@ -38,4 +38,7 @@ struct-patch = "0.5"
|
||||
# Rust/UI Components
|
||||
leptos_ui = "0.3"
|
||||
tw_merge = "0.1"
|
||||
strum = { version = "0.26", features = ["derive"] }
|
||||
strum = { version = "0.26", features = ["derive"] }
|
||||
|
||||
[package.metadata.leptos]
|
||||
tailwind-input-file = "input.css"
|
||||
@@ -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": "",
|
||||
"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": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"autoprefixer": "^10.4.23",
|
||||
@@ -17,11 +16,13 @@
|
||||
"postcss-preset-env": "^10.1.3",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
"keywords": [],
|
||||
"license": "ISC",
|
||||
"main": "tailwind.config.js",
|
||||
"name": "frontend",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"type": "module",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -6,20 +6,20 @@ use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::components::{Router, Routes, Route};
|
||||
use leptos_router::hooks::use_navigate;
|
||||
use crate::components::toast::Toaster;
|
||||
use crate::components::ui::toast::Toaster;
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
crate::components::ui::toast::provide_toaster();
|
||||
view! {
|
||||
<InnerApp />
|
||||
<Toaster />
|
||||
<InnerApp />
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn InnerApp() -> impl IntoView {
|
||||
crate::store::provide_torrent_store();
|
||||
crate::components::toast::provide_toast_context();
|
||||
let store = use_context::<crate::store::TorrentStore>();
|
||||
|
||||
let is_loading = signal(true);
|
||||
|
||||
@@ -41,7 +41,8 @@ pub fn Setup() -> impl IntoView {
|
||||
}
|
||||
Err(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ pub mod context_menu;
|
||||
pub mod layout;
|
||||
pub mod torrent;
|
||||
pub mod auth;
|
||||
pub mod toast;
|
||||
// pub mod toast; (Removed)
|
||||
pub mod ui;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod button;
|
||||
pub mod card;
|
||||
pub mod input;
|
||||
pub mod toast;
|
||||
|
||||
232
frontend/src/components/ui/toast.rs
Normal file
232
frontend/src/components/ui/toast.rs
Normal 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); }
|
||||
@@ -7,19 +7,21 @@ use std::collections::HashMap;
|
||||
use struct_patch::traits::Patch;
|
||||
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>) {
|
||||
let msg = message.into();
|
||||
gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level));
|
||||
log::info!("Displaying toast: [{:?}] {}", level, msg);
|
||||
|
||||
if let Some(context) = use_context::<ToastContext>() {
|
||||
context.add(msg, level);
|
||||
} else {
|
||||
log::error!("ToastContext not found!");
|
||||
gloo_console::error!("ToastContext not found!");
|
||||
}
|
||||
let variant = match level {
|
||||
NotificationLevel::Success => ToastType::Success,
|
||||
NotificationLevel::Error => ToastType::Error,
|
||||
NotificationLevel::Warning => ToastType::Warning,
|
||||
NotificationLevel::Info => ToastType::Info,
|
||||
};
|
||||
|
||||
toast(msg, variant);
|
||||
}
|
||||
|
||||
|
||||
|
||||
2
frontend/ui_config.toml
Normal file
2
frontend/ui_config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
base_color = "neutral"
|
||||
base_path_components = "src/components"
|
||||
@@ -10,6 +10,7 @@ struct-patch = "0.5"
|
||||
rmp-serde = "1.3"
|
||||
bytes = "1"
|
||||
http = "1"
|
||||
tracing = "0.1"
|
||||
|
||||
# Leptos 0.8.7
|
||||
leptos = { version = "0.8.15", features = ["nightly", "msgpack"] }
|
||||
|
||||
@@ -28,8 +28,17 @@ impl Db {
|
||||
}
|
||||
|
||||
async fn run_migrations(&self) -> Result<()> {
|
||||
sqlx::migrate!("./migrations").run(&self.pool).await?;
|
||||
Ok(())
|
||||
tracing::info!("Starting database migrations...");
|
||||
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 ---
|
||||
|
||||
@@ -24,9 +24,19 @@ pub struct SetupStatus {
|
||||
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
|
||||
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
|
||||
.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 {
|
||||
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> {
|
||||
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
|
||||
let has_users = db_context.db.has_users().await.unwrap_or(false);
|
||||
if has_users {
|
||||
tracing::warn!("Setup attempt blocked: Setup already completed");
|
||||
return Err(ServerFnError::new("Setup already completed"));
|
||||
}
|
||||
|
||||
// Hash password (low cost for MIPS)
|
||||
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
|
||||
.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(())
|
||||
}
|
||||
|
||||
|
||||
BIN
vibetorrent.db
BIN
vibetorrent.db
Binary file not shown.
Reference in New Issue
Block a user