init
This commit is contained in:
3
dollhouse/.env
Executable file
3
dollhouse/.env
Executable file
@@ -0,0 +1,3 @@
|
||||
POSTGRES_USER=dollhouse_user
|
||||
POSTGRES_PASSWORD=hahahadollhouse
|
||||
POSTGRES_DB=dollhouse_db
|
||||
3770
dollhouse/Cargo.lock
generated
Executable file
3770
dollhouse/Cargo.lock
generated
Executable file
File diff suppressed because it is too large
Load Diff
12
dollhouse/Cargo.toml
Executable file
12
dollhouse/Cargo.toml
Executable file
@@ -0,0 +1,12 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/dollhouse-backend",
|
||||
"crates/dollhouse-db",
|
||||
"crates/dollhouse-frontend",
|
||||
"crates/dollhouse-api-types",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
thiserror = "2.0.17"
|
||||
uuid = { version = "1.3.0", features = ["v4", "v1", "serde", "js", "rng"] }
|
||||
80
dollhouse/compose.yaml
Executable file
80
dollhouse/compose.yaml
Executable file
@@ -0,0 +1,80 @@
|
||||
name: dollhouse
|
||||
services:
|
||||
base:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.base
|
||||
image: dollhouse-base:latest
|
||||
|
||||
backend:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dollhouse-backend/Dockerfile
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
base:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
volumes:
|
||||
- dollhouse-firmwares:/app/firmware
|
||||
networks:
|
||||
- dollhouse-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dollhouse-frontend/Dockerfile
|
||||
depends_on:
|
||||
- base
|
||||
- backend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- dollhouse-network
|
||||
|
||||
db:
|
||||
image: postgres:17.2
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
POSTGRES_MULTIPLE_USERS: "yes"
|
||||
command: |
|
||||
postgres
|
||||
-c shared_preload_libraries=pg_stat_statements
|
||||
-c pg_stat_statements.track=all
|
||||
volumes:
|
||||
- dollhouse-postgres-data:/var/lib/postgresql/data/pgdata
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
tty: true
|
||||
networks:
|
||||
- dollhouse-network
|
||||
|
||||
cleaner:
|
||||
build:
|
||||
context: docker/dollhouse-cleaner
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- dollhouse-firmwares:/firmware
|
||||
environment:
|
||||
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
networks:
|
||||
- dollhouse-network
|
||||
|
||||
networks:
|
||||
dollhouse-network:
|
||||
|
||||
volumes:
|
||||
dollhouse-postgres-data:
|
||||
dollhouse-firmwares:
|
||||
9
dollhouse/crates/dollhouse-api-types/Cargo.toml
Executable file
9
dollhouse/crates/dollhouse-api-types/Cargo.toml
Executable file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "dollhouse-api-types"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
validator = { version = "0.20.0", features = ["derive"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
uuid = { workspace = true }
|
||||
163
dollhouse/crates/dollhouse-api-types/src/lib.rs
Executable file
163
dollhouse/crates/dollhouse-api-types/src/lib.rs
Executable file
@@ -0,0 +1,163 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::convert::TryFrom;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReplicantGender {
|
||||
Male,
|
||||
Female,
|
||||
NonBinary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReplicantStatus {
|
||||
Active,
|
||||
Decommissioned,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ReplicantResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub status: ReplicantStatus,
|
||||
pub gender: ReplicantGender,
|
||||
pub is_private: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ReplicantFullResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub status: ReplicantStatus,
|
||||
pub gender: ReplicantGender,
|
||||
pub firmware_file: Option<String>,
|
||||
pub is_private: bool,
|
||||
pub corp_id: Uuid,
|
||||
pub health: i32,
|
||||
pub strength: i32,
|
||||
pub intelligence: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UserRole {
|
||||
CorpAdmin,
|
||||
User,
|
||||
}
|
||||
|
||||
impl TryFrom<String> for UserRole {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
match value.as_str() {
|
||||
"corp_admin" => Ok(UserRole::CorpAdmin),
|
||||
"user" => Ok(UserRole::User),
|
||||
_ => Err("Invalid user role"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<String> for UserRole {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_into(self) -> Result<String, Self::Error> {
|
||||
match self {
|
||||
UserRole::CorpAdmin => Ok("corp_admin".to_string()),
|
||||
UserRole::User => Ok("user".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
|
||||
pub struct CreateUserRequest {
|
||||
#[validate(length(min = 5, max = 50))]
|
||||
pub username: String,
|
||||
#[validate(length(min = 8))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
|
||||
pub struct CreateCorpRequest {
|
||||
#[validate(length(min = 5, max = 50))]
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
|
||||
pub struct CreateReplicantRequest {
|
||||
#[validate(length(min = 5, max = 50))]
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub gender: ReplicantGender,
|
||||
pub corp_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateReplicantResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub gender: ReplicantGender,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
|
||||
pub struct LoginRequest {
|
||||
#[validate(length(min = 5, max = 50))]
|
||||
pub username: String,
|
||||
#[validate(length(min = 12, max = 50))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub role: UserRole,
|
||||
pub username: String,
|
||||
pub corp_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StaffResponse {
|
||||
pub id: Uuid,
|
||||
pub role: UserRole,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CorpResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub staff: Vec<StaffResponse>,
|
||||
pub invite_code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct InviteCodeResponse {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct JoinCorpRequest {
|
||||
pub invite_code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ChangePrivacyRequest {
|
||||
pub is_private: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ChangeReplicantOwnerRequest {
|
||||
pub new_corp: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FirmwareOutputResponse {
|
||||
pub output: String,
|
||||
}
|
||||
26
dollhouse/crates/dollhouse-backend/Cargo.toml
Executable file
26
dollhouse/crates/dollhouse-backend/Cargo.toml
Executable file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "dollhouse-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
actix-cors = "0.7.1"
|
||||
dollhouse-db = { path = "../dollhouse-db" }
|
||||
dollhouse-api-types = { path = "../dollhouse-api-types" }
|
||||
actix-session = { version = "0.11.0", features = ["cookie-session"] }
|
||||
validator = { version = "0.20.0", features = ["derive"] }
|
||||
actix-web = "4.11.0"
|
||||
argon2 = "0.5.3"
|
||||
env_logger = "0.11.8"
|
||||
log = "0.4.28"
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.145"
|
||||
thiserror = "2.0.17"
|
||||
tokio = "1.48.0"
|
||||
mlua = { version = "0.11.4", features = ["lua53", "async", "send"] }
|
||||
rand = "0.9.2"
|
||||
actix-multipart = "0.7.2"
|
||||
uuid = { workspace = true }
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.42"
|
||||
sha2 = "0.10.9"
|
||||
159
dollhouse/crates/dollhouse-backend/src/conversions.rs
Executable file
159
dollhouse/crates/dollhouse-backend/src/conversions.rs
Executable file
@@ -0,0 +1,159 @@
|
||||
use dollhouse_api_types::ReplicantGender as ApiReplicantGender;
|
||||
use dollhouse_api_types::ReplicantStatus as ApiReplicantStatus;
|
||||
use dollhouse_api_types::UserRole as ApiUserRole;
|
||||
use dollhouse_db::ReplicantGender as DbReplicantGender;
|
||||
use dollhouse_db::ReplicantStatus as DbReplicantStatus;
|
||||
use dollhouse_db::UserRole as DbUserRole;
|
||||
|
||||
pub trait UserRoleConvert {
|
||||
fn to_db_role(&self) -> Result<DbUserRole, &'static str>;
|
||||
fn to_api_role(&self) -> Result<ApiUserRole, &'static str>;
|
||||
}
|
||||
|
||||
impl UserRoleConvert for DbUserRole {
|
||||
fn to_db_role(&self) -> Result<DbUserRole, &'static str> {
|
||||
Ok(self.clone())
|
||||
}
|
||||
|
||||
fn to_api_role(&self) -> Result<ApiUserRole, &'static str> {
|
||||
match self {
|
||||
DbUserRole::CorpAdmin => Ok(ApiUserRole::CorpAdmin),
|
||||
DbUserRole::User => Ok(ApiUserRole::User),
|
||||
_ => Err("Unknown user role"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserRoleConvert for ApiUserRole {
|
||||
fn to_db_role(&self) -> Result<DbUserRole, &'static str> {
|
||||
match self {
|
||||
ApiUserRole::CorpAdmin => Ok(DbUserRole::CorpAdmin),
|
||||
ApiUserRole::User => Ok(DbUserRole::User),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_api_role(&self) -> Result<ApiUserRole, &'static str> {
|
||||
Ok(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ReplicantStatusConvert {
|
||||
fn to_db_status(&self) -> Result<DbReplicantStatus, &'static str>;
|
||||
fn to_api_status(&self) -> Result<ApiReplicantStatus, &'static str>;
|
||||
}
|
||||
|
||||
impl ReplicantStatusConvert for DbReplicantStatus {
|
||||
fn to_db_status(&self) -> Result<DbReplicantStatus, &'static str> {
|
||||
Ok(self.clone())
|
||||
}
|
||||
|
||||
fn to_api_status(&self) -> Result<ApiReplicantStatus, &'static str> {
|
||||
match self {
|
||||
DbReplicantStatus::Active => Ok(ApiReplicantStatus::Active),
|
||||
DbReplicantStatus::Decommissioned => Ok(ApiReplicantStatus::Decommissioned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReplicantStatusConvert for ApiReplicantStatus {
|
||||
fn to_db_status(&self) -> Result<DbReplicantStatus, &'static str> {
|
||||
match self {
|
||||
ApiReplicantStatus::Active => Ok(DbReplicantStatus::Active),
|
||||
ApiReplicantStatus::Decommissioned => Ok(DbReplicantStatus::Decommissioned),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_api_status(&self) -> Result<ApiReplicantStatus, &'static str> {
|
||||
Ok(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ReplicantGenderConvert {
|
||||
fn to_db_gender(&self) -> Result<DbReplicantGender, &'static str>;
|
||||
fn to_api_gender(&self) -> Result<ApiReplicantGender, &'static str>;
|
||||
}
|
||||
|
||||
impl ReplicantGenderConvert for DbReplicantGender {
|
||||
fn to_db_gender(&self) -> Result<DbReplicantGender, &'static str> {
|
||||
Ok(self.clone())
|
||||
}
|
||||
|
||||
fn to_api_gender(&self) -> Result<ApiReplicantGender, &'static str> {
|
||||
match self {
|
||||
DbReplicantGender::Male => Ok(ApiReplicantGender::Male),
|
||||
DbReplicantGender::Female => Ok(ApiReplicantGender::Female),
|
||||
DbReplicantGender::NonBinary => Ok(ApiReplicantGender::NonBinary),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReplicantGenderConvert for ApiReplicantGender {
|
||||
fn to_db_gender(&self) -> Result<DbReplicantGender, &'static str> {
|
||||
match self {
|
||||
ApiReplicantGender::Male => Ok(DbReplicantGender::Male),
|
||||
ApiReplicantGender::Female => Ok(DbReplicantGender::Female),
|
||||
ApiReplicantGender::NonBinary => Ok(DbReplicantGender::NonBinary),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_api_gender(&self) -> Result<ApiReplicantGender, &'static str> {
|
||||
Ok(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ReplicantStatusConvert for Option<T>
|
||||
where
|
||||
T: ReplicantStatusConvert,
|
||||
{
|
||||
fn to_db_status(&self) -> Result<DbReplicantStatus, &'static str> {
|
||||
match self {
|
||||
Some(status) => status.to_db_status(),
|
||||
None => Err("Status is None"),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_api_status(&self) -> Result<ApiReplicantStatus, &'static str> {
|
||||
match self {
|
||||
Some(status) => status.to_api_status(),
|
||||
None => Err("Status is None"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ReplicantGenderConvert for Option<T>
|
||||
where
|
||||
T: ReplicantGenderConvert,
|
||||
{
|
||||
fn to_db_gender(&self) -> Result<DbReplicantGender, &'static str> {
|
||||
match self {
|
||||
Some(gender) => gender.to_db_gender(),
|
||||
None => Err("Gender is None"),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_api_gender(&self) -> Result<ApiReplicantGender, &'static str> {
|
||||
match self {
|
||||
Some(gender) => gender.to_api_gender(),
|
||||
None => Err("Gender is None"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> UserRoleConvert for Option<T>
|
||||
where
|
||||
T: UserRoleConvert,
|
||||
{
|
||||
fn to_db_role(&self) -> Result<DbUserRole, &'static str> {
|
||||
match self {
|
||||
Some(role) => role.to_db_role(),
|
||||
None => Err("Role is None"),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_api_role(&self) -> Result<ApiUserRole, &'static str> {
|
||||
match self {
|
||||
Some(role) => role.to_api_role(),
|
||||
None => Err("Role is None"),
|
||||
}
|
||||
}
|
||||
}
|
||||
290
dollhouse/crates/dollhouse-backend/src/handlers.rs
Executable file
290
dollhouse/crates/dollhouse-backend/src/handlers.rs
Executable file
@@ -0,0 +1,290 @@
|
||||
use crate::services::*;
|
||||
use crate::utils::AppError;
|
||||
use actix_multipart::form::MultipartForm;
|
||||
use actix_multipart::form::tempfile::TempFile;
|
||||
use actix_session::Session;
|
||||
use actix_web::HttpResponse;
|
||||
use actix_web::web;
|
||||
use dollhouse_api_types::*;
|
||||
use dollhouse_db::Pool;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PaginationParams {
|
||||
page: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, MultipartForm)]
|
||||
pub struct UploadFirmwareForm {
|
||||
#[multipart(limit = "2MB")]
|
||||
file: TempFile,
|
||||
}
|
||||
|
||||
pub async fn create_user(
|
||||
pool: web::Data<Pool>,
|
||||
data: web::Json<CreateUserRequest>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let req = data.into_inner();
|
||||
let pool = pool.into_inner();
|
||||
let pool_ref = pool.as_ref();
|
||||
|
||||
match AuthService::register(pool_ref, req).await {
|
||||
Ok(()) => {
|
||||
log::info!("User created successfully");
|
||||
Ok(HttpResponse::Created().finish())
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Registration error: {:?}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login_user(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
data: web::Json<CreateUserRequest>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let req = data.into_inner();
|
||||
let pool = pool.into_inner();
|
||||
match AuthService::login(&pool, req).await {
|
||||
Ok(user) => {
|
||||
session
|
||||
.insert("user_id", &user.id)
|
||||
.map_err(|_| AppError::InternalServerError)?;
|
||||
session
|
||||
.insert("role", &user.role)
|
||||
.map_err(|_| AppError::InternalServerError)?;
|
||||
session
|
||||
.insert("username", &user.username)
|
||||
.map_err(|_| AppError::InternalServerError)?;
|
||||
session
|
||||
.insert("corp_id", &user.corp_id)
|
||||
.map_err(|_| AppError::InternalServerError)?;
|
||||
Ok(HttpResponse::Ok().json(user))
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Login error: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout_user(mut session: Session) -> Result<HttpResponse, AppError> {
|
||||
AuthService::logout(&mut session);
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
pub async fn get_current_user(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let user_id = AuthService::check_session(session.clone())?;
|
||||
let user = UserService::get_user(&pool.into_inner(), user_id).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(user))
|
||||
}
|
||||
|
||||
pub async fn create_corp(
|
||||
pool: web::Data<Pool>,
|
||||
data: web::Json<CreateCorpRequest>,
|
||||
session: Session,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let req = data.into_inner();
|
||||
let user_id = AuthService::check_session(session)?;
|
||||
match CorpService::create(&pool.into_inner(), user_id, req).await {
|
||||
Ok(corp) => Ok(HttpResponse::Ok().json(corp)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_corp(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let session_user_id = AuthService::check_session(session)?;
|
||||
if session_user_id != path.into_inner() {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
match CorpService::get_user_corp(&pool.into_inner(), session_user_id).await {
|
||||
Ok(corp) => Ok(HttpResponse::Ok().json(corp)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn join_corp(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
path: web::Path<Uuid>,
|
||||
data: web::Json<JoinCorpRequest>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let session_user_id = AuthService::check_session(session)?;
|
||||
let requested_user_id = path.into_inner();
|
||||
if session_user_id != requested_user_id {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
match CorpService::join(&pool.into_inner(), session_user_id, &data.into_inner()).await {
|
||||
Ok(()) => Ok(HttpResponse::Ok().finish()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_replicant(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
data: web::Json<CreateReplicantRequest>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let user_id = AuthService::check_session(session)?;
|
||||
match ReplicantService::create(
|
||||
&pool.into_inner(),
|
||||
user_id,
|
||||
path.into_inner(),
|
||||
data.into_inner(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(replicant) => Ok(HttpResponse::Ok().json(replicant)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_replicant(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let user_id = AuthService::check_session(session)?;
|
||||
match ReplicantService::get_replicant(&pool.into_inner(), user_id, path.into_inner()).await {
|
||||
Ok(replicant) => Ok(HttpResponse::Ok().json(replicant)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_replicants(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
query: web::Query<PaginationParams>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query.limit.unwrap_or(10);
|
||||
let offset = (page - 1) * page_size;
|
||||
let _ = AuthService::check_session(session)?;
|
||||
match ReplicantService::get_replicants(&pool.into_inner(), page_size, offset).await {
|
||||
Ok(replicants) => Ok(HttpResponse::Ok().json(replicants)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upload_replicant_firmware(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
path: web::Path<Uuid>,
|
||||
MultipartForm(form): MultipartForm<UploadFirmwareForm>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let user_id = AuthService::check_session(session)?;
|
||||
let pool = pool.into_inner();
|
||||
match ReplicantService::load_firmware(pool.as_ref(), user_id, path.into_inner(), form.file)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(HttpResponse::Ok().finish()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_corp_replicants(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
path: web::Path<Uuid>,
|
||||
query: web::Query<PaginationParams>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query.limit.unwrap_or(10);
|
||||
let offset = (page - 1) * page_size;
|
||||
let user_id = AuthService::check_session(session)?;
|
||||
match ReplicantService::get_corp_replicants(
|
||||
&pool.into_inner(),
|
||||
user_id,
|
||||
path.into_inner(),
|
||||
page_size,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(replicants) => Ok(HttpResponse::Ok().json(replicants)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn change_replicant_privacy(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
path: web::Path<Uuid>,
|
||||
data: web::Json<ChangePrivacyRequest>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let user_id = AuthService::check_session(session)?;
|
||||
match ReplicantService::change_privacy(
|
||||
&pool.into_inner(),
|
||||
user_id,
|
||||
path.into_inner(),
|
||||
data.into_inner().is_private,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(HttpResponse::Ok().finish()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn change_replicant_owner(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
path: web::Path<Uuid>,
|
||||
data: web::Json<ChangeReplicantOwnerRequest>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let user_id = AuthService::check_session(session)?;
|
||||
let replicant_id = path.into_inner();
|
||||
match ReplicantService::change_owner(
|
||||
&pool.into_inner(),
|
||||
user_id,
|
||||
replicant_id,
|
||||
data.into_inner().new_corp,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(HttpResponse::Ok().finish()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_firmware(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let user_id = AuthService::check_session(session)?;
|
||||
let pool = pool.into_inner();
|
||||
|
||||
match LuaService::run(&pool, user_id, path.into_inner()).await {
|
||||
Ok(output) => Ok(HttpResponse::Ok().json(output)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_firmware(
|
||||
pool: web::Data<Pool>,
|
||||
session: Session,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let user_id = AuthService::check_session(session)?;
|
||||
let pool = pool.into_inner();
|
||||
|
||||
match ReplicantService::download_firmware(&pool, user_id, path.into_inner()).await {
|
||||
Ok(firmware) => Ok(HttpResponse::Ok().json(firmware)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
99
dollhouse/crates/dollhouse-backend/src/main.rs
Executable file
99
dollhouse/crates/dollhouse-backend/src/main.rs
Executable file
@@ -0,0 +1,99 @@
|
||||
use crate::utils::AppError;
|
||||
use actix_cors::Cors;
|
||||
use actix_multipart::form::MultipartFormConfig;
|
||||
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
|
||||
use actix_web::cookie::Key;
|
||||
use actix_web::middleware::Logger;
|
||||
use actix_web::{App, HttpServer, web};
|
||||
use dollhouse_db::create_db_pool;
|
||||
use env_logger::Env;
|
||||
use log;
|
||||
|
||||
mod conversions;
|
||||
mod handlers;
|
||||
mod services;
|
||||
mod utils;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
env_logger::Builder::from_env(Env::default().default_filter_or("debug"))
|
||||
.format_timestamp_millis()
|
||||
.format_module_path(false)
|
||||
.format_target(false)
|
||||
.init();
|
||||
|
||||
log::info!("Starting server");
|
||||
|
||||
let db_pool = create_db_pool().await;
|
||||
|
||||
let key = Key::generate();
|
||||
|
||||
HttpServer::new(move || {
|
||||
let key = key.clone();
|
||||
let cors = Cors::permissive();
|
||||
App::new()
|
||||
.wrap(
|
||||
SessionMiddleware::builder(CookieSessionStore::default(), key)
|
||||
.cookie_secure(false)
|
||||
.cookie_http_only(false)
|
||||
.cookie_same_site(actix_web::cookie::SameSite::Lax)
|
||||
.cookie_name("session".to_string())
|
||||
.cookie_path("/".to_string())
|
||||
.build(),
|
||||
)
|
||||
.wrap(Logger::new("%a %t \"%r\" %s"))
|
||||
.app_data(web::Data::new(db_pool.clone()))
|
||||
.app_data(
|
||||
web::PathConfig::default().error_handler(|_, _| AppError::InvalidUuidFormat.into()),
|
||||
)
|
||||
.app_data(
|
||||
MultipartFormConfig::default()
|
||||
.total_limit(10 * 1024 * 1024)
|
||||
.error_handler(|err, _| {
|
||||
log::error!("Multipart error: {:?}", err);
|
||||
AppError::MultipartError(err.to_string()).into()
|
||||
}),
|
||||
)
|
||||
.service(
|
||||
web::scope("/api")
|
||||
.route("/auth/login", web::post().to(handlers::login_user))
|
||||
.route("/auth/logout", web::post().to(handlers::logout_user))
|
||||
.route("/auth/register", web::post().to(handlers::create_user))
|
||||
.route("/auth/me", web::get().to(handlers::get_current_user))
|
||||
.route("/user/{id}/corp", web::get().to(handlers::get_user_corp))
|
||||
.route("/user/{id}/corp", web::post().to(handlers::create_corp))
|
||||
.route("/user/{id}/join-corp", web::post().to(handlers::join_corp))
|
||||
.route(
|
||||
"/corp/{id}/replicants",
|
||||
web::get().to(handlers::get_corp_replicants),
|
||||
)
|
||||
.route(
|
||||
"/corp/{id}/replicant",
|
||||
web::post().to(handlers::create_replicant),
|
||||
)
|
||||
.route("/replicants", web::get().to(handlers::get_replicants))
|
||||
.route(
|
||||
"/replicant/{id}/firmware",
|
||||
web::post().to(handlers::upload_replicant_firmware),
|
||||
)
|
||||
.route("/replicant/{id}", web::get().to(handlers::get_replicant))
|
||||
.route(
|
||||
"/replicant/{id}/change-privacy",
|
||||
web::post().to(handlers::change_replicant_privacy),
|
||||
)
|
||||
.route(
|
||||
"/replicant/{id}/change-owner",
|
||||
web::post().to(handlers::change_replicant_owner),
|
||||
)
|
||||
.route("/replicant/{id}/run", web::get().to(handlers::run_firmware))
|
||||
.route(
|
||||
"/replicant/{id}/firmware",
|
||||
web::get().to(handlers::download_firmware),
|
||||
),
|
||||
)
|
||||
.wrap(cors)
|
||||
})
|
||||
.bind("0.0.0.0:5555")?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
124
dollhouse/crates/dollhouse-backend/src/services/auth.rs
Executable file
124
dollhouse/crates/dollhouse-backend/src/services/auth.rs
Executable file
@@ -0,0 +1,124 @@
|
||||
use crate::conversions::UserRoleConvert;
|
||||
use crate::utils::*;
|
||||
use actix_session::Session;
|
||||
use argon2::Argon2;
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use dollhouse_api_types::{CreateUserRequest, UserResponse, UserRole};
|
||||
use dollhouse_db::{AsyncPgConnection, NewUser, Pool, repositories::UserRepository};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct AuthService;
|
||||
|
||||
impl AuthService {
|
||||
pub async fn login(pool: &Pool, req: CreateUserRequest) -> Result<UserResponse, AppError> {
|
||||
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
|
||||
let user = UserRepository::find_by_username(&mut conn, &req.username)
|
||||
.await
|
||||
.map_err(|_| AppError::RepositoryError)?;
|
||||
|
||||
match user {
|
||||
Some(user) => {
|
||||
if Self::verify_password(&user.password, &req.password)? {
|
||||
Ok(UserResponse {
|
||||
id: user.id,
|
||||
role: user.role.to_api_role().unwrap_or(UserRole::User),
|
||||
username: user.username,
|
||||
corp_id: user.corp_id,
|
||||
})
|
||||
} else {
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
None => Err(AppError::Unauthorized),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn logout(session: &mut Session) {
|
||||
session.remove("user_id");
|
||||
session.remove("username");
|
||||
session.remove("role");
|
||||
}
|
||||
|
||||
pub async fn register(pool: &Pool, req: CreateUserRequest) -> Result<(), AppError> {
|
||||
let hashed_password = Self::hash_password(&req.password)?;
|
||||
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
|
||||
|
||||
if Self::is_username_taken(&mut conn, &req.username).await? {
|
||||
return Err(AppError::BadRequest("Username already exists".to_string()));
|
||||
}
|
||||
|
||||
let new_user = NewUser {
|
||||
username: req.username,
|
||||
password: hashed_password,
|
||||
};
|
||||
|
||||
UserRepository::create_user(&mut conn, new_user)
|
||||
.await
|
||||
.map_err(|_| AppError::RepositoryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_username_taken(
|
||||
conn: &mut AsyncPgConnection,
|
||||
username: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let user = UserRepository::find_by_username(conn, username)
|
||||
.await
|
||||
.map_err(|_| AppError::RepositoryError)?;
|
||||
Ok(user.is_some())
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> Result<String, PasswordError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
match argon2.hash_password(password.as_bytes(), &salt) {
|
||||
Ok(hash) => {
|
||||
log::debug!("Password hashed successfully");
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
log::error!("Password hashing failed: {}", error_msg);
|
||||
Err(PasswordError::HashError(error_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_session(session: Session) -> Result<Uuid, AppError> {
|
||||
match session.get::<Uuid>("user_id") {
|
||||
Ok(Some(id)) => Ok(id),
|
||||
Ok(None) => Err(AppError::Unauthorized),
|
||||
Err(_) => Err(AppError::InternalServerError),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_password(hash: &str, password: &str) -> Result<bool, PasswordError> {
|
||||
match PasswordHash::new(hash) {
|
||||
Ok(parsed_hash) => {
|
||||
match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) {
|
||||
Ok(_) => {
|
||||
log::debug!("Password verification successful");
|
||||
Ok(true)
|
||||
}
|
||||
Err(argon2::password_hash::Error::Password) => {
|
||||
log::debug!("Password verification failed - incorrect password");
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
log::error!("Password verification error: {}", error_msg);
|
||||
Err(PasswordError::VerificationFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
log::error!("Failed to parse password hash: {}", error_msg);
|
||||
Err(PasswordError::HashError(error_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
dollhouse/crates/dollhouse-backend/src/services/corp.rs
Executable file
114
dollhouse/crates/dollhouse-backend/src/services/corp.rs
Executable file
@@ -0,0 +1,114 @@
|
||||
use crate::{conversions::UserRoleConvert, utils::AppError};
|
||||
use base64::prelude::*;
|
||||
use dollhouse_api_types::{CorpResponse, CreateCorpRequest, JoinCorpRequest, StaffResponse};
|
||||
use dollhouse_db::{
|
||||
NewCorp, Pool,
|
||||
errors::DbError,
|
||||
repositories::{UserRepository, corp::CorpRepository},
|
||||
};
|
||||
use rand::Rng;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::{Context, Timestamp, Uuid};
|
||||
|
||||
pub struct CorpService;
|
||||
|
||||
impl CorpService {
|
||||
pub async fn create(
|
||||
pool: &Pool,
|
||||
user_id: Uuid,
|
||||
req: CreateCorpRequest,
|
||||
) -> Result<CorpResponse, AppError> {
|
||||
let mut rng = rand::rng();
|
||||
let count = rng.random::<u16>();
|
||||
|
||||
let corp_id = Self::gen_uuid(count);
|
||||
let invite_code = Self::gen_uuid(count + 1);
|
||||
|
||||
let new_corp = NewCorp {
|
||||
id: corp_id,
|
||||
invite_code: BASE64_STANDARD.encode(&invite_code.to_string()),
|
||||
description: req.description.clone(),
|
||||
name: req.name.clone(),
|
||||
};
|
||||
|
||||
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
|
||||
match CorpRepository::create(&mut conn, new_corp).await {
|
||||
Err(e) => {
|
||||
log::error!("Some error during creating corp: {}", e);
|
||||
Err(AppError::InternalServerError)
|
||||
}
|
||||
Ok(new_corp) => {
|
||||
UserRepository::update_role(&mut conn, user_id, dollhouse_db::UserRole::CorpAdmin)
|
||||
.await?;
|
||||
UserRepository::update_corp_id(&mut conn, user_id, Some(new_corp.id)).await?;
|
||||
let staff = CorpRepository::get_corp_user_ids_with_names(&mut conn, new_corp.id)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|(id, name, role)| StaffResponse {
|
||||
id: *id,
|
||||
username: name.clone(),
|
||||
role: role.to_api_role().unwrap(),
|
||||
})
|
||||
.collect();
|
||||
Ok(CorpResponse {
|
||||
id: new_corp.id,
|
||||
invite_code: new_corp.invite_code,
|
||||
staff,
|
||||
description: req.description,
|
||||
name: req.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_uuid(rng: u16) -> Uuid {
|
||||
let context = Context::new(rng);
|
||||
let ts = Timestamp::now(&context);
|
||||
let (ticks, counter) = ts.to_gregorian();
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
|
||||
hasher.update(ticks.to_be_bytes());
|
||||
hasher.update(counter.to_be_bytes());
|
||||
|
||||
let hash = hasher.finalize();
|
||||
let node_id = hash[hash.len() - 6..].try_into().unwrap();
|
||||
|
||||
Uuid::new_v1(ts, &node_id)
|
||||
}
|
||||
|
||||
pub async fn get_user_corp(pool: &Pool, user_id: Uuid) -> Result<CorpResponse, AppError> {
|
||||
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
|
||||
match UserRepository::get_user_corp(&mut conn, user_id).await {
|
||||
Ok(Some(corp)) => {
|
||||
let staff = CorpRepository::get_corp_user_ids_with_names(&mut conn, corp.id)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|(id, name, role)| StaffResponse {
|
||||
id: *id,
|
||||
username: name.clone(),
|
||||
role: role.to_api_role().unwrap(),
|
||||
})
|
||||
.collect();
|
||||
Ok(CorpResponse {
|
||||
id: corp.id,
|
||||
name: corp.name,
|
||||
description: corp.description,
|
||||
staff,
|
||||
invite_code: corp.invite_code,
|
||||
})
|
||||
}
|
||||
Ok(None) => Err(AppError::NotFound),
|
||||
Err(_) => Err(AppError::RepositoryError),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn join(pool: &Pool, user_id: Uuid, req: &JoinCorpRequest) -> Result<(), AppError> {
|
||||
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
|
||||
match CorpRepository::join_by_invite(&mut conn, user_id, req.invite_code.as_str()).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(DbError::NotFound) => Err(AppError::NotFound),
|
||||
Err(_) => Err(AppError::RepositoryError),
|
||||
}
|
||||
}
|
||||
}
|
||||
226
dollhouse/crates/dollhouse-backend/src/services/lua.rs
Executable file
226
dollhouse/crates/dollhouse-backend/src/services/lua.rs
Executable file
@@ -0,0 +1,226 @@
|
||||
use crate::{services::replicant::ReplicantService, utils::AppError};
|
||||
use base64::prelude::*;
|
||||
use dollhouse_api_types::FirmwareOutputResponse;
|
||||
use dollhouse_db::{Pool, repositories::ReplicantRepository};
|
||||
use mlua::{Lua, Table, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::time::{Duration, timeout};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct LuaService;
|
||||
|
||||
const MEMORY_LIMIT: usize = 10 * 1024 * 1024;
|
||||
const TIME_LIMIT_MS: u64 = 1000;
|
||||
|
||||
impl LuaService {
|
||||
fn create_lua_instance() -> Result<Lua, AppError> {
|
||||
let lua = Lua::new();
|
||||
lua.set_memory_limit(MEMORY_LIMIT)?;
|
||||
Ok(lua)
|
||||
}
|
||||
|
||||
fn setup_sandbox(lua: &Lua) -> Result<(), AppError> {
|
||||
let globals = lua.globals();
|
||||
|
||||
let dangerous_libs = [
|
||||
"os",
|
||||
"io",
|
||||
"debug",
|
||||
"load",
|
||||
"loadstring",
|
||||
"dofile",
|
||||
"loadfile",
|
||||
];
|
||||
for lib in &dangerous_libs {
|
||||
globals.set(*lib, Value::Nil)?;
|
||||
}
|
||||
|
||||
let g_mt = lua.create_table()?;
|
||||
|
||||
let allowed_globals = vec![
|
||||
"_VERSION",
|
||||
"print",
|
||||
"type",
|
||||
"assert",
|
||||
"error",
|
||||
"pairs",
|
||||
"ipairs",
|
||||
"next",
|
||||
"select",
|
||||
"pcall",
|
||||
"xpcall",
|
||||
"table",
|
||||
"string",
|
||||
"math",
|
||||
"tonumber",
|
||||
"tostring",
|
||||
"setmetatable",
|
||||
"getmetatable",
|
||||
"rawset",
|
||||
"rawget",
|
||||
"rawequal",
|
||||
];
|
||||
|
||||
let allowed_globals_clone1 = allowed_globals.clone();
|
||||
|
||||
g_mt.set(
|
||||
"__newindex",
|
||||
lua.create_function(move |_, (t, name, value): (Table, String, Value)| {
|
||||
if !allowed_globals_clone1.contains(&name.as_str()) {
|
||||
return Err(mlua::Error::RuntimeError(format!(
|
||||
"Security: creating global '{}' is not allowed",
|
||||
name
|
||||
)));
|
||||
}
|
||||
|
||||
t.raw_set(name, value)?;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
let allowed_globals_clone2 = allowed_globals.clone();
|
||||
let dangerous = vec!["io", "os", "debug", "package"];
|
||||
|
||||
g_mt.set(
|
||||
"__index",
|
||||
lua.create_function(move |lua, (t, name): (Table, String)| {
|
||||
if dangerous.contains(&name.as_str()) {
|
||||
return Err(mlua::Error::RuntimeError(format!(
|
||||
"Security: access to '{}' is prohibited",
|
||||
name
|
||||
)));
|
||||
}
|
||||
|
||||
if allowed_globals_clone2.contains(&name.as_str()) {
|
||||
let globals = lua.globals();
|
||||
return Ok(globals.raw_get::<Value>(name)?);
|
||||
}
|
||||
|
||||
Ok(Value::Nil)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
globals.set_metatable(Some(g_mt));
|
||||
|
||||
Self::setup_safe_print(lua)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_safe_print(lua: &Lua) -> Result<(), AppError> {
|
||||
let safe_print = lua.create_function(|_, args: mlua::MultiValue| {
|
||||
let output: String = args
|
||||
.into_iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join("\t");
|
||||
|
||||
println!("{}", output);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
lua.globals().set("print", safe_print)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_with_timeout(
|
||||
lua: Arc<Lua>,
|
||||
bytecode: Vec<u8>,
|
||||
) -> Result<mlua::Value, AppError> {
|
||||
let task = tokio::task::spawn_blocking(move || {
|
||||
let lua_clone = Arc::clone(&lua);
|
||||
lua_clone
|
||||
.load(&bytecode)
|
||||
.set_name("[[user_firmware]]")
|
||||
.eval()
|
||||
});
|
||||
|
||||
match timeout(Duration::from_millis(TIME_LIMIT_MS), task).await {
|
||||
Ok(Ok(result)) => result.map_err(|e| {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("not enough memory") {
|
||||
log::error!("Memory limit exceeded");
|
||||
AppError::InternalServerError
|
||||
} else {
|
||||
AppError::LuaExecutionError(e)
|
||||
}
|
||||
}),
|
||||
Ok(Err(join_err)) => {
|
||||
log::error!("Join error: {}", join_err);
|
||||
Err(AppError::InternalServerError)
|
||||
}
|
||||
Err(_) => {
|
||||
tokio::task::yield_now().await;
|
||||
Err(AppError::InternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_string(value: mlua::Value) -> Result<String, AppError> {
|
||||
match value {
|
||||
mlua::Value::String(s) => Ok(s.to_str()?.to_string()),
|
||||
mlua::Value::Nil => Ok("nil".to_string()),
|
||||
mlua::Value::Boolean(b) => Ok(b.to_string()),
|
||||
mlua::Value::Number(n) => Ok(n.to_string()),
|
||||
mlua::Value::Integer(i) => Ok(i.to_string()),
|
||||
mlua::Value::Table(t) => {
|
||||
let mut parts = Vec::new();
|
||||
for pair in t.pairs::<mlua::Value, mlua::Value>() {
|
||||
let (key, value) = pair?;
|
||||
parts.push(format!("{}: {}", key.to_string()?, value.to_string()?));
|
||||
}
|
||||
Ok(format!("{{{}}}", parts.join(", ")))
|
||||
}
|
||||
_ => Ok(format!("{:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
pool: &Pool,
|
||||
user_id: Uuid,
|
||||
replicant_id: Uuid,
|
||||
) -> Result<FirmwareOutputResponse, AppError> {
|
||||
let lua = Arc::new(Self::create_lua_instance()?);
|
||||
|
||||
Self::setup_sandbox(&lua)?;
|
||||
|
||||
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
|
||||
let replicant = ReplicantRepository::get(&mut conn, replicant_id)
|
||||
.await
|
||||
.map_err(|_| AppError::RepositoryError)?;
|
||||
|
||||
ReplicantService::check_replicant_access(
|
||||
&mut conn,
|
||||
user_id,
|
||||
replicant.is_private,
|
||||
replicant.corp_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
match replicant.firmware_file {
|
||||
Some(filename) => {
|
||||
let firmware_path = std::path::Path::new("firmware").join(filename);
|
||||
let firmware_data = tokio::fs::read(&firmware_path).await.map_err(|e| {
|
||||
log::error!(
|
||||
"Failed to read firmware file from {:?}: {}",
|
||||
firmware_path,
|
||||
e
|
||||
);
|
||||
AppError::InternalServerError
|
||||
})?;
|
||||
|
||||
if firmware_data.is_empty() {
|
||||
return Err(AppError::InternalServerError);
|
||||
}
|
||||
|
||||
let result = Self::execute_with_timeout(Arc::clone(&lua), firmware_data).await?;
|
||||
let mut output = Self::value_to_string(result)?;
|
||||
|
||||
output = BASE64_STANDARD.encode(output);
|
||||
|
||||
Ok(FirmwareOutputResponse { output })
|
||||
}
|
||||
None => Err(AppError::NotFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
11
dollhouse/crates/dollhouse-backend/src/services/mod.rs
Executable file
11
dollhouse/crates/dollhouse-backend/src/services/mod.rs
Executable file
@@ -0,0 +1,11 @@
|
||||
pub mod auth;
|
||||
pub mod corp;
|
||||
pub mod lua;
|
||||
pub mod replicant;
|
||||
pub mod user;
|
||||
|
||||
pub use self::auth::*;
|
||||
pub use self::corp::*;
|
||||
pub use self::lua::*;
|
||||
pub use self::replicant::*;
|
||||
pub use self::user::*;
|
||||
466
dollhouse/crates/dollhouse-backend/src/services/replicant.rs
Executable file
466
dollhouse/crates/dollhouse-backend/src/services/replicant.rs
Executable file
@@ -0,0 +1,466 @@
|
||||
use crate::{
|
||||
conversions::{ReplicantGenderConvert, ReplicantStatusConvert},
|
||||
utils::AppError,
|
||||
};
|
||||
use actix_multipart::form::tempfile::TempFile;
|
||||
use base64::prelude::*;
|
||||
use dollhouse_api_types::{CreateReplicantRequest, FirmwareOutputResponse, ReplicantFullResponse};
|
||||
use dollhouse_db::{
|
||||
AsyncPgConnection, NewReplicant, Pool, ReplicantStatus, UserRole,
|
||||
repositories::{CorpRepository, ReplicantRepository, UserRepository},
|
||||
};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct ReplicantService;
|
||||
|
||||
impl ReplicantService {
|
||||
pub async fn create(
|
||||
pool: &Pool,
|
||||
user_id: Uuid,
|
||||
corp_id: Uuid,
|
||||
data: CreateReplicantRequest,
|
||||
) -> Result<ReplicantFullResponse, AppError> {
|
||||
let mut conn = pool.get().await.map_err(|_| AppError::RepositoryError)?;
|
||||
|
||||
Self::check_user_corp(&mut conn, user_id, corp_id).await?;
|
||||
if !Self::is_admin(&mut conn, user_id).await? {
|
||||
log::warn!(
|
||||
"User {} attempted to create replicant in corp {} without admin rights",
|
||||
user_id,
|
||||
corp_id
|
||||
);
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let new_replicant = NewReplicant {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
status: ReplicantStatus::Active,
|
||||
gender: data.gender.to_db_gender().unwrap(),
|
||||
corp_id,
|
||||
};
|
||||
|
||||
match ReplicantRepository::create(&mut conn, new_replicant).await {
|
||||
Ok(replicant) => Ok(ReplicantFullResponse {
|
||||
id: replicant.id,
|
||||
name: replicant.name,
|
||||
description: replicant.description,
|
||||
health: replicant.health,
|
||||
strength: replicant.strength,
|
||||
intelligence: replicant.intelligence,
|
||||
gender: replicant.gender.to_api_gender().unwrap(),
|
||||
status: replicant.status.to_api_status().unwrap(),
|
||||
is_private: replicant.is_private,
|
||||
firmware_file: replicant.firmware_file,
|
||||
corp_id: replicant.corp_id,
|
||||
}),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_replicant(
|
||||
pool: &Pool,
|
||||
user_id: Uuid,
|
||||
replicant_id: Uuid,
|
||||
) -> Result<ReplicantFullResponse, AppError> {
|
||||
let mut conn = pool.get().await.map_err(|e| {
|
||||
log::error!("Failed to get connection from pool: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
let replicant = ReplicantRepository::get_replicant_full(&mut conn, replicant_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Some error: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
Self::check_replicant_access(&mut conn, user_id, replicant.is_private, replicant.corp_id)
|
||||
.await?;
|
||||
|
||||
Ok(ReplicantFullResponse {
|
||||
id: replicant.id,
|
||||
name: replicant.name,
|
||||
description: replicant.description,
|
||||
health: replicant.health,
|
||||
strength: replicant.strength,
|
||||
intelligence: replicant.intelligence,
|
||||
gender: replicant.gender.to_api_gender().unwrap(),
|
||||
status: replicant.status.to_api_status().unwrap(),
|
||||
is_private: replicant.is_private,
|
||||
firmware_file: replicant.firmware_file,
|
||||
corp_id: replicant.corp_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_replicants(
|
||||
pool: &Pool,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<ReplicantFullResponse>, AppError> {
|
||||
let mut conn = pool.get().await.map_err(|e| {
|
||||
log::error!("Failed to get connection from pool: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
let replicants = ReplicantRepository::get_much(&mut conn, limit, offset)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to get replicants: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
Ok(replicants
|
||||
.into_iter()
|
||||
.map(|replicant| ReplicantFullResponse {
|
||||
id: replicant.id,
|
||||
name: replicant.name,
|
||||
description: replicant.description,
|
||||
gender: replicant.gender.to_api_gender().unwrap(),
|
||||
status: replicant.status.to_api_status().unwrap(),
|
||||
is_private: replicant.is_private,
|
||||
firmware_file: replicant.firmware_file,
|
||||
health: replicant.health,
|
||||
strength: replicant.strength,
|
||||
intelligence: replicant.intelligence,
|
||||
corp_id: replicant.corp_id,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_corp_replicants(
|
||||
pool: &Pool,
|
||||
user_id: Uuid,
|
||||
corp_id: Uuid,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<ReplicantFullResponse>, AppError> {
|
||||
let mut conn = pool.get().await.map_err(|e| {
|
||||
log::error!("Failed to get connection from pool: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
Self::check_user_corp(&mut conn, user_id, corp_id).await?;
|
||||
|
||||
let replicants =
|
||||
ReplicantRepository::get_corp_replicants_full(&mut conn, corp_id, limit, offset)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to get corp replicants: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
log::debug!("Number of replicants: {}", replicants.len());
|
||||
|
||||
Ok(replicants
|
||||
.into_iter()
|
||||
.map(|replicant| ReplicantFullResponse {
|
||||
id: replicant.id,
|
||||
name: replicant.name,
|
||||
description: replicant.description,
|
||||
gender: replicant.gender.to_api_gender().unwrap(),
|
||||
status: replicant.status.to_api_status().unwrap(),
|
||||
is_private: replicant.is_private,
|
||||
firmware_file: replicant.firmware_file,
|
||||
corp_id: replicant.corp_id,
|
||||
health: replicant.health,
|
||||
strength: replicant.strength,
|
||||
intelligence: replicant.intelligence,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn change_privacy(
|
||||
pool: &Pool,
|
||||
user_id: Uuid,
|
||||
replicant_id: Uuid,
|
||||
privacy: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = pool.get().await.map_err(|e| {
|
||||
log::error!("Failed to get connection from pool: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
let replicant = ReplicantRepository::get(&mut conn, replicant_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to change privacy: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
Self::check_user_corp(&mut conn, user_id, replicant.corp_id).await?;
|
||||
|
||||
let is_admin = Self::is_admin(&mut conn, user_id).await?;
|
||||
if !is_admin {
|
||||
log::warn!(
|
||||
"User {} attempted to change privacy without admin rights",
|
||||
user_id
|
||||
);
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
ReplicantRepository::change_privacy(&mut conn, replicant_id, privacy).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn change_owner(
|
||||
pool: &Pool,
|
||||
user_id: Uuid,
|
||||
replicant_id: Uuid,
|
||||
new_owner_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = pool.get().await.map_err(|e| {
|
||||
log::error!("Failed to get connection from pool: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
let replicant = ReplicantRepository::get(&mut conn, replicant_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to change owner: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
Self::check_user_corp(&mut conn, user_id, replicant.corp_id).await?;
|
||||
if !Self::is_admin(&mut conn, user_id).await? {
|
||||
log::warn!(
|
||||
"User {} attempted to change owner of replicant {} without admin rights",
|
||||
user_id,
|
||||
replicant_id
|
||||
);
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let _ = CorpRepository::get_corp(&mut conn, new_owner_id).await.map_err(|e| {
|
||||
log::error!("New corp {} not found: {}", new_owner_id, e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
ReplicantRepository::change_owner(&mut conn, replicant_id, new_owner_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to change owner: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn check_replicant_access(
|
||||
conn: &mut AsyncPgConnection,
|
||||
user_id: Uuid,
|
||||
is_private: bool,
|
||||
corp_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
if !is_private {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let user_corp = UserRepository::get_user_corp(conn, user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Database error while checking user corp: {}", e);
|
||||
AppError::InternalServerError
|
||||
})?;
|
||||
|
||||
log::debug!("User {} corporation: {:?}", user_id, user_corp);
|
||||
|
||||
match user_corp {
|
||||
Some(corp) if corp.id == corp_id => Ok(()),
|
||||
Some(_) => {
|
||||
log::warn!(
|
||||
"User {} attempted to access unauthorized replicant",
|
||||
user_id
|
||||
);
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
None => {
|
||||
log::warn!("User {} has no corporation", user_id);
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_user_corp(
|
||||
conn: &mut AsyncPgConnection,
|
||||
user_id: Uuid,
|
||||
corp_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_corp = CorpRepository::get_corp_by_user(conn, user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Database error while checking user corp: {}", e);
|
||||
AppError::InternalServerError
|
||||
})?;
|
||||
|
||||
match user_corp {
|
||||
Some(corp) if corp.id == corp_id => Ok(()),
|
||||
Some(_) => {
|
||||
log::warn!(
|
||||
"User {} attempted to access unauthorized corp {}",
|
||||
user_id,
|
||||
corp_id
|
||||
);
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
None => {
|
||||
log::warn!("User {} has no corporation", user_id);
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_admin(conn: &mut AsyncPgConnection, user_id: Uuid) -> Result<bool, AppError> {
|
||||
let user = UserRepository::get_user(conn, user_id).await?;
|
||||
|
||||
match user {
|
||||
Some(user) => Ok(user.role == UserRole::CorpAdmin),
|
||||
None => {
|
||||
log::warn!("User {} not found while checking admin status", user_id);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_firmware(
|
||||
pool: &Pool,
|
||||
user_id: Uuid,
|
||||
replicant_id: Uuid,
|
||||
firmware: TempFile,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = pool.get().await.map_err(|e| {
|
||||
log::error!("Failed to get connection from pool: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
let replicant = ReplicantRepository::get(&mut conn, replicant_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to load firmware: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
let corp_id = replicant.corp_id;
|
||||
|
||||
Self::check_user_corp(&mut conn, user_id, corp_id).await?;
|
||||
if !Self::is_admin(&mut conn, user_id).await? {
|
||||
log::warn!(
|
||||
"User {} attempted to upload firmware for replicant {} without admin rights",
|
||||
user_id,
|
||||
replicant_id
|
||||
);
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let file_content = Self::validate_firmware_file(&firmware)?;
|
||||
|
||||
let filename = format!("firmware_{}", replicant.id);
|
||||
let firmware_dir = Path::new("firmware");
|
||||
|
||||
tokio::fs::create_dir_all(firmware_dir).await.map_err(|e| {
|
||||
log::error!("Failed to create firmware directory: {}", e);
|
||||
AppError::InternalServerError
|
||||
})?;
|
||||
|
||||
let file_path = firmware_dir.join(&filename);
|
||||
tokio::fs::write(&file_path, file_content)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!(
|
||||
"Failed to write firmware file {}: {}",
|
||||
file_path.display(),
|
||||
e
|
||||
);
|
||||
AppError::InternalServerError
|
||||
})?;
|
||||
|
||||
ReplicantRepository::update_firmware(&mut conn, replicant_id, filename)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to update firmware in database: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"Firmware loaded successfully for replicant {} by user {}, file: {}",
|
||||
replicant_id,
|
||||
user_id,
|
||||
file_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_firmware_file(firmware_file: &TempFile) -> Result<Vec<u8>, AppError> {
|
||||
let file = firmware_file.file.as_ref();
|
||||
|
||||
let metadata = file.metadata().map_err(|e| {
|
||||
log::error!("Failed to get file metadata: {}", e);
|
||||
AppError::InternalServerError
|
||||
})?;
|
||||
|
||||
if metadata.len() > 10 * 1024 * 1024 {
|
||||
log::warn!("Firmware file too large: {} bytes", metadata.len());
|
||||
return Err(AppError::BadRequest(
|
||||
"Firmware file too large (max 10MB)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut file_content = Vec::new();
|
||||
let mut file_handle = std::fs::File::open(&file).map_err(|e| {
|
||||
log::error!("Failed to open firmware file: {}", e);
|
||||
AppError::InternalServerError
|
||||
})?;
|
||||
|
||||
file_handle.read_to_end(&mut file_content).map_err(|e| {
|
||||
log::error!("Failed to read firmware file: {}", e);
|
||||
AppError::InternalServerError
|
||||
})?;
|
||||
|
||||
if file_content.is_empty() {
|
||||
log::warn!("Empty firmware file provided");
|
||||
return Err(AppError::BadRequest("Firmware file is empty".to_string()));
|
||||
}
|
||||
|
||||
Ok(file_content)
|
||||
}
|
||||
|
||||
pub async fn download_firmware(
|
||||
pool: &Pool,
|
||||
user_id: Uuid,
|
||||
replicant_id: Uuid,
|
||||
) -> Result<FirmwareOutputResponse, AppError> {
|
||||
let mut conn = pool.get().await.map_err(|e| {
|
||||
log::error!("Failed to get connection from pool: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
|
||||
let replicant = ReplicantRepository::get(&mut conn, replicant_id)
|
||||
.await
|
||||
.map_err(|_| AppError::RepositoryError)?;
|
||||
Self::check_replicant_access(&mut conn, user_id, replicant.is_private, replicant.corp_id)
|
||||
.await?;
|
||||
|
||||
match replicant.firmware_file {
|
||||
Some(filename) => {
|
||||
let firmware_path = std::path::Path::new("firmware").join(filename);
|
||||
let firmware_data = tokio::fs::read(&firmware_path).await.map_err(|e| {
|
||||
log::error!(
|
||||
"Failed to read firmware file from {:?}: {}",
|
||||
firmware_path,
|
||||
e
|
||||
);
|
||||
AppError::InternalServerError
|
||||
})?;
|
||||
|
||||
if firmware_data.is_empty() {
|
||||
return Err(AppError::InternalServerError);
|
||||
}
|
||||
let output = BASE64_STANDARD.encode(firmware_data);
|
||||
Ok(FirmwareOutputResponse { output })
|
||||
}
|
||||
None => Err(AppError::NotFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
25
dollhouse/crates/dollhouse-backend/src/services/user.rs
Executable file
25
dollhouse/crates/dollhouse-backend/src/services/user.rs
Executable file
@@ -0,0 +1,25 @@
|
||||
use crate::{conversions::UserRoleConvert, utils::AppError};
|
||||
use dollhouse_api_types::UserResponse;
|
||||
use dollhouse_db::{Pool, repositories::UserRepository};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct UserService;
|
||||
|
||||
impl UserService {
|
||||
pub async fn get_user(pool: &Pool, user_id: Uuid) -> Result<UserResponse, AppError> {
|
||||
let mut conn = pool.get().await.map_err(|e| {
|
||||
log::error!("Some error with pool: {}", e);
|
||||
AppError::RepositoryError
|
||||
})?;
|
||||
match UserRepository::get_user(&mut conn, user_id).await {
|
||||
Ok(Some(user)) => Ok(UserResponse {
|
||||
id: user.id,
|
||||
role: user.role.to_api_role().unwrap(),
|
||||
username: user.username,
|
||||
corp_id: user.corp_id,
|
||||
}),
|
||||
Ok(None) => Err(AppError::NotFound),
|
||||
Err(e) => Err(AppError::RepositoryError),
|
||||
}
|
||||
}
|
||||
}
|
||||
114
dollhouse/crates/dollhouse-backend/src/utils.rs
Executable file
114
dollhouse/crates/dollhouse-backend/src/utils.rs
Executable file
@@ -0,0 +1,114 @@
|
||||
use actix_web::HttpResponse;
|
||||
use actix_web::error::ResponseError;
|
||||
use dollhouse_db::errors::DbError;
|
||||
use log::{debug, error};
|
||||
use serde_json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PasswordError {
|
||||
#[error("Failed to verify password")]
|
||||
VerificationFailed,
|
||||
#[error("Failed to hash password: {0}")]
|
||||
HashError(String),
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
#[error("Password error: {0}")]
|
||||
PasswordError(#[from] PasswordError),
|
||||
#[error("Database error")]
|
||||
RepositoryError,
|
||||
#[error("Not Found")]
|
||||
NotFound,
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("Internal server error")]
|
||||
InternalServerError,
|
||||
#[error("Invalid UUID format")]
|
||||
InvalidUuidFormat,
|
||||
#[error("Lua execution error: {0}")]
|
||||
LuaExecutionError(#[from] mlua::Error),
|
||||
#[error("Diesel error: {0}")]
|
||||
DieselError(#[from] DbError),
|
||||
#[error("Bad Request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("Multipart form error: {0}")]
|
||||
MultipartError(String),
|
||||
}
|
||||
|
||||
impl ResponseError for AppError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
match self {
|
||||
AppError::RepositoryError => {
|
||||
error!("Database error");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Database error occurred",
|
||||
"message": "An error occurred while accessing the database"
|
||||
}))
|
||||
}
|
||||
AppError::PasswordError(e) => {
|
||||
error!("Password error: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Authentication error",
|
||||
"message": "An error occurred during authentication"
|
||||
}))
|
||||
}
|
||||
AppError::NotFound => {
|
||||
debug!("Resource not found");
|
||||
HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "Not found",
|
||||
"message": "The requested resource was not found"
|
||||
}))
|
||||
}
|
||||
AppError::Unauthorized => {
|
||||
debug!("Unauthorized access attempt");
|
||||
HttpResponse::Unauthorized().json(serde_json::json!({
|
||||
"error": "Unauthorized",
|
||||
"message": "Access denied"
|
||||
}))
|
||||
}
|
||||
AppError::InternalServerError => {
|
||||
error!("Internal server error");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Internal server error",
|
||||
"message": "An unexpected error occurred"
|
||||
}))
|
||||
}
|
||||
AppError::InvalidUuidFormat => {
|
||||
error!("Invalid UUID format");
|
||||
HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "Invalid UUID format",
|
||||
}))
|
||||
}
|
||||
AppError::LuaExecutionError(err) => {
|
||||
error!("Lua execution error: {}", err.to_string());
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Lua execution error",
|
||||
"message": err.to_string()
|
||||
}))
|
||||
}
|
||||
AppError::DieselError(_) => {
|
||||
error!("Diesel error");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Diesel error",
|
||||
"message": "An error occurred during Diesel execution"
|
||||
}))
|
||||
}
|
||||
AppError::BadRequest(msg) => {
|
||||
error!("Bad Request: {}", msg);
|
||||
HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "Bad Request",
|
||||
"message": msg
|
||||
}))
|
||||
}
|
||||
AppError::MultipartError(msg) => {
|
||||
error!("Multipart error: {}", msg);
|
||||
HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "Multipart error",
|
||||
"message": msg
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
dollhouse/crates/dollhouse-db/.env
Executable file
1
dollhouse/crates/dollhouse-db/.env
Executable file
@@ -0,0 +1 @@
|
||||
DATABASE_URL=postgres://dollhouse_user:hahahadollhouse@localhost:5432/dollhouse_db
|
||||
16
dollhouse/crates/dollhouse-db/Cargo.toml
Executable file
16
dollhouse/crates/dollhouse-db/Cargo.toml
Executable file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "dollhouse-db"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bb8 = "0.9.0"
|
||||
chrono = "0.4.42"
|
||||
diesel = { version = "2.2.0", features = ["postgres", "chrono", "uuid"] }
|
||||
diesel-async = { version = "0.7.4", features = ["postgres", "pool", "bb8"] }
|
||||
diesel-derive-enum = { version = "2.1.0", features = ["postgres"] }
|
||||
diesel_migrations = "2.3.0"
|
||||
r2d2 = "0.8.10"
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
dotenv = "0.15"
|
||||
9
dollhouse/crates/dollhouse-db/diesel.toml
Executable file
9
dollhouse/crates/dollhouse-db/diesel.toml
Executable file
@@ -0,0 +1,9 @@
|
||||
# For documentation on how to configure this file,
|
||||
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/schema.rs"
|
||||
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||
|
||||
[migrations_directory]
|
||||
dir = "migrations"
|
||||
0
dollhouse/crates/dollhouse-db/migrations/.diesel_lock
Executable file
0
dollhouse/crates/dollhouse-db/migrations/.diesel_lock
Executable file
0
dollhouse/crates/dollhouse-db/migrations/.keep
Executable file
0
dollhouse/crates/dollhouse-db/migrations/.keep
Executable file
@@ -0,0 +1,6 @@
|
||||
-- This file was automatically created by Diesel to setup helper functions
|
||||
-- and other internal bookkeeping. This file is safe to edit, any future
|
||||
-- changes will be added to existing projects as new migrations.
|
||||
|
||||
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
|
||||
DROP FUNCTION IF EXISTS diesel_set_updated_at();
|
||||
@@ -0,0 +1,36 @@
|
||||
-- This file was automatically created by Diesel to setup helper functions
|
||||
-- and other internal bookkeeping. This file is safe to edit, any future
|
||||
-- changes will be added to existing projects as new migrations.
|
||||
|
||||
|
||||
|
||||
|
||||
-- Sets up a trigger for the given table to automatically set a column called
|
||||
-- `updated_at` whenever the row is modified (unless `updated_at` was included
|
||||
-- in the modified columns)
|
||||
--
|
||||
-- # Example
|
||||
--
|
||||
-- ```sql
|
||||
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
|
||||
--
|
||||
-- SELECT diesel_manage_updated_at('users');
|
||||
-- ```
|
||||
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
|
||||
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (
|
||||
NEW IS DISTINCT FROM OLD AND
|
||||
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
|
||||
) THEN
|
||||
NEW.updated_at := current_timestamp;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
|
||||
DROP TABLE IF EXISTS corps_replicants;
|
||||
DROP TABLE IF EXISTS replicants_stats;
|
||||
DROP TABLE IF EXISTS replicants;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS corps;
|
||||
|
||||
DROP TYPE IF EXISTS replicant_gender;
|
||||
DROP TYPE IF EXISTS replicant_status;
|
||||
DROP TYPE IF EXISTS user_role;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Your SQL goes here
|
||||
CREATE TYPE user_role AS ENUM ('corp_admin', 'user');
|
||||
CREATE TYPE replicant_status AS ENUM ('active', 'decommissioned');
|
||||
CREATE TYPE replicant_gender AS ENUM ('male', 'female', 'non-binary');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS corps (
|
||||
id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
invite_code VARCHAR(255) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
role user_role NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
corp_id UUID REFERENCES corps(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS replicants (
|
||||
id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status replicant_status NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
gender replicant_gender NOT NULL,
|
||||
corp_id UUID REFERENCES corps(id) NOT NULL,
|
||||
is_private BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
firmware_file VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS replicants_stats (
|
||||
replicant_id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid() REFERENCES replicants(id),
|
||||
health INTEGER NOT NULL DEFAULT 100,
|
||||
strength INTEGER NOT NULL DEFAULT 100,
|
||||
intelligence INTEGER NOT NULL DEFAULT 100,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_replicant_stats_replicant_id ON replicants_stats(replicant_id);
|
||||
CREATE INDEX idx_replicants_status ON replicants(status);
|
||||
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE replicants_stats
|
||||
DROP CONSTRAINT IF EXISTS replicants_stats_replicant_id_fkey;
|
||||
|
||||
ALTER TABLE replicants_stats
|
||||
ADD CONSTRAINT replicants_stats_replicant_id_fkey
|
||||
FOREIGN KEY (replicant_id) REFERENCES replicants(id);
|
||||
|
||||
ALTER TABLE replicants
|
||||
DROP CONSTRAINT IF EXISTS replicants_corp_id_fkey;
|
||||
|
||||
ALTER TABLE replicants
|
||||
ADD CONSTRAINT replicants_corp_id_fkey
|
||||
FOREIGN KEY (corp_id) REFERENCES corps(id);
|
||||
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_corp_id_fkey;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_corp_id_fkey
|
||||
FOREIGN KEY (corp_id) REFERENCES corps(id);
|
||||
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_corp_id_fkey;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_corp_id_fkey
|
||||
FOREIGN KEY (corp_id) REFERENCES corps(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE replicants
|
||||
DROP CONSTRAINT IF EXISTS replicants_corp_id_fkey;
|
||||
|
||||
ALTER TABLE replicants
|
||||
ADD CONSTRAINT replicants_corp_id_fkey
|
||||
FOREIGN KEY (corp_id) REFERENCES corps(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE replicants_stats
|
||||
DROP CONSTRAINT IF EXISTS replicants_stats_replicant_id_fkey;
|
||||
|
||||
ALTER TABLE replicants_stats
|
||||
ADD CONSTRAINT replicants_stats_replicant_id_fkey
|
||||
FOREIGN KEY (replicant_id) REFERENCES replicants(id) ON DELETE CASCADE;
|
||||
35
dollhouse/crates/dollhouse-db/src/errors.rs
Executable file
35
dollhouse/crates/dollhouse-db/src/errors.rs
Executable file
@@ -0,0 +1,35 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DbError {
|
||||
#[error("Database query error")]
|
||||
QueryError,
|
||||
|
||||
#[error("Not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("Already exists")]
|
||||
AlreadyExists,
|
||||
|
||||
#[error("Unique constraint violation")]
|
||||
UniqueViolation,
|
||||
|
||||
#[error("Foreign key violation")]
|
||||
ForeignKeyViolation,
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for DbError {
|
||||
fn from(err: diesel::result::Error) -> Self {
|
||||
match err {
|
||||
diesel::result::Error::NotFound => DbError::NotFound,
|
||||
diesel::result::Error::DatabaseError(kind, _) => match kind {
|
||||
diesel::result::DatabaseErrorKind::UniqueViolation => DbError::UniqueViolation,
|
||||
diesel::result::DatabaseErrorKind::ForeignKeyViolation => {
|
||||
DbError::ForeignKeyViolation
|
||||
}
|
||||
_ => DbError::QueryError,
|
||||
},
|
||||
_ => DbError::QueryError,
|
||||
}
|
||||
}
|
||||
}
|
||||
33
dollhouse/crates/dollhouse-db/src/lib.rs
Executable file
33
dollhouse/crates/dollhouse-db/src/lib.rs
Executable file
@@ -0,0 +1,33 @@
|
||||
pub mod errors;
|
||||
mod models;
|
||||
pub mod repositories;
|
||||
mod schema;
|
||||
|
||||
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
|
||||
use diesel_migrations::{EmbeddedMigrations, embed_migrations};
|
||||
pub use models::{NewCorp, NewReplicant, NewUser, ReplicantGender, ReplicantStatus, UserRole};
|
||||
use std::env;
|
||||
|
||||
pub use diesel_async::AsyncPgConnection;
|
||||
|
||||
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||
|
||||
pub type Pool = bb8::Pool<AsyncDieselConnectionManager<AsyncPgConnection>>;
|
||||
|
||||
fn database_url() -> String {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
dotenv::dotenv().ok();
|
||||
}
|
||||
env::var("DATABASE_URL").expect("DATABASE_URL must be set")
|
||||
}
|
||||
|
||||
pub async fn create_db_pool() -> Pool {
|
||||
let database_url = database_url();
|
||||
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(database_url);
|
||||
|
||||
Pool::builder()
|
||||
.build(config)
|
||||
.await
|
||||
.expect("Failed to create pool")
|
||||
}
|
||||
175
dollhouse/crates/dollhouse-db/src/models.rs
Executable file
175
dollhouse/crates/dollhouse-db/src/models.rs
Executable file
@@ -0,0 +1,175 @@
|
||||
use crate::schema::users;
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::prelude::*;
|
||||
use std::convert::TryFrom;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(diesel_derive_enum::DbEnum, Debug, Clone, PartialEq)]
|
||||
#[ExistingTypePath = "crate::schema::sql_types::ReplicantStatus"]
|
||||
pub enum ReplicantStatus {
|
||||
#[db_rename = "active"]
|
||||
Active,
|
||||
#[db_rename = "decommissioned"]
|
||||
Decommissioned,
|
||||
}
|
||||
|
||||
#[derive(diesel_derive_enum::DbEnum, Debug, Clone, PartialEq)]
|
||||
#[ExistingTypePath = "crate::schema::sql_types::ReplicantGender"]
|
||||
pub enum ReplicantGender {
|
||||
#[db_rename = "male"]
|
||||
Male,
|
||||
#[db_rename = "female"]
|
||||
Female,
|
||||
#[db_rename = "non-binary"]
|
||||
NonBinary,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, diesel_derive_enum::DbEnum)]
|
||||
#[ExistingTypePath = "crate::schema::sql_types::UserRole"]
|
||||
pub enum UserRole {
|
||||
#[db_rename = "corp_admin"]
|
||||
CorpAdmin,
|
||||
#[db_rename = "user"]
|
||||
User,
|
||||
}
|
||||
|
||||
#[derive(Insertable, AsChangeset)]
|
||||
#[diesel(table_name = crate::schema::users)]
|
||||
pub struct NewUser {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Selectable)]
|
||||
#[diesel(belongs_to(Corp))]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub role: UserRole,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub corp_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Insertable, AsChangeset)]
|
||||
#[diesel(table_name = crate::schema::corps)]
|
||||
pub struct NewCorp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub invite_code: String,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Selectable, Debug)]
|
||||
#[diesel(table_name = crate::schema::corps)]
|
||||
#[diesel(check_for_backend(diesel::pg::Pg))]
|
||||
#[diesel(belongs_to(User))]
|
||||
pub struct Corp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub invite_code: String,
|
||||
}
|
||||
|
||||
#[derive(Insertable, AsChangeset)]
|
||||
#[diesel(table_name = crate::schema::replicants)]
|
||||
#[diesel(check_for_backend(diesel::pg::Pg))]
|
||||
pub struct NewReplicant {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub status: ReplicantStatus,
|
||||
pub gender: ReplicantGender,
|
||||
pub corp_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Insertable, AsChangeset)]
|
||||
#[diesel(table_name = crate::schema::replicants_stats)]
|
||||
#[diesel(check_for_backend(diesel::pg::Pg))]
|
||||
pub struct NewReplicantStats {
|
||||
pub replicant_id: Uuid,
|
||||
pub health: i32,
|
||||
pub strength: i32,
|
||||
pub intelligence: i32,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Selectable)]
|
||||
#[diesel(table_name = crate::schema::replicants)]
|
||||
#[diesel(check_for_backend(diesel::pg::Pg))]
|
||||
#[diesel(belongs_to(Corp))]
|
||||
pub struct Replicant {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub status: ReplicantStatus,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub gender: ReplicantGender,
|
||||
pub corp_id: Uuid,
|
||||
pub is_private: bool,
|
||||
pub firmware_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Selectable)]
|
||||
#[diesel(table_name = crate::schema::replicants_stats)]
|
||||
#[diesel(check_for_backend(diesel::pg::Pg))]
|
||||
#[diesel(belongs_to(Replicant))]
|
||||
pub struct ReplicantStats {
|
||||
pub replicant_id: Uuid,
|
||||
pub health: i32,
|
||||
pub strength: i32,
|
||||
pub intelligence: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReplicantFull {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub gender: ReplicantGender,
|
||||
pub status: ReplicantStatus,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub firmware_file: Option<String>,
|
||||
pub is_private: bool,
|
||||
pub corp_id: Uuid,
|
||||
pub health: i32,
|
||||
pub strength: i32,
|
||||
pub intelligence: i32,
|
||||
}
|
||||
|
||||
impl TryFrom<String> for ReplicantStatus {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
match value.as_str() {
|
||||
"active" => Ok(ReplicantStatus::Active),
|
||||
"decommissioned" => Ok(ReplicantStatus::Decommissioned),
|
||||
_ => Err("Invalid status"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for ReplicantGender {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
match value.as_str() {
|
||||
"male" => Ok(ReplicantGender::Male),
|
||||
"female" => Ok(ReplicantGender::Female),
|
||||
"non_binary" => Ok(ReplicantGender::NonBinary),
|
||||
_ => Err("Invalid gender"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for UserRole {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
match value.as_str() {
|
||||
"corp_admin" => Ok(UserRole::CorpAdmin),
|
||||
"user" => Ok(UserRole::User),
|
||||
_ => Err("Invalid role"),
|
||||
}
|
||||
}
|
||||
}
|
||||
173
dollhouse/crates/dollhouse-db/src/repositories/corp.rs
Executable file
173
dollhouse/crates/dollhouse-db/src/repositories/corp.rs
Executable file
@@ -0,0 +1,173 @@
|
||||
use crate::errors::DbError;
|
||||
use crate::models::{Corp, NewCorp, UserRole};
|
||||
use diesel::prelude::*;
|
||||
use diesel_async::{AsyncPgConnection, RunQueryDsl};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct CorpRepository;
|
||||
|
||||
impl CorpRepository {
|
||||
pub async fn create(conn: &mut AsyncPgConnection, new_corp: NewCorp) -> Result<Corp, DbError> {
|
||||
use crate::schema::corps::dsl::*;
|
||||
|
||||
diesel::insert_into(corps)
|
||||
.values(&new_corp)
|
||||
.get_result(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_corp(conn: &mut AsyncPgConnection, corp_id: Uuid) -> Result<Corp, DbError> {
|
||||
use crate::schema::corps::dsl::*;
|
||||
|
||||
corps
|
||||
.find(corp_id)
|
||||
.select(Corp::as_select())
|
||||
.first(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_corps(
|
||||
conn: &mut AsyncPgConnection,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<Corp>, DbError> {
|
||||
use crate::schema::corps::dsl::*;
|
||||
|
||||
corps
|
||||
.select(Corp::as_select())
|
||||
.limit(limit as i64)
|
||||
.offset(offset as i64)
|
||||
.get_results(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_corps_by_admin_id(
|
||||
conn: &mut AsyncPgConnection,
|
||||
admin_id: Uuid,
|
||||
) -> Result<Vec<Corp>, DbError> {
|
||||
use crate::schema::{corps, users};
|
||||
|
||||
corps::table
|
||||
.inner_join(users::table.on(users::corp_id.eq(corps::id.nullable())))
|
||||
.filter(users::id.eq(admin_id))
|
||||
.filter(users::role.eq(UserRole::CorpAdmin))
|
||||
.select(corps::all_columns)
|
||||
.get_results(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_corp_by_user(
|
||||
conn: &mut AsyncPgConnection,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<Corp>, DbError> {
|
||||
use crate::schema::{corps, users};
|
||||
|
||||
corps::table
|
||||
.inner_join(users::table.on(users::corp_id.eq(corps::id.nullable())))
|
||||
.filter(users::id.eq(user_id))
|
||||
.select(corps::all_columns)
|
||||
.first(conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_corp_user_ids_with_names(
|
||||
conn: &mut AsyncPgConnection,
|
||||
c_id: Uuid,
|
||||
) -> Result<Vec<(Uuid, String, UserRole)>, DbError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
users
|
||||
.filter(corp_id.eq(c_id))
|
||||
.select((id, username, role))
|
||||
.load(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_corp_user_ids(
|
||||
conn: &mut AsyncPgConnection,
|
||||
c_id: Uuid,
|
||||
) -> Result<Vec<Uuid>, DbError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
users
|
||||
.filter(corp_id.eq(c_id))
|
||||
.select(id)
|
||||
.load(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_corp_user_count(
|
||||
conn: &mut AsyncPgConnection,
|
||||
c_id: Uuid,
|
||||
) -> Result<i64, DbError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
users
|
||||
.filter(corp_id.eq(c_id))
|
||||
.count()
|
||||
.get_result(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn join_by_invite(
|
||||
conn: &mut AsyncPgConnection,
|
||||
user_id: Uuid,
|
||||
invite_code: &str,
|
||||
) -> Result<(), DbError> {
|
||||
use crate::schema::{corps, users};
|
||||
let user_exists = users::table
|
||||
.find(user_id)
|
||||
.select(users::id)
|
||||
.first::<Uuid>(conn)
|
||||
.await
|
||||
.optional()?;
|
||||
|
||||
if user_exists.is_none() {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
|
||||
let current_corp = users::table
|
||||
.find(user_id)
|
||||
.select(users::corp_id)
|
||||
.first::<Option<Uuid>>(conn)
|
||||
.await?;
|
||||
|
||||
if current_corp.is_some() {
|
||||
return Err(DbError::UniqueViolation);
|
||||
}
|
||||
|
||||
let corp = corps::table
|
||||
.filter(corps::invite_code.eq(invite_code))
|
||||
.first::<Corp>(conn)
|
||||
.await?;
|
||||
|
||||
diesel::update(users::table.find(user_id))
|
||||
.set(users::corp_id.eq(corp.id))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_by_invite_code(
|
||||
conn: &mut AsyncPgConnection,
|
||||
code: &str,
|
||||
) -> Result<Corp, DbError> {
|
||||
use crate::schema::corps::dsl::*;
|
||||
|
||||
corps
|
||||
.filter(invite_code.eq(code))
|
||||
.first(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
7
dollhouse/crates/dollhouse-db/src/repositories/mod.rs
Executable file
7
dollhouse/crates/dollhouse-db/src/repositories/mod.rs
Executable file
@@ -0,0 +1,7 @@
|
||||
pub mod corp;
|
||||
pub mod replicant;
|
||||
pub mod user;
|
||||
|
||||
pub use corp::CorpRepository;
|
||||
pub use replicant::ReplicantRepository;
|
||||
pub use user::UserRepository;
|
||||
334
dollhouse/crates/dollhouse-db/src/repositories/replicant.rs
Executable file
334
dollhouse/crates/dollhouse-db/src/repositories/replicant.rs
Executable file
@@ -0,0 +1,334 @@
|
||||
use crate::errors::DbError;
|
||||
use crate::models::{
|
||||
NewReplicant, NewReplicantStats, Replicant, ReplicantFull, ReplicantStats, ReplicantStatus,
|
||||
};
|
||||
use diesel::prelude::*;
|
||||
use diesel_async::{AsyncPgConnection, RunQueryDsl};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct ReplicantRepository;
|
||||
|
||||
impl ReplicantRepository {
|
||||
pub async fn create(
|
||||
conn: &mut AsyncPgConnection,
|
||||
new_replicant: NewReplicant,
|
||||
) -> Result<ReplicantFull, DbError> {
|
||||
use crate::schema::{replicants, replicants_stats};
|
||||
|
||||
let replicant = diesel::insert_into(replicants::table)
|
||||
.values(&new_replicant)
|
||||
.get_result::<Replicant>(conn)
|
||||
.await?;
|
||||
|
||||
let stats = NewReplicantStats {
|
||||
replicant_id: replicant.id,
|
||||
health: 100,
|
||||
strength: 100,
|
||||
intelligence: 100,
|
||||
};
|
||||
|
||||
let stats = diesel::insert_into(replicants_stats::table)
|
||||
.values(&stats)
|
||||
.get_result::<ReplicantStats>(conn)
|
||||
.await?;
|
||||
|
||||
Ok(ReplicantFull {
|
||||
id: replicant.id,
|
||||
name: replicant.name,
|
||||
description: replicant.description,
|
||||
gender: replicant.gender,
|
||||
status: replicant.status,
|
||||
created_at: replicant.created_at,
|
||||
is_private: replicant.is_private,
|
||||
firmware_file: replicant.firmware_file,
|
||||
corp_id: replicant.corp_id,
|
||||
health: stats.health,
|
||||
strength: stats.strength,
|
||||
intelligence: stats.intelligence,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
conn: &mut AsyncPgConnection,
|
||||
replicant_id: Uuid,
|
||||
) -> Result<Replicant, DbError> {
|
||||
use crate::schema::replicants::dsl::*;
|
||||
|
||||
replicants
|
||||
.find(replicant_id)
|
||||
.first(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_optional(
|
||||
conn: &mut AsyncPgConnection,
|
||||
replicant_id: Uuid,
|
||||
) -> Result<Option<Replicant>, DbError> {
|
||||
use crate::schema::replicants::dsl::*;
|
||||
|
||||
replicants
|
||||
.find(replicant_id)
|
||||
.first(conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_much(
|
||||
conn: &mut AsyncPgConnection,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<ReplicantFull>, DbError> {
|
||||
use crate::schema::{replicants, replicants_stats};
|
||||
|
||||
let results = replicants::table
|
||||
.inner_join(replicants_stats::table)
|
||||
.filter(replicants::is_private.eq(false))
|
||||
.select((Replicant::as_select(), ReplicantStats::as_select()))
|
||||
.limit(limit as i64)
|
||||
.offset(offset as i64)
|
||||
.load::<(Replicant, ReplicantStats)>(conn)
|
||||
.await?;
|
||||
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|(rep, stats)| ReplicantFull {
|
||||
id: rep.id,
|
||||
name: rep.name,
|
||||
description: rep.description,
|
||||
gender: rep.gender,
|
||||
status: rep.status,
|
||||
created_at: rep.created_at,
|
||||
is_private: rep.is_private,
|
||||
firmware_file: rep.firmware_file,
|
||||
corp_id: rep.corp_id,
|
||||
health: stats.health,
|
||||
strength: stats.strength,
|
||||
intelligence: stats.intelligence,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn apply_mission_damage(
|
||||
conn: &mut AsyncPgConnection,
|
||||
replicant_id: Uuid,
|
||||
damage: i32,
|
||||
) -> Result<(i32, ReplicantStatus), DbError> {
|
||||
use crate::schema::{replicants, replicants_stats};
|
||||
let (current_health, current_status): (i32, ReplicantStatus) = replicants::table
|
||||
.inner_join(replicants_stats::table)
|
||||
.filter(replicants::id.eq(replicant_id))
|
||||
.select((replicants_stats::health, replicants::status))
|
||||
.first(conn)
|
||||
.await?;
|
||||
|
||||
let new_health = (current_health - damage).max(0);
|
||||
|
||||
diesel::update(replicants_stats::table.find(replicant_id))
|
||||
.set(replicants_stats::health.eq(new_health))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
let mut new_status = current_status.clone();
|
||||
if new_health <= 0 && current_status != ReplicantStatus::Decommissioned {
|
||||
diesel::update(replicants::table.find(replicant_id))
|
||||
.set(replicants::status.eq(ReplicantStatus::Decommissioned))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
new_status = ReplicantStatus::Decommissioned;
|
||||
}
|
||||
|
||||
Ok((new_health, new_status))
|
||||
}
|
||||
|
||||
pub async fn get_firmware(
|
||||
conn: &mut AsyncPgConnection,
|
||||
replicant_id: Uuid,
|
||||
) -> Result<Option<String>, DbError> {
|
||||
use crate::schema::replicants::dsl::*;
|
||||
|
||||
replicants
|
||||
.filter(id.eq(replicant_id))
|
||||
.select(firmware_file)
|
||||
.first(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_corp_replicants(
|
||||
conn: &mut AsyncPgConnection,
|
||||
c_id: Uuid,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<Replicant>, DbError> {
|
||||
use crate::schema::replicants::dsl::*;
|
||||
|
||||
replicants
|
||||
.filter(corp_id.eq(c_id))
|
||||
.select(Replicant::as_select())
|
||||
.limit(limit as i64)
|
||||
.offset(offset as i64)
|
||||
.load(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_corp_replicants_full(
|
||||
conn: &mut AsyncPgConnection,
|
||||
c_id: Uuid,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<ReplicantFull>, DbError> {
|
||||
use crate::schema::{replicants, replicants_stats};
|
||||
|
||||
let results = replicants::table
|
||||
.inner_join(replicants_stats::table)
|
||||
.filter(replicants::corp_id.eq(c_id))
|
||||
.select((Replicant::as_select(), ReplicantStats::as_select()))
|
||||
.limit(limit as i64)
|
||||
.offset(offset as i64)
|
||||
.load::<(Replicant, ReplicantStats)>(conn)
|
||||
.await?;
|
||||
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|(rep, stats)| ReplicantFull {
|
||||
id: rep.id,
|
||||
name: rep.name,
|
||||
description: rep.description,
|
||||
gender: rep.gender,
|
||||
status: rep.status,
|
||||
created_at: rep.created_at,
|
||||
is_private: rep.is_private,
|
||||
firmware_file: rep.firmware_file,
|
||||
corp_id: rep.corp_id,
|
||||
health: stats.health,
|
||||
strength: stats.strength,
|
||||
intelligence: stats.intelligence,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_replicant_full(
|
||||
conn: &mut AsyncPgConnection,
|
||||
replicant_id: Uuid,
|
||||
) -> Result<ReplicantFull, DbError> {
|
||||
use crate::schema::{replicants, replicants_stats};
|
||||
|
||||
let (rep, stats) = replicants::table
|
||||
.inner_join(replicants_stats::table)
|
||||
.filter(replicants::id.eq(replicant_id))
|
||||
.select((Replicant::as_select(), ReplicantStats::as_select()))
|
||||
.first(conn)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => DbError::NotFound,
|
||||
_ => DbError::from(e),
|
||||
})?;
|
||||
|
||||
Ok(ReplicantFull {
|
||||
id: rep.id,
|
||||
name: rep.name,
|
||||
description: rep.description,
|
||||
gender: rep.gender,
|
||||
status: rep.status,
|
||||
created_at: rep.created_at,
|
||||
is_private: rep.is_private,
|
||||
firmware_file: rep.firmware_file,
|
||||
corp_id: rep.corp_id,
|
||||
health: stats.health,
|
||||
strength: stats.strength,
|
||||
intelligence: stats.intelligence,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_stats(
|
||||
conn: &mut AsyncPgConnection,
|
||||
r_id: Uuid,
|
||||
) -> Result<ReplicantStats, DbError> {
|
||||
use crate::schema::replicants_stats::dsl::*;
|
||||
|
||||
replicants_stats
|
||||
.find(r_id)
|
||||
.first(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn update_stats(
|
||||
conn: &mut AsyncPgConnection,
|
||||
r_id: Uuid,
|
||||
stats: ReplicantStats,
|
||||
) -> Result<ReplicantStats, DbError> {
|
||||
use crate::schema::replicants_stats::dsl::*;
|
||||
|
||||
diesel::update(replicants_stats.find(r_id))
|
||||
.set((
|
||||
health.eq(stats.health),
|
||||
strength.eq(stats.strength),
|
||||
intelligence.eq(stats.intelligence),
|
||||
))
|
||||
.get_result(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_count_by_status(
|
||||
conn: &mut AsyncPgConnection,
|
||||
status_query: ReplicantStatus,
|
||||
) -> Result<i64, DbError> {
|
||||
use crate::schema::replicants::dsl::*;
|
||||
|
||||
replicants
|
||||
.filter(status.eq(status_query))
|
||||
.count()
|
||||
.get_result(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn change_privacy(
|
||||
conn: &mut AsyncPgConnection,
|
||||
replicant_id: Uuid,
|
||||
privacy: bool,
|
||||
) -> Result<(), DbError> {
|
||||
use crate::schema::replicants::dsl::*;
|
||||
|
||||
diesel::update(replicants.find(replicant_id))
|
||||
.set(is_private.eq(privacy))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn change_owner(
|
||||
conn: &mut AsyncPgConnection,
|
||||
replicant_id: Uuid,
|
||||
new_owner_id: Uuid,
|
||||
) -> Result<(), DbError> {
|
||||
use crate::schema::replicants::dsl::*;
|
||||
|
||||
diesel::update(replicants.find(replicant_id))
|
||||
.set((corp_id.eq(new_owner_id), is_private.eq(true)))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_firmware(
|
||||
conn: &mut AsyncPgConnection,
|
||||
replicant_id: Uuid,
|
||||
filename: String,
|
||||
) -> Result<Replicant, DbError> {
|
||||
use crate::schema::replicants::dsl::*;
|
||||
|
||||
diesel::update(replicants.find(replicant_id))
|
||||
.set(firmware_file.eq(filename))
|
||||
.get_result(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
137
dollhouse/crates/dollhouse-db/src/repositories/user.rs
Executable file
137
dollhouse/crates/dollhouse-db/src/repositories/user.rs
Executable file
@@ -0,0 +1,137 @@
|
||||
use crate::errors::DbError;
|
||||
use crate::models::{Corp, NewUser, User, UserRole};
|
||||
use diesel::prelude::*;
|
||||
use diesel_async::{AsyncPgConnection, RunQueryDsl};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct UserRepository;
|
||||
|
||||
impl UserRepository {
|
||||
pub async fn create_user(
|
||||
conn: &mut AsyncPgConnection,
|
||||
new_user: NewUser,
|
||||
) -> Result<User, DbError> {
|
||||
use crate::schema::users;
|
||||
|
||||
diesel::insert_into(users::table)
|
||||
.values(&new_user)
|
||||
.get_result(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_user(
|
||||
conn: &mut AsyncPgConnection,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<User>, DbError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
users
|
||||
.find(user_id)
|
||||
.first(conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn find_by_username(
|
||||
conn: &mut AsyncPgConnection,
|
||||
username_query: &str,
|
||||
) -> Result<Option<User>, DbError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
users
|
||||
.filter(username.eq(username_query))
|
||||
.first(conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn update_role(
|
||||
conn: &mut AsyncPgConnection,
|
||||
user_id: Uuid,
|
||||
new_role: UserRole,
|
||||
) -> Result<User, DbError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
diesel::update(users.find(user_id))
|
||||
.set(role.eq(new_role))
|
||||
.get_result(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn update_corp_id(
|
||||
conn: &mut AsyncPgConnection,
|
||||
user_id: Uuid,
|
||||
c_id: Option<Uuid>,
|
||||
) -> Result<User, DbError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
diesel::update(users.find(user_id))
|
||||
.set(corp_id.eq(c_id))
|
||||
.get_result(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_user_corp(
|
||||
conn: &mut AsyncPgConnection,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<Corp>, DbError> {
|
||||
use crate::schema::{corps, users};
|
||||
|
||||
corps::table
|
||||
.inner_join(users::table.on(users::corp_id.eq(corps::id.nullable())))
|
||||
.filter(users::id.eq(user_id))
|
||||
.select(corps::all_columns)
|
||||
.first(conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_user_with_corp(
|
||||
conn: &mut AsyncPgConnection,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<(User, Corp)>, DbError> {
|
||||
use crate::schema::{corps, users};
|
||||
|
||||
users::table
|
||||
.find(user_id)
|
||||
.inner_join(corps::table)
|
||||
.first(conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_users_by_corp_id(
|
||||
conn: &mut AsyncPgConnection,
|
||||
c_id: Uuid,
|
||||
) -> Result<Vec<User>, DbError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
users
|
||||
.filter(corp_id.eq(c_id))
|
||||
.load(conn)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_corp_admin(
|
||||
conn: &mut AsyncPgConnection,
|
||||
c_id: Uuid,
|
||||
) -> Result<Option<User>, DbError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
users
|
||||
.filter(corp_id.eq(c_id))
|
||||
.filter(role.eq(UserRole::CorpAdmin))
|
||||
.first(conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
79
dollhouse/crates/dollhouse-db/src/schema.rs
Executable file
79
dollhouse/crates/dollhouse-db/src/schema.rs
Executable file
@@ -0,0 +1,79 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
pub mod sql_types {
|
||||
#[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]
|
||||
#[diesel(postgres_type(name = "replicant_gender"))]
|
||||
pub struct ReplicantGender;
|
||||
|
||||
#[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]
|
||||
#[diesel(postgres_type(name = "replicant_status"))]
|
||||
pub struct ReplicantStatus;
|
||||
|
||||
#[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)]
|
||||
#[diesel(postgres_type(name = "user_role"))]
|
||||
pub struct UserRole;
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
corps (id) {
|
||||
id -> Uuid,
|
||||
#[max_length = 255]
|
||||
name -> Varchar,
|
||||
description -> Text,
|
||||
created_at -> Timestamp,
|
||||
#[max_length = 255]
|
||||
invite_code -> Varchar,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
use diesel::sql_types::*;
|
||||
use super::sql_types::ReplicantStatus;
|
||||
use super::sql_types::ReplicantGender;
|
||||
|
||||
replicants (id) {
|
||||
id -> Uuid,
|
||||
#[max_length = 255]
|
||||
name -> Varchar,
|
||||
description -> Text,
|
||||
status -> ReplicantStatus,
|
||||
created_at -> Timestamp,
|
||||
gender -> ReplicantGender,
|
||||
corp_id -> Uuid,
|
||||
is_private -> Bool,
|
||||
#[max_length = 255]
|
||||
firmware_file -> Nullable<Varchar>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
replicants_stats (replicant_id) {
|
||||
replicant_id -> Uuid,
|
||||
health -> Int4,
|
||||
strength -> Int4,
|
||||
intelligence -> Int4,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
use diesel::sql_types::*;
|
||||
use super::sql_types::UserRole;
|
||||
|
||||
users (id) {
|
||||
id -> Uuid,
|
||||
#[max_length = 255]
|
||||
username -> Varchar,
|
||||
#[max_length = 255]
|
||||
password -> Varchar,
|
||||
role -> UserRole,
|
||||
created_at -> Timestamp,
|
||||
corp_id -> Nullable<Uuid>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(replicants -> corps (corp_id));
|
||||
diesel::joinable!(replicants_stats -> replicants (replicant_id));
|
||||
diesel::joinable!(users -> corps (corp_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(corps, replicants, replicants_stats, users,);
|
||||
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
22
dollhouse/docker/Dockerfile.base
Executable file
22
dollhouse/docker/Dockerfile.base
Executable file
@@ -0,0 +1,22 @@
|
||||
FROM lukemathwalker/cargo-chef:latest-rust-1.91.1-slim-trixie AS chef
|
||||
WORKDIR /app
|
||||
|
||||
FROM chef AS planner
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/ ./crates/
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM chef AS dependencies
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libpq-dev \
|
||||
liblua5.3-dev \
|
||||
lua5.3 \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
|
||||
FROM dependencies AS base
|
||||
24
dollhouse/docker/dollhouse-backend/Dockerfile
Executable file
24
dollhouse/docker/dollhouse-backend/Dockerfile
Executable file
@@ -0,0 +1,24 @@
|
||||
FROM dollhouse-base:latest AS backend-builder
|
||||
|
||||
RUN cargo install diesel_cli --no-default-features --features postgres
|
||||
|
||||
COPY . .
|
||||
RUN cargo build --release --bin dollhouse-backend
|
||||
|
||||
FROM ubuntu:24.04 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY docker/dollhouse-backend/entrypoint.sh .
|
||||
COPY --from=backend-builder /app/target/release/dollhouse-backend /usr/local/bin/backend
|
||||
COPY --from=backend-builder /usr/local/cargo/bin/diesel ./diesel
|
||||
COPY crates/dollhouse-db/migrations ./migrations
|
||||
COPY crates/dollhouse-db/diesel.toml .
|
||||
|
||||
RUN apt-get update && apt install -y \
|
||||
libpq-dev \
|
||||
liblua5.3-dev \
|
||||
lua5.3 && \
|
||||
rm -rf /var/lib/apt/lists/* \
|
||||
&& chmod +x ./entrypoint.sh
|
||||
|
||||
ENTRYPOINT [ "./entrypoint.sh" ]
|
||||
6
dollhouse/docker/dollhouse-backend/entrypoint.sh
Executable file
6
dollhouse/docker/dollhouse-backend/entrypoint.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -x
|
||||
|
||||
./diesel setup || exit 1
|
||||
exec /usr/local/bin/backend
|
||||
8
dollhouse/docker/dollhouse-cleaner/Dockerfile
Executable file
8
dollhouse/docker/dollhouse-cleaner/Dockerfile
Executable file
@@ -0,0 +1,8 @@
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache postgresql-client bash
|
||||
|
||||
COPY cleaner.sh /cleaner.sh
|
||||
RUN chmod +x /cleaner.sh
|
||||
|
||||
ENTRYPOINT ["/cleaner.sh"]
|
||||
29
dollhouse/docker/dollhouse-cleaner/cleaner.sh
Executable file
29
dollhouse/docker/dollhouse-cleaner/cleaner.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DIR="/firmware"
|
||||
|
||||
while true; do
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$TIMESTAMP] Starting cleanup"
|
||||
|
||||
if [ -d "${DIR}" ]; then
|
||||
find "${DIR}" -type f -mmin +5 -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ -n "${DATABASE_URL}" ]; then
|
||||
psql "${DATABASE_URL}" -v "ON_ERROR_STOP=1" <<'SQL'
|
||||
BEGIN;
|
||||
DELETE FROM replicants_stats WHERE created_at <= NOW() - INTERVAL '5 minutes';
|
||||
DELETE FROM replicants WHERE created_at <= NOW() - INTERVAL '5 minutes';
|
||||
DELETE FROM users WHERE created_at <= NOW() - INTERVAL '5 minutes';
|
||||
DELETE FROM corps WHERE created_at <= NOW() - INTERVAL '5 minutes';
|
||||
COMMIT;
|
||||
SQL
|
||||
else
|
||||
echo " DATABASE_URL not set, skipping DB cleanup"
|
||||
fi
|
||||
|
||||
echo "[$TIMESTAMP] Cleanup completed"
|
||||
sleep 60
|
||||
done
|
||||
19
dollhouse/docker/dollhouse-frontend/Dockerfile
Executable file
19
dollhouse/docker/dollhouse-frontend/Dockerfile
Executable file
@@ -0,0 +1,19 @@
|
||||
FROM dollhouse-base:latest AS frontend-builder
|
||||
|
||||
RUN wget https://github.com/trunk-rs/trunk/releases/download/v0.21.14/trunk-x86_64-unknown-linux-gnu.tar.gz && \
|
||||
tar -xvf trunk-x86_64-unknown-linux-gnu.tar.gz && \
|
||||
mv trunk /usr/local/bin/ && \
|
||||
rm trunk-x86_64-unknown-linux-gnu.tar.gz
|
||||
|
||||
RUN rustup target add wasm32-unknown-unknown
|
||||
|
||||
COPY . .
|
||||
RUN cd crates/dollhouse-frontend && trunk build --release --no-sri
|
||||
|
||||
FROM nginx:1.24-alpine AS runtime
|
||||
COPY --from=frontend-builder /app/crates/dollhouse-frontend/dist /usr/share/nginx/html
|
||||
COPY docker/dollhouse-frontend/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
53
dollhouse/docker/dollhouse-frontend/nginx.conf
Executable file
53
dollhouse/docker/dollhouse-frontend/nginx.conf
Executable file
@@ -0,0 +1,53 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:5555/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# CORS headers
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
|
||||
# Handle preflight
|
||||
if ($request_method = OPTIONS) {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(wasm)$ {
|
||||
add_header Content-Type application/wasm;
|
||||
default_type application/wasm;
|
||||
expires max;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
4
dollhouse/run-me.sh
Executable file
4
dollhouse/run-me.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
docker compose build base
|
||||
docker compose up --build -d
|
||||
Reference in New Issue
Block a user