Compare commits
2 Commits
89815e7e21
...
c8b4beafff
| Author | SHA1 | Date | |
|---|---|---|---|
| c8b4beafff | |||
| d9f6694b2f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ config.yaml
|
|||||||
backend/data/zhealth.db
|
backend/data/zhealth.db
|
||||||
backend/data/zhealth.db-wal
|
backend/data/zhealth.db-wal
|
||||||
backend/data/zhealth.db-shm
|
backend/data/zhealth.db-shm
|
||||||
|
backend/data/uploads/
|
||||||
|
|
||||||
# FRONTEND
|
# FRONTEND
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
# Web Framework
|
# Web Framework
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
|
axum-extra = { version = "0.10", features = ["multipart"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ server:
|
|||||||
paths:
|
paths:
|
||||||
database: "./data/zhealth.db"
|
database: "./data/zhealth.db"
|
||||||
logs: "./logs"
|
logs: "./logs"
|
||||||
|
uploads: "./data/uploads"
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: "info" # Options: trace | debug | info | warn | error
|
level: "info" # Options: trace | debug | info | warn | error
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct ServerConfig {
|
|||||||
pub struct PathsConfig {
|
pub struct PathsConfig {
|
||||||
pub database: String,
|
pub database: String,
|
||||||
pub logs: String,
|
pub logs: String,
|
||||||
|
pub uploads: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, S
|
|||||||
use sea_orm::sea_query::SqliteQueryBuilder;
|
use sea_orm::sea_query::SqliteQueryBuilder;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::models::bio::{biomarker, biomarker_category, biomarker_entry, biomarker_reference_rule};
|
use crate::models::bio::{biomarker, biomarker_category, biomarker_entry, biomarker_reference_rule, source};
|
||||||
use crate::models::user::{diet, role, session, user};
|
use crate::models::user::{diet, role, session, user};
|
||||||
|
|
||||||
/// Connect to the SQLite database.
|
/// Connect to the SQLite database.
|
||||||
@@ -33,6 +33,7 @@ pub async fn run_migrations(db: &DatabaseConnection) -> Result<(), DbErr> {
|
|||||||
schema.create_table_from_entity(biomarker_category::Entity),
|
schema.create_table_from_entity(biomarker_category::Entity),
|
||||||
schema.create_table_from_entity(biomarker::Entity),
|
schema.create_table_from_entity(biomarker::Entity),
|
||||||
schema.create_table_from_entity(biomarker_reference_rule::Entity),
|
schema.create_table_from_entity(biomarker_reference_rule::Entity),
|
||||||
|
schema.create_table_from_entity(source::Entity),
|
||||||
schema.create_table_from_entity(biomarker_entry::Entity),
|
schema.create_table_from_entity(biomarker_entry::Entity),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ pub async fn create_entry(
|
|||||||
value: Set(req.value),
|
value: Set(req.value),
|
||||||
measured_at: Set(measured_at),
|
measured_at: Set(measured_at),
|
||||||
notes: Set(req.notes.clone()),
|
notes: Set(req.notes.clone()),
|
||||||
|
source_id: Set(None),
|
||||||
created_at: Set(now),
|
created_at: Set(now),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ pub mod biomarkers;
|
|||||||
pub mod categories;
|
pub mod categories;
|
||||||
pub mod diets;
|
pub mod diets;
|
||||||
pub mod entries;
|
pub mod entries;
|
||||||
|
pub mod sources;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
263
backend/src/handlers/sources.rs
Normal file
263
backend/src/handlers/sources.rs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
//! Sources API handlers - file upload and management.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::Multipart;
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::fs;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
use crate::models::bio::source;
|
||||||
|
|
||||||
|
/// Response for a source.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SourceResponse {
|
||||||
|
pub id: i32,
|
||||||
|
pub user_id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub file_type: String,
|
||||||
|
pub file_size: i64,
|
||||||
|
pub ocr_data: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub uploaded_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State that includes config for upload path.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SourcesState {
|
||||||
|
pub db: DatabaseConnection,
|
||||||
|
pub uploads_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/sources - List all sources for current user.
|
||||||
|
pub async fn list_sources(
|
||||||
|
State(state): State<SourcesState>,
|
||||||
|
// TODO: Get user_id from session
|
||||||
|
) -> Result<Json<Vec<SourceResponse>>, StatusCode> {
|
||||||
|
let sources = source::Entity::find()
|
||||||
|
.all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let items: Vec<SourceResponse> = sources
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| SourceResponse {
|
||||||
|
id: s.id,
|
||||||
|
user_id: s.user_id,
|
||||||
|
name: s.name,
|
||||||
|
file_path: s.file_path,
|
||||||
|
file_type: s.file_type,
|
||||||
|
file_size: s.file_size,
|
||||||
|
ocr_data: s.ocr_data,
|
||||||
|
description: s.description,
|
||||||
|
uploaded_at: s.uploaded_at.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/sources/:id - Get a source by ID.
|
||||||
|
pub async fn get_source(
|
||||||
|
State(state): State<SourcesState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Json<SourceResponse>, StatusCode> {
|
||||||
|
let s = source::Entity::find_by_id(id)
|
||||||
|
.one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
Ok(Json(SourceResponse {
|
||||||
|
id: s.id,
|
||||||
|
user_id: s.user_id,
|
||||||
|
name: s.name,
|
||||||
|
file_path: s.file_path,
|
||||||
|
file_type: s.file_type,
|
||||||
|
file_size: s.file_size,
|
||||||
|
ocr_data: s.ocr_data,
|
||||||
|
description: s.description,
|
||||||
|
uploaded_at: s.uploaded_at.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/sources - Upload a new source file.
|
||||||
|
pub async fn upload_source(
|
||||||
|
State(state): State<SourcesState>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<SourceResponse>, StatusCode> {
|
||||||
|
let mut file_name: Option<String> = None;
|
||||||
|
let mut file_type: Option<String> = None;
|
||||||
|
let mut file_data: Option<Vec<u8>> = None;
|
||||||
|
let mut name: Option<String> = None;
|
||||||
|
let mut description: Option<String> = None;
|
||||||
|
let mut user_id: Option<i32> = None;
|
||||||
|
|
||||||
|
while let Some(field) = multipart.next_field().await.map_err(|e| {
|
||||||
|
tracing::error!("Multipart error: {:?}", e);
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
})? {
|
||||||
|
let field_name = field.name().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
match field_name.as_str() {
|
||||||
|
"file" => {
|
||||||
|
file_name = field.file_name().map(|s| s.to_string());
|
||||||
|
file_type = field.content_type().map(|s| s.to_string());
|
||||||
|
file_data = Some(field.bytes().await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to read file data: {:?}", e);
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
})?.to_vec());
|
||||||
|
}
|
||||||
|
"name" => {
|
||||||
|
name = Some(field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?);
|
||||||
|
}
|
||||||
|
"description" => {
|
||||||
|
description = Some(field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?);
|
||||||
|
}
|
||||||
|
"user_id" => {
|
||||||
|
let text = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
user_id = Some(text.parse().map_err(|_| StatusCode::BAD_REQUEST)?);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_data = file_data.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
let user_id = user_id.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
let original_name = file_name.unwrap_or_else(|| "upload".to_string());
|
||||||
|
let display_name = name.unwrap_or_else(|| original_name.clone());
|
||||||
|
let content_type = file_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
|
let file_size = file_data.len() as i64;
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
let timestamp = Utc::now().timestamp_millis();
|
||||||
|
let safe_name = original_name.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
|
||||||
|
let stored_name = format!("{}_{}", timestamp, safe_name);
|
||||||
|
|
||||||
|
// Ensure uploads directory exists
|
||||||
|
fs::create_dir_all(&state.uploads_path).await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to create uploads dir: {:?}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
let file_path = state.uploads_path.join(&stored_name);
|
||||||
|
let mut file = fs::File::create(&file_path).await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to create file: {:?}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
file.write_all(&file_data).await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to write file: {:?}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
let new_source = source::ActiveModel {
|
||||||
|
user_id: Set(user_id),
|
||||||
|
name: Set(display_name.clone()),
|
||||||
|
file_path: Set(file_path.to_string_lossy().to_string()),
|
||||||
|
file_type: Set(content_type.clone()),
|
||||||
|
file_size: Set(file_size),
|
||||||
|
ocr_data: Set(None),
|
||||||
|
description: Set(description.clone()),
|
||||||
|
uploaded_at: Set(now),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let inserted = new_source
|
||||||
|
.insert(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to insert source: {:?}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(SourceResponse {
|
||||||
|
id: inserted.id,
|
||||||
|
user_id: inserted.user_id,
|
||||||
|
name: inserted.name,
|
||||||
|
file_path: inserted.file_path,
|
||||||
|
file_type: inserted.file_type,
|
||||||
|
file_size: inserted.file_size,
|
||||||
|
ocr_data: inserted.ocr_data,
|
||||||
|
description: inserted.description,
|
||||||
|
uploaded_at: inserted.uploaded_at.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/sources/:id - Delete a source.
|
||||||
|
pub async fn delete_source(
|
||||||
|
State(state): State<SourcesState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
// Get the source first to delete the file
|
||||||
|
let s = source::Entity::find_by_id(id)
|
||||||
|
.one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
// Delete file from disk
|
||||||
|
if let Err(e) = fs::remove_file(&s.file_path).await {
|
||||||
|
tracing::warn!("Failed to delete file {}: {:?}", s.file_path, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
let result = source::Entity::delete_by_id(id)
|
||||||
|
.exec(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if result.rows_affected == 0 {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to update OCR data for a source.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateOcrRequest {
|
||||||
|
pub ocr_data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/sources/:id/ocr - Update OCR data for a source.
|
||||||
|
pub async fn update_ocr(
|
||||||
|
State(state): State<SourcesState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Json(req): Json<UpdateOcrRequest>,
|
||||||
|
) -> Result<Json<SourceResponse>, StatusCode> {
|
||||||
|
let existing = source::Entity::find_by_id(id)
|
||||||
|
.one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let mut active: source::ActiveModel = existing.into();
|
||||||
|
active.ocr_data = Set(Some(req.ocr_data));
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(SourceResponse {
|
||||||
|
id: updated.id,
|
||||||
|
user_id: updated.user_id,
|
||||||
|
name: updated.name,
|
||||||
|
file_path: updated.file_path,
|
||||||
|
file_type: updated.file_type,
|
||||||
|
file_size: updated.file_size,
|
||||||
|
ocr_data: updated.ocr_data,
|
||||||
|
description: updated.description,
|
||||||
|
uploaded_at: updated.uploaded_at.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use axum_login::{
|
|||||||
};
|
};
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::DatabaseConnection;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
use time::Duration;
|
use time::Duration;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
@@ -144,9 +145,24 @@ fn create_router(db: DatabaseConnection, config: &config::Config) -> Router {
|
|||||||
.route("/api/users/{user_id}/entries", get(handlers::entries::list_user_entries))
|
.route("/api/users/{user_id}/entries", get(handlers::entries::list_user_entries))
|
||||||
.route_layer(middleware::from_fn(require_auth));
|
.route_layer(middleware::from_fn(require_auth));
|
||||||
|
|
||||||
|
// Sources routes (need separate state for uploads path)
|
||||||
|
let sources_state = handlers::sources::SourcesState {
|
||||||
|
db: db.clone(),
|
||||||
|
uploads_path: PathBuf::from(&config.paths.uploads),
|
||||||
|
};
|
||||||
|
let sources_routes = Router::new()
|
||||||
|
.route("/api/sources", get(handlers::sources::list_sources)
|
||||||
|
.post(handlers::sources::upload_source))
|
||||||
|
.route("/api/sources/{id}", get(handlers::sources::get_source)
|
||||||
|
.delete(handlers::sources::delete_source))
|
||||||
|
.route("/api/sources/{id}/ocr", put(handlers::sources::update_ocr))
|
||||||
|
.route_layer(middleware::from_fn(require_auth))
|
||||||
|
.with_state(sources_state);
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(public_routes)
|
.merge(public_routes)
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
|
.merge(sources_routes)
|
||||||
.layer(auth_layer)
|
.layer(auth_layer)
|
||||||
.with_state(db)
|
.with_state(db)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ pub struct Model {
|
|||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
|
|
||||||
|
/// Optional foreign key to source document
|
||||||
|
pub source_id: Option<i32>,
|
||||||
|
|
||||||
pub created_at: DateTime,
|
pub created_at: DateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +47,13 @@ pub enum Relation {
|
|||||||
to = "crate::models::user::user::Column::Id"
|
to = "crate::models::user::user::Column::Id"
|
||||||
)]
|
)]
|
||||||
User,
|
User,
|
||||||
|
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::source::Entity",
|
||||||
|
from = "Column::SourceId",
|
||||||
|
to = "super::source::Column::Id"
|
||||||
|
)]
|
||||||
|
Source,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<super::biomarker::Entity> for Entity {
|
impl Related<super::biomarker::Entity> for Entity {
|
||||||
@@ -58,4 +68,10 @@ impl Related<crate::models::user::user::Entity> for Entity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Related<super::source::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Source.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ pub mod biomarker;
|
|||||||
pub mod biomarker_category;
|
pub mod biomarker_category;
|
||||||
pub mod biomarker_entry;
|
pub mod biomarker_entry;
|
||||||
pub mod biomarker_reference_rule;
|
pub mod biomarker_reference_rule;
|
||||||
|
pub mod source;
|
||||||
|
|
||||||
pub use biomarker::Entity as Biomarker;
|
pub use biomarker::Entity as Biomarker;
|
||||||
pub use biomarker_category::Entity as BiomarkerCategory;
|
pub use biomarker_category::Entity as BiomarkerCategory;
|
||||||
pub use biomarker_entry::Entity as BiomarkerEntry;
|
pub use biomarker_entry::Entity as BiomarkerEntry;
|
||||||
pub use biomarker_reference_rule::Entity as BiomarkerReferenceRule;
|
pub use biomarker_reference_rule::Entity as BiomarkerReferenceRule;
|
||||||
|
pub use source::Entity as Source;
|
||||||
|
|||||||
67
backend/src/models/bio/source.rs
Normal file
67
backend/src/models/bio/source.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//! Source entity - user-uploaded documents with OCR data.
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "sources")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
|
||||||
|
/// Foreign key to users table
|
||||||
|
pub user_id: i32,
|
||||||
|
|
||||||
|
/// Display name for the source
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Path to stored file
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub file_path: String,
|
||||||
|
|
||||||
|
/// MIME type (e.g., "application/pdf", "image/jpeg")
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub file_type: String,
|
||||||
|
|
||||||
|
/// File size in bytes
|
||||||
|
pub file_size: i64,
|
||||||
|
|
||||||
|
/// OCR parsed data as JSON
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub ocr_data: Option<String>,
|
||||||
|
|
||||||
|
/// Optional description/notes
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
|
||||||
|
/// When the file was uploaded
|
||||||
|
pub uploaded_at: DateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "crate::models::user::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "crate::models::user::user::Column::Id"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
|
||||||
|
#[sea_orm(has_many = "super::biomarker_entry::Entity")]
|
||||||
|
BiomarkerEntries,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<crate::models::user::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::biomarker_entry::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::BiomarkerEntries.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
BIN
frontend/public/icons/general/icons8-checkmark-50.png
Normal file
BIN
frontend/public/icons/general/icons8-checkmark-50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 856 B |
BIN
frontend/public/icons/general/icons8-clock-50.png
Normal file
BIN
frontend/public/icons/general/icons8-clock-50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/public/icons/general/icons8-collapse-arrow-50.png
Normal file
BIN
frontend/public/icons/general/icons8-collapse-arrow-50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 420 B |
BIN
frontend/public/icons/general/icons8-trash-50.png
Normal file
BIN
frontend/public/icons/general/icons8-trash-50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 625 B |
BIN
frontend/public/icons/general/icons8-upload-to-the-cloud-50.png
Normal file
BIN
frontend/public/icons/general/icons8-upload-to-the-cloud-50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 667 B |
@@ -110,6 +110,224 @@ a:hover {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
UTILITY CLASSES
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Spacing - Margin Bottom */
|
||||||
|
.mb-xs {
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-sm {
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-md {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-lg {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-xl {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacing - Margin Top */
|
||||||
|
.mt-xs {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-sm {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-md {
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-lg {
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-xl {
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacing - Margin Left */
|
||||||
|
.ml-xs {
|
||||||
|
margin-left: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-sm {
|
||||||
|
margin-left: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-md {
|
||||||
|
margin-left: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacing - Padding */
|
||||||
|
.p-sm {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-md {
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-lg {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-xl {
|
||||||
|
padding: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flex Layouts */
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-between {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gaps */
|
||||||
|
.gap-xs {
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-sm {
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-md {
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-lg {
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text Utilities */
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-xs {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-medium {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-semibold {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uppercase {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display */
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Width */
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-sm {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-md {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-lg {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-xl {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Min Width / Flex */
|
||||||
|
.min-w-0 {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Border */
|
||||||
|
.border-t {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
END UTILITY CLASSES
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
/* Button Base */
|
/* Button Base */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -463,7 +681,7 @@ select.input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.biomarker-info {
|
.biomarker-info {
|
||||||
flex: 0 0 240px;
|
flex: 0 0 320px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -473,13 +691,11 @@ select.input {
|
|||||||
|
|
||||||
.biomarker-info .biomarker-name {
|
.biomarker-info .biomarker-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.biomarker-info .biomarker-unit {
|
.biomarker-info .biomarker-unit {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Biomarker Scale Bar */
|
/* Biomarker Scale Bar */
|
||||||
@@ -785,4 +1001,97 @@ select.input {
|
|||||||
.page-loading {
|
.page-loading {
|
||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
SOURCES PAGE
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Upload Zone */
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background-color: color-mix(in srgb, var(--accent) 5%, var(--bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone.drag-over {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background-color: color-mix(in srgb, var(--accent) 5%, var(--bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone.uploading {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Icon */
|
||||||
|
.upload-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Source Item */
|
||||||
|
.source-item {
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Parsed */
|
||||||
|
.status-parsed {
|
||||||
|
color: var(--indicator-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon sizes */
|
||||||
|
.icon-sm {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
DASHBOARD PAGE
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Category Card */
|
||||||
|
.category-card {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category Name */
|
||||||
|
.category-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible Header Button */
|
||||||
|
.collapsible-header {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapse Icon */
|
||||||
|
.collapse-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
INSIGHTS PAGE
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
.coming-soon-icon {
|
||||||
|
font-size: 48px;
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ export function AdminPage() {
|
|||||||
const isConfigAdmin = (user: User) => user.username === CONFIG_ADMIN_USERNAME
|
const isConfigAdmin = (user: User) => user.username === CONFIG_ADMIN_USERNAME
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div>
|
return <div className="p-lg">Loading...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentUser || currentUser.role !== 'admin') {
|
if (!currentUser || currentUser.role !== 'admin') {
|
||||||
@@ -129,10 +129,10 @@ export function AdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 'var(--space-lg)', maxWidth: '1000px', margin: '0 auto' }}>
|
<div className="admin-page p-lg max-w-xl">
|
||||||
<header style={{ marginBottom: 'var(--space-xl)' }}>
|
<header className="mb-xl">
|
||||||
<Link to="/" className="text-secondary text-sm">← Back to Dashboard</Link>
|
<Link to="/" className="text-secondary text-sm">← Back to Dashboard</Link>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 'var(--space-sm)' }}>
|
<div className="flex-between mt-sm">
|
||||||
<h1>User Management</h1>
|
<h1>User Management</h1>
|
||||||
<button className="btn btn-primary" onClick={() => setShowCreateModal(true)}>
|
<button className="btn btn-primary" onClick={() => setShowCreateModal(true)}>
|
||||||
+ New User
|
+ New User
|
||||||
@@ -172,7 +172,7 @@ export function AdminPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="text-secondary text-sm">{new Date(user.created_at).toLocaleDateString()}</td>
|
<td className="text-secondary text-sm">{new Date(user.created_at).toLocaleDateString()}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-xs)' }}>
|
<div className="flex gap-xs">
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
onClick={() => setEditingUser(user)}
|
onClick={() => setEditingUser(user)}
|
||||||
@@ -243,7 +243,7 @@ export function AdminPage() {
|
|||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginTop: 'var(--space-lg)' }}>
|
<div className="flex gap-sm mt-lg">
|
||||||
<button type="submit" className="btn btn-primary">Create</button>
|
<button type="submit" className="btn btn-primary">Create</button>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setShowCreateModal(false)}>Cancel</button>
|
<button type="button" className="btn btn-secondary" onClick={() => setShowCreateModal(false)}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,7 +327,7 @@ function EditUserModal({ user, onClose, onSave, setMessage }: EditUserModalProps
|
|||||||
placeholder="Full name"
|
placeholder="Full name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginTop: 'var(--space-lg)' }}>
|
<div className="flex gap-sm mt-lg">
|
||||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
{saving ? 'Saving...' : 'Save'}
|
{saving ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
@@ -419,7 +419,7 @@ function ResetPasswordModal({ user, onClose, onSave, setMessage }: ResetPassword
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginTop: 'var(--space-lg)' }}>
|
<div className="flex gap-sm mt-lg">
|
||||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
{saving ? 'Resetting...' : 'Reset Password'}
|
{saving ? 'Resetting...' : 'Reset Password'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -60,45 +60,38 @@ export function DashboardPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 style={{ marginBottom: 'var(--space-md)' }}>Biomarker Categories</h2>
|
<h2 className="mb-md">Biomarker Categories</h2>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }}>
|
<div className="flex-col gap-sm">
|
||||||
{categories.map(category => {
|
{categories.map(category => {
|
||||||
const categoryBiomarkers = getBiomarkersForCategory(category.id)
|
const categoryBiomarkers = getBiomarkersForCategory(category.id)
|
||||||
const isExpanded = expandedCategories.has(category.id)
|
const isExpanded = expandedCategories.has(category.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category.id} className="card" style={{ padding: 0 }}>
|
<div key={category.id} className="card category-card">
|
||||||
<button
|
<button
|
||||||
className="collapsible-header"
|
className="collapsible-header w-full p-md flex-between"
|
||||||
onClick={() => toggleCategory(category.id)}
|
onClick={() => toggleCategory(category.id)}
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: 'var(--space-md)',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'left',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span style={{ fontSize: '16px', fontWeight: 600, textTransform: 'uppercase' }}>{category.name}</span>
|
<span className="category-name">{category.name}</span>
|
||||||
<span className="text-secondary text-sm" style={{ marginLeft: 'var(--space-sm)' }}>
|
<span className="text-secondary text-sm ml-sm">
|
||||||
({categoryBiomarkers.length} biomarkers)
|
({categoryBiomarkers.length} biomarkers)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: '18px', transition: 'transform 0.2s', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0)' }}>
|
<img
|
||||||
▼
|
src="/icons/general/icons8-collapse-arrow-50.png"
|
||||||
</span>
|
alt="expand"
|
||||||
|
className="theme-icon collapse-icon"
|
||||||
|
style={{
|
||||||
|
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div style={{ borderTop: '1px solid var(--border)', padding: 'var(--space-sm)' }}>
|
<div className="category-content border-t p-sm">
|
||||||
{categoryBiomarkers.length === 0 ? (
|
{categoryBiomarkers.length === 0 ? (
|
||||||
<p className="text-secondary text-sm" style={{ padding: 'var(--space-sm)' }}>
|
<p className="text-secondary text-sm p-sm">
|
||||||
No biomarkers in this category
|
No biomarkers in this category
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export function InsightsPage() {
|
|||||||
<p className="text-secondary">AI-powered analysis of your health data</p>
|
<p className="text-secondary">AI-powered analysis of your health data</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="card" style={{ textAlign: 'center', padding: 'var(--space-xl)' }}>
|
<div className="card text-center p-xl">
|
||||||
<div style={{ fontSize: '48px', marginBottom: 'var(--space-md)' }}>🚀</div>
|
<div className="coming-soon-icon mb-md">🚀</div>
|
||||||
<h3>Coming Soon</h3>
|
<h3>Coming Soon</h3>
|
||||||
<p className="text-secondary">
|
<p className="text-secondary">
|
||||||
AI-generated health insights and recommendations based on your biomarker data.
|
AI-generated health insights and recommendations based on your biomarker data.
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-secondary text-sm" style={{ marginTop: 'var(--space-md)', textAlign: 'center' }}>
|
<p className="text-secondary text-sm mt-md text-center">
|
||||||
Don't have an account? <Link to="/signup">Sign up</Link>
|
Don't have an account? <Link to="/signup">Sign up</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page" style={{ maxWidth: '600px' }}>
|
<div className="page max-w-md">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<h1>Profile</h1>
|
<h1>Profile</h1>
|
||||||
<p className="text-secondary">Manage your account and health information</p>
|
<p className="text-secondary">Manage your account and health information</p>
|
||||||
@@ -131,8 +131,8 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{/* Account Info */}
|
{/* Account Info */}
|
||||||
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
<div className="card mb-lg">
|
||||||
<h3 style={{ marginBottom: 'var(--space-md)' }}>Account</h3>
|
<h3 className="mb-md">Account</h3>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<input type="text" className="input" value={username} disabled />
|
<input type="text" className="input" value={username} disabled />
|
||||||
@@ -165,8 +165,8 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Physical Info */}
|
{/* Physical Info */}
|
||||||
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
<div className="card mb-lg">
|
||||||
<h3 style={{ marginBottom: 'var(--space-md)' }}>Physical Info</h3>
|
<h3 className="mb-md">Physical Info</h3>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="height">Height (cm)</label>
|
<label htmlFor="height">Height (cm)</label>
|
||||||
@@ -214,8 +214,8 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lifestyle */}
|
{/* Lifestyle */}
|
||||||
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
<div className="card mb-lg">
|
||||||
<h3 style={{ marginBottom: 'var(--space-md)' }}>Lifestyle</h3>
|
<h3 className="mb-md">Lifestyle</h3>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="diet">Diet</label>
|
<label htmlFor="diet">Diet</label>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function SignupPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-secondary text-sm" style={{ marginTop: 'var(--space-md)', textAlign: 'center' }}>
|
<p className="text-secondary text-sm mt-md text-center">
|
||||||
Already have an account? <Link to="/login">Sign in</Link>
|
Already have an account? <Link to="/login">Sign in</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,114 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface Source {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
name: string
|
||||||
|
file_path: string
|
||||||
|
file_type: string
|
||||||
|
file_size: number
|
||||||
|
ocr_data: string | null
|
||||||
|
description: string | null
|
||||||
|
uploaded_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export function SourcesPage() {
|
export function SourcesPage() {
|
||||||
|
const [sources, setSources] = useState<Source[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Fetch sources on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSources()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchSources = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sources', { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setSources(data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch sources:', e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = async (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Get current user ID from session
|
||||||
|
const authRes = await fetch('/api/auth/me', { credentials: 'include' })
|
||||||
|
const authData = await authRes.json()
|
||||||
|
if (!authData.user) {
|
||||||
|
setError('Please log in to upload files')
|
||||||
|
setUploading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('user_id', authData.user.id.toString())
|
||||||
|
formData.append('name', file.name)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sources', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(err || 'Upload failed')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Failed to upload ${file.name}`)
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(false)
|
||||||
|
fetchSources() // Refresh the list
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sources/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setSources(sources.filter(s => s.id !== id))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete:', e)
|
||||||
|
} finally {
|
||||||
|
setDeleteConfirmId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
@@ -7,35 +117,119 @@ export function SourcesPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 style={{ marginBottom: 'var(--space-md)' }}>Upload Data</h3>
|
<h3 className="mb-md">Upload Data</h3>
|
||||||
<p className="text-secondary text-sm" style={{ marginBottom: 'var(--space-lg)' }}>
|
<p className="text-secondary text-sm mb-lg">
|
||||||
Upload lab reports in PDF, CSV, or Excel format to import your biomarker data.
|
Upload lab reports in PDF, CSV, or Excel format to import your biomarker data.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-error mb-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.csv,.xlsx,.xls,.jpg,.jpeg,.png"
|
||||||
|
onChange={(e) => handleUpload(e.target.files)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="upload-zone"
|
className={`upload-zone ${dragOver ? 'drag-over' : ''}`}
|
||||||
style={{
|
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||||
border: '2px dashed var(--border)',
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||||
borderRadius: 'var(--radius-md)',
|
onDragLeave={() => setDragOver(false)}
|
||||||
padding: 'var(--space-xl)',
|
onDrop={(e) => {
|
||||||
textAlign: 'center',
|
e.preventDefault()
|
||||||
cursor: 'pointer',
|
setDragOver(false)
|
||||||
|
handleUpload(e.dataTransfer.files)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '36px', marginBottom: 'var(--space-sm)' }}>📤</div>
|
{uploading ? (
|
||||||
<p className="text-secondary">
|
<>
|
||||||
Drag & drop files here, or click to browse
|
<div className="mb-sm text-center">
|
||||||
</p>
|
<img src="/icons/general/icons8-clock-50.png" alt="Uploading" className="upload-icon theme-icon" />
|
||||||
<p className="text-secondary text-xs" style={{ marginTop: 'var(--space-sm)' }}>
|
</div>
|
||||||
Supported: PDF, CSV, XLSX
|
<p className="text-secondary">Uploading...</p>
|
||||||
</p>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-sm text-center">
|
||||||
|
<img src="/icons/general/icons8-upload-to-the-cloud-50.png" alt="Upload" className="upload-icon theme-icon" />
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary">
|
||||||
|
Drag & drop files here, or click to browse
|
||||||
|
</p>
|
||||||
|
<p className="text-secondary text-xs mt-sm">
|
||||||
|
Supported: PDF, CSV, XLSX, Images
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ marginTop: 'var(--space-lg)' }}>
|
<div className="card mt-lg">
|
||||||
<h3 style={{ marginBottom: 'var(--space-md)' }}>Recent Uploads</h3>
|
<h3 className="mb-md">Recent Uploads</h3>
|
||||||
<p className="text-secondary text-sm">No files uploaded yet.</p>
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-secondary text-sm">Loading...</p>
|
||||||
|
) : sources.length === 0 ? (
|
||||||
|
<p className="text-secondary text-sm">No files uploaded yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="sources-list">
|
||||||
|
{sources.map(source => (
|
||||||
|
<div key={source.id} className="source-item flex-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{source.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary text-xs">
|
||||||
|
{source.file_type} • {formatFileSize(source.file_size)} • {formatDate(source.uploaded_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-sm items-center">
|
||||||
|
{source.ocr_data ? (
|
||||||
|
<span className="status-parsed flex items-center gap-xs text-xs">
|
||||||
|
<img src="/icons/general/icons8-checkmark-50.png" alt="Parsed" className="icon-sm" /> Parsed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary text-xs">Pending</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={() => setDeleteConfirmId(source.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{deleteConfirmId !== null && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="card modal-content">
|
||||||
|
<h3 className="mb-md">Delete File?</h3>
|
||||||
|
<p className="text-secondary mb-lg">
|
||||||
|
Are you sure you want to delete this file? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-sm justify-center">
|
||||||
|
<button className="btn" onClick={() => setDeleteConfirmId(null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger" onClick={() => handleDelete(deleteConfirmId)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user