This commit is contained in:
root
2025-12-14 10:39:18 +03:00
commit 639f4e2b4e
179 changed files with 21065 additions and 0 deletions

View 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 }

View 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"

View 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>

View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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>
}
}

View File

@@ -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>
}
}

View 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>
}
}

View 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;

View 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>
}
}

View 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>
}
}

View 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;

View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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();
}

View 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>
}
}

View 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>
}
}

View 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;

View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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>
},
}
}

View 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())),
}
}
}

View 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(),
}
}

View File

@@ -0,0 +1,4 @@
pub mod api;
pub mod auth;
pub use api::ApiService;

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff