add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
497
crates/chattyness-user-ui/src/pages/login.rs
Normal file
497
crates/chattyness-user-ui/src/pages/login.rs
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
//! Login page for realm users.
|
||||
|
||||
use leptos::ev::SubmitEvent;
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos_router::hooks::use_navigate;
|
||||
|
||||
use crate::components::{Card, CenteredLayout, ErrorAlert, JoinRealmModal, SubmitButton};
|
||||
use chattyness_db::models::RealmSummary;
|
||||
|
||||
/// Main login page component.
|
||||
#[component]
|
||||
pub fn LoginPage() -> impl IntoView {
|
||||
view! {
|
||||
<CenteredLayout>
|
||||
<div class="w-full max-w-lg">
|
||||
// Logo and title
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">
|
||||
<span aria-hidden="true">"Chattyness"</span>
|
||||
</h1>
|
||||
<p class="text-gray-400">"Sign in to explore virtual community spaces"</p>
|
||||
</div>
|
||||
|
||||
<Card class="p-6">
|
||||
<RealmLoginForm />
|
||||
</Card>
|
||||
</div>
|
||||
</CenteredLayout>
|
||||
}
|
||||
}
|
||||
|
||||
/// Realm login form component.
|
||||
#[component]
|
||||
fn RealmLoginForm() -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate = use_navigate();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate_for_submit = navigate.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate_for_join = navigate.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate_for_guest = navigate.clone();
|
||||
|
||||
// Form state
|
||||
let (username, set_username) = signal(String::new());
|
||||
let (password, set_password) = signal(String::new());
|
||||
let (selected_realm, set_selected_realm) = signal(Option::<String>::None);
|
||||
let (private_realm, set_private_realm) = signal(String::new());
|
||||
let (error, set_error) = signal(Option::<String>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (guest_pending, set_guest_pending) = signal(false);
|
||||
|
||||
// Join modal state
|
||||
let (show_join_modal, set_show_join_modal) = signal(false);
|
||||
let (join_pending, set_join_pending) = signal(false);
|
||||
let (pending_realm, set_pending_realm) = signal(Option::<RealmSummary>::None);
|
||||
|
||||
// Fetch public realms
|
||||
let realms = LocalResource::new(move || async move {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ListResponse {
|
||||
realms: Vec<RealmSummary>,
|
||||
}
|
||||
let response = Request::get("/api/realms?include_nsfw=false&limit=20").send().await;
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => resp.json::<ListResponse>().await.ok().map(|r| r.realms),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
{
|
||||
None::<Vec<RealmSummary>>
|
||||
}
|
||||
});
|
||||
|
||||
// Get the realm slug to use
|
||||
let realm_slug = Signal::derive(move || {
|
||||
if !private_realm.get().is_empty() {
|
||||
Some(private_realm.get())
|
||||
} else {
|
||||
selected_realm.get()
|
||||
}
|
||||
});
|
||||
|
||||
// Handle login submission
|
||||
let on_submit = move |ev: SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_error.set(None);
|
||||
|
||||
let slug = realm_slug.get();
|
||||
if slug.is_none() {
|
||||
set_error.set(Some(
|
||||
"Please select a realm or enter a private realm name".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let slug = slug.unwrap();
|
||||
let uname = username.get();
|
||||
let pwd = password.get();
|
||||
|
||||
if uname.is_empty() || pwd.is_empty() {
|
||||
set_error.set(Some("Username and password are required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
set_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use chattyness_db::models::{LoginRequest, LoginResponse, LoginType};
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let navigate = navigate_for_submit.clone();
|
||||
spawn_local(async move {
|
||||
let request = LoginRequest {
|
||||
username: uname,
|
||||
password: pwd,
|
||||
login_type: LoginType::Realm,
|
||||
realm_slug: Some(slug),
|
||||
};
|
||||
|
||||
let response = Request::post("/api/auth/login")
|
||||
.json(&request)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
if let Ok(login_resp) = resp.json::<LoginResponse>().await {
|
||||
if login_resp.requires_pw_reset {
|
||||
navigate(&login_resp.redirect_url, Default::default());
|
||||
} else if login_resp.is_member == Some(false) {
|
||||
if let Some(realm) = login_resp.realm {
|
||||
set_pending_realm.set(Some(realm));
|
||||
set_show_join_modal.set(true);
|
||||
}
|
||||
} else {
|
||||
navigate(&login_resp.redirect_url, Default::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status == 401 {
|
||||
set_error.set(Some("Invalid username or password".to_string()));
|
||||
} else if status == 403 {
|
||||
set_error.set(Some("Your account is suspended or banned".to_string()));
|
||||
} else {
|
||||
set_error.set(Some("Login failed. Please try again.".to_string()));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some(
|
||||
"Network error. Please check your connection.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle join confirmation
|
||||
let on_join_confirm = move |_| {
|
||||
if pending_realm.get().is_some() {
|
||||
set_join_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use chattyness_db::models::JoinRealmRequest;
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let realm = pending_realm.get().unwrap();
|
||||
let navigate = navigate_for_join.clone();
|
||||
let realm_id = realm.id;
|
||||
let realm_slug = realm.slug.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let request = JoinRealmRequest { realm_id };
|
||||
|
||||
let response = Request::post("/api/auth/join-realm")
|
||||
.json(&request)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_join_pending.set(false);
|
||||
set_show_join_modal.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
navigate(&format!("/realms/{}", realm_slug), Default::default());
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status == 403 {
|
||||
set_error.set(Some("Cannot join this realm".to_string()));
|
||||
} else {
|
||||
set_error.set(Some(
|
||||
"Failed to join realm. Please try again.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some(
|
||||
"Network error. Please check your connection.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let on_join_cancel = move |_| {
|
||||
set_show_join_modal.set(false);
|
||||
set_pending_realm.set(None);
|
||||
};
|
||||
|
||||
// Handle guest login
|
||||
let on_guest_click = move |_| {
|
||||
set_error.set(None);
|
||||
|
||||
let slug = realm_slug.get();
|
||||
if slug.is_none() {
|
||||
set_error.set(Some("Please select a realm first".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
set_guest_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use chattyness_db::models::{GuestLoginRequest, GuestLoginResponse};
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let navigate = navigate_for_guest.clone();
|
||||
let realm_slug_val = slug.unwrap();
|
||||
|
||||
spawn_local(async move {
|
||||
let request = GuestLoginRequest {
|
||||
realm_slug: realm_slug_val,
|
||||
};
|
||||
|
||||
let response = Request::post("/api/auth/guest")
|
||||
.json(&request)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_guest_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
if let Ok(guest_resp) = resp.json::<GuestLoginResponse>().await {
|
||||
navigate(&guest_resp.redirect_url, Default::default());
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorResp {
|
||||
error: String,
|
||||
}
|
||||
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||
set_error.set(Some(err.error));
|
||||
} else {
|
||||
set_error.set(Some(
|
||||
"Guest access not available for this realm".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some(
|
||||
"Network error. Please check your connection.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<form on:submit=on_submit class="space-y-6">
|
||||
// Realm selection
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-gray-300 mb-3">"Choose a Realm"</legend>
|
||||
|
||||
// Public realm list
|
||||
<Suspense fallback=move || {
|
||||
view! { <p class="text-gray-400">"Loading realms..."</p> }
|
||||
}>
|
||||
{move || {
|
||||
realms
|
||||
.get()
|
||||
.map(|maybe_realms: Option<Vec<RealmSummary>>| {
|
||||
match maybe_realms {
|
||||
Some(realms) if !realms.is_empty() => {
|
||||
view! {
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto mb-4">
|
||||
{realms
|
||||
.into_iter()
|
||||
.map(|realm| {
|
||||
let slug = realm.slug.clone();
|
||||
let slug_for_click = slug.clone();
|
||||
let is_selected = Signal::derive(move || {
|
||||
selected_realm.get() == Some(slug.clone())
|
||||
});
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
let base = "w-full text-left p-3 rounded-lg border transition-colors";
|
||||
if is_selected.get() {
|
||||
format!("{} border-blue-500 bg-blue-500/10", base)
|
||||
} else {
|
||||
format!(
|
||||
"{} border-gray-700 hover:border-gray-600 hover:bg-gray-700/50",
|
||||
base,
|
||||
)
|
||||
}
|
||||
}
|
||||
on:click=move |_| {
|
||||
set_selected_realm.set(Some(slug_for_click.clone()));
|
||||
set_private_realm.set(String::new());
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-white font-medium">
|
||||
{realm.name.clone()}
|
||||
</span>
|
||||
<span class="text-gray-500 text-sm ml-2">
|
||||
{format!("/{}", realm.slug)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-gray-400 text-sm">
|
||||
{realm.current_user_count}
|
||||
" online"
|
||||
</span>
|
||||
</div>
|
||||
{realm
|
||||
.tagline
|
||||
.as_ref()
|
||||
.map(|t: &String| {
|
||||
view! {
|
||||
<p class="text-gray-400 text-sm mt-1">{t.clone()}</p>
|
||||
}
|
||||
})}
|
||||
</button>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
_ => {
|
||||
view! {
|
||||
<p class="text-gray-400 mb-4">"No public realms available"</p>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
|
||||
// Private realm input
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<span class="text-gray-500">"/"</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Or enter a private realm name"
|
||||
class="input-base pl-6"
|
||||
prop:value=move || private_realm.get()
|
||||
on:input=move |ev| {
|
||||
set_private_realm.set(event_target_value(&ev));
|
||||
set_selected_realm.set(None);
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
// Credentials
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
"Username"
|
||||
<span class="text-red-400" aria-hidden="true">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required=true
|
||||
autocomplete="username"
|
||||
class="input-base"
|
||||
prop:value=move || username.get()
|
||||
on:input=move |ev| set_username.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
"Password"
|
||||
<span class="text-red-400" aria-hidden="true">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required=true
|
||||
autocomplete="current-password"
|
||||
class="input-base"
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Error message
|
||||
<ErrorAlert message=Signal::derive(move || error.get()) />
|
||||
|
||||
// Submit button
|
||||
<SubmitButton
|
||||
text="Enter Realm"
|
||||
loading_text="Signing in..."
|
||||
pending=Signal::derive(move || pending.get())
|
||||
/>
|
||||
|
||||
// Divider
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-700"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-gray-800 text-gray-400">"or"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Guest button
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-3 px-4 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 hover:border-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=move || guest_pending.get() || realm_slug.get().is_none()
|
||||
on:click=on_guest_click
|
||||
>
|
||||
{move || {
|
||||
if guest_pending.get() { "Joining as guest..." } else { "Continue as Guest" }
|
||||
}}
|
||||
</button>
|
||||
|
||||
// Sign up link
|
||||
<p class="text-center text-gray-400 text-sm">
|
||||
"Don't have an account? "
|
||||
<a href="/signup" class="text-blue-400 hover:underline">
|
||||
"Sign up"
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
// Join modal
|
||||
{
|
||||
let on_join_confirm = on_join_confirm.clone();
|
||||
let on_join_cancel = on_join_cancel.clone();
|
||||
move || {
|
||||
let on_join_confirm = on_join_confirm.clone();
|
||||
let on_join_cancel = on_join_cancel.clone();
|
||||
pending_realm
|
||||
.get()
|
||||
.map(|realm| {
|
||||
view! {
|
||||
<JoinRealmModal
|
||||
open=Signal::derive(move || show_join_modal.get())
|
||||
realm_name=realm.name.clone()
|
||||
realm_slug=realm.slug.clone()
|
||||
pending=Signal::derive(move || join_pending.get())
|
||||
on_confirm=Callback::new(on_join_confirm.clone())
|
||||
on_cancel=Callback::new(on_join_cancel.clone())
|
||||
/>
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue