init
This commit is contained in:
24
dollhouse/crates/dollhouse-frontend/Cargo.toml
Executable file
24
dollhouse/crates/dollhouse-frontend/Cargo.toml
Executable file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "dollhouse-frontend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
yew = { version = "0.21", features = ["csr"] }
|
||||
validator = { version = "0.20.0", features = ["derive"] }
|
||||
gloo-net = "0.4"
|
||||
gloo-timers = "0.3"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = "0.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
gloo-storage = "0.3"
|
||||
gloo-events = "0.2"
|
||||
wasm-logger = "0.2"
|
||||
log = "0.4"
|
||||
yew-router = "0.18"
|
||||
once_cell = "1.21.3"
|
||||
dollhouse-api-types = { path = "../dollhouse-api-types" }
|
||||
uuid = { workspace = true }
|
||||
14
dollhouse/crates/dollhouse-frontend/Trunk.toml
Executable file
14
dollhouse/crates/dollhouse-frontend/Trunk.toml
Executable file
@@ -0,0 +1,14 @@
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "dist"
|
||||
|
||||
[watch]
|
||||
watch = ["src", "index.html", "styles.css"]
|
||||
|
||||
[serve]
|
||||
address = "127.0.0.1"
|
||||
port = 3000
|
||||
|
||||
|
||||
[[proxy]]
|
||||
backend = "http://localhost:5555/api"
|
||||
14
dollhouse/crates/dollhouse-frontend/index.html
Executable file
14
dollhouse/crates/dollhouse-frontend/index.html
Executable file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DOLLHOUSE - Replicant Management System</title>
|
||||
<link data-trunk rel="sass" href="styles.css"/>
|
||||
<link data-trunk rel="copy-dir" href="./static"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
212
dollhouse/crates/dollhouse-frontend/src/components/auth_form.rs
Executable file
212
dollhouse/crates/dollhouse-frontend/src/components/auth_form.rs
Executable file
@@ -0,0 +1,212 @@
|
||||
use crate::routes::Route;
|
||||
use crate::services::{auth::AuthContext, ApiService};
|
||||
use dollhouse_api_types::{CreateUserRequest, LoginRequest, UserResponse};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct AuthFormProps {
|
||||
pub on_authenticated: Callback<UserResponse>,
|
||||
pub context: AuthContext,
|
||||
pub default_mode: AuthMode,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone)]
|
||||
pub enum AuthMode {
|
||||
Login,
|
||||
Register,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn AuthForm(props: &AuthFormProps) -> Html {
|
||||
let username = use_state(|| String::new());
|
||||
let password = use_state(|| String::new());
|
||||
let error = use_state(|| None::<String>);
|
||||
let loading = use_state(|| false);
|
||||
let mode = use_state(|| props.default_mode.clone());
|
||||
let navigator = use_navigator().unwrap();
|
||||
|
||||
let on_toggle = {
|
||||
let mode = mode.clone();
|
||||
let username = username.clone();
|
||||
let password = password.clone();
|
||||
let error = error.clone();
|
||||
let navigator = navigator.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
match *mode {
|
||||
AuthMode::Login => navigator.push(&Route::Register),
|
||||
AuthMode::Register => navigator.push(&Route::Login),
|
||||
};
|
||||
username.set(String::new());
|
||||
password.set(String::new());
|
||||
error.set(None);
|
||||
})
|
||||
};
|
||||
|
||||
let is_form_valid = !username.is_empty() && !password.is_empty();
|
||||
|
||||
let on_submit = {
|
||||
let username = username.clone();
|
||||
let password = password.clone();
|
||||
let error = error.clone();
|
||||
let loading = loading.clone();
|
||||
let on_authenticated = props.on_authenticated.clone();
|
||||
let mode = mode.clone();
|
||||
let navigator = navigator.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
let navigator = navigator.clone();
|
||||
|
||||
if !is_form_valid {
|
||||
error.set(Some("Please fill all fields".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
let username = (*username).clone();
|
||||
let password = (*password).clone();
|
||||
let error = error.clone();
|
||||
let loading = loading.clone();
|
||||
let on_authenticated = on_authenticated.clone();
|
||||
let current_mode = (*mode).clone();
|
||||
|
||||
loading.set(true);
|
||||
error.set(None);
|
||||
|
||||
spawn_local(async move {
|
||||
let navigator = navigator.clone();
|
||||
match current_mode {
|
||||
AuthMode::Login => {
|
||||
match ApiService::login(LoginRequest {
|
||||
username: username.clone(),
|
||||
password: password.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(user) => {
|
||||
on_authenticated.emit(user);
|
||||
navigator.push(&Route::Replicants);
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
AuthMode::Register => {
|
||||
if let Err(e) = ApiService::register(CreateUserRequest {
|
||||
username: username.clone(),
|
||||
password: password.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
error.set(Some(e));
|
||||
} else {
|
||||
navigator.push(&Route::Login);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loading.set(false);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let is_login = matches!(*mode, AuthMode::Login);
|
||||
|
||||
html! {
|
||||
<div class="auth-form-container">
|
||||
<div class="auth-form">
|
||||
<h2 class="auth-title">
|
||||
{ if is_login { "REPLICANT LOGIN" } else { "CREATE ACCOUNT" } }
|
||||
</h2>
|
||||
|
||||
<div class="auth-subtitle">
|
||||
{ if is_login {
|
||||
"Access the Dollhouse system"
|
||||
} else {
|
||||
"Register new user account"
|
||||
}}
|
||||
</div>
|
||||
|
||||
{ if let Some(err) = &*error {
|
||||
html!{ <div class="auth-error">{err}</div> }
|
||||
} else {
|
||||
html!{}
|
||||
} }
|
||||
|
||||
<form class="auth-fields" onsubmit={on_submit}>
|
||||
<div class="form-field">
|
||||
<label>{"USERNAME"}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(*username).clone()}
|
||||
placeholder="Enter your username"
|
||||
oninput={{
|
||||
let username = username.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input = e.target_unchecked_into::<web_sys::HtmlInputElement>();
|
||||
username.set(input.value());
|
||||
})
|
||||
}}
|
||||
disabled={*loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>{"PASSWORD"}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={(*password).clone()}
|
||||
placeholder="Enter your password"
|
||||
oninput={{
|
||||
let password = password.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input = e.target_unchecked_into::<web_sys::HtmlInputElement>();
|
||||
password.set(input.value());
|
||||
})
|
||||
}}
|
||||
disabled={*loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="auth-submit-btn"
|
||||
type="submit"
|
||||
disabled={*loading || !is_form_valid}
|
||||
>
|
||||
{ if *loading {
|
||||
"PROCESSING..."
|
||||
} else if is_login {
|
||||
"LOGIN"
|
||||
} else {
|
||||
"REGISTER"
|
||||
}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-toggle">
|
||||
<span class="toggle-text">
|
||||
{ if is_login {
|
||||
"New to the system?"
|
||||
} else {
|
||||
"Already have an account?"
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
onclick={on_toggle}
|
||||
disabled={*loading}
|
||||
>
|
||||
{ if is_login {
|
||||
"CREATE ACCOUNT"
|
||||
} else {
|
||||
"LOGIN"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
90
dollhouse/crates/dollhouse-frontend/src/components/corp/corp_info_tab.rs
Executable file
90
dollhouse/crates/dollhouse-frontend/src/components/corp/corp_info_tab.rs
Executable file
@@ -0,0 +1,90 @@
|
||||
use dollhouse_api_types::{CorpResponse, StaffResponse, UserRole};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CorpInfoTabProps {
|
||||
pub corp_data: CorpResponse,
|
||||
}
|
||||
|
||||
#[function_component(CorpInfoTab)]
|
||||
pub fn corp_info_tab(props: &CorpInfoTabProps) -> Html {
|
||||
html! {
|
||||
<div class="info-tab">
|
||||
<div class="corp-content">
|
||||
<div class="corp-header">
|
||||
<div class="corp-basic-info">
|
||||
<div class="corp-name">{&props.corp_data.name}</div>
|
||||
<div class="corp-description">{&props.corp_data.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-content">
|
||||
<div class="corp-details dashboard-card">
|
||||
<h3>{"CORPORATION DETAILS"}</h3>
|
||||
<div class="corp-details-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{"Corporation ID"}</span>
|
||||
<span class="detail-value">{props.corp_data.id.to_string()}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{"Name"}</span>
|
||||
<span class="detail-value">{&props.corp_data.name}</span>
|
||||
</div>
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label">{"Description"}</span>
|
||||
<span class="detail-value">{&props.corp_data.description}</span>
|
||||
</div>
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label">{"Invite Code"}</span>
|
||||
<div class="invite-section">
|
||||
<div class="invite-code-display">
|
||||
<code class="invite-code">{&props.corp_data.invite_code}</code>
|
||||
</div>
|
||||
<div class="invite-hint">
|
||||
{"Share this code to invite members to your corporation"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h3>{"CORP STAFF"}</h3>
|
||||
<div class="staff-list">
|
||||
{if props.corp_data.staff.is_empty() {
|
||||
html! {
|
||||
<div class="no-staff-message">
|
||||
<p>{"No staff members yet"}</p>
|
||||
<p class="hint">{"Share the invite code above to add members"}</p>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
props.corp_data.staff.iter().map(|staff: &StaffResponse| {
|
||||
html! {
|
||||
<div class="staff-item">
|
||||
<div class="staff-info">
|
||||
<span class="staff-username">{&staff.username}</span>
|
||||
<span class="staff-id">{"ID: "}{staff.id}</span>
|
||||
<span class={if staff.role == UserRole::CorpAdmin {
|
||||
"status-badge"
|
||||
} else {
|
||||
"status-badge staff-member"
|
||||
}}>
|
||||
{if staff.role == UserRole::CorpAdmin {
|
||||
"ADMIN"
|
||||
} else {
|
||||
"STAFF"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
220
dollhouse/crates/dollhouse-frontend/src/components/corp/corp_replicants_tab.rs
Executable file
220
dollhouse/crates/dollhouse-frontend/src/components/corp/corp_replicants_tab.rs
Executable file
@@ -0,0 +1,220 @@
|
||||
use crate::components::CardType;
|
||||
use crate::components::ReplicantCard;
|
||||
use crate::services::ApiService;
|
||||
use dollhouse_api_types::{CorpResponse, ReplicantFullResponse};
|
||||
use uuid::Uuid;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::prelude::*;
|
||||
|
||||
const PAGE_SIZE: usize = 10;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CorpReplicantsTabProps {
|
||||
pub corp_data: CorpResponse,
|
||||
pub on_create_replicant: Callback<MouseEvent>,
|
||||
}
|
||||
|
||||
#[function_component(CorpReplicantsTab)]
|
||||
pub fn corp_replicants_tab(props: &CorpReplicantsTabProps) -> Html {
|
||||
let replicants_data = use_state(Vec::<ReplicantFullResponse>::new);
|
||||
let replicants_loading = use_state(|| true);
|
||||
let replicants_error = use_state(|| None::<String>);
|
||||
|
||||
let current_page = use_state(|| 1);
|
||||
let has_more = use_state(|| true);
|
||||
|
||||
{
|
||||
let replicants_data = replicants_data.clone();
|
||||
let replicants_loading = replicants_loading.clone();
|
||||
let replicants_error = replicants_error.clone();
|
||||
let current_page = current_page.clone();
|
||||
let has_more = has_more.clone();
|
||||
let corp_id = props.corp_data.id;
|
||||
|
||||
use_effect_with((corp_id, *current_page), move |(corp_id, page)| {
|
||||
let corp_id_for_async = *corp_id;
|
||||
let page_for_async = *page;
|
||||
|
||||
spawn_local(async move {
|
||||
replicants_loading.set(true);
|
||||
replicants_error.set(None);
|
||||
|
||||
match ApiService::get_corp_replicants(
|
||||
corp_id_for_async,
|
||||
Some(page_for_async),
|
||||
Some(PAGE_SIZE),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(replicants) => {
|
||||
replicants_data.set(replicants.clone());
|
||||
|
||||
if replicants.len() < PAGE_SIZE {
|
||||
has_more.set(false);
|
||||
} else {
|
||||
has_more.set(true);
|
||||
}
|
||||
|
||||
replicants_loading.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
replicants_error.set(Some(format!("Failed to load replicants: {}", e)));
|
||||
replicants_loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|| {}
|
||||
});
|
||||
}
|
||||
|
||||
let load_next_page = {
|
||||
let current_page = current_page.clone();
|
||||
let has_more = has_more.clone();
|
||||
let replicants_loading = replicants_loading.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
if *has_more && !*replicants_loading {
|
||||
replicants_loading.set(true);
|
||||
current_page.set(*current_page + 1);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let load_prev_page = {
|
||||
let current_page = current_page.clone();
|
||||
let replicants_loading = replicants_loading.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
if *current_page > 1 && !*replicants_loading {
|
||||
replicants_loading.set(true);
|
||||
current_page.set(*current_page - 1);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="replicants-tab">
|
||||
<div class="replicants-content">
|
||||
<div class="replicants-header">
|
||||
<h3>{"REPLICANTS"}</h3>
|
||||
<button class="btn-primary" onclick={props.on_create_replicant.clone()}>
|
||||
{"Add New Replicant"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{if *replicants_loading {
|
||||
html! {
|
||||
<div class="loading-container">
|
||||
<div class="neural-spinner"></div>
|
||||
<p class="loading-text">{"Accessing database..."}</p>
|
||||
<div class="system-message">
|
||||
{"[SYSTEM] Scanning replicant"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else if let Some(err) = &*replicants_error {
|
||||
html! {
|
||||
<div class="error-card">
|
||||
<div class="error-header">
|
||||
<span class="error-icon">{"⚠"}</span>
|
||||
<span class="error-title">{"CONNECTION ERROR"}</span>
|
||||
</div>
|
||||
<p class="error-message">{err}</p>
|
||||
<div class="system-message error">
|
||||
{"[ERROR] Connection failed"}
|
||||
</div>
|
||||
<button
|
||||
class="retry-btn"
|
||||
onclick={Callback::from(move |_| {
|
||||
replicants_loading.set(true);
|
||||
current_page.set(1);
|
||||
})}
|
||||
>
|
||||
{"Retry Connection"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else if replicants_data.is_empty() {
|
||||
html! {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">{"🔍"}</div>
|
||||
<h3 class="empty-title">{"NO REPLICANTS FOUND"}</h3>
|
||||
<p class="empty-message">
|
||||
{"The corporation database is empty. Create your first replicant to get started."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<>
|
||||
<div class="database-header">
|
||||
<h3 class="database-title">
|
||||
<span class="title-accent">{"[CORPORATION REPLICANTS]"}</span>
|
||||
<span class="title-page">
|
||||
{format!(" [PAGE {:02}]", *current_page)}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="replicants-grid">
|
||||
{replicants_data.iter().map(|replicant| {
|
||||
html! {
|
||||
<ReplicantCard
|
||||
key={replicant.id.to_string()}
|
||||
card_type={CardType::Corp}
|
||||
replicant={replicant.clone()}
|
||||
user_corp_id={None}
|
||||
/>
|
||||
}
|
||||
}).collect::<Html>()}
|
||||
</div>
|
||||
|
||||
<div class="fixed-pagination">
|
||||
<div class="pagination-container">
|
||||
<div class="pagination-info">
|
||||
<span class="pagination-text">
|
||||
{format!("PAGE {:02}", *current_page)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pagination-controls">
|
||||
<button
|
||||
onclick={load_prev_page.clone()}
|
||||
disabled={*current_page == 1 || *replicants_loading}
|
||||
class="pagination-btn pagination-prev"
|
||||
>
|
||||
<span class="btn-icon">{"⟨"}</span>
|
||||
<span class="btn-text">{"PREV"}</span>
|
||||
<span class="btn-glow"></span>
|
||||
</button>
|
||||
|
||||
<div class="pagination-indicator">
|
||||
<div class="indicator-dots">
|
||||
<div class="dot active"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
<span class="indicator-text">
|
||||
{format!("{:02}", *current_page)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={load_next_page.clone()}
|
||||
disabled={!*has_more || *replicants_loading}
|
||||
class="pagination-btn pagination-next"
|
||||
>
|
||||
<span class="btn-text">{"NEXT"}</span>
|
||||
<span class="btn-icon">{"⟩"}</span>
|
||||
<span class="btn-glow"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
158
dollhouse/crates/dollhouse-frontend/src/components/corp/create_corp_form.rs
Executable file
158
dollhouse/crates/dollhouse-frontend/src/components/corp/create_corp_form.rs
Executable file
@@ -0,0 +1,158 @@
|
||||
use crate::services::ApiService;
|
||||
use uuid::Uuid;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CreateCorpModalProps {
|
||||
pub on_close: Callback<MouseEvent>,
|
||||
pub on_success: Callback<()>,
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
#[function_component(CreateCorpModal)]
|
||||
pub fn create_corp_modal(props: &CreateCorpModalProps) -> Html {
|
||||
let name = use_state(|| String::new());
|
||||
let description = use_state(|| String::new());
|
||||
let loading = use_state(|| false);
|
||||
let error = use_state(|| None::<String>);
|
||||
|
||||
let on_name_input = {
|
||||
let name = name.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
name.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_description_input = {
|
||||
let description = description.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
description.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_close = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_| {
|
||||
on_close.emit(MouseEvent::new("click").unwrap());
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let name = name.clone();
|
||||
let description = description.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let on_success = props.on_success.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
let user_id = props.user_id;
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
if name.is_empty() || description.is_empty() {
|
||||
error.set(Some("All fields are required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
loading.set(true);
|
||||
error.set(None);
|
||||
|
||||
let name = name.to_string();
|
||||
let description = description.to_string();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let on_success = on_success.clone();
|
||||
let on_close = on_close.clone();
|
||||
let user_id = user_id.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match ApiService::create_corp(user_id, name, description).await {
|
||||
Ok(_corp) => {
|
||||
loading.set(false);
|
||||
on_success.emit(());
|
||||
on_close.emit(MouseEvent::new("click").unwrap());
|
||||
}
|
||||
Err(err) => {
|
||||
loading.set(false);
|
||||
error.set(Some(err));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{"ESTABLISH CORPORATION"}</h2>
|
||||
<button class="modal-close" onclick={on_close.clone()}>
|
||||
{"×"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="corp-form" onsubmit={on_submit}>
|
||||
{if let Some(error_msg) = &*error {
|
||||
html! {
|
||||
<div class="auth-error">
|
||||
{error_msg}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<div class="form-field">
|
||||
<label for="corp-name">{"CORPORATION NAME"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="corp-name"
|
||||
value={(*name).clone()}
|
||||
oninput={on_name_input}
|
||||
placeholder="Enter corporation name"
|
||||
disabled={*loading}
|
||||
autofocus=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="corp-description">{"DESCRIPTION"}</label>
|
||||
<textarea
|
||||
id="corp-description"
|
||||
value={(*description).clone()}
|
||||
oninput={on_description_input}
|
||||
placeholder="Describe your corporation's purpose"
|
||||
rows=4
|
||||
disabled={*loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
onclick={on_close}
|
||||
disabled={*loading}
|
||||
>
|
||||
{"CANCEL"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
disabled={*loading || name.is_empty() || description.is_empty()}
|
||||
>
|
||||
if *loading {
|
||||
<span class="btn-loading">{"PROCESSING..."}</span>
|
||||
} else {
|
||||
{"ESTABLISH"}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use crate::services::ApiService;
|
||||
use dollhouse_api_types::*;
|
||||
use uuid::Uuid;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CreateReplicantModalProps {
|
||||
pub on_close: Callback<MouseEvent>,
|
||||
pub on_success: Callback<()>,
|
||||
pub corp_id: Uuid,
|
||||
}
|
||||
|
||||
#[function_component(CreateReplicantModal)]
|
||||
pub fn create_replicant_modal(props: &CreateReplicantModalProps) -> Html {
|
||||
let name = use_state(|| String::new());
|
||||
let description = use_state(|| String::new());
|
||||
let gender = use_state(|| ReplicantGender::NonBinary);
|
||||
let loading = use_state(|| false);
|
||||
let error = use_state(|| None::<String>);
|
||||
|
||||
let on_name_input = {
|
||||
let name = name.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
name.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_description_input = {
|
||||
let description = description.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
description.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_gender_change = {
|
||||
let gender = gender.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
match input.value().as_str() {
|
||||
"male" => gender.set(ReplicantGender::Male),
|
||||
"female" => gender.set(ReplicantGender::Female),
|
||||
"non-binary" => gender.set(ReplicantGender::NonBinary),
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_close = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.stop_propagation();
|
||||
on_close.emit(e);
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let name = name.clone();
|
||||
let description = description.clone();
|
||||
let gender = gender.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let on_success = props.on_success.clone();
|
||||
let corp_id = props.corp_id;
|
||||
let on_close = on_close.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
if name.is_empty() || description.is_empty() {
|
||||
error.set(Some("Name and description are required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
loading.set(true);
|
||||
error.set(None);
|
||||
|
||||
let name = name.to_string();
|
||||
let description = description.to_string();
|
||||
let gender = (*gender).clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let on_success = on_success.clone();
|
||||
let on_close = on_close.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let on_close = on_close.clone();
|
||||
|
||||
let new_replicant = CreateReplicantRequest {
|
||||
name: name.clone(),
|
||||
description: description.clone(),
|
||||
gender: gender.clone(),
|
||||
corp_id,
|
||||
};
|
||||
|
||||
match ApiService::create_replicant(corp_id, new_replicant).await {
|
||||
Ok(_) => {
|
||||
loading.set(false);
|
||||
on_success.emit(());
|
||||
on_close.emit(MouseEvent::new("click").unwrap());
|
||||
}
|
||||
Err(err) => {
|
||||
loading.set(false);
|
||||
error.set(Some(err.to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{"CREATE REPLICANT"}</h2>
|
||||
<button class="modal-close" onclick={on_close.clone()}>{"×"}</button>
|
||||
</div>
|
||||
|
||||
<form class="replicant-form" onsubmit={on_submit}>
|
||||
{if let Some(error_msg) = &*error {
|
||||
html! {
|
||||
<div class="auth-error">
|
||||
{error_msg}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label for="replicant-name">{"NAME"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="replicant-name"
|
||||
value={(*name).clone()}
|
||||
oninput={on_name_input}
|
||||
placeholder="Enter replicant name"
|
||||
disabled={*loading}
|
||||
autofocus=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="replicant-gender">{"GENDER"}</label>
|
||||
<select
|
||||
id="replicant-gender"
|
||||
oninput={on_gender_change}
|
||||
disabled={*loading}
|
||||
>
|
||||
<option value="male" selected={*gender == ReplicantGender::Male}>
|
||||
{"Male"}
|
||||
</option>
|
||||
<option value="female" selected={*gender == ReplicantGender::Female}>
|
||||
{"Female"}
|
||||
</option>
|
||||
<option value="non-binary" selected={*gender == ReplicantGender::NonBinary}>
|
||||
{"Non-binary"}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="replicant-description">{"DESCRIPTION"}</label>
|
||||
<textarea
|
||||
id="replicant-description"
|
||||
value={(*description).clone()}
|
||||
oninput={on_description_input}
|
||||
placeholder="Describe the replicant's purpose and characteristics"
|
||||
rows=4
|
||||
disabled={*loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
onclick={on_close}
|
||||
disabled={*loading}
|
||||
>
|
||||
{"CANCEL"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
disabled={*loading || name.is_empty() || description.is_empty()}
|
||||
>
|
||||
if *loading {
|
||||
<span class="btn-loading">{"CREATING..."}</span>
|
||||
} else {
|
||||
{"CREATE REPLICANT"}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
136
dollhouse/crates/dollhouse-frontend/src/components/corp/join_corp_modal.rs
Executable file
136
dollhouse/crates/dollhouse-frontend/src/components/corp/join_corp_modal.rs
Executable file
@@ -0,0 +1,136 @@
|
||||
use crate::services::ApiService;
|
||||
use uuid::Uuid;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct JoinCorpModalProps {
|
||||
pub on_close: Callback<MouseEvent>,
|
||||
pub on_success: Callback<()>,
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
#[function_component(JoinCorpModal)]
|
||||
pub fn join_corp_modal(props: &JoinCorpModalProps) -> Html {
|
||||
let invite_code = use_state(|| String::new());
|
||||
let loading = use_state(|| false);
|
||||
let error = use_state(|| None::<String>);
|
||||
|
||||
let on_invite_code_input = {
|
||||
let invite_code = invite_code.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
invite_code.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_close = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.stop_propagation();
|
||||
on_close.emit(e);
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let invite_code = invite_code.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let on_success = props.on_success.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
let user_id = props.user_id;
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
if invite_code.is_empty() {
|
||||
error.set(Some("All fields are required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
loading.set(true);
|
||||
error.set(None);
|
||||
|
||||
let invite_code = invite_code.to_string();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let on_success = on_success.clone();
|
||||
let on_close = on_close.clone();
|
||||
let user_id = user_id.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match ApiService::join_corp(user_id, invite_code).await {
|
||||
Ok(_corp) => {
|
||||
loading.set(false);
|
||||
on_success.emit(());
|
||||
on_close.emit(MouseEvent::new("click").unwrap());
|
||||
}
|
||||
Err(err) => {
|
||||
loading.set(false);
|
||||
error.set(Some(err));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{"ESTABLISH CORPORATION"}</h2>
|
||||
<button class="modal-close" onclick={on_close.clone()}>
|
||||
{"×"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="corp-form" onsubmit={on_submit}>
|
||||
{if let Some(error_msg) = &*error {
|
||||
html! {
|
||||
<div class="auth-error">
|
||||
{error_msg}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<div class="form-field">
|
||||
<label for="corp-name">{"INVITE CODE"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="corp-name"
|
||||
value={(*invite_code).clone()}
|
||||
oninput={on_invite_code_input}
|
||||
placeholder="Enter invite code"
|
||||
disabled={*loading}
|
||||
autofocus=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
onclick={on_close}
|
||||
disabled={*loading}
|
||||
>
|
||||
{"CANCEL"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
disabled={*loading || invite_code.is_empty()}
|
||||
>
|
||||
if *loading {
|
||||
<span class="btn-loading">{"PROCESSING..."}</span>
|
||||
} else {
|
||||
{"ESTABLISH"}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
5
dollhouse/crates/dollhouse-frontend/src/components/corp/mod.rs
Executable file
5
dollhouse/crates/dollhouse-frontend/src/components/corp/mod.rs
Executable file
@@ -0,0 +1,5 @@
|
||||
pub mod corp_info_tab;
|
||||
pub mod corp_replicants_tab;
|
||||
pub mod create_corp_form;
|
||||
pub mod create_replicant_modal;
|
||||
pub mod join_corp_modal;
|
||||
34
dollhouse/crates/dollhouse-frontend/src/components/header.rs
Executable file
34
dollhouse/crates/dollhouse-frontend/src/components/header.rs
Executable file
@@ -0,0 +1,34 @@
|
||||
use chrono::Utc;
|
||||
use gloo_timers::callback::Interval;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[function_component]
|
||||
pub fn Header() -> Html {
|
||||
let current_time = use_state(|| Utc::now());
|
||||
|
||||
{
|
||||
let current_time = current_time.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let interval = Interval::new(1000, move || {
|
||||
current_time.set(Utc::now());
|
||||
});
|
||||
|
||||
move || {
|
||||
interval.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
html! {
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<h1>{"DOLLHOUSE"}</h1>
|
||||
<p>{"Replicant Management System"}</p>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<span class="status-indicator">{"SYSTEM ONLINE"}</span>
|
||||
<span class="time">{current_time.format("%Y-%m-%d %H:%M:%S UTC").to_string()}</span>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
79
dollhouse/crates/dollhouse-frontend/src/components/layout.rs
Executable file
79
dollhouse/crates/dollhouse-frontend/src/components/layout.rs
Executable file
@@ -0,0 +1,79 @@
|
||||
use crate::{
|
||||
components::{Header, Sidebar},
|
||||
routes::Route,
|
||||
services::auth::use_auth,
|
||||
};
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct LayoutProps {
|
||||
pub children: Children,
|
||||
}
|
||||
|
||||
#[function_component(Layout)]
|
||||
pub fn layout(props: &LayoutProps) -> Html {
|
||||
let active_page = use_state(|| "replicants".to_string());
|
||||
let auth_context = use_auth();
|
||||
let navigator = use_navigator().unwrap();
|
||||
|
||||
use_effect_with(
|
||||
(auth_context.is_loading, auth_context.is_authenticated()),
|
||||
{
|
||||
let navigator = navigator.clone();
|
||||
|
||||
move |(loading, authenticated)| {
|
||||
if !loading && !authenticated {
|
||||
web_sys::console::log_1(&"Layout: No auth, redirecting to login".into());
|
||||
navigator.replace(&Route::Login);
|
||||
}
|
||||
|
||||
|| {}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if auth_context.is_loading {
|
||||
return html! {
|
||||
<div class="app">
|
||||
<div class="auth-loading">
|
||||
<div class="spinner"></div>
|
||||
<p>{"Loading session..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
if !auth_context.is_authenticated() {
|
||||
return html! {
|
||||
<div class="app">
|
||||
<div class="auth-redirecting">
|
||||
<div class="spinner"></div>
|
||||
<p>{"Redirecting to login..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
web_sys::console::log_1(&"Layout: User authenticated, rendering content".into());
|
||||
|
||||
let on_navigation = {
|
||||
let active_page = active_page.clone();
|
||||
Callback::from(move |page: String| {
|
||||
active_page.set(page);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="app">
|
||||
<Header />
|
||||
<main class="main-content">
|
||||
<Sidebar
|
||||
active_page={(*active_page).clone()}
|
||||
on_navigation={on_navigation}
|
||||
/>
|
||||
{props.children.clone()}
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
20
dollhouse/crates/dollhouse-frontend/src/components/mod.rs
Executable file
20
dollhouse/crates/dollhouse-frontend/src/components/mod.rs
Executable file
@@ -0,0 +1,20 @@
|
||||
pub mod auth_form;
|
||||
pub mod corp;
|
||||
pub mod header;
|
||||
pub mod layout;
|
||||
pub mod pagination;
|
||||
pub mod replicant_card;
|
||||
pub mod sidebar;
|
||||
|
||||
pub use auth_form::{AuthForm, AuthMode};
|
||||
pub use corp::corp_info_tab::CorpInfoTab;
|
||||
pub use corp::corp_replicants_tab::CorpReplicantsTab;
|
||||
pub use corp::create_corp_form::CreateCorpModal;
|
||||
pub use corp::create_replicant_modal::CreateReplicantModal;
|
||||
pub use corp::join_corp_modal::JoinCorpModal;
|
||||
pub use header::Header;
|
||||
pub use layout::Layout;
|
||||
pub use pagination::Pagination;
|
||||
pub use replicant_card::CardType;
|
||||
pub use replicant_card::ReplicantCard;
|
||||
pub use sidebar::Sidebar;
|
||||
108
dollhouse/crates/dollhouse-frontend/src/components/pagination.rs
Executable file
108
dollhouse/crates/dollhouse-frontend/src/components/pagination.rs
Executable file
@@ -0,0 +1,108 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct PaginationProps {
|
||||
pub current_page: usize,
|
||||
pub total_pages: usize,
|
||||
pub on_page_change: Callback<usize>,
|
||||
pub on_next: Callback<MouseEvent>,
|
||||
pub on_prev: Callback<MouseEvent>,
|
||||
pub on_first: Callback<MouseEvent>,
|
||||
pub on_last: Callback<MouseEvent>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Pagination(props: &PaginationProps) -> Html {
|
||||
let current = props.current_page;
|
||||
let total = props.total_pages;
|
||||
|
||||
let page_numbers = {
|
||||
let mut pages = Vec::new();
|
||||
|
||||
if current > 3 {
|
||||
pages.push(1);
|
||||
if current > 4 {
|
||||
pages.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
let start = (current as i32 - 2).max(1) as usize;
|
||||
let end = (current + 2).min(total);
|
||||
|
||||
for page in start..=end {
|
||||
pages.push(page);
|
||||
}
|
||||
|
||||
if current < total - 2 {
|
||||
if current < total - 3 {
|
||||
pages.push(0);
|
||||
}
|
||||
pages.push(total);
|
||||
}
|
||||
|
||||
pages
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
<span class="page-info">
|
||||
{"Page "}<strong>{current}</strong>{" of "}<strong>{total}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pagination-buttons">
|
||||
<button
|
||||
class="pagination-btn first"
|
||||
onclick={props.on_first.clone()}
|
||||
disabled={current <= 1}
|
||||
>
|
||||
{"« First"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="pagination-btn prev"
|
||||
onclick={props.on_prev.clone()}
|
||||
disabled={current <= 1}
|
||||
>
|
||||
{"‹ Prev"}
|
||||
</button>
|
||||
|
||||
{for page_numbers.iter().map(|&page| {
|
||||
if page == 0 {
|
||||
html! { <span class="pagination-ellipsis">{"..."}</span> }
|
||||
} else {
|
||||
let is_current = page == current;
|
||||
let page_callback = props.on_page_change.clone();
|
||||
|
||||
html! {
|
||||
<button
|
||||
class={classes!("pagination-btn", "page-number", is_current.then_some("active"))}
|
||||
onclick={Callback::from(move |_| page_callback.emit(page))}
|
||||
disabled={is_current}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
})}
|
||||
|
||||
<button
|
||||
class="pagination-btn next"
|
||||
onclick={props.on_next.clone()}
|
||||
disabled={current >= total}
|
||||
>
|
||||
{"Next ›"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="pagination-btn last"
|
||||
onclick={props.on_last.clone()}
|
||||
disabled={current >= total}
|
||||
>
|
||||
{"Last »"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
199
dollhouse/crates/dollhouse-frontend/src/components/replicant_card.rs
Executable file
199
dollhouse/crates/dollhouse-frontend/src/components/replicant_card.rs
Executable file
@@ -0,0 +1,199 @@
|
||||
use crate::routes::Route;
|
||||
use crate::AuthContext;
|
||||
use dollhouse_api_types::ReplicantFullResponse;
|
||||
use dollhouse_api_types::{ReplicantGender, ReplicantStatus};
|
||||
use uuid::Uuid;
|
||||
use yew::platform::spawn_local;
|
||||
use yew::prelude::*;
|
||||
use yew_router::hooks::use_navigator;
|
||||
|
||||
use crate::services::ApiService;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CardType {
|
||||
Corp,
|
||||
Public,
|
||||
}
|
||||
|
||||
fn status_display_name(status: ReplicantStatus) -> String {
|
||||
match status {
|
||||
ReplicantStatus::Active => "Active".to_string(),
|
||||
ReplicantStatus::Decommissioned => "Decommissioned".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_color(status: ReplicantStatus) -> &'static str {
|
||||
match status {
|
||||
ReplicantStatus::Active => "#00ff41",
|
||||
ReplicantStatus::Decommissioned => "#ff0000",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gender_color(gender: &ReplicantGender) -> &'static str {
|
||||
match gender {
|
||||
ReplicantGender::Male => "#00ff41",
|
||||
ReplicantGender::Female => "#ff6b35",
|
||||
ReplicantGender::NonBinary => "#ff0000",
|
||||
}
|
||||
}
|
||||
|
||||
fn gender_svg(gender: &ReplicantGender) -> Html {
|
||||
match gender {
|
||||
ReplicantGender::Male => html! {
|
||||
<img src="/static/male.svg" alt="Male" class="gender-icon" />
|
||||
},
|
||||
ReplicantGender::Female => html! {
|
||||
<img src="/static/female.svg" alt="Female" class="gender-icon" />
|
||||
},
|
||||
ReplicantGender::NonBinary => html! {
|
||||
<img src="/static/non-binary.svg" alt="Non-binary" class="gender-icon" />
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn stat_color(value: i32) -> &'static str {
|
||||
match value {
|
||||
0..=30 => "#ff4444",
|
||||
31..=70 => "#ffaa00",
|
||||
_ => "#00ff41",
|
||||
}
|
||||
}
|
||||
|
||||
fn stat_bar(value: i32, max: i32) -> Html {
|
||||
let percentage = (value as f32 / max as f32 * 100.0) as i32;
|
||||
|
||||
html! {
|
||||
<div class="stat-bar">
|
||||
<div
|
||||
class="stat-bar-fill"
|
||||
style={format!("width: {}%; background-color: {}", percentage, stat_color(value))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ReplicantCardProps {
|
||||
pub card_type: CardType,
|
||||
pub replicant: ReplicantFullResponse,
|
||||
pub user_corp_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn ReplicantCard(props: &ReplicantCardProps) -> Html {
|
||||
let auth_context = use_context::<AuthContext>().expect("AuthContext not found");
|
||||
|
||||
let user_corp_id = auth_context.user.as_ref().and_then(|user| user.corp_id);
|
||||
|
||||
let on_take = {
|
||||
let replicant = props.replicant.clone();
|
||||
let corp_id = props.user_corp_id.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
spawn_local(async move {
|
||||
match ApiService::change_replicant_owner(replicant.id, corp_id.unwrap()).await {
|
||||
Ok(_) => {}
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let on_change_privacy = {
|
||||
let replicant = props.replicant.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
spawn_local(async move {
|
||||
match ApiService::change_replicant_privacy(replicant.id, !replicant.is_private)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let on_edit = {
|
||||
let replicant = props.replicant.clone();
|
||||
let nav = use_navigator().unwrap();
|
||||
Callback::from(move |_: MouseEvent| nav.push(&Route::ReplicantDetail { id: replicant.id }))
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="replicant-card">
|
||||
<div class="card-header">
|
||||
<div class="replicant-title">
|
||||
<h3>{&props.replicant.name}</h3>
|
||||
{gender_svg(&props.replicant.gender)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="status-line">
|
||||
<span class="status-label">{"Status:"}</span>
|
||||
<span class="status-badge" style={format!("background-color: {}", status_color(props.replicant.status.clone()))}>
|
||||
{status_display_name(props.replicant.status.clone())}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-line">
|
||||
<span class="status-label">{"is private:"}</span>
|
||||
<span class="status-label">
|
||||
{props.replicant.is_private}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="replicant-description">{&props.replicant.description}</p>
|
||||
|
||||
<div class="stats-section">
|
||||
<h4>{"STATISTICS"}</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">{"HEALTH"}</span>
|
||||
<span class="stat-value" style={format!("color: {}", stat_color(props.replicant.health))}>
|
||||
{props.replicant.health}
|
||||
</span>
|
||||
</div>
|
||||
{stat_bar(props.replicant.health, 100)}
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">{"STRENGTH"}</span>
|
||||
<span class="stat-value" style={format!("color: {}", stat_color(props.replicant.strength))}>
|
||||
{props.replicant.strength}
|
||||
</span>
|
||||
</div>
|
||||
{stat_bar(props.replicant.strength, 100)}
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">{"INTELLIGENCE"}</span>
|
||||
<span class="stat-value" style={format!("color: {}", stat_color(props.replicant.intelligence))}>
|
||||
{props.replicant.intelligence}
|
||||
</span>
|
||||
</div>
|
||||
{stat_bar(props.replicant.intelligence, 100)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ if user_corp_id.is_none() {
|
||||
html! {}
|
||||
} else if props.card_type == CardType::Public {
|
||||
html! {
|
||||
<div class="card-actions">
|
||||
<button class="btn-secondary" onclick={on_take}>{"TAKE"}</button>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="card-actions">
|
||||
<button class="btn-secondary" onclick={on_change_privacy}>{"CHANGE PRIVACY"}</button>
|
||||
<button class="btn-secondary" onclick={on_edit}>{"EDIT"}</button>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
74
dollhouse/crates/dollhouse-frontend/src/components/sidebar.rs
Executable file
74
dollhouse/crates/dollhouse-frontend/src/components/sidebar.rs
Executable file
@@ -0,0 +1,74 @@
|
||||
use crate::routes::Route;
|
||||
use crate::services::auth::AuthContext;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SidebarProps {
|
||||
pub active_page: String,
|
||||
pub on_navigation: Callback<String>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Sidebar(props: &SidebarProps) -> Html {
|
||||
let auth_context = use_context::<AuthContext>().unwrap();
|
||||
let nav_items = vec![
|
||||
("replicants".to_string(), "Replicants"),
|
||||
("corp".to_string(), "Corp"),
|
||||
("logout".to_string(), "Logout"),
|
||||
];
|
||||
|
||||
let navigator = use_navigator().unwrap();
|
||||
let on_nav_click = {
|
||||
let navigator = navigator.clone();
|
||||
let on_navigation = props.on_navigation.clone();
|
||||
let auth_context = auth_context.clone();
|
||||
|
||||
Callback::from(move |page: String| {
|
||||
let navigator = navigator.clone();
|
||||
let on_navigation = on_navigation.clone();
|
||||
let auth_context = auth_context.clone();
|
||||
|
||||
match page.as_str() {
|
||||
"replicants" => navigator.push(&Route::Replicants),
|
||||
"logout" => {
|
||||
spawn_local(async move {
|
||||
let _ = auth_context.logout().await;
|
||||
navigator.push(&Route::Login);
|
||||
});
|
||||
}
|
||||
"corp" => navigator.push(&Route::Corp),
|
||||
_ => {}
|
||||
}
|
||||
on_navigation.emit(page.clone());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="sidebar">
|
||||
<nav class="nav">
|
||||
{nav_items.iter().filter_map(|(id, label)| {
|
||||
let is_active = props.active_page == *id;
|
||||
let nav_id = id.clone();
|
||||
let on_click = {
|
||||
let on_nav_click = on_nav_click.clone();
|
||||
let nav_id = nav_id.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_nav_click.emit(nav_id.clone());
|
||||
})
|
||||
};
|
||||
|
||||
Some(html! {
|
||||
<button
|
||||
class={if is_active { "nav-btn active" } else { "nav-btn" }}
|
||||
onclick={on_click}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
})
|
||||
}).collect::<Html>()}
|
||||
</nav>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
28
dollhouse/crates/dollhouse-frontend/src/main.rs
Executable file
28
dollhouse/crates/dollhouse-frontend/src/main.rs
Executable file
@@ -0,0 +1,28 @@
|
||||
use crate::components::Layout;
|
||||
use crate::routes::switch;
|
||||
use crate::routes::Route;
|
||||
use crate::services::auth::{use_auth, AuthContext};
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
mod components;
|
||||
mod pages;
|
||||
mod routes;
|
||||
mod services;
|
||||
|
||||
#[function_component(App)]
|
||||
fn app() -> Html {
|
||||
let auth_context = use_auth();
|
||||
|
||||
html! {
|
||||
<ContextProvider<AuthContext> context={auth_context}>
|
||||
<BrowserRouter>
|
||||
<Switch<Route> render={switch} />
|
||||
</BrowserRouter>
|
||||
</ContextProvider<AuthContext>>
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
yew::Renderer::<App>::new().render();
|
||||
}
|
||||
267
dollhouse/crates/dollhouse-frontend/src/pages/corp.rs
Executable file
267
dollhouse/crates/dollhouse-frontend/src/pages/corp.rs
Executable file
@@ -0,0 +1,267 @@
|
||||
use crate::components::{
|
||||
CorpInfoTab, CorpReplicantsTab, CreateCorpModal, CreateReplicantModal, JoinCorpModal,
|
||||
};
|
||||
use crate::services::{auth::use_auth, ApiService};
|
||||
use dollhouse_api_types::CorpResponse;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(PartialEq, Clone)]
|
||||
enum ActiveTab {
|
||||
Info,
|
||||
Replicants,
|
||||
}
|
||||
|
||||
#[function_component(CorpPage)]
|
||||
pub fn corp_page() -> Html {
|
||||
let corp_data = use_state(|| None::<CorpResponse>);
|
||||
let loading = use_state(|| true);
|
||||
let error = use_state(|| None::<String>);
|
||||
let auth_context = use_auth();
|
||||
let show_create_modal = use_state(|| false);
|
||||
let show_join_modal = use_state(|| false);
|
||||
let show_create_replicant_modal = use_state(|| false);
|
||||
let active_tab = use_state(|| ActiveTab::Info);
|
||||
|
||||
let switch_to_info = {
|
||||
let active_tab = active_tab.clone();
|
||||
Callback::from(move |_| {
|
||||
active_tab.set(ActiveTab::Info);
|
||||
})
|
||||
};
|
||||
|
||||
let switch_to_replicants = {
|
||||
let active_tab = active_tab.clone();
|
||||
Callback::from(move |_| {
|
||||
active_tab.set(ActiveTab::Replicants);
|
||||
})
|
||||
};
|
||||
|
||||
let open_create_replicant_modal = {
|
||||
let show_create_replicant_modal = show_create_replicant_modal.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_create_replicant_modal.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
let close_modal = {
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let show_join_modal = show_join_modal.clone();
|
||||
let show_create_replicant_modal = show_create_replicant_modal.clone();
|
||||
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_create_modal.set(false);
|
||||
show_join_modal.set(false);
|
||||
show_create_replicant_modal.set(false);
|
||||
})
|
||||
};
|
||||
|
||||
let on_success = {
|
||||
let corp_data = corp_data.clone();
|
||||
let loading = loading.clone();
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let show_join_modal = show_join_modal.clone();
|
||||
let auth_context = auth_context.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
show_create_modal.set(false);
|
||||
show_join_modal.set(false);
|
||||
if let Some(user) = &auth_context.user {
|
||||
let user_id = user.id;
|
||||
let corp_data = corp_data.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match ApiService::get_user_corp(user_id).await {
|
||||
Ok(Some(corp)) => {
|
||||
corp_data.set(Some(corp));
|
||||
loading.set(false);
|
||||
}
|
||||
Ok(None) => {
|
||||
corp_data.set(None);
|
||||
loading.set(false);
|
||||
}
|
||||
Err(_) => {
|
||||
corp_data.set(None);
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
{
|
||||
let corp_data = corp_data.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
use_effect_with(auth_context.user.clone(), move |user| {
|
||||
if let Some(user) = user {
|
||||
let user_id = user.id;
|
||||
spawn_local(async move {
|
||||
match ApiService::get_user_corp(user_id).await {
|
||||
Ok(Some(corp)) => {
|
||||
corp_data.set(Some(corp));
|
||||
loading.set(false);
|
||||
}
|
||||
Ok(None) => {
|
||||
corp_data.set(None);
|
||||
loading.set(false);
|
||||
}
|
||||
Err(_) => {
|
||||
corp_data.set(None);
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|| {}
|
||||
});
|
||||
}
|
||||
|
||||
let open_create_corp_modal = {
|
||||
let show_modal = show_create_modal.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_modal.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
let open_join_corp_modal = {
|
||||
let show_modal = show_join_modal.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_modal.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
let tab_content = if let Some(corp) = &*corp_data {
|
||||
match &*active_tab {
|
||||
ActiveTab::Info => html! {
|
||||
<CorpInfoTab corp_data={corp.clone()} />
|
||||
},
|
||||
ActiveTab::Replicants => html! {
|
||||
<CorpReplicantsTab
|
||||
corp_data={corp.clone()}
|
||||
on_create_replicant={open_create_replicant_modal.clone()}
|
||||
/>
|
||||
},
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
};
|
||||
|
||||
let modal = if *show_create_modal {
|
||||
if let Some(user) = &auth_context.user {
|
||||
html! {
|
||||
<CreateCorpModal
|
||||
on_close={close_modal}
|
||||
on_success={on_success.clone()}
|
||||
user_id={user.id}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
} else if *show_join_modal {
|
||||
if let Some(user) = &auth_context.user {
|
||||
html! {
|
||||
<JoinCorpModal
|
||||
on_close={close_modal}
|
||||
on_success={on_success.clone()}
|
||||
user_id={user.id}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
} else if *show_create_replicant_modal {
|
||||
if let Some(corp) = &*corp_data {
|
||||
html! {
|
||||
<CreateReplicantModal
|
||||
on_close={close_modal}
|
||||
on_success={on_success.clone()}
|
||||
corp_id={corp.id}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="content">
|
||||
<div class="page-header-with-tabs">
|
||||
<div class="header-main">
|
||||
<div class="content-header">
|
||||
<h2>{"Corporation"}</h2>
|
||||
</div>
|
||||
{if corp_data.is_some() && *active_tab == ActiveTab::Replicants {
|
||||
html! {}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
{if corp_data.is_some() {
|
||||
html! {
|
||||
<div class="tabs-container">
|
||||
<button
|
||||
class={if *active_tab == ActiveTab::Info { "btn-primary active" } else { "btn-secondary" }}
|
||||
onclick={switch_to_info}
|
||||
>
|
||||
{"INFO"}
|
||||
</button>
|
||||
<button
|
||||
class={if *active_tab == ActiveTab::Replicants { "btn-primary active" } else { "btn-secondary" }}
|
||||
onclick={switch_to_replicants}
|
||||
>
|
||||
{"REPLICANTS"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
{if *loading {
|
||||
html! {
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>{"loading..."}</p>
|
||||
</div>
|
||||
}
|
||||
} else if let Some(error_msg) = &*error {
|
||||
html! {
|
||||
<div class="error">
|
||||
<h2>{"ERROR"}</h2>
|
||||
<p>{error_msg}</p>
|
||||
<button class="btn-primary" onclick={Callback::from(|_| ())}>
|
||||
{"REPEAT"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else if let Some(_corp) = &*corp_data {
|
||||
html! {
|
||||
<div class="corp-content">
|
||||
{tab_content}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="no-corp">
|
||||
<h2>{"CORP NOT FOUND"}</h2>
|
||||
<p>{"You don't have an active corporation"}</p>
|
||||
<div class="no-corp-actions">
|
||||
<button class="btn-primary" onclick={open_create_corp_modal}>{"CREATE CORP"}</button>
|
||||
<button class="btn-secondary" onclick={open_join_corp_modal}>{"JOIN CORP"}</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
|
||||
{modal}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
30
dollhouse/crates/dollhouse-frontend/src/pages/login_page.rs
Executable file
30
dollhouse/crates/dollhouse-frontend/src/pages/login_page.rs
Executable file
@@ -0,0 +1,30 @@
|
||||
use crate::components::{AuthForm, AuthMode};
|
||||
use crate::routes::Route;
|
||||
use crate::services::auth::use_auth;
|
||||
use dollhouse_api_types::UserResponse;
|
||||
use yew::prelude::*;
|
||||
use yew_router::hooks::use_navigator;
|
||||
|
||||
#[function_component]
|
||||
pub fn LoginPage() -> Html {
|
||||
let auth_context = use_auth();
|
||||
let navigator = use_navigator().unwrap();
|
||||
|
||||
let on_authenticated = {
|
||||
let auth_context = auth_context.clone();
|
||||
Callback::from(move |user: UserResponse| {
|
||||
auth_context.set_user.emit(Some(user));
|
||||
navigator.push(&Route::Corp);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="login-page">
|
||||
<AuthForm
|
||||
default_mode={AuthMode::Login}
|
||||
on_authenticated={on_authenticated}
|
||||
context={auth_context}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
13
dollhouse/crates/dollhouse-frontend/src/pages/mod.rs
Executable file
13
dollhouse/crates/dollhouse-frontend/src/pages/mod.rs
Executable file
@@ -0,0 +1,13 @@
|
||||
pub mod corp;
|
||||
pub mod login_page;
|
||||
pub mod not_found;
|
||||
pub mod register_page;
|
||||
pub mod replicant;
|
||||
pub mod replicants;
|
||||
|
||||
pub use corp::CorpPage;
|
||||
pub use login_page::LoginPage;
|
||||
pub use not_found::NotFound;
|
||||
pub use register_page::RegisterPage;
|
||||
pub use replicant::ReplicantDetail;
|
||||
pub use replicants::ReplicantsPage;
|
||||
11
dollhouse/crates/dollhouse-frontend/src/pages/not_found.rs
Executable file
11
dollhouse/crates/dollhouse-frontend/src/pages/not_found.rs
Executable file
@@ -0,0 +1,11 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
#[function_component(NotFound)]
|
||||
pub fn not_found() -> Html {
|
||||
html! {
|
||||
<div class="not-found">
|
||||
<h1>{"404 Not Found"}</h1>
|
||||
<p>{"The page you are looking for does not exist."}</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
30
dollhouse/crates/dollhouse-frontend/src/pages/register_page.rs
Executable file
30
dollhouse/crates/dollhouse-frontend/src/pages/register_page.rs
Executable file
@@ -0,0 +1,30 @@
|
||||
use crate::components::{AuthForm, AuthMode};
|
||||
use crate::routes::Route;
|
||||
use crate::services::auth::use_auth;
|
||||
use dollhouse_api_types::UserResponse;
|
||||
use yew::prelude::*;
|
||||
use yew_router::hooks::use_navigator;
|
||||
|
||||
#[function_component]
|
||||
pub fn RegisterPage() -> Html {
|
||||
let auth_context = use_auth();
|
||||
let nav = use_navigator().unwrap();
|
||||
|
||||
let on_register = {
|
||||
let auth_context = auth_context.clone();
|
||||
Callback::from(move |user: UserResponse| {
|
||||
auth_context.set_user.emit(Some(user));
|
||||
nav.push(&Route::Login)
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="login-page">
|
||||
<AuthForm
|
||||
default_mode={AuthMode::Register}
|
||||
on_authenticated={on_register}
|
||||
context={auth_context}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
454
dollhouse/crates/dollhouse-frontend/src/pages/replicant.rs
Executable file
454
dollhouse/crates/dollhouse-frontend/src/pages/replicant.rs
Executable file
@@ -0,0 +1,454 @@
|
||||
use crate::services::ApiService;
|
||||
use dollhouse_api_types::ReplicantFullResponse;
|
||||
use dollhouse_api_types::{ReplicantGender, ReplicantStatus};
|
||||
use uuid::Uuid;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::{File, HtmlInputElement};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct ReplicantDetailProps {
|
||||
pub replicant_id: Uuid,
|
||||
}
|
||||
|
||||
fn status_display_name(status: ReplicantStatus) -> String {
|
||||
match status {
|
||||
ReplicantStatus::Active => "Active".to_string(),
|
||||
ReplicantStatus::Decommissioned => "Decommissioned".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_color(status: ReplicantStatus) -> &'static str {
|
||||
match status {
|
||||
ReplicantStatus::Active => "var(--primary-neon)",
|
||||
ReplicantStatus::Decommissioned => "var(--danger-neon)",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gender_color(gender: &ReplicantGender) -> &'static str {
|
||||
match gender {
|
||||
ReplicantGender::Male => "var(--primary-neon)",
|
||||
ReplicantGender::Female => "var(--secondary-neon)",
|
||||
ReplicantGender::NonBinary => "var(--accent-neon)",
|
||||
}
|
||||
}
|
||||
|
||||
fn stat_color(value: i32) -> &'static str {
|
||||
match value {
|
||||
0..=30 => "var(--danger-neon)",
|
||||
31..=70 => "var(--accent-neon)",
|
||||
_ => "var(--primary-neon)",
|
||||
}
|
||||
}
|
||||
|
||||
fn gender_svg(gender: &ReplicantGender) -> Html {
|
||||
match gender {
|
||||
ReplicantGender::Male => html! {
|
||||
<img src="/static/male.svg" alt="Male" class="gender-icon" />
|
||||
},
|
||||
ReplicantGender::Female => html! {
|
||||
<img src="/static/female.svg" alt="Female" class="gender-icon" />
|
||||
},
|
||||
ReplicantGender::NonBinary => html! {
|
||||
<img src="/static/non-binary.svg" alt="Non-binary" class="gender-icon" />
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn stat_bar(value: i32, max: i32) -> Html {
|
||||
let percentage = (value as f32 / max as f32 * 100.0) as i32;
|
||||
|
||||
html! {
|
||||
<div class="stat-bar">
|
||||
<div
|
||||
class="stat-bar-fill"
|
||||
style={format!("width: {}%; background-color: {}", percentage, stat_color(value))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(ReplicantDetail)]
|
||||
pub fn replicant_detail(props: &ReplicantDetailProps) -> Html {
|
||||
let replicant_data = use_state(|| None::<ReplicantFullResponse>);
|
||||
let loading = use_state(|| true);
|
||||
let error = use_state(|| None::<String>);
|
||||
let running_firmware = use_state(|| false);
|
||||
let firmware_output = use_state(|| None::<String>);
|
||||
let show_firmware_output = use_state(|| false);
|
||||
|
||||
let show_firmware_form = use_state(|| false);
|
||||
let selected_file = use_state(|| None::<File>);
|
||||
let uploading = use_state(|| false);
|
||||
|
||||
{
|
||||
let replicant_data = replicant_data.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let replicant_id = props.replicant_id;
|
||||
|
||||
use_effect_with(props.replicant_id, move |_| {
|
||||
spawn_local(async move {
|
||||
match ApiService::get_replicant(replicant_id).await {
|
||||
Ok(replicant) => {
|
||||
replicant_data.set(Some(replicant));
|
||||
loading.set(false);
|
||||
}
|
||||
Err(err) => {
|
||||
error.set(Some(err.to_string()));
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|| {}
|
||||
});
|
||||
}
|
||||
|
||||
let toggle_load_firmware_form = {
|
||||
let show_firmware_form = show_firmware_form.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_firmware_form.set(!*show_firmware_form);
|
||||
})
|
||||
};
|
||||
|
||||
let on_run_firmware_click = {
|
||||
let replicant_id = props.replicant_id;
|
||||
let running_firmware = running_firmware.clone();
|
||||
let firmware_output = firmware_output.clone();
|
||||
let error = error.clone();
|
||||
let show_firmware_output = show_firmware_output.clone();
|
||||
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
running_firmware.set(true);
|
||||
firmware_output.set(None);
|
||||
show_firmware_output.set(true);
|
||||
|
||||
let replicant_id = replicant_id;
|
||||
let running_firmware = running_firmware.clone();
|
||||
let firmware_output = firmware_output.clone();
|
||||
let error = error.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match ApiService::run_firmware(replicant_id).await {
|
||||
Ok(output) => {
|
||||
firmware_output.set(Some(output.output));
|
||||
}
|
||||
Err(err) => {
|
||||
error.set(Some(err.to_string()));
|
||||
}
|
||||
}
|
||||
running_firmware.set(false);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let toggle_firmware_output = {
|
||||
let show_firmware_output = show_firmware_output.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_firmware_output.set(!*show_firmware_output);
|
||||
})
|
||||
};
|
||||
|
||||
let clear_firmware_output = {
|
||||
let firmware_output = firmware_output.clone();
|
||||
let show_firmware_output = show_firmware_output.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
firmware_output.set(None);
|
||||
show_firmware_output.set(false);
|
||||
})
|
||||
};
|
||||
|
||||
let on_file_change = {
|
||||
let selected_file = selected_file.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
if let Some(files) = input.files() {
|
||||
if files.length() > 0 {
|
||||
if let Some(file) = files.get(0) {
|
||||
selected_file.set(Some(file));
|
||||
}
|
||||
} else {
|
||||
selected_file.set(None);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let upload_firmware = {
|
||||
let selected_file = selected_file.clone();
|
||||
let uploading = uploading.clone();
|
||||
let replicant_id = props.replicant_id;
|
||||
let show_firmware_form = show_firmware_form.clone();
|
||||
let replicant_data = replicant_data.clone();
|
||||
let error = error.clone();
|
||||
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(file) = &*selected_file {
|
||||
uploading.set(true);
|
||||
|
||||
let file = file.clone();
|
||||
let uploading = uploading.clone();
|
||||
let show_firmware_form = show_firmware_form.clone();
|
||||
let replicant_data = replicant_data.clone();
|
||||
let replicant_id = replicant_id;
|
||||
let error = error.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match ApiService::load_firmware(replicant_id, file).await {
|
||||
Ok(_) => {
|
||||
uploading.set(false);
|
||||
show_firmware_form.set(false);
|
||||
|
||||
match ApiService::get_replicant(replicant_id).await {
|
||||
Ok(updated_replicant) => {
|
||||
replicant_data.set(Some(updated_replicant));
|
||||
}
|
||||
Err(err) => error.set(Some(err.to_string())),
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
uploading.set(false);
|
||||
error.set(Some(err.to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if *loading {
|
||||
return html! {
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>{"Loading replicant data..."}</p>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(error_msg) = &*error {
|
||||
return html! {
|
||||
<div class="error-container">
|
||||
<h2>{"ERROR"}</h2>
|
||||
<p>{error_msg}</p>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
if replicant_data.is_none() {
|
||||
return html! {
|
||||
<div class="error-container">
|
||||
<h2>{"REPLICANT NOT FOUND"}</h2>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
let replicant = replicant_data.as_ref().unwrap();
|
||||
let has_firmware = replicant.firmware_file.is_some();
|
||||
|
||||
html! {
|
||||
<div class="replicant-detail-container">
|
||||
<div class="replicant-detail">
|
||||
<div class="detail-header">
|
||||
<div class="replicant-title">
|
||||
{gender_svg(&replicant.gender)}
|
||||
<h1>{&replicant.name}</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onclick={toggle_load_firmware_form.clone()}
|
||||
disabled={*uploading}
|
||||
>
|
||||
{"LOAD FIRMWARE"}
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
onclick={on_run_firmware_click.clone()}
|
||||
disabled={*running_firmware || !has_firmware}
|
||||
title={if !has_firmware { "No firmware loaded" } else { "" }}
|
||||
>
|
||||
{if *running_firmware { "RUNNING..." } else { "RUN FIRMWARE" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-content">
|
||||
<div class="info-section">
|
||||
<h2>{"BASIC INFORMATION"}</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>{"ID"}</label>
|
||||
<span>{replicant.id.to_string()}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>{"GENDER"}</label>
|
||||
<span style={format!("color: {}", gender_color(&replicant.gender))}>
|
||||
{format!("{:?}", &replicant.gender)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>{"STATUS"}</label>
|
||||
<span class="status-badge" style={format!("background-color: {}", status_color(replicant.status.clone()))}>
|
||||
{status_display_name(replicant.status.clone())}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>{"DESCRIPTION"}</h2>
|
||||
<div class="description-box">
|
||||
{&replicant.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>{"FIRMWARE"}</h2>
|
||||
<div class="firmware-panel">
|
||||
{if has_firmware {
|
||||
let firmware_filename = replicant.firmware_file.as_ref().unwrap();
|
||||
html! {
|
||||
<>
|
||||
<div class="firmware-info-grid">
|
||||
<div class="info-item">
|
||||
<label>{"FILE NAME"}</label>
|
||||
<span class="firmware-filename">{firmware_filename}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>{"STATUS"}</label>
|
||||
<span class="firmware-status">{"Loaded"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if firmware_output.is_some() {
|
||||
html! {
|
||||
<div class="firmware-output-status">
|
||||
<span class="output-label">{"Output available"}</span>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onclick={toggle_firmware_output.clone()}
|
||||
>
|
||||
{if *show_firmware_output { "Hide output" } else { "Show output" }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="no-firmware">
|
||||
<p>{"No firmware loaded"}</p>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>{"STATISTICS"}</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">{"HEALTH"}</span>
|
||||
<span class="stat-value" style={format!("color: {}", stat_color(replicant.health))}>
|
||||
{replicant.health}
|
||||
</span>
|
||||
</div>
|
||||
{stat_bar(replicant.health, 100)}
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">{"STRENGTH"}</span>
|
||||
<span class="stat-value" style={format!("color: {}", stat_color(replicant.strength))}>
|
||||
{replicant.strength}
|
||||
</span>
|
||||
</div>
|
||||
{stat_bar(replicant.strength, 100)}
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">{"INTELLIGENCE"}</span>
|
||||
<span class="stat-value" style={format!("color: {}", stat_color(replicant.intelligence))}>
|
||||
{replicant.intelligence}
|
||||
</span>
|
||||
</div>
|
||||
{stat_bar(replicant.intelligence, 100)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if *show_firmware_output && firmware_output.is_some() {
|
||||
html! {
|
||||
<div class="firmware-output-section">
|
||||
<div class="output-header">
|
||||
<h3>{"FIRMWARE OUTPUT"}</h3>
|
||||
<div class="output-actions">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onclick={clear_firmware_output.clone()}
|
||||
>
|
||||
{"CLEAR"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="output-content">
|
||||
<pre>{firmware_output.as_ref().unwrap()}</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
if *show_firmware_form {
|
||||
<div class="firmware-form-section">
|
||||
<h3>{"UPLOAD FIRMWARE"}</h3>
|
||||
<div class="form-content">
|
||||
<div class="form-field">
|
||||
<label>{"FIRMWARE FILE"}</label>
|
||||
<input
|
||||
type="file"
|
||||
onchange={on_file_change}
|
||||
accept=".lua,.luac"
|
||||
disabled={*uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
if selected_file.is_some() {
|
||||
<div class="file-info">
|
||||
{"Selected: "}
|
||||
<span class="file-name">
|
||||
{selected_file.as_ref().unwrap().name()}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onclick={toggle_load_firmware_form.clone()}
|
||||
disabled={*uploading}
|
||||
>
|
||||
{"CANCEL"}
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
onclick={upload_firmware}
|
||||
disabled={*uploading || selected_file.is_none()}
|
||||
>
|
||||
{if *uploading { "UPLOADING..." } else { "UPLOAD FIRMWARE" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
209
dollhouse/crates/dollhouse-frontend/src/pages/replicants.rs
Executable file
209
dollhouse/crates/dollhouse-frontend/src/pages/replicants.rs
Executable file
@@ -0,0 +1,209 @@
|
||||
use crate::{
|
||||
components::{replicant_card::CardType, ReplicantCard},
|
||||
services::ApiService,
|
||||
AuthContext,
|
||||
};
|
||||
use dollhouse_api_types::ReplicantFullResponse;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::prelude::*;
|
||||
|
||||
const PAGE_SIZE: usize = 10;
|
||||
|
||||
#[function_component]
|
||||
pub fn ReplicantsPage() -> Html {
|
||||
let replicants = use_state(Vec::new);
|
||||
let loading = use_state(|| true);
|
||||
let error = use_state(|| None::<String>);
|
||||
let auth_context = use_context::<AuthContext>().expect("AuthContext not found");
|
||||
|
||||
let current_page = use_state(|| 1);
|
||||
let has_more = use_state(|| true);
|
||||
|
||||
{
|
||||
let replicants = replicants.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let current_page = current_page.clone();
|
||||
let has_more = has_more.clone();
|
||||
|
||||
use_effect_with((*current_page,), move |(page,)| {
|
||||
let page = *page;
|
||||
spawn_local(async move {
|
||||
match ApiService::get_replicants(Some(page), Some(PAGE_SIZE)).await {
|
||||
Ok(fetched_replicants) => {
|
||||
replicants.set(fetched_replicants.clone());
|
||||
|
||||
if fetched_replicants.len() < PAGE_SIZE {
|
||||
has_more.set(false);
|
||||
} else {
|
||||
has_more.set(true);
|
||||
}
|
||||
|
||||
loading.set(false);
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(format!("Failed to load replicants: {}", e)));
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
|
||||
let load_next_page = {
|
||||
let current_page = current_page.clone();
|
||||
let has_more = has_more.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
if *has_more && !*loading {
|
||||
loading.set(true);
|
||||
current_page.set(*current_page + 1);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let load_prev_page = {
|
||||
let current_page = current_page.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
if *current_page > 1 && !*loading {
|
||||
loading.set(true);
|
||||
current_page.set(*current_page - 1);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let user_corp_id = auth_context.user.as_ref().and_then(|user| user.corp_id);
|
||||
|
||||
html! {
|
||||
<div class="content">
|
||||
<div class="content-header">
|
||||
<h2 class="page-title">{"REPLICANT DATABASE"}</h2>
|
||||
<div class="page-subtitle">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if *loading {
|
||||
html! {
|
||||
<div class="loading-container">
|
||||
<div class="neural-spinner"></div>
|
||||
<p class="loading-text">{"Accessing database..."}</p>
|
||||
<div class="system-message">
|
||||
{"[SYSTEM] Scanning replicant"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else if let Some(err) = &*error {
|
||||
html! {
|
||||
<div class="error-card">
|
||||
<div class="error-header">
|
||||
<span class="error-icon">{"⚠"}</span>
|
||||
<span class="error-title">{"CONNECTION ERROR"}</span>
|
||||
</div>
|
||||
<p class="error-message">{err}</p>
|
||||
<div class="system-message error">
|
||||
{"[ERROR] Neural network connection failed"}
|
||||
</div>
|
||||
<button
|
||||
class="retry-btn"
|
||||
onclick={Callback::from(move |_| {
|
||||
loading.set(true);
|
||||
current_page.set(1);
|
||||
})}
|
||||
>
|
||||
{"Retry Connection"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else if replicants.is_empty() {
|
||||
html! {
|
||||
<div class="empty-state">
|
||||
<h3 class="empty-title">{"NO REPLICANTS FOUND"}</h3>
|
||||
<button
|
||||
class="refresh-btn"
|
||||
onclick={Callback::from(move |_| {
|
||||
loading.set(true);
|
||||
current_page.set(1);
|
||||
})}
|
||||
>
|
||||
{"Refresh Database"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<>
|
||||
<div class="database-header">
|
||||
<h3 class="database-title">
|
||||
<span class="title-accent">{"[REPLICANTS]"}</span>
|
||||
<span class="title-page">
|
||||
{format!(" [PAGE {:02}]", *current_page)}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="replicant-grid">
|
||||
{(*replicants).iter().map(|replicant: &ReplicantFullResponse| {
|
||||
html! {
|
||||
<ReplicantCard
|
||||
key={replicant.id.to_string()}
|
||||
card_type={CardType::Public}
|
||||
replicant={replicant.clone()}
|
||||
user_corp_id={user_corp_id}
|
||||
/>
|
||||
}
|
||||
}).collect::<Html>()}
|
||||
</div>
|
||||
|
||||
<div class="fixed-pagination">
|
||||
<div class="pagination-container">
|
||||
<div class="pagination-info">
|
||||
<span class="pagination-text">
|
||||
{format!("PAGE {:02}", *current_page)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pagination-controls">
|
||||
<button
|
||||
onclick={load_prev_page.clone()}
|
||||
disabled={*current_page == 1 || *loading}
|
||||
class="pagination-btn pagination-prev"
|
||||
>
|
||||
<span class="btn-icon">{"⟨"}</span>
|
||||
<span class="btn-text">{"PREV"}</span>
|
||||
<span class="btn-glow"></span>
|
||||
</button>
|
||||
|
||||
<div class="pagination-indicator">
|
||||
<div class="indicator-dots">
|
||||
<div class="dot active"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
<span class="indicator-text">
|
||||
{format!("{:02}", *current_page)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={load_next_page.clone()}
|
||||
disabled={!*has_more || *loading}
|
||||
class="pagination-btn pagination-next"
|
||||
>
|
||||
<span class="btn-text">{"NEXT"}</span>
|
||||
<span class="btn-icon">{"⟩"}</span>
|
||||
<span class="btn-glow"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
50
dollhouse/crates/dollhouse-frontend/src/routes.rs
Executable file
50
dollhouse/crates/dollhouse-frontend/src/routes.rs
Executable file
@@ -0,0 +1,50 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
use crate::pages::{CorpPage, LoginPage, NotFound, RegisterPage, ReplicantDetail, ReplicantsPage};
|
||||
use crate::Layout;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Routable, PartialEq)]
|
||||
pub enum Route {
|
||||
#[at("/")]
|
||||
Replicants,
|
||||
#[at("/replicants/:id")]
|
||||
ReplicantDetail { id: Uuid },
|
||||
#[not_found]
|
||||
#[at("/404")]
|
||||
NotFound,
|
||||
#[at("/login")]
|
||||
Login,
|
||||
#[at("/register")]
|
||||
Register,
|
||||
#[at("/corp")]
|
||||
Corp,
|
||||
}
|
||||
|
||||
pub fn switch(routes: Route) -> Html {
|
||||
match routes {
|
||||
Route::Login => html! { <LoginPage /> },
|
||||
Route::Register => html! { <RegisterPage /> },
|
||||
Route::Replicants => html! {
|
||||
<Layout>
|
||||
<ReplicantsPage />
|
||||
</Layout>
|
||||
},
|
||||
Route::ReplicantDetail { id } => {
|
||||
html! {
|
||||
<Layout>
|
||||
<ReplicantDetail replicant_id={id} />
|
||||
</Layout>
|
||||
}
|
||||
}
|
||||
Route::NotFound => html! {
|
||||
<NotFound />
|
||||
},
|
||||
Route::Corp => html! {
|
||||
<Layout>
|
||||
<CorpPage />
|
||||
</Layout>
|
||||
},
|
||||
}
|
||||
}
|
||||
305
dollhouse/crates/dollhouse-frontend/src/services/api.rs
Executable file
305
dollhouse/crates/dollhouse-frontend/src/services/api.rs
Executable file
@@ -0,0 +1,305 @@
|
||||
use dollhouse_api_types::*;
|
||||
use gloo_net::http::Request;
|
||||
use gloo_net::Error;
|
||||
use uuid::Uuid;
|
||||
use web_sys::{File, FormData, RequestCredentials};
|
||||
|
||||
const API_BASE_URL: &str = "/api";
|
||||
|
||||
pub struct ApiService;
|
||||
|
||||
impl ApiService {
|
||||
pub async fn login(req: LoginRequest) -> Result<UserResponse, String> {
|
||||
let response = Request::post(&format!("{}/auth/login", API_BASE_URL))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.json(&req)
|
||||
.map_err(|e| format!("Serialization error: {}", e))?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
match response.status() {
|
||||
201 | 200 => response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("JSON parse error: {}", e)),
|
||||
401 => Err("Invalid credentials".to_string()),
|
||||
400 => Err("User already exists".to_string()),
|
||||
404 => Err("Not Found".to_string()),
|
||||
500 => Err("Server error".to_string()),
|
||||
status => Err(format!("HTTP error: {}", status)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register(req: CreateUserRequest) -> Result<(), String> {
|
||||
let response = Request::post(&format!("{}/auth/register", API_BASE_URL))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.json(&req)
|
||||
.map_err(|e| format!("Serialization error: {}", e))?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
match response.status() {
|
||||
201 => Ok(()),
|
||||
400 => Err("Invalid request format".to_string()),
|
||||
404 => Err("Not Found".to_string()),
|
||||
500 => Err("Server error".to_string()),
|
||||
status => Err(format!("HTTP error: {}", status)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_replicants(
|
||||
page: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<ReplicantFullResponse>, Error> {
|
||||
let req = Request::get(&format!(
|
||||
"{}/replicants?page={}&limit={}",
|
||||
API_BASE_URL,
|
||||
page.unwrap_or(0),
|
||||
limit.unwrap_or(10)
|
||||
));
|
||||
req.credentials(RequestCredentials::Include)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_corp_replicants(
|
||||
corp_id: Uuid,
|
||||
page: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<ReplicantFullResponse>, String> {
|
||||
let response = Request::get(&format!(
|
||||
"{}/corp/{}/replicants?page={}&limit={}",
|
||||
API_BASE_URL,
|
||||
corp_id,
|
||||
page.unwrap_or(0),
|
||||
limit.unwrap_or(10)
|
||||
))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
match response.status() {
|
||||
201 | 200 => response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("JSON parse error: {}", e)),
|
||||
401 => Err("Unauthorized".to_string()),
|
||||
400 => Err("Invalid request format".to_string()),
|
||||
404 => Err("Not Found".to_string()),
|
||||
500 => Err("Server error".to_string()),
|
||||
status => Err(format!("HTTP error: {}", status)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_replicant(id: Uuid) -> Result<ReplicantFullResponse, String> {
|
||||
let response = Request::get(&format!("{}/replicant/{}", API_BASE_URL, id))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
match response.status() {
|
||||
200 => response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("JSON parse error: {}", e)),
|
||||
400 => Err("Invalid request format".to_string()),
|
||||
404 => Err("Not Found".to_string()),
|
||||
500 => Err("Server error".to_string()),
|
||||
status => Err(format!("HTTP error: {}", status)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_replicant(
|
||||
corp_id: Uuid,
|
||||
request: CreateReplicantRequest,
|
||||
) -> Result<ReplicantResponse, Error> {
|
||||
let req = Request::post(&format!("{}/corp/{}/replicant", API_BASE_URL, corp_id));
|
||||
req.credentials(RequestCredentials::Include)
|
||||
.json(&request)?
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_current_user() -> Result<UserResponse, String> {
|
||||
let response = Request::get(&format!("{}/auth/me", API_BASE_URL))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {:?}", e))?;
|
||||
|
||||
if response.ok() {
|
||||
let user_data: UserResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("JSON parse error: {:?}", e))?;
|
||||
Ok(user_data)
|
||||
} else {
|
||||
Err(format!("HTTP error: {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout_user() -> Result<(), String> {
|
||||
let response = Request::post(&format!("{}/auth/logout", API_BASE_URL))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {:?}", e))?;
|
||||
|
||||
if response.ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("HTTP error: {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_corp(user_id: Uuid) -> Result<Option<CorpResponse>, String> {
|
||||
let response = Request::get(&format!("{}/user/{}/corp", API_BASE_URL, user_id))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {:?}", e))?;
|
||||
|
||||
match response.status() {
|
||||
200 => {
|
||||
let corp_data: CorpResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("JSON parse error: {:?}", e))?;
|
||||
Ok(Some(corp_data))
|
||||
}
|
||||
404 => Ok(None),
|
||||
_ => Err(format!("HTTP error: {}", response.status())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_corp(
|
||||
user_id: Uuid,
|
||||
name: String,
|
||||
description: String,
|
||||
) -> Result<CorpResponse, String> {
|
||||
let req = Request::post(&format!("{}/user/{}/corp", API_BASE_URL, user_id))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.json(&CreateCorpRequest { name, description })
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match req {
|
||||
Ok(response) => {
|
||||
if response.ok() {
|
||||
let corp_data: CorpResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("JSON parse error: {:?}", e))?;
|
||||
Ok(corp_data)
|
||||
} else {
|
||||
Err(format!("HTTP error: {}", response.status()))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Network error: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn join_corp(user_id: Uuid, invite_code: String) -> Result<(), String> {
|
||||
let response = Request::post(&format!("{}/user/{}/join-corp", API_BASE_URL, user_id))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.json(&JoinCorpRequest { invite_code })
|
||||
.unwrap()
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {:?}", e))?;
|
||||
|
||||
match response.status() {
|
||||
200 => Ok(()),
|
||||
404 => Err(format!("Corp with this invite code does not exists")),
|
||||
_ => Err(format!("HTTP error: {}", response.status())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn change_replicant_privacy(
|
||||
replicant_id: Uuid,
|
||||
is_private: bool,
|
||||
) -> Result<(), String> {
|
||||
let response = Request::post(&format!(
|
||||
"{}/replicant/{}/change-privacy",
|
||||
API_BASE_URL, replicant_id
|
||||
))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.json(&ChangePrivacyRequest { is_private })
|
||||
.unwrap()
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {:?}", e))?;
|
||||
|
||||
match response.status() {
|
||||
200 => Ok(()),
|
||||
_ => Err(format!("HTTP error: {}", response.status())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn change_replicant_owner(replicant_id: Uuid, new_corp: Uuid) -> Result<(), String> {
|
||||
let response = Request::post(&format!(
|
||||
"{}/replicant/{}/change-owner",
|
||||
API_BASE_URL, replicant_id
|
||||
))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.json(&ChangeReplicantOwnerRequest { new_corp })
|
||||
.unwrap()
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {:?}", e))?;
|
||||
|
||||
match response.status() {
|
||||
200 => Ok(()),
|
||||
_ => Err(format!("HTTP error: {}", response.status())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_firmware(replicant_id: Uuid, file: File) -> Result<(), String> {
|
||||
let form_data = FormData::new().map_err(|_| "Failed to create form data".to_string())?;
|
||||
|
||||
form_data
|
||||
.append_with_blob("file", &file)
|
||||
.map_err(|_| "Failed to append file".to_string())?;
|
||||
|
||||
let response = Request::post(&format!(
|
||||
"{}/replicant/{}/firmware",
|
||||
API_BASE_URL, replicant_id
|
||||
))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.body(form_data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {:?}", e))?;
|
||||
|
||||
match response.status() {
|
||||
200 => Ok(()),
|
||||
_ => Err(format!("HTTP error: {}", response.status())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_firmware(replicant_id: Uuid) -> Result<FirmwareOutputResponse, String> {
|
||||
let response = Request::get(&format!("{}/replicant/{}/run", API_BASE_URL, replicant_id))
|
||||
.credentials(RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {:?}", e))?;
|
||||
|
||||
match response.status() {
|
||||
200 => Ok(response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {:?}", e))?),
|
||||
_ => Err(format!("HTTP error: {}", response.status())),
|
||||
}
|
||||
}
|
||||
}
|
||||
62
dollhouse/crates/dollhouse-frontend/src/services/auth.rs
Executable file
62
dollhouse/crates/dollhouse-frontend/src/services/auth.rs
Executable file
@@ -0,0 +1,62 @@
|
||||
use crate::services::api::ApiService;
|
||||
use dollhouse_api_types::UserResponse;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct AuthContext {
|
||||
pub user: Option<UserResponse>,
|
||||
pub set_user: Callback<Option<UserResponse>>,
|
||||
pub is_loading: bool,
|
||||
}
|
||||
|
||||
impl AuthContext {
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.user.is_some()
|
||||
}
|
||||
|
||||
pub async fn logout(&self) -> Result<(), String> {
|
||||
ApiService::logout_user().await?;
|
||||
self.set_user.emit(None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[hook]
|
||||
pub fn use_auth() -> AuthContext {
|
||||
let user: UseStateHandle<Option<UserResponse>> = use_state(|| None);
|
||||
let is_loading = use_state(|| true);
|
||||
|
||||
let set_user = {
|
||||
let user = user.clone();
|
||||
Callback::from(move |new_user: Option<UserResponse>| {
|
||||
user.set(new_user);
|
||||
})
|
||||
};
|
||||
|
||||
{
|
||||
let set_user = set_user.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
use_effect_with((), move |_| {
|
||||
spawn_local(async move {
|
||||
match ApiService::get_current_user().await {
|
||||
Ok(user_data) => {
|
||||
set_user.emit(Some(user_data));
|
||||
}
|
||||
Err(_) => {
|
||||
set_user.emit(None);
|
||||
}
|
||||
}
|
||||
is_loading.set(false);
|
||||
});
|
||||
|
||||
|| {}
|
||||
});
|
||||
}
|
||||
|
||||
AuthContext {
|
||||
user: (*user).clone(),
|
||||
set_user,
|
||||
is_loading: (*is_loading).clone(),
|
||||
}
|
||||
}
|
||||
4
dollhouse/crates/dollhouse-frontend/src/services/mod.rs
Executable file
4
dollhouse/crates/dollhouse-frontend/src/services/mod.rs
Executable file
@@ -0,0 +1,4 @@
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
|
||||
pub use api::ApiService;
|
||||
11
dollhouse/crates/dollhouse-frontend/static/female.svg
Executable file
11
dollhouse/crates/dollhouse-frontend/static/female.svg
Executable file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="-3.5 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="icomoon-ignore">
|
||||
</g>
|
||||
<path d="M12.584 3.412c-6.953 0-12.588 5.636-12.588 12.588s5.635 12.588 12.588 12.588c6.952 0 12.588-5.636 12.588-12.588s-5.636-12.588-12.588-12.588zM5.679 25.24c0.77-0.283 1.615-0.569 2.368-0.822 2.568-0.863 2.964-0.996 2.964-1.862v-2.026l-0.877-0.146c-0.063-0.011-1.54-0.255-2.532-0.255-0.512 0-0.803-0.013-1.084-0.197 0.722-1.581 1.469-4.054 1.752-6.010l0.054 0.019 0.078-1.386c0.123-2.221 1.96-3.961 4.183-3.961 2.222 0 4.059 1.74 4.183 3.961l0.091 1.381 0.040-0.014c0.283 1.956 1.030 4.429 1.752 6.010-0.28 0.184-0.572 0.197-1.083 0.197-1.007 0-2.434 0.318-2.593 0.354l-0.817 0.185v1.887c0 0.857 0.41 1.002 3.077 1.944 0.692 0.245 1.465 0.519 2.182 0.79-1.915 1.411-4.278 2.248-6.833 2.248-2.587 0-4.978-0.855-6.905-2.299zM20.349 24.528c-2.14-0.847-5.143-1.777-5.143-1.971 0-0.24 0-1.050 0-1.050s1.442-0.328 2.36-0.328 1.574-0.057 2.36-1.041c-0.984-1.737-2.098-5.647-2.098-7.646l-0.015 0.005c-0.153-2.76-2.432-4.952-5.23-4.952s-5.077 2.192-5.231 4.952l-0.014-0.005c0 2-1.115 5.909-2.098 7.646 0.787 0.983 1.442 1.041 2.36 1.041s2.36 0.24 2.36 0.24 0 0.897 0 1.137c0 0.197-3.071 1.081-5.206 1.911-2.28-2.11-3.711-5.124-3.711-8.468 0-6.363 5.176-11.539 11.539-11.539s11.539 5.177 11.539 11.539c0 3.375-1.456 6.416-3.774 8.528z" fill="#000000">
|
||||
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
11
dollhouse/crates/dollhouse-frontend/static/male.svg
Executable file
11
dollhouse/crates/dollhouse-frontend/static/male.svg
Executable file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="icomoon-ignore">
|
||||
</g>
|
||||
<path d="M16 3.205c-7.067 0-12.795 5.728-12.795 12.795s5.728 12.795 12.795 12.795 12.795-5.728 12.795-12.795c0-7.067-5.728-12.795-12.795-12.795zM16 4.271c6.467 0 11.729 5.261 11.729 11.729 0 2.845-1.019 5.457-2.711 7.49-1.169-0.488-3.93-1.446-5.638-1.951-0.146-0.046-0.169-0.053-0.169-0.66 0-0.501 0.206-1.005 0.407-1.432 0.218-0.464 0.476-1.244 0.569-1.944 0.259-0.301 0.612-0.895 0.839-2.026 0.199-0.997 0.106-1.36-0.026-1.7-0.014-0.036-0.028-0.071-0.039-0.107-0.050-0.234 0.019-1.448 0.189-2.391 0.118-0.647-0.030-2.022-0.921-3.159-0.562-0.719-1.638-1.601-3.603-1.724l-1.078 0.001c-1.932 0.122-3.008 1.004-3.57 1.723-0.89 1.137-1.038 2.513-0.92 3.159 0.172 0.943 0.239 2.157 0.191 2.387-0.010 0.040-0.025 0.075-0.040 0.111-0.131 0.341-0.225 0.703-0.025 1.7 0.226 1.131 0.579 1.725 0.839 2.026 0.092 0.7 0.35 1.48 0.569 1.944 0.159 0.339 0.234 0.801 0.234 1.454 0 0.607-0.023 0.614-0.159 0.657-1.767 0.522-4.579 1.538-5.628 1.997-1.725-2.042-2.768-4.679-2.768-7.555 0-6.467 5.261-11.729 11.729-11.729zM7.811 24.386c1.201-0.49 3.594-1.344 5.167-1.808 0.914-0.288 0.914-1.058 0.914-1.677 0-0.513-0.035-1.269-0.335-1.908-0.206-0.438-0.442-1.189-0.494-1.776-0.011-0.137-0.076-0.265-0.18-0.355-0.151-0.132-0.458-0.616-0.654-1.593-0.155-0.773-0.089-0.942-0.026-1.106 0.027-0.070 0.053-0.139 0.074-0.216 0.128-0.468-0.015-2.005-0.17-2.858-0.068-0.371 0.018-1.424 0.711-2.311 0.622-0.795 1.563-1.238 2.764-1.315l1.011-0.001c1.233 0.078 2.174 0.521 2.797 1.316 0.694 0.887 0.778 1.94 0.71 2.312-0.154 0.852-0.298 2.39-0.17 2.857 0.022 0.078 0.047 0.147 0.074 0.217 0.064 0.163 0.129 0.333-0.025 1.106-0.196 0.977-0.504 1.461-0.655 1.593-0.103 0.091-0.168 0.218-0.18 0.355-0.051 0.588-0.286 1.338-0.492 1.776-0.236 0.502-0.508 1.171-0.508 1.886 0 0.619 0 1.389 0.924 1.68 1.505 0.445 3.91 1.271 5.18 1.77-2.121 2.1-5.035 3.4-8.248 3.4-3.183 0-6.073-1.277-8.188-3.342z" fill="#000000">
|
||||
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
11
dollhouse/crates/dollhouse-frontend/static/non-binary.svg
Executable file
11
dollhouse/crates/dollhouse-frontend/static/non-binary.svg
Executable file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="icomoon-ignore">
|
||||
</g>
|
||||
<path d="M16 3.205c-7.067 0-12.795 5.728-12.795 12.795s5.728 12.795 12.795 12.795 12.795-5.728 12.795-12.795c0-7.067-5.728-12.795-12.795-12.795zM16 4.271c6.467 0 11.729 5.261 11.729 11.729 0 2.845-1.019 5.457-2.711 7.49-1.169-0.488-3.93-1.446-5.638-1.951-0.146-0.046-0.169-0.053-0.169-0.66 0-0.501 0.206-1.005 0.407-1.432 0.218-0.464 0.476-1.244 0.569-1.944 0.259-0.301 0.612-0.895 0.839-2.026 0.199-0.997 0.106-1.36-0.026-1.7-0.014-0.036-0.028-0.071-0.039-0.107-0.050-0.234 0.019-1.448 0.189-2.391 0.118-0.647-0.030-2.022-0.921-3.159-0.562-0.719-1.638-1.601-3.603-1.724l-1.078 0.001c-1.932 0.122-3.008 1.004-3.57 1.723-0.89 1.137-1.038 2.513-0.92 3.159 0.172 0.943 0.239 2.157 0.191 2.387-0.010 0.040-0.025 0.075-0.040 0.111-0.131 0.341-0.225 0.703-0.025 1.7 0.226 1.131 0.579 1.725 0.839 2.026 0.092 0.7 0.35 1.48 0.569 1.944 0.159 0.339 0.234 0.801 0.234 1.454 0 0.607-0.023 0.614-0.159 0.657-1.767 0.522-4.579 1.538-5.628 1.997-1.725-2.042-2.768-4.679-2.768-7.555 0-6.467 5.261-11.729 11.729-11.729zM7.811 24.386c1.201-0.49 3.594-1.344 5.167-1.808 0.914-0.288 0.914-1.058 0.914-1.677 0-0.513-0.035-1.269-0.335-1.908-0.206-0.438-0.442-1.189-0.494-1.776-0.011-0.137-0.076-0.265-0.18-0.355-0.151-0.132-0.458-0.616-0.654-1.593-0.155-0.773-0.089-0.942-0.026-1.106 0.027-0.070 0.053-0.139 0.074-0.216 0.128-0.468-0.015-2.005-0.17-2.858-0.068-0.371 0.018-1.424 0.711-2.311 0.622-0.795 1.563-1.238 2.764-1.315l1.011-0.001c1.233 0.078 2.174 0.521 2.797 1.316 0.694 0.887 0.778 1.94 0.71 2.312-0.154 0.852-0.298 2.39-0.17 2.857 0.022 0.078 0.047 0.147 0.074 0.217 0.064 0.163 0.129 0.333-0.025 1.106-0.196 0.977-0.504 1.461-0.655 1.593-0.103 0.091-0.168 0.218-0.18 0.355-0.051 0.588-0.286 1.338-0.492 1.776-0.236 0.502-0.508 1.171-0.508 1.886 0 0.619 0 1.389 0.924 1.68 1.505 0.445 3.91 1.271 5.18 1.77-2.121 2.1-5.035 3.4-8.248 3.4-3.183 0-6.073-1.277-8.188-3.342z" fill="#000000">
|
||||
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
2232
dollhouse/crates/dollhouse-frontend/styles.css
Executable file
2232
dollhouse/crates/dollhouse-frontend/styles.css
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user